const Icon = ({ children }) => (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round">
{children}
</svg>
);
const Icons = {
Sun: () => <Icon><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></Icon>,
Moon: () => <Icon><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></Icon>,
Coffee: () => <Icon><path d="M18 8h1a4 4 0 0 1 0 8h-1"></path><path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z"></path><line x1="6" y1="1" x2="6" y2="4"></line><line x1="10" y1="1" x2="10" y2="4"></line><line x1="14" y1="1" x2="14" y2="4"></line></Icon>,
Target: () => <Icon><circle cx="12" cy="12" r="10"></circle><circle cx="12" cy="12" r="6"></circle><circle cx="12" cy="12" r="2"></circle></Icon>,
Stages: () => <Icon><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></Icon>,
Kanban: () => <Icon><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><line x1="9" y1="3" x2="9" y2="21"></line><line x1="15" y1="3" x2="15" y2="21"></line></Icon>,
Activity: () => <Icon><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline></Icon>,
MessageSquare: () => <Icon><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></Icon>,
User: () => <Icon><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></Icon>,
Users: () => <Icon><path d="M17 21v-2a4 4 0 0 0-3-3.87"></path><path d="M9 21v-2a4 4 0 0 1 3-3.87"></path><circle cx="12" cy="7" r="4"></circle><circle cx="17" cy="7" r="4"></circle><circle cx="7" cy="7" r="4"></circle></Icon>,
Settings: () => <Icon><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></Icon>,
Check: () => <Icon><polyline points="20 6 9 17 4 12"></polyline></Icon>,
CheckCircle: () => <Icon><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></Icon>,
Edit: () => <Icon><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></Icon>,
Lock: () => <Icon><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></Icon>,
Plus: () => <Icon><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></Icon>,
X: () => <Icon><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></Icon>,
Trash: () => <Icon><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></Icon>,
Clock: () => <Icon><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></Icon>,
WifiOff: () => <Icon><line x1="2" y1="2" x2="22" y2="22"></line><path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"></path><path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39"></path><path d="M10.71 5.05A16 16 0 0 1 22.58 9"></path><path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88"></path><path d="M8.53 16.11a6 6 0 0 1 6.95 0"></path><line x1="12" y1="20" x2="12.01" y2="20"></line></Icon>,
Play: () => <Icon><polygon points="5 3 19 12 5 21 5 3"></polygon></Icon>,
Stop: () => <Icon><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect></Icon>,
Pause: () => <Icon><rect x="6" y="4" width="4" height="16"></rect><rect x="14" y="4" width="4" height="16"></rect></Icon>,
Folder: () => <Icon><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></Icon>,
Upload: () => <Icon><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" y1="3" x2="12" y2="15"></line></Icon>,
Download: () => <Icon><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></Icon>,
Calendar: () => <Icon><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></Icon>,
List: () => <Icon><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></Icon>,
History: () => <Icon><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path><path d="M12 7v5l4 2"></path></Icon>,
Euro: () => <Icon><path d="M18.5 4.804a8 8 0 1 0 0 14.392M5 10h11M5 14h11"/></Icon>,
Package: () => <Icon><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path><polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline><line x1="12" y1="22.08" x2="12" y2="12"></line></Icon>,
PieChart: () => <Icon><path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/></Icon>,
Home: () => <Icon><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline></Icon>,
Bell: () => <Icon><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M13.73 21a2 2 0 0 1-3.46 0"></path></Icon>,
AlertTriangle: () => <Icon><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></Icon>,
CheckSquare: () => <Icon><polyline points="9 11 12 14 22 4"></polyline><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path></Icon>,
Square: () => <Icon><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect></Icon>,
UserPlus: () => <Icon><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></Icon>,
UserMinus: () => <Icon><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="23" y1="11" x2="17" y2="11"/></Icon>,
Undo: () => <Icon><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><polyline points="3 3 3 8 8 8"></polyline></Icon>,
Star: () => <Icon><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></Icon>,
Link: () => <Icon><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></Icon>,
ArrowUp: () => <Icon><line x1="12" y1="19" x2="12" y2="5"></line><polyline points="5 12 12 5 19 12"></polyline></Icon>,
ArrowDown: () => <Icon><line x1="12" y1="5" x2="12" y2="19"></line><polyline points="19 12 12 19 5 12"></polyline></Icon>,
Repeat: () => <Icon><polyline points="17 1 21 5 17 9"></polyline><path d="M3 11V9a4 4 0 0 1 4-4h14"></path><polyline points="7 23 3 19 7 15"></polyline><path d="M21 13v2a4 4 0 0 1-4 4H3"></path></Icon>,
Mail: () => <Icon><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></Icon>,
Phone: () => <Icon><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path></Icon>,
Hash: () => <Icon><line x1="4" y1="9" x2="20" y2="9"></line><line x1="4" y1="15" x2="20" y2="15"></line><line x1="10" y1="3" x2="8" y2="21"></line><line x1="16" y1="3" x2="14" y2="21"></line></Icon>,
Hourglass: () => <Icon><path d="M5 22h14"/><path d="M5 2h14"/><path d="M14 22V15l-4-4-4 4v7"/><path d="M14 2v7l-4 4-4-4V2"/></Icon>,
Lightbulb: () => <Icon><path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A6 6 0 1 0 7.5 11.5c.76.76 1.23 1.52 1.41 2.5"/></Icon>,
Eye: () => <Icon><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></Icon>,
Flag: () => <Icon><path  d="M5 21V4h9l.4 2H20v10h-7l-.4-2H7v7z"/></Icon>,
FileText: () => <Icon><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></Icon>,
EmojiGood: () => <Icon><path d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10s-4.477 10-10 10"/><path d="M16.5 14.5s-1.5 2-4.5 2s-4.5-2-4.5-2"/><path  d="M15.5 9a.5.5 0 1 1 0-1a.5.5 0 0 1 0 1m-7 0a.5.5 0 1 1 0-1a.5.5 0 0 1 0 1"/></Icon>,
EmojiMid: () => <Icon><path d="M14 15h3m5-3c0 5.523-4.477 10-10 10S2 17.523 2 12S6.477 2 12 2s10 4.477 10 10"/><path  d="M8.5 9a.5.5 0 1 1 0-1a.5.5 0 0 1 0 1m7 0a.5.5 0 1 1 0-1a.5.5 0 0 1 0 1"/></Icon>,
EmojiBad: () => <Icon><path d="M8.5 14a.5.5 0 1 1 0-1a.5.5 0 0 1 0 1m7 0a.5.5 0 1 1 0-1a.5.5 0 0 1 0 1"/><path d="M10 18h4m8-6c0 5.523-4.477 10-10 10S2 17.523 2 12S6.477 2 12 2s10 4.477 10 10"/></Icon>,
};
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 (
<div>
<div className="header">
<div className="skeleton-block skeleton-title"></div>
<div className="skeleton-block skeleton-text" style={{width: '60%'}}></div>
<div className="skeleton-block skeleton-text" style={{width: '40%'}}></div>
</div>
<div className="admin-card" style={{borderLeft: '5px solid var(--border-color)'}}>
<div className="skeleton-block skeleton-card"></div>
<div className="skeleton-block skeleton-card" style={{height: '80px', opacity: 0.7}}></div>
<div className="skeleton-block skeleton-card" style={{height: '80px', opacity: 0.5}}></div>
</div>
</div>
);
}
// --- 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 (
<AuthContext.Provider value={{ user, userProfile, theme, login, logout, updateProfileData, cycleTheme }}>
{children}
</AuthContext.Provider>
);
}
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 (
<div className="flex justify-between items-center mb-1" style={{position: 'relative', zIndex: 100, borderBottom: '1px solid var(--border-color)', paddingBottom: '15px'}}>
<button className="btn-icon" onClick={() => navigate('home')} title="Accueil">
<Icons.Home />
</button>
<div className="flex gap-1 items-center">
<div className="flex gap-1">
<button className="btn-icon" onClick={cycleTheme} title="Changer de thème visuel">
<ThemeIcon />
</button>
</div>
{user ? (
<>
<NotificationBell navigate={navigate} />
<span
className="badge badge-interactive"
onClick={() => setShowProfile(true)}
style={{padding: '8px 15px'}}>
<Icons.User /> {userProfile?.username}
</span>
</>
) : (
route.path !== 'home' && (
<button className="btn btn-primary" onClick={() => navigate('home')}>
Se connecter
</button>
)
)}
</div>
</div>
);
}
// --- 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 (
<div className="container">
<Header
route={route}
navigate={navigate}
setShowProfile={setShowProfile}
/>
{route.path === 'home' && <HomeView navigate={navigate} />}
{route.path === 'global-user' && <GlobalUserView navigate={navigate} startGlobalFocus={startGlobalFocus} />}
{route.path === 'project' &&
<ProjectViewer
projectId={route.id}
navigate={navigate}
startGlobalFocus={startGlobalFocus}
useProjectData={useProjectData}
useAuth={useAuth}
pushNotify={pushNotify}
/>
}
{route.path === 'admin' && <AdminView projectId={route.id} navigate={navigate} />}
{showProfile && user && (
<UserProfileModal
uid={user}
onClose={() => setShowProfile(false)}
onLogout={() => {
logout();
setShowProfile(false);
navigate('home');
}}
/>
)}
{activeFocus && (
<FocusTimerOverlay
task={activeFocus.task}
onEndFocus={handleGlobalEndFocus}
user={user}
enableReview={activeFocus.enableReview}
userProfile={userProfile}
/>
)}
</div>
);
}
// --- 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 (
<div style={{ padding: '40px', textAlign: 'center', fontFamily: 'sans-serif' }}>
<h2 style={{ color: 'var(--danger)' }}>Oups, une erreur inattendue est survenue.</h2>
<p style={{ color: 'var(--gray-dark)', marginBottom: '20px' }}>
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.
</p>
<button
onClick={() => window.location.reload()}
style={{ padding: '10px 20px', cursor: 'pointer', background: 'var(--primary)', color: 'white', border: 'none', borderRadius: '5px' }}>
Recharger l'application
</button>
<details style={{ marginTop: '20px', whiteSpace: 'pre-wrap', textAlign: 'left', background: '#f8f9fa', padding: '10px', borderRadius: '5px', color: '#333' }}>
{this.state.error && this.state.error.toString()}
</details>
</div>
);
}
return this.props.children;
}
}
// --- APP ---
function App(props) {
return (
<AuthProvider>
<ErrorBoundary>
<AppContent {...props} />
</ErrorBoundary>
</AuthProvider>
);
}
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 (
<div className="center-box" style={{boxShadow: '0 20px 40px rgba(0,0,0,0.1)', border: 'none'}}>
<h1 style={{color: 'var(--primary)', fontSize: '3.5rem', marginBottom: '5px', display:'flex', alignItems:'center', justifyContent:'center', gap:'10px'}}>
<Icons.Activity /> Task.us
</h1>
<p className="mb-2" style={{color: 'var(--gray-dark)'}}>Gérez vos projets et votre temps, simplement.</p>
{authError && <div style={{color: 'var(--danger)', marginBottom: '10px', fontWeight: 'bold'}}>{authError}</div>}
<div className="form-group">
<input
value={username}
onChange={e=>setUsername(e.target.value)}
onKeyDown={e=>e.key==='Enter'&&handleAuth()}
placeholder="Pseudo"
style={{fontSize: '1.2rem', textAlign: 'center'}}
/>
</div>
<div className="form-group">
<input
type="password"
value={password}
onChange={e=>setPassword(e.target.value)}
onKeyDown={e=>e.key==='Enter'&&handleAuth()}
placeholder="Mot de passe"
style={{fontSize: '1.2rem', textAlign: 'center'}}
/>
</div>
<button className="btn btn-primary w-full" style={{fontSize: '1.2rem', padding: '15px'}} onClick={handleAuth}>
{isRegistering ? "Créer un compte" : "Se connecter"}
</button>
<div className="mt-2 text-center">
<a href="#" style={{color: 'var(--primary)', textDecoration: 'none', fontWeight: 'bold'}} onClick={(e) => { e.preventDefault(); setIsRegistering(!isRegistering); setAuthError(""); }}>
{isRegistering ? "Déjà un compte ? Se connecter" : "Nouveau ? Créer un compte"}
</a>
</div>
</div>
);
}
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 (
<div>
<div className="hero-section" style={{marginBottom: '20px', padding: '30px', borderRadius: '12px', background: 'var(--primary)', color: 'white', textAlign: 'center'}}>
<h1 style={{margin: '0 0 10px 0'}}>{greeting}, {userProfile?.username} !</h1>
<p style={{margin: '0 0 20px 0'}}>Prêt à avancer sur vos projets ?</p>
<button
className="btn"
style={{background: 'white', color: 'var(--primary)', fontSize: '1.1rem', fontWeight: 'bold'}}
onClick={() => navigate('global-user')}>
<Icons.List /> Mes Tâches Globales
</button>
</div>
<DailyCheckIn/>
<div className="flex gap-2 flex-wrap mb-2">
{}
<div className="admin-card flex-1" style={{width: '300px'}}>
<h3 className="mb-1" style={{color: 'var(--warning)'}}><Icons.Star /> Projets Favoris</h3>
<div style={{display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))', gap: '15px'}}>
{favoriteProjectsToDisplay.length > 0 ? favoriteProjectsToDisplay.map(project => (
<div
key={project.id}
className="admin-card"
style={{
margin: 0, padding: '15px', display: 'flex', flexDirection: 'column',
gap: '10px', borderTop: '4px solid var(--warning)'
}}>
<div style={{fontWeight: 'bold', fontSize: '1.2rem', wordBreak: 'break-word'}}>
{project.name}
</div>
<div style={{fontSize: '0.8rem', color: 'var(--gray-dark)'}}>
ID: {project.id}
</div>
<div className="flex gap-1 mt-1 w-full">
<button
className="btn btn-primary flex-1"
onClick={() => navigate('project', project.slug || project.id)}>
Ouvrir
</button>
<button
className="btn-icon"
style={{color: 'var(--danger)'}}
title="Retirer des favoris"
onClick={() => updateProfileData('favorites', favorites.filter(p => p !== project.id))}>
<Icons.X />
</button>
</div>
</div>
)) : (
<p style={{color: 'var(--gray-dark)'}}>Aucun projet en favoris.</p>
)}
</div>
</div>
{}
<div className="admin-card flex-1" style={{width: '300px'}}>
<h3 className="flex items-center gap-1 mb-2" style={{color: 'var(--primary)'}}>
<Icons.Folder /> Mes projets récents
</h3>
{otherProjects.length > 0
? (
<div style={{
display: 'flex',
flexDirection: 'column',
gap: '10px',
maxHeight: '300px',
overflowY: 'auto',
paddingRight: '5px'
}}>
{otherProjects.map(project => (
<div
key={project.id}
className="flex flex-wrap justify-between items-center"
style={{
padding: '12px 15px',
background: 'var(--bg-color)',
borderRadius: '8px',
borderLeft: '4px solid var(--primary)'
}}>
<div>
<div style={{fontWeight: 'bold'}}>{project.name}</div>
<div style={{fontSize: '0.8rem', color: 'var(--gray-dark)'}}>ID: {project.id}</div>
</div>
<div className="flex gap-1 items-center">
<button
className="btn-icon"
style={{color: 'var(--gray-dark)'}}
title="Ajouter aux favoris"
onClick={() => updateProfileData('favorites', [...favorites, project.id])}>
<Icons.Star />
</button>
<button
className="btn btn-outline"
onClick={() => navigate('project', project.slug || project.id)}>
Ouvrir
</button>
</div>
</div>
))}
</div>
)
: (
<p style={{color: 'var(--gray-dark)'}}>Aucun projet récent.</p>
)
}
</div>
{}
<div className="flex-1" style={{width: '300px', display: 'flex', flexDirection: 'column', gap: '15px'}}>
{}
<div className="admin-card" style={{margin: 0}}>
<h3 className="flex items-center gap-1 mb-2"><Icons.Plus /> Nouveau Projet</h3>
<div className="form-group">
<label>Nom du projet</label>
<input value={createName} onChange={e=>setCreateName(e.target.value)} onKeyDown={e=>e.key==='Enter'&&createProject()} placeholder="Ex: Refonte Site Web" />
</div>
<div className="form-group">
<label>Modèle</label>
<select value={template} onChange={e=>setTemplate(e.target.value)}>
<option value="blank">Vide (Défaut)</option>
{templates.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
</div>
<button
className="btn btn-primary w-full mt-1"
onClick={createProject}
disabled={!createName.trim()}>
Créer le projet
</button>
<div className="mt-2 text-center" style={{borderTop: '1px solid var(--border-color)', paddingTop: '15px'}}>
<label className="btn btn-outline w-full" style={{cursor: 'pointer'}}>
<Icons.Upload /> Importer JSON
<input type="file" style={{display: 'none'}} accept=".json" onChange={handleImportProject} />
</label>
</div>
</div>
{}
<div className="admin-card" style={{margin: 0}}>
<h3 className="flex items-center gap-1 mb-2"><Icons.Folder /> Rejoindre un Projet</h3>
<div className="form-group">
<label>ID du projet</label>
<input
value={joinId}
onChange={e => setJoinId(e.target.value)}
onKeyDown={e => e.key === 'Enter' && joinProject(joinId)}
placeholder="Ex: un-super-projet" />
</div>
<button
className="btn btn-outline w-full mt-1"
onClick={()=>joinProject(joinId)}
disabled={!joinId.trim()}>
Rejoindre / Ouvrir
</button>
</div>
</div>
</div>
</div>
);
}
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 (
<div className={`task-card ${isDone ? 'done' : ''} ${isReview ? 'review' : ''} ${task.isBlocked ? 'blocked' : ''}`} style={cardStyle}>
<div className="task-content">
{}
<h4 className="flex items-center gap-1" style={{margin: 0, fontSize: '1.1rem'}}>
{task.title || "Tâche sans titre"}
{isDone && <span style={{color:'var(--success)'}}><Icons.Check /></span>}
</h4>
<p style={{fontSize: '0.9rem', color: 'var(--gray-dark)', margin: '10px 0'}}>{task.description}</p>
{}
<div className="task-footer">
<div className="task-badges">
{task.isBlocked &&
<span className="badge" style={{background: 'var(--danger)', color: '#fff'}}>
<Icons.AlertTriangle /> {task.blockReason || "Bloqué"}
</span>
}
{isReview &&
<span className="badge" style={{background: 'var(--primary)', color: 'white'}}>
<Icons.Eye /> À valider
</span>
}
{projectName &&
<span className="badge badge-interactive" onClick={(e) => { e.stopPropagation(); navigate('project', task._projectId); }}>
<Icons.Folder /> {projectName}
</span>
}
<span className="badge badge-interactive" onClick={(e) => { e.stopPropagation(); if(navigateView) navigateView('categories', task.category); }}>
{task.category}
</span>
{task.plannedCost > 0 &&
<span className="badge" style={{ color: isDone ? 'var(--success)' : 'inherit', border: isDone ? '1px solid var(--success)' : '1px solid transparent' }}>
<Icons.Coins /> {task.plannedCost} €
</span>
}
{task.plannedMaterial &&
<span className="badge" style={{ color: isDone ? 'var(--success)' : 'inherit', border: isDone ? '1px solid var(--success)' : '1px solid transparent' }}>
<Icons.Package /> {task.materialQty || 1}x {task.plannedMaterial}
</span>
}
{task.timeSpent > 0 &&
<span className="badge" style={{color: 'var(--primary)', borderColor: 'var(--primary)'}}>
<Icons.Clock /> {formatTime(task.timeSpent)}
</span>
}
{task.deadline &&
<span className="badge badge-interactive" onClick={(e) => { e.stopPropagation(); if(navigateView) navigateView('gantt'); }}>
<Icons.Calendar /> {task.deadline}
</span>
}
{!hideAssigneeBadge && task.assignee && (
<span className={`badge badge-interactive ${isFocused ? 'slow-blink' : ''}`} onClick={(e) => { e.stopPropagation(); if(openProfile) openProfile(task.assignee); }}>
<Icons.User /> {assigneeProfile['username']}
</span>
)}
{subs.length > 0 && onOpenSubtasks && (
<span className="badge badge-interactive" style={{ background: subsDone === subs.length ? 'var(--success)' : 'var(--gray-light)', color: subsDone === subs.length ? '#fff' : 'var(--text-color)' }} onClick={(e) => { e.stopPropagation(); onOpenSubtasks(task); }}>
<Icons.CheckSquare /> {subsDone}/{subs.length}
</span>
)}
{task.attachment && (
<a href={task.attachment.startsWith('/') ? `${API_URL?.replace('/api','') || ''}${task.attachment}` : task.attachment} target="_blank" rel="noreferrer" className="badge badge-interactive" style={{color: 'var(--primary)', textDecoration: 'none'}} onClick={e => e.stopPropagation()}>
<Icons.Link /> Fichier
</a>
)}
{onOpenDiscussion && (
<span className="badge badge-interactive" onClick={(e) => { e.stopPropagation(); onOpenDiscussion(task); }}>
<Icons.MessageSquare /> {commentCount > 0 && commentCount}
</span>
)}
</div>
{}
{showActions && (
<div className="task-actions-inline">
{}
{showAssignBtn && (
<button className="btn-icon" onClick={(e) => { e.stopPropagation(); assignTask(task.id, user); }} title="S'assigner">
<Icons.UserPlus />
</button>
)}
{}
{isAssignee && (
<>
{showBlockBtn && (
<button className={`btn-icon ${task.isBlocked ? 'btn-icon-danger' : ''}`} style={task.isBlocked ? {color:'var(--danger)'} : {}} onClick={(e) => { e.stopPropagation(); toggleBlockTask(task.id); }} title={task.isBlocked ? "Débloquer" : "Bloquer"}>
<Icons.AlertTriangle />
</button>
)}
{showPlayBtn && (
<button className="btn-icon" onClick={(e) => { e.stopPropagation(); toggleInProgress(task.id); }} title="Passer en cours">
<Icons.Play />
</button>
)}
{showActiveBtns && (
<>
{startFocus && (
<button className="btn-icon" style={{color: 'var(--warning)'}} onClick={(e) => { e.stopPropagation(); startFocus(task); }} title="Lancer le chrono (Focus)">
<Icons.Clock />
</button>
)}
{completeTask && (
<button className="btn-icon btn-icon-success" style={{color: 'var(--success)'}} onClick={(e) => { e.stopPropagation(); completeTask(task.id); }} title={enableReview ? "Envoyer en validation" : "Valider la tâche"}>
{enableReview ? <Icons.ArrowUp /> : <Icons.Check />}
</button>
)}
{toggleInProgress && (
<button className="btn-icon" onClick={(e) => { e.stopPropagation(); toggleInProgress(task.id); }} title="Mettre en pause">
<Icons.Pause />
</button>
)}
</>
)}
{showUnassignBtn && (
<button className="btn-icon btn-icon-danger" style={{color: 'var(--danger)'}} onClick={(e) => { e.stopPropagation(); assignTask(task.id, false); }} title="Ne plus faire">
<Icons.UserMinus />
</button>
)}
</>
)}
{}
{canReview && approveTask && rejectTask && (
<>
<button className="btn-icon btn-icon-success" style={{color: 'var(--success)'}} onClick={(e) => { e.stopPropagation(); approveTask(task); }} title="Approuver le travail">
<Icons.CheckCircle />
</button>
<button className="btn-icon btn-icon-danger" style={{color: 'var(--danger)'}} onClick={(e) => { e.stopPropagation(); rejectTask(task); }} title="Rejeter (Renvoi en cours)">
<Icons.X />
</button>
</>
)}
{}
{showUndoBtn && (
<button className="btn-icon" onClick={(e) => { e.stopPropagation(); undoTask(task); }} title="Annuler la complétion">
<Icons.Undo />
</button>
)}
</div>
)}
</div>
</div>
</div>
);
}
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 <SkeletonProject />;
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 (
<div>
<div className="header flex justify-between items-center mb-2 flex-wrap">
<h2 style={{margin:0, display:'flex', alignItems:'center', gap:'8px'}}>
<Icons.Target /> Mes tâches
</h2>
</div>
{pinnedTask && (
<div className="pinned-task-container">
<div className="container" style={{padding: '0', maxWidth: '1200px', margin: '0 auto'}}>
<h4 style={{color: 'var(--danger)', marginBottom: '10px'}} className="flex items-center gap-1">
<Icons.AlertTriangle /> Tâche Prioritaire
</h4>
<TaskCard
task={pinnedTask}
projectName={pinnedTask._projectName}
isPriority={true}
{...commonCardProps}
/>
</div>
</div>
)}
<div className="admin-card" style={{borderLeft: '5px solid var(--warning)', paddingBottom: '30px'}}>
<h3 className="mb-2">Tâches en cours ({otherActiveTasks.length})</h3>
{otherActiveTasks.length === 0 ? <p style={{color:'var(--gray-dark)'}}>Aucune autre tâche en cours.</p> : (
<div className="task-list">
{otherActiveTasks.map((t, idx) => (
<TaskCard
key={`${t._projectId}-${t.id}`}
task={t}
projectName={t._projectName}
index={idx}
totalTasks={otherActiveTasks.length}
{...commonCardProps}
/>
))}
</div>
)}
</div>
{doneTasks.length > 0 && (
<div className="admin-card" style={{borderLeft: '5px solid var(--success)', marginTop: '20px'}}>
<details className="details-btn" style={{cursor: 'pointer'}}>
<summary className="btn btn-outline w-full text-center">Tâches terminées ({doneTasks.length})</summary>
<div className="task-list mt-1">
{doneTasks.map((t, idx) => (
<TaskCard
key={`${t._projectId}-${t.id}`}
task={t}
projectName={t._projectName}
index={idx}
totalTasks={doneTasks.length}
hideActions={true}
{...commonCardProps}
/>
))}
</div>
</details>
</div>
)}
{discussionTask && (
<DiscussionModal
task={discussionTask}
user={user}
isAdmin={false}
onClose={() => setDiscussionTask(null)}
projectUsers={[user]}
onUpdateTask={(updates, logMsg) => {
handleModalUpdate(discussionTask.id, discussionTask._projectId, updates, logMsg);
setDiscussionTask({...discussionTask, ...updates});
}}
/>
)}
{subtasksTask && (
<SubtaskModal
task={subtasksTask}
user={user}
onClose={() => setSubtasksTask(null)}
onUpdateTask={(updates, logMsg) => {
handleModalUpdate(subtasksTask.id, subtasksTask._projectId, updates, logMsg);
setSubtasksTask({...subtasksTask, ...updates});
}}
/>
)}
</div>
);
}
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 (
<div
className="admin-card flex flex-wrap justify-center text-center mb-2 gap-2"
style={{
border: '2px solid var(--primary)',
background: 'color-mix(in srgb, var(--primary) 5%, var(--card-bg))',
padding: '20px',
borderRadius: '12px'
}}>
<h3 className="flex items-center justify-center gap-1" style={{ color: 'var(--primary)', margin: 0, fontSize: '0.9rem'}}>
<Icons.Sun /> Comment te sens-tu aujourd'hui ?
</h3>
<div className="flex gap-2 justify-center flex-wrap" style={{marginLeft: '40px'}}>
<button
className={`btn-icon ${value === '😊' ? 'active' : ''}`}
style={style}
onClick={() => handleMoodSelect('😊', 'En pleine forme')}
title="En pleine forme"
onMouseEnter={e => e.currentTarget.style.transform = 'scale(1.1)'}
onMouseLeave={e => e.currentTarget.style.transform = 'scale(1)'}
>
<Icons.EmojiGood/>
</button>
<button
className={`btn-icon ${value === '😐' ? 'active' : ''}`}
style={style}
onClick={() => handleMoodSelect('😐', 'Normal')}
title="Normal"
onMouseEnter={e => e.currentTarget.style.transform = 'scale(1.1)'}
onMouseLeave={e => e.currentTarget.style.transform = 'scale(1)'}
>
<Icons.EmojiMid/>
</button>
<button
className={`btn-icon ${value === '😴' ? 'active' : ''}`}
style={style}
onClick={() => handleMoodSelect('😴', 'Fatigué(e)')}
title="Fatigué(e)"
onMouseEnter={e => e.currentTarget.style.transform = 'scale(1.1)'}
onMouseLeave={e => e.currentTarget.style.transform = 'scale(1)'}
>
<Icons.EmojiBad/>
</button>
</div>
</div>
);
}
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 (
<div style={{ position: 'relative' }}>
<button className={`btn-icon ${isOpen ? 'active' : ''}`} onClick={() => setIsOpen(!isOpen)} style={{ position: 'relative' }}>
<Icons.Bell />
{unreadCount > 0 && (
<span style={{
position: 'absolute', top: '-5px', right: '-5px',
background: 'var(--danger)', color: 'white', borderRadius: '50%',
width: '18px', height: '18px', fontSize: '0.7rem',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 'bold'
}}>
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</button>
{isOpen && (
<div style={{
position: 'absolute', top: '100%', right: 0, marginTop: '10px',
width: '320px', background: 'var(--card-bg)', border: '1px solid var(--border-color)',
borderRadius: '8px', boxShadow: '0 10px 25px rgba(0,0,0,0.1)', zIndex: 1000,
maxHeight: '400px', display: 'flex', flexDirection: 'column'
}}>
<div className="flex justify-between items-center" style={{ padding: '10px 15px', borderBottom: '1px solid var(--border-color)' }}>
<h4 style={{ margin: 0 }}>Notifications</h4>
{unreadCount > 0 && (
<button className="btn-icon" style={{ fontSize: '0.8rem', color: 'var(--primary)' }} onClick={markAllAsRead}>
<Icons.CheckCircle />
</button>
)}
</div>
<div style={{ overflowY: 'auto', flex: 1 }}>
{notifications.length === 0 ? (
<div style={{ padding: '20px', textAlign: 'center', color: 'var(--gray-dark)' }}>
Aucune notification.
</div>
) : (
notifications.map(n => (
<div key={n.id}
onClick={() => 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'
}}
>
<div style={{ fontSize: '0.75rem', color: 'var(--primary)', fontWeight: 'bold', marginBottom: '3px' }}>
{n.projectName}
</div>
<div style={{ fontSize: '0.9rem', color: 'var(--text-color)', fontWeight: n.isRead ? 'normal' : 'bold' }}>
{n.message}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--gray-dark)', marginTop: '5px' }}>
{new Date(n.date).toLocaleString('fr')}
</div>
</div>
))
)}
</div>
</div>
)}
</div>
);
}
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: <Icons.Stages />, label: 'Étapes' },
{ id: 'kanban', icon: <Icons.Kanban />, label: 'Kanban' },
{ id: 'categories', icon: <Icons.List />, label: 'Catégories' },
{ id: 'gantt', icon: <Icons.Calendar />, label: 'Planning' },
{ id: 'team', icon: <Icons.Users />, label: 'Équipe' },
{ id: 'budget', icon: <Icons.Euro />, label: 'Budget', disable: !data.features?.enableBudget},
{ id: 'inventory', icon: <Icons.Package />, label: 'Matériel', disable: !data.features?.enableInventory},
{ id: 'stats', icon: <Icons.PieChart />, label: 'Stats & Temps' },
{ id: 'history', icon: <Icons.History />, label: 'Historique' }
];
return (
<div className="nav-buttons">
{navItems.map(item => {
if (item.disable) return;
return (
<button
key={item.id}
className={currentView === item.id ? 'active' : ''}
onClick={() => onViewChange(item.id)}
>
{item.icon} {item.label}
</button>
)
})}
</div>
);
};
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 <SkeletonProject />;
if (error) return (
<div className="center-box" style={{color: 'var(--danger)'}}>
<h2>Erreur</h2>
<p>{error}</p>
<button className="btn btn-primary mt-1" onClick={()=>navigate('home')}>
Retour
</button>
</div>
);
if (needsPwd) return (
<div className="center-box">
<h2><Icons.Lock /> Projet Protégé</h2>
<p className="mb-1" style={{color:'var(--gray-dark)'}}>
Ce projet nécessite un mot de passe pour être visualisé.
</p>
<input
type="password"
value={pwdAttempt}
onChange={e => setPwdAttempt(e.target.value)}
onKeyDown={e => e.key === 'Enter' && fetchProject(pwdAttempt)}
placeholder="Mot de passe"
className="mb-1"
/>
<button className="btn btn-primary w-full" onClick={() => fetchProject(pwdAttempt)}>
Accéder
</button>
</div>
);
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 (
<div>
{!isOnline &&
<div className="offline-banner">
<Icons.WifiOff /> Mode Hors-Ligne. Changements limités ou en attente.
</div>
}
<div className="header">
<div className="header-top w-full">
<div>
<h2 className="flex flex-wrap items-center gap-1" style={{margin: 0}}>
{user &&
<button
className="btn-icon"
onClick={() => navigate('admin', projectId)}
style={{marginRight: '5px'}}
title="Panel administrateur">
<Icons.Settings />
</button>
}
<button
className={`btn-icon ${isFav ? 'active' : ''}`}
onClick={toggleFav} style={{marginRight: '5px'}}
title={isFav ? "Retirer des favoris" : "Ajouter aux favoris"}>
<Icons.Star />
</button>
{data.name}
</h2>
{data.description && <p style={{color:'var(--gray-dark)', fontSize:'0.9rem', marginTop:'5px'}}>{data.description}</p>}
</div>
</div>
<NavigationMenu data={data} currentView={view} onViewChange={setView} />
</div>
{view === 'stages' &&
<StagesView
{...commonViewProps}
isStageLocked={(order) => isStageLockedLogic(order, data.stages, data.tasks)}
onOpenDiscussion={setDiscussionTask}
onOpenSubtasks={setSubtasksTask}
/>
}
{view === 'kanban' && <KanbanView {...commonViewProps} onOpenDiscussion={setDiscussionTask} onOpenSubtasks={setSubtasksTask} />}
{view === 'categories' && <CategoriesView {...commonViewProps} onOpenDiscussion={setDiscussionTask} onOpenSubtasks={setSubtasksTask} />}
{view === 'gantt' && <GanttView {...commonViewProps} onOpenDiscussion={setDiscussionTask} onOpenSubtasks={setSubtasksTask} />}
{view === 'team' && <TeamView data={data} openProfile={setProfileUser} />}
{view === 'budget' && <BudgetView data={data} user={user} updateProjectData={actions.updateProjectData} addExpense={actions.addExpense} />}
{view === 'inventory' && <InventoryView data={data} user={user} updateProjectData={actions.updateProjectData} />}
{view === 'stats' && <StatsView projectId={projectId} data={data} />}
{view === 'history' && <HistoryView data={data} />}
{discussionTask && (
<DiscussionModal
task={discussionTask}
user={user}
isAdmin={false}
onClose={() => setDiscussionTask(null)}
projectUsers={projectUsers}
onUpdateTask={(updates, logMsg) => {
actions.updateTask(discussionTask.id, updates, logMsg);
setDiscussionTask({...discussionTask, ...updates});
}}
/>
)}
{subtasksTask && (
<SubtaskModal
task={subtasksTask}
user={user}
onClose={() => setSubtasksTask(null)}
toggleSubtask={actions.toggleSubtask}
onUpdateTask={(updates, logMsg) => {
actions.updateTask(subtasksTask.id, updates, logMsg);
setSubtasksTask({...subtasksTask, ...updates});
}}
/>
)}
{profileUser && (
<UserProfileModal
uid={profileUser}
onClose={() => setProfileUser(null)}
/>
)}
{focusUsers.length > 0 && (
<div className="group-focus-banner">
<span className="slow-blink" style={{border: "none", color: 'var(--danger)', fontSize: '1.2rem'}}>●</span>
<span>{focusUsers.length} en ligne</span>
</div>
)}
</div>
);
}
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 = `
<!DOCTYPE html>
<html><head><title>Rapport Financier - ${data.name}</title>
<style>
body { font-family: system-ui, sans-serif; padding: 40px; color: #334155; line-height: 1.6; }
h1 { color: #4f46e5; border-bottom: 2px solid #e2e8f0; padding-bottom: 10px; margin-bottom: 5px;}
.subtitle { color: #64748b; font-size: 0.9rem; margin-bottom: 30px; }
.grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 20px; margin-bottom: 30px; }
.box { padding: 20px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; font-size: 1.1rem; }
.box strong { display: block; font-size: 0.9rem; color: #64748b; margin-bottom: 5px; text-transform: uppercase; }
.val { font-size: 1.8rem; font-weight: bold; }
.success { color: #10b981; } .warning { color: #f59e0b; } .danger { color: #ef4444; }
table { width: 100%; border-collapse: collapse; margin-top: 15px; margin-bottom: 30px;}
th, td { border: 1px solid #e2e8f0; padding: 12px; text-align: left; }
th { background: #f1f5f9; color: #475569; font-weight: 600; }
tr:nth-child(even) { background: #f8fafc; }
</style>
</head><body>
<h1>Rapport Financier : ${data.name}</h1>
<div class="subtitle">Généré le ${new Date().toLocaleString()}</div>
<div class="grid">
<div class="box">
<strong>Budget Global</strong>
<div class="val">${totalBudget > 0 ? totalBudget + ' €' : 'Non défini'}</div>
</div>
<div class="box">
<strong>Total Dépensé (Validé)</strong>
<div class="val ${totalBudget > 0 && totalSpent > totalBudget ? 'danger' : 'success'}">${totalSpent} €</div>
</div>
<div class="box">
<strong>Coûts Futurs (Prévus)</strong>
<div class="val warning">${totalPlanned} €</div>
</div>
</div>
<h3>Dépenses Validées</h3>
<table>
<thead><tr><th>Date / Référence</th><th>Description</th><th>Montant</th></tr></thead>
<tbody>
${allExpenses.length === 0 ? '<tr><td colspan="3" style="text-align:center;">Aucune dépense enregistrée.</td></tr>' : ''}
${allExpenses.map(e => `<tr><td>${e.date ? new Date(e.date).toLocaleDateString("fr") : 'N/A'}</td><td>${e.label}</td><td><strong>${e.amount} €</strong></td></tr>`).join('')}
</tbody>
</table>
${plannedTaskExpenses.length > 0 ? `
<h3>Dépenses Futures (Selon tâches en attente)</h3>
<table>
<thead><tr><th>Description de la tâche</th><th>Montant Estimé</th></tr></thead>
<tbody>
${plannedTaskExpenses.map(e => `<tr><td>${e.label}</td><td><strong>${e.amount} €</strong></td></tr>`).join('')}
</tbody>
</table>
` : ''}
</body></html>`;
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 (
<div className="admin-card mt-2" style={{ border: '1px solid var(--danger)', borderTopWidth: '4px' }}>
<h3 className="flex items-center gap-1 text-danger mb-2">
Paramètres Sensibles du Projet
</h3>
<div className="flex flex-wrap gap-2">
{}
<div className="flex-1" style={style}>
<h4 className="mb-1">URL Personnalisée</h4>
<p style={{
fontSize: '0.85rem',
color: 'var(--gray-dark)',
marginBottom: '15px',
}}>
Choisissez un texte facile à retenir pour le lien du projet.
</p>
<div className="form-group flex items-center" style={{
borderRadius: '6px',
background: "var(--gray-light)",
fontSize: '0.80rem',
}}>
<span style={{
padding: '5px',
background: 'var(--gray-light)',
borderTopLeftRadius: '6px',
borderBottomLeftRadius: '6px',
color: 'var(--gray-dark)'
}}>
/#projects/
</span>
<input
type="text"
value={slug}
onChange={e => setSlug(e.target.value)}
style={{ borderTopLeftRadius: 0, borderBottomLeftRadius: 0, flex: 1 }}
placeholder="mon-super-projet"
/>
</div>
</div>
{}
<div className="flex-1" style={style}>
<h4 className="mb-1">Changer le mot de passe Admin</h4>
<p style={{ fontSize: '0.85rem', color: 'var(--gray-dark)', marginBottom: '15px' }}>
Laissez vide si vous ne souhaitez pas le changer.
</p>
<div className="form-group mb-1">
<input
type="password"
placeholder="Nouveau mot de passe"
value={newAdminPwd}
onChange={e => setNewAdminPwd(e.target.value)}
/>
</div>
<div className="form-group">
<input
type="password"
placeholder="Confirmer le nouveau mot de passe"
value={confirmAdminPwd}
onChange={e => setConfirmAdminPwd(e.target.value)}
/>
</div>
</div>
{}
<div className="flex-1" style={style}>
<h4 className="mb-1">Changer le mot de passe Public</h4>
<p style={{ fontSize: '0.85rem', color: 'var(--gray-dark)', marginBottom: '15px' }}>
Laissez vide si vous ne souhaitez pas le changer.
</p>
<div className="form-group mb-1">
<input
type="password"
placeholder="Nouveau mot de passe"
value={newPublicPwd}
onChange={e => setNewPublicPwd(e.target.value)}
/>
</div>
<div className="form-group">
<input
type="password"
placeholder="Confirmer le nouveau mot de passe"
value={confirmPublicPwd}
onChange={e => setConfirmPublicPwd(e.target.value)}
/>
</div>
</div>
</div>
{status.msg && (
<div
className="mt-2"
style={{
padding: '10px',
borderRadius: '6px',
background: status.type === 'error' ? 'var(--danger)' : 'var(--success)',
color: 'white',
textAlign: 'center'
}}>
{status.msg}
</div>
)}
<button
className="btn btn-outline mt-2"
style={{color: 'var(--danger)', borderColor: 'var(--danger)'}}
onClick={handleDeleteProject}>
<Icons.Trash /> Supprimer ce projet
</button>
<div className="flex justify-end mt-2">
<button className="btn btn-danger" onClick={handleSaveAdminSettings} style={{ padding: '10px 30px' }}>
<Icons.Check /> Enregistrer
</button>
</div>
</div>
);
}
// --- 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 (
<div className="center-box">
<h2><Icons.Lock /> Projet Protégé</h2>
<p className="mb-2">Les paramètres d'administration nécessitent le mot de passe administrateur.</p>
{error && <p className="mb-1" style={{color:'var(--danger)'}}>{error}</p>}
<input
type="password"
value={editPwd}
onChange={e => setEditPwd(e.target.value)}
onKeyDown={e => e.key === 'Enter' && fetchProject(editPwd)}
placeholder="Mot de passe"
className="mb-1" />
<button className="btn btn-primary w-full mb-1" onClick={() => fetchProject(editPwd)}>Accéder à l'administration</button>
<button className="btn btn-primary" onClick={() => navigate('project', projectId)}>Aller au projet</button>
</div>
);
if (!data) return <SkeletonProject />;
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 (
<div key={task.id} className="flex gap-1 items-start mb-2 pb-2 admin-task-inputs" style={{borderBottom: '1px dashed var(--border-color)'}}>
<div className="flex flex-col gap-1 mt-1 flex-shrink-0">
<button className="btn-icon" onClick={() => moveTaskWithinStage(task, -1)} disabled={tIdx === 0}><Icons.ArrowUp /></button>
<button className="btn-icon" onClick={() => moveTaskWithinStage(task, 1)} disabled={tIdx === stageTasks.length - 1}><Icons.ArrowDown /></button>
</div>
<div className="flex-col w-full gap-1" style={{borderLeft: `6px solid color-mix(in srgb, ${baseColor} ${opacity*100}%, transparent)`, paddingLeft: '10px', minWidth: 0}}>
<div className="flex gap-1">
<input
className="w-full"
value={task.title}
onChange={e => 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'}} />
<button
className="btn-icon"
style={{color: 'var(--danger)'}}
onClick={() => updateData({...data, tasks: data.tasks.filter(t => t.id !== task.id)})}>
<Icons.Trash />
</button>
</div>
<textarea
value={task.description || ''}
onChange={e => updateData({
...data,
tasks: data.tasks.map(t => t.id === task.id ? { ...t, description: e.target.value } : t)
})}
placeholder="Description de la tâche..."
rows={2}
style={{
width: '100%',
resize: 'vertical',
fontSize: '0.9rem',
marginTop: '5px',
fontFamily: 'inherit'
}}
/>
<div className="md-flex-row mt-1" style={{background: 'var(--bg-color)', padding: '5px 10px', borderRadius: '6px'}}>
<div className="flex items-center gap-1 flex-1 min-w-0">
<Icons.Euro />
<input type="number" min="0" value={task.plannedCost || ''} onChange={e => updateData({...data, tasks: data.tasks.map(t => t.id === task.id ? { ...t, plannedCost: parseFloat(e.target.value) || 0 } : t)})} placeholder="Coût prévu (€)" style={{border: 'none', padding: '5px', flex:1, background:'transparent'}} />
</div>
<div className="flex items-center gap-1 flex-1 min-w-0">
<Icons.Package />
<input type="number" min="1" value={task.materialQty || ''} onChange={e => updateData({...data, tasks: data.tasks.map(t => t.id === task.id ? { ...t, materialQty: parseInt(e.target.value) || 1 } : t)})} placeholder="Qté" style={{border: 'none', padding: '5px', width: '60px', background:'transparent'}} />
<input value={task.plannedMaterial || ''} onChange={e => updateData({...data, tasks: data.tasks.map(t => t.id === task.id ? { ...t, plannedMaterial: e.target.value } : t)})} placeholder="Matériel nécessaire..." style={{border: 'none', padding: '5px', flex:1, background:'transparent'}} />
</div>
</div>
<div className="md-flex-row mt-1">
<div className="flex items-center gap-1 w-full" style={{background: 'var(--bg-color)', padding: '5px 10px', borderRadius: '6px'}}>
<Icons.Link />
<input value={task.attachment || ''} onChange={e => updateData({...data, tasks: data.tasks.map(t => t.id === task.id ? { ...t, attachment: e.target.value } : t)})} placeholder="Lien HTTP, ou bouton Envoyer ➡️" style={{border: 'none', padding: '5px', background:'transparent', flex: 1}} />
<label className="btn btn-outline" style={{cursor: 'pointer', padding: '5px 10px'}}>
<Icons.Upload /> <span className="hide-mobile">Envoyer</span>
<input type="file" style={{display: 'none'}} onChange={(e) => handleFileUpload(e, task.id)} />
</label>
</div>
</div>
{task.isBlocked && (
<div style={{background: 'color-mix(in srgb, var(--danger) 10%, transparent)', padding: '10px', borderRadius: '6px', color: 'var(--danger)', fontWeight: 'bold', display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '5px'}}>
<span><Icons.AlertTriangle /> Bloqué : {task.blockReason}</span>
<button className="btn btn-danger" style={{padding: '5px 10px'}} onClick={() => updateData({...data, tasks: data.tasks.map(t => t.id === task.id ? {...t, isBlocked: false, blockReason: ''} : t)})}>Débloquer</button>
</div>
)}
<div style={{background: 'var(--bg-color)', padding: '10px', borderRadius: '6px', marginTop: '5px'}}>
<p style={{margin: '0 0 5px 0', fontSize: '0.85rem', fontWeight: 'bold'}}>Sous-tâches</p>
{(task.subtasks || []).map((sub) => (
<div key={sub.id} className="flex justify-between items-center mb-1" style={{fontSize: '0.85rem'}}>
<span style={{wordBreak: 'break-word'}}>- {sub.text}</span>
<button className="btn-icon flex-shrink-0" style={{width: '24px', height: '24px', color: 'var(--danger)'}} onClick={() => updateData({...data, tasks: data.tasks.map(t => t.id === task.id ? {...t, subtasks: t.subtasks.filter(s => s.id !== sub.id)} : t)})}><Icons.X /></button>
</div>
))}
<div className="flex gap-1 mt-1">
<input value={newSubtext[task.id] || ''} onChange={e => setNewSubtext({...newSubtext, [task.id]: e.target.value})} onKeyDown={e => e.key === 'Enter' && addSubtask(task.id)} placeholder="Ajouter une étape..." style={{padding: '6px', fontSize: '0.85rem', background:'var(--card-bg)'}} />
<button className="btn btn-outline btn-icon" style={{width:'32px', height:'32px'}} onClick={() => addSubtask(task.id)}><Icons.Plus /></button>
</div>
</div>
<div className="md-flex-row mt-1">
<select value={task.category} onChange={e => updateData({...data, tasks: data.tasks.map(t => t.id === task.id ? { ...t, category: e.target.value } : t)})} style={{flex:1}}>
{(data.categories||[]).map(c => <option key={c} value={c}>{c}</option>)}
{!(data.categories||[]).includes(task.category) && <option value={task.category}>{task.category}</option>}
</select>
<div className="flex flex-col" style={{flex: 1}}>
<div className="flex items-center gap-1">
<Icons.Calendar />
<input type="date" value={task.deadline || ''} onChange={e => updateData({...data, tasks: data.tasks.map(t => t.id === task.id ? { ...t, deadline: e.target.value } : t)})} style={{flex: 1}}/>
</div>
{dateWarningMsg && <div style={{fontSize: '0.75rem', color: 'var(--danger)', fontWeight: 'bold', marginTop: '2px'}}>{dateWarningMsg}</div>}
</div>
<div className="flex items-center gap-1" title="Étape" style={{flex:1}}>
<Icons.Stages />
<select value={task.stageId} onChange={e => updateData({...data, tasks: data.tasks.map(t => t.id === task.id ? { ...t, stageId: parseInt(e.target.value) } : t)})}>
{(data.stages||[]).sort((a,b)=>a.order-b.order).map(s => <option key={s.id} value={s.id}>Étape {s.order}</option>)}
</select>
</div>
<div className="flex items-center gap-1" title="Récurrence" style={{flex:1}}>
<Icons.Repeat />
<select value={task.recurrence || 'none'} onChange={e => updateData({...data, tasks: data.tasks.map(t => t.id === task.id ? { ...t, recurrence: e.target.value } : t)})}>
<option value="none">Aucune</option>
<option value="daily">Quotidienne</option>
<option value="weekly">Hebdo</option>
<option value="monthly">Mensuelle</option>
</select>
</div>
</div>
</div>
</div>
);
};
return (
<div>
{adminError && <div className="toast-error">{adminError}</div>}
{hasUnsavedChanges &&
<div
className="floating-save-btn">
<button
className="btn btn-success"
onClick={handleSaveAll}>
<Icons.Check /> Sauvegarder
</button>
</div>
}
<div className="header">
<div className="w-full flex-wrap">
<div>
<h2 className="flex items-center gap-1">
<button className="btn-icon active" onClick={() => navigate('project', projectId)} style={{marginRight: '10px'}} title="Retour au projet"><Icons.Settings /></button>
Panel admin : {data.name}
</h2>
</div>
<div className="flex gap-1 items-center flex-wrap">
<span className="badge">Lien Public</span>
<a href={publicLink} target="_blank" rel="noopener noreferrer" style={{color: 'var(--primary)', wordBreak: 'break-all'}}>
{publicLink}
</a>
</div>
</div>
</div>
<div className="admin-card">
<ProjectAdminSettings data={data} editPwd={editPwd} navigate={navigate} projectId={projectId} updateProjectData={saveData} />
<h3>Informations et Sécurité</h3>
<div className="form-group"><label>Nom du projet</label><input value={data.name} onChange={e => updateData({...data, name: e.target.value})} /></div>
<div className="form-group"><label>Description du projet</label><textarea value={data.description || ''} onChange={e => updateData({...data, description: e.target.value})} placeholder="Objectif, liens utiles, notes..."></textarea></div>
<div className="form-group">
<label>Visibilité Globale</label>
<select
value={data.privacyLevel || 'public_anon'}
onChange={e => updateData({...data, privacyLevel: e.target.value})}>
<option value="public_anon">Ouvert à tous (Lien Public)</option>
<option value="public_auth">Fermé (Comptes Enregistrés Uniquement)</option>
</select>
</div>
<div className="flex gap-2 flex-wrap mb-1">
<div className="form-group flex-1"><label>Limite de tâches par utilisateur (0 = illimité)</label><input type="number" min="0" value={data.maxTasksPerUser || 0} onChange={e => updateData({...data, maxTasksPerUser: parseInt(e.target.value) || 0})} /></div>
<div className="form-group flex-1"><label>Budget global (€)</label><input type="number" min="0" value={data.budgetLimit || 0} onChange={e => updateData({...data, budgetLimit: parseInt(e.target.value) || 0})} placeholder="0 = illimité" /></div>
</div>
<div className="form-group mt-2">
<label className="flex items-center gap-1"><Icons.Activity /> Webhooks (Discord & Matrix)</label>
<div className="flex flex-col gap-1 mb-1">
<input type="text" value={data.webhookUrl || ''} onChange={e => updateData({...data, webhookUrl: e.target.value})} placeholder="URL Discord (https://discord.com/api/webhooks/...)" />
<input type="text" value={data.matrixWebhookUrl || ''} onChange={e => updateData({...data, matrixWebhookUrl: e.target.value})} placeholder="URL Matrix (Hookshot / Bridge...)" />
</div>
<div className="flex gap-2 flex-wrap mt-1" style={{fontSize: '0.9rem', color: 'var(--gray-dark)', background: 'var(--gray-light)', padding: '15px', borderRadius: '8px'}}>
<div style={{width: '100%', marginBottom: '5px'}}><strong style={{color: 'var(--text-color)'}}>Événements qui déclenchent les Webhooks :</strong></div>
<label className="checkbox-label mb-0"><input type="checkbox" checked={data.webhookEvents?.created !== false} onChange={e => updateData({...data, webhookEvents: {...(data.webhookEvents||{}), created: e.target.checked}})} /> Création</label>
<label className="checkbox-label mb-0"><input type="checkbox" checked={data.webhookEvents?.done !== false} onChange={e => updateData({...data, webhookEvents: {...(data.webhookEvents||{}), done: e.target.checked}})} /> Validation</label>
<label className="checkbox-label mb-0"><input type="checkbox" checked={data.webhookEvents?.started !== false} onChange={e => updateData({...data, webhookEvents: {...(data.webhookEvents||{}), started: e.target.checked}})} /> Mises en cours</label>
<label className="checkbox-label mb-0"><input type="checkbox" checked={data.webhookEvents?.commented !== false} onChange={e => updateData({...data, webhookEvents: {...(data.webhookEvents||{}), commented: e.target.checked}})} /> Commentaires</label>
<label className="checkbox-label mb-0"><input type="checkbox" checked={data.webhookEvents?.files !== false} onChange={e => updateData({...data, webhookEvents: {...(data.webhookEvents||{}), files: e.target.checked}})} /> Ajout fichiers</label>
<label className="checkbox-label mb-0"><input type="checkbox" checked={data.webhookEvents?.blocked !== false} onChange={e => updateData({...data, webhookEvents: {...(data.webhookEvents||{}), blocked: e.target.checked}})} /> Blocages</label>
<label className="checkbox-label mb-0"><input type="checkbox" checked={data.webhookEvents?.deleted !== false} onChange={e => updateData({...data, webhookEvents: {...(data.webhookEvents||{}), deleted: e.target.checked}})} /> Suppressions</label>
</div>
</div>
<div className="form-group mt-2">
<label>Fonctionnalités & Catégories</label>
<div style={{
background: 'var(--gray-light)',
padding: '15px',
borderRadius: '8px',
marginBottom: '15px'
}}>
<label className="checkbox-label mb-0">
<input
style={{marginRight: '10px'}}
type="checkbox"
checked={data.features?.enableReview || false}
onChange={e => toggleFeature('enableReview', e.target.checked)} />
<strong>Activer l'étape "À valider"</strong> : Les tâches devront être approuvées par un pair avant d'être terminées.
</label>
</div>
<div style={{
background: 'var(--gray-light)',
padding: '15px',
borderRadius: '8px',
marginBottom: '15px'
}}>
<label className="checkbox-label mb-0">
<input
style={{marginRight: '10px'}}
type="checkbox"
checked={data.features?.enableBudget || false}
onChange={e => toggleFeature('enableBudget', e.target.checked)} />
<strong>Budget</strong> : Affiche l'onglet budget.
</label>
</div>
<div style={{
background: 'var(--gray-light)',
padding: '15px',
borderRadius: '8px',
marginBottom: '15px'
}}>
<label className="checkbox-label mb-0">
<input
style={{marginRight: '10px'}}
type="checkbox"
checked={data.features?.enableInventory || false}
onChange={e => toggleFeature('enableInventory', e.target.checked)} />
<strong>Inventaire</strong> : Affiche l'onglet inventaire.
</label>
</div>
<div className="flex flex-wrap gap-1 mb-1">
{(data.categories || []).map(c =>
<span key={c} className="badge flex items-center gap-1">
{c}
<button
className="btn-icon"
style={{width:'20px', height:'20px', padding:0, background:'transparent'}}
onClick={() => updateData({...data, categories: data.categories.filter(x=>x!==c)})}>
<Icons.X />
</button>
</span>)
}
</div>
<div className="flex flex-wrap gap-1 mt-1">
<select
value=""
onChange={e => {
if (e.target.value)
updateData({...data, categories: [...(data.categories||[]), e.target.value]});
}}>
<option value="">+ Ajouter une catégorie globale</option>
{missingGlobals.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
</div>
<div className="mt-2 pt-2 flex justify-between flex-wrap gap-1" style={{borderTop: '1px solid var(--border-color)'}}>
<div className="flex gap-1 flex-wrap">
<button className="btn btn-outline" onClick={() => exportProjectAsJSON(data)}><Icons.Download /> JSON</button>
<button className="btn btn-outline" onClick={() => exportProjectAsPDF(data)}><Icons.FileText /> PDF (Rapport Financier)</button>
</div>
</div>
</div>
<div className="flex justify-between items-center mb-1 mt-2">
<h2>Jalons</h2>
</div>
<div className="admin-card">
<p style={{fontSize: '0.85rem', color: 'var(--gray-dark)', marginBottom: '15px'}}>
Les jalons s'affichent sous forme de losanges sur le planning.
</p>
{}
<div className="form-group flex items-center gap-1 mb-2 flex-wrap">
<input
type="text"
placeholder="Titre du jalon"
value={newEventTitle}
onChange={e => setNewEventTitle(e.target.value)}
style={{flex: 1, minWidth: '200px'}}
/>
<input
type="date"
value={newEventDate}
onChange={e => setNewEventDate(e.target.value)}
style={{flex: 1, minWidth: '200px'}}
/>
<button
className="btn btn-primary"
style={{flex: 1, minWidth: '200px'}}
onClick={() => {
if(!newEventTitle || !newEventDate) return;
const newEvt = {
id: Date.now(),
title: newEventTitle,
date: new Date(newEventDate).toISOString()
};
// On met à jour le projet global (sera envoyé lors du clic sur Sauvegarder)
updateData({...data, events: [...(data.events||[]), newEvt]});
setNewEventTitle("");
setNewEventDate("");
}}>
<Icons.Plus /> Ajouter
</button>
</div>
{}
{(data.events || []).length === 0 ? (
<p style={{color: 'var(--gray-dark)', fontStyle: 'italic'}}>Aucun jalon défini pour le moment.</p>
) : (
<div style={{border: '1px solid var(--border-color)', borderRadius: '8px', overflow: 'hidden'}}>
{(data.events || []).sort((a,b) => new Date(a.date) - new Date(b.date)).map(evt => (
<div key={evt.id} className="flex justify-between items-center" style={{padding: '10px 15px', borderBottom: '1px solid var(--border-color)', background: 'var(--card-bg)'}}>
<div>
<strong style={{color: 'var(--danger)'}}>♦ {evt.title}</strong>
<div style={{fontSize: '0.85rem', color: 'var(--gray-dark)', marginTop: '2px'}}>
Prévu le {new Date(evt.date).toLocaleDateString('fr')}
</div>
</div>
<button
className="btn-icon"
style={{color: 'var(--danger)'}}
title="Supprimer ce jalon"
onClick={() => {
if(window.confirm(`Retirer le jalon "${evt.title}" ?`)) {
updateData({...data, events: data.events.filter(e => e.id !== evt.id)});
}
}}>
<Icons.Trash />
</button>
</div>
))}
</div>
)}
</div>
<div className="flex justify-between items-center mb-1 mt-2">
<h2>Étapes</h2>
<button
className="btn btn-primary"
onClick={() => updateData({
...data,
stages: [...(data.stages||[]), { id: Date.now(), order: (data.stages||[]).length + 1, name: "Nouvelle Étape", description: "" }]}
)}>
<Icons.Plus /> Ajouter Étape
</button>
</div>
{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 (
<div key={stage.id} className="admin-card mb-2" style={{borderLeft: '5px solid var(--primary)'}}>
<div className="flex gap-1 items-center mb-1">
<div className="flex flex-col gap-1"><button className="btn-icon" onClick={() => moveStage(sIdx, -1)} disabled={sIdx === 0}><Icons.ArrowUp /></button><button className="btn-icon" onClick={() => moveStage(sIdx, 1)} disabled={sIdx === sortedStages.length - 1}><Icons.ArrowDown /></button></div>
<div className="flex-col w-full gap-1">
<input
value={stage.name}
onChange={e => updateData({...data, stages: data.stages.map(s => s.id === stage.id ? { ...s, name: e.target.value } : s)})}
style={{fontWeight: 'bold', fontSize: '1.1rem'}} />
<input
value={stage.description || ''}
onChange={e => updateData({...data, stages: data.stages.map(s => s.id === stage.id ? { ...s, description: e.target.value } : s)})}
placeholder="Description de l'étape" />
</div>
<button className="btn-icon" style={{color: 'var(--danger)'}} onClick={() => updateData({...data, stages: data.stages.filter(s => s.id !== stage.id)})}><Icons.Trash /></button>
</div>
<div className="mt-2 pt-2" style={{borderTop: '1px solid var(--border-color)', paddingLeft: '40px'}}>
{activeTasks.map(
(task) => renderAdminTask(task, stageTasks, stageTasks.findIndex(t => t.id === task.id)))
}
{doneTasks.length > 0 && (
<details className="mt-2 details-btn" style={{cursor: 'pointer'}}><summary className="btn btn-outline w-full text-center">Tâches terminées ({doneTasks.length})</summary><div className="mt-1">{doneTasks.map((task) => renderAdminTask(task, stageTasks, stageTasks.findIndex(t => t.id === task.id)))}</div></details>
)}
</div>
<div className="mt-2" style={{marginLeft: '10px'}}>
<button className="btn btn-outline w-full" onClick={() => addTaskToStage(stage.id)}>
<Icons.Plus /> Ajouter une tâche ici
</button>
</div>
</div>
);
})}
<div className="admin-card mt-2">
<h3><Icons.MessageSquare /> Modération des discussions</h3>
{allComments.length === 0 ? <p style={{color: 'var(--gray-dark)'}}>Aucun message sur ce projet.</p> : (
<div style={{maxHeight: '400px', overflowY: 'auto'}}>
{allComments.map(c => (
<div key={`${c.taskId}-${c.id}`} style={{borderBottom: '1px solid var(--border-color)', padding: '10px 0', display: 'flex', justifyContent: 'space-between'}}>
<div>
<div style={{fontSize: '0.8rem', color: 'var(--gray-dark)'}}>Dans la tâche : <strong>{c.taskTitle}</strong> - par <strong>{c.author}</strong> le {new Date(c.date).toLocaleString()}</div>
<div style={{marginTop: '5px'}}>{c.text}</div>
</div>
<button className="btn-icon" style={{color: 'var(--danger)'}} onClick={() => deleteGlobalComment(c.taskId, c.id)}><Icons.Trash /></button>
</div>
))}
</div>
)}
</div>
</div>
);
}
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 (
<div className="admin-card">
<h3 className="flex items-center gap-1 mb-2"><Icons.Euro /> Gestion du Budget</h3>
{}
<div style={{background: 'var(--bg-color)', padding: '20px', borderRadius: '8px', marginBottom: '20px'}}>
<div style={{display: 'flex', gap:'20px', flexWrap: 'wrap', marginBottom: budgetLimit > 0 ? '15px' : '0'}}>
<div>
<strong className="flex items-center gap-1" style={{color: budgetLimit > 0 && totalActualSpent > budgetLimit ? 'var(--danger)' : 'var(--success)'}}>
<Icons.CheckCircle /> Total Dépensé : {totalActualSpent} €
</strong>
</div>
<div>
<strong className="flex items-center gap-1" style={{color: 'var(--warning)'}}>
<Icons.Hourglass /> Reste Prévu : {totalPlannedSpent} €
</strong>
</div>
{budgetLimit > 0 && (
<div style={{width:'100%', color: 'var(--gray-dark)', fontSize: '0.9rem', marginTop: '-5px'}}>
Budget initial alloué : {budgetLimit} €
</div>
)}
</div>
{}
{budgetLimit > 0 && (
<div style={{width: '100%', height: '12px', background: 'var(--gray-light)', borderRadius: '6px', overflow: 'hidden', display: 'flex'}}>
<div style={{width: `${spentPercent}%`, background: totalActualSpent > budgetLimit ? 'var(--danger)' : 'var(--success)', transition: 'width 0.3s ease'}} title={`Dépensé: ${totalActualSpent}€`} />
<div style={{width: `${plannedPercent}%`, background: 'var(--warning)', transition: 'width 0.3s ease'}} title={`Prévu: ${totalPlannedSpent}€`} />
</div>
)}
</div>
{}
{user && (
<div className="flex gap-1 flex-wrap mb-2" style={{alignItems: 'center'}}>
<input type="text" placeholder="Description de l'achat manuel..." value={label} onChange={e => setLabel(e.target.value)} style={{flex: 2, minWidth: '200px'}} />
{}
<input type="number" placeholder="Montant €" value={amount} onChange={e => setAmount(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} style={{flex: 1, minWidth: '100px'}} />
{}
<button className="btn btn-primary" onClick={handleAdd} disabled={!label || !amount}>
Ajouter
</button>
</div>
)}
{}
{actualExpenses.length > 0 && <h4 className="mt-2 mb-1" style={{color: 'var(--success)'}}>Acquis / Dépensé</h4>}
{actualExpenses.length > 0 && (
<div style={{border: '1px solid var(--border-color)', borderRadius: '8px', overflow: 'hidden', marginBottom: '15px'}}>
{actualExpenses.map(exp => (
<div key={exp.id} className="flex justify-between items-center" style={{padding: '10px 15px', borderBottom: '1px solid var(--border-color)', background: 'var(--card-bg)'}}>
<div className="flex items-center gap-1 flex-wrap">
{exp.label}
{exp.isTask && <span className="badge" style={{backgroundColor: 'var(--success)', color: 'white'}}>Par tâche terminée</span>}
{!exp.isTask && exp.user && <span className="badge" style={{color: 'var(--gray-dark)'}}>par {exp.user}</span>}
</div>
<div className="flex items-center gap-1">
<strong>{exp.amount} €</strong>
{}
{!exp.isTask && user && (
<button className="btn-icon" style={{color: 'var(--danger)', padding: '5px'}} onClick={() => handleDeleteExpense(exp.id, exp.label)} title="Supprimer cet achat">
<Icons.Trash />
</button>
)}
</div>
</div>
))}
</div>
)}
{}
{plannedTaskExpenses.length > 0 && <h4 className="mt-2 mb-1" style={{color: 'var(--warning)'}}>Futurs Coûts Prévus (Tâches non finies)</h4>}
{plannedTaskExpenses.length > 0 && (
<div style={{border: '1px solid var(--border-color)', borderRadius: '8px', overflow: 'hidden'}}>
{plannedTaskExpenses.map(exp => (
<div key={exp.id} className="flex justify-between items-center" style={{padding: '10px 15px', borderBottom: '1px solid var(--border-color)', background: 'var(--bg-color)', opacity: 0.8}}>
<div>{exp.label}</div>
<strong>{exp.amount} €</strong>
</div>
))}
</div>
)}
</div>
);
}
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 (
<div style={{ display: 'flex', gap: '20px', flexWrap: 'wrap', paddingBottom: '20px' }}>
{columns.map(col => {
if(col.tasks.length === 0) return null;
return (
<div key={col.id} style={{ flex: '1 1 280px', minWidth: '280px', background: 'var(--gray-light)', padding: '15px', borderRadius: '8px' }}>
<h3 className="mb-2 text-center" style={{color: 'var(--text-color)'}}>{col.title} ({col.tasks.length})</h3>
<div className="task-list mt-0">
{col.tasks.map((task) =>
<TaskCard
{...props}
key={task.id}
task={task}
locked={false}
index={0}
totalTasks={1}
enableReview={enableReview} />)}
</div>
</div>
)})}
</div>
);
}
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 (
<div className="admin-card text-center" style={{color: 'var(--gray-dark)'}}>
Aucune tâche ni événement n'a de date définie pour le moment.
</div>
);
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 (
<div className="admin-card">
<div style={{
display: 'flex',
flexDirection: isMobile ? 'row' : 'column',
height: isMobile ? '65vh' : 'auto',
minHeight: isMobile ? mobileMinHeight : 'auto'
}}>
<div style={{
display: 'flex',
flexDirection: isMobile ? 'column' : 'row',
justifyContent: 'space-between',
padding: isMobile ? '0 10px 0 0' : '0 0 10px 0',
borderRight: isMobile ? '2px solid var(--border-color)' : 'none',
borderBottom: isMobile ? 'none' : '2px solid var(--border-color)',
marginBottom: isMobile ? '0' : '15px',
marginRight: isMobile ? '15px' : '0',
color: 'var(--gray-dark)',
fontSize: '0.9rem',
flexShrink: 0
}}>
<span style={{ writingMode }}>Début : {new Date(minDate).toLocaleDateString("fr")}</span>
<span style={{ writingMode }}>Fin : {new Date(maxDate).toLocaleDateString("fr")}</span>
</div>
<div style={{
flex: 1,
overflowX: 'auto',
overflowY: isMobile ? 'hidden' : 'auto',
display: 'flex',
flexDirection: isMobile ? 'column' : 'row'
}}>
<div style={{
display: 'flex',
flexDirection: isMobile ? 'row' : 'column',
flex: isMobile ? '0 0 auto' : '0 0 150px',
borderBottom: isMobile ? '2px solid var(--border-color)' : 'none',
borderRight: isMobile ? 'none' : '2px solid var(--border-color)',
minWidth: isMobile ? 'max-content' : 'auto'
}}>
{activeCategories.map(cat => (
<div key={cat} style={{
display: 'flex', alignItems: 'center', justifyContent: isMobile ? 'center' : 'flex-end',
padding: '10px', width: isMobile ? '50px' : '100%', height: isMobile ? 'auto' : '50px',
textAlign: 'center', flexShrink: 0
}}>
<span className="badge" style={{ backgroundColor: 'var(--primary)', padding: '4px 6px', color: 'white', whiteSpace: 'nowrap', writingMode }}>
{cat}
</span>
</div>
))}
</div>
<div style={{
position: 'relative',
display: 'flex',
flexDirection: isMobile ? 'row' : 'column',
flex: 1,
minWidth: isMobile ? 'max-content' : '600px',
minHeight: isMobile ? '85%' : 'auto'
}}>
{stagesWithBounds.map(s => (
<div key={`stage-bg-${s.id}`} style={{
position: 'absolute',
[isMobile ? 'top' : 'left']: `${s.startPct - 1}%`,
[isMobile ? 'height' : 'width']: Math.max(s.endPct - s.startPct + 2, 2) + '%',
[isMobile ? 'width' : 'height']: '100%',
[isMobile ? 'borderTop' : 'borderLeft']: '2px dashed rgba(150,150,150,0.4)',
[isMobile ? 'borderBottom' : 'borderRight']: '2px dashed rgba(150,150,150,0.4)',
backgroundColor: 'rgba(150,150,150,0.03)',
zIndex: 0,
pointerEvents: 'none'
}}>
<span style={{
position: 'absolute',
[isMobile ? 'left' : 'top']: '-25px',
[isMobile ? 'top' : 'left']: '50%',
transform: isMobile ? 'translateY(-50%)' : 'translateX(-50%)',
fontStyle: 'italic', opacity: 0.6, fontSize: '0.85rem', whiteSpace: 'nowrap', color: 'var(--text-color)',
writingMode
}}>
Étape {s.order}
</span>
</div>
))}
{}
<div style={{
position: 'absolute', background: 'rgba(255, 0, 0, 0.5)', zIndex: 1, pointerEvents: 'none',
...(isMobile ? { top: `${todayPercent}%`, left: 0, width: '100%', height: '2px' } : { left: `${todayPercent}%`, top: 0, height: '100%', width: '2px' })
}}></div>
{}
{events.map(evt => {
const evtTime = new Date(evt.date).getTime();
const percent = range === 0 ? 50 : 5 + (((evtTime - minDate) / range) * 90);
return (
<div key={evt.id} style={{
position: 'absolute',
[isMobile ? 'top' : 'left']: `${percent}%`,
[isMobile ? 'left' : 'top']: 0,
[isMobile ? 'width' : 'height']: '100%',
[isMobile ? 'height' : 'width']: '2px',
backgroundColor: 'color-mix(in srgb, var(--primary) 50%, transparent)',
zIndex: 5
}}>
{}
<div
className="gantt-dot"
onClick={(e) => { 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"
/>
</div>
);
})}
{}
{activeCategories.map(cat => {
const catTasks = datedTasks.filter(t => t.category === cat);
return (
<div key={cat} style={{
position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center',
width: isMobile ? '50px' : '100%', height: isMobile ? '100%' : '50px', flexShrink: 0, zIndex: 2
}}>
<div style={{ backgroundColor: 'var(--border-color)', borderRadius: '2px', width: isMobile ? '4px' : '100%', height: isMobile ? '100%' : '4px' }}></div>
{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 (
<div
key={task.id}
className="gantt-dot"
onClick={() => setSelectedTask(task)}
style={{
[isMobile ? 'top' : 'left']: `${percent}%`,
[isMobile ? 'left' : 'top']: '50%',
transform: 'translate(-50%, -50%)',
backgroundColor: dotColor
}}
title={`${task.title} (${task.deadline})`}
></div>
);
})}
</div>
);
})}
</div>
</div>
</div>
{}
{activeEvent && (
<div
onClick={() => setActiveEvent(null)}
style={{
position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.6)', zIndex: 3000,
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px'
}}>
<div className="admin-card text-center" onClick={e => e.stopPropagation()} style={{ maxWidth: '350px', width: '100%' }}>
<h3 style={{ color: 'var(--danger)', margin: '0 0 10px 0', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '5px' }}>
<Icons.Flag /> Détail du Jalon
</h3>
<p style={{ fontWeight: 'bold', fontSize: '1.2rem', marginBottom: '5px' }}>
{activeEvent.title}
</p>
<p style={{ color: 'var(--gray-dark)', marginBottom: '20px' }}>
<Icons.Calendar style={{width:'14px', height:'14px', verticalAlign:'middle'}}/> Date : {new Date(activeEvent.date).toLocaleDateString("fr")}
</p>
<button className="btn btn-primary w-full mt-1" onClick={() => setActiveEvent(null)}>Fermer</button>
</div>
</div>
)}
{}
{selectedTask && (
<div
onClick={() => setSelectedTask(null)}
style={{
position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.5)', zIndex: 2000,
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px'
}}>
<div onClick={e => e.stopPropagation()} style={{ width: '100%', maxWidth: '600px' }}>
<div className="flex justify-between items-center" style={{background: 'var(--card-bg)', padding: '15px', borderBottom: '1px solid var(--border-color)', borderTopLeftRadius: '8px', borderTopRightRadius: '8px'}}>
<h3 style={{margin:0}}>Détail de la tâche</h3>
<button className="btn-icon" onClick={() => setSelectedTask(null)}>
<Icons.X />
</button>
</div>
<div style={{background: 'var(--card-bg)', padding: '15px', borderBottomLeftRadius: '8px', borderBottomRightRadius: '8px'}}>
<TaskCard task={selectedTask} locked={false} {...taskActions} />
</div>
</div>
</div>
)}
</div>
);
}
function HistoryView({ data }) {
const getIconForAction = (action) => {
const a = action.toLowerCase();
if (a.includes('supprimé')) return <Icons.Trash />;
if (a.includes('validé') || a.includes('terminé') || a.includes('approuvé')) return <Icons.Check />;
if (a.includes('commenté')) return <Icons.MessageSquare />;
if (a.includes('créé') || a.includes('ajouté')) return <Icons.Plus />;
if (a.includes('assigné')) return <Icons.UserPlus />;
if (a.includes('bloqué') || a.includes('rejeté')) return <Icons.AlertTriangle />;
if (a.includes('cours') || a.includes('lancé')) return <Icons.Play />;
if (a.includes('validation')) return <Icons.Eye />;
return <Icons.Activity />;
};
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 (
<div className="admin-card">
<h3 className="mb-2"><Icons.History /> Registre des Actions (Audit)</h3>
{(data.auditLogs || []).length === 0 ? <div style={{padding:'15px', color:'var(--gray-dark)'}}>Aucun historique.</div> :
<div className="history-timeline">
{data.auditLogs.map(log => {
const iconColor = getColorForAction(log.action);
return (
<div key={log.id} className="history-item">
<div className="history-icon-wrapper" style={{color: iconColor, borderColor: iconColor}}>
{getIconForAction(log.action)}
</div>
<div className="history-content">
<div style={{fontSize: '0.85rem', color: 'var(--gray-dark)', marginBottom: '5px'}}>
{new Date(log.date).toLocaleString()}
</div>
<div><strong>{log.username}</strong> {log.action}</div>
</div>
</div>
);
})}
</div>
}
</div>
);
}
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 (
<div className="admin-card">
<h3 className="flex items-center gap-1 mb-2"><Icons.Package /> Inventaire Matériel</h3>
{}
{user && (
<div className="flex gap-1 flex-wrap mb-2" style={{alignItems: 'center'}}>
<input
type="text"
placeholder="Article manuel (ex: Vidéoprojecteur)"
value={label}
onChange={e => setLabel(e.target.value)}
style={{flex: 2, minWidth: '200px'}}
/>
<input
type="number"
min="1"
placeholder="Qté"
value={quantity}
onChange={e => setQuantity(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAdd()}
style={{flex: 1, minWidth: '80px', maxWidth: '120px'}}
/>
<button
className="btn btn-primary"
onClick={handleAdd}
disabled={!label.trim()}
>
Ajouter
</button>
</div>
)}
{}
<h4 className="mt-2 mb-1" style={{color: 'var(--success)'}}>Matériel Acquis</h4>
{allActualInventory.length === 0 ? (
<p style={{color: 'var(--gray-dark)', fontStyle: 'italic', fontSize: '0.9rem'}}>Aucun matériel acquis pour le moment.</p>
) : (
<div style={{border: '1px solid var(--border-color)', borderRadius: '8px', overflow: 'hidden', marginBottom: '15px'}}>
{allActualInventory.map(item => (
<div key={item.id} className="flex justify-between items-center" style={{padding: '10px 15px', borderBottom: '1px solid var(--border-color)', background: 'var(--card-bg)'}}>
<div className="flex items-center gap-1 flex-wrap">
<span style={{fontWeight: '500'}}>{item.label}</span>
{}
{item.isTask
? <span className="badge" style={{backgroundColor: 'var(--success)', color: 'white', fontSize: '0.75rem'}}>Via tâche : {item.taskTitle}</span>
: item.user && <span className="badge" style={{color: 'var(--gray-dark)', fontSize: '0.75rem'}}>Ajouté par {item.user}</span>
}
</div>
<div className="flex items-center gap-2">
<span className="badge" style={{background: 'var(--primary)', color:'white', fontSize: '1rem', padding: '4px 8px'}}>
x{item.quantity}
</span>
{}
{!item.isTask && user && (
<button
className="btn-icon"
style={{color: 'var(--danger)', padding: '5px', width: '30px', height: '30px'}}
onClick={() => handleDelete(item.id, item.label)}
title="Retirer cet élément"
>
<Icons.Trash />
</button>
)}
</div>
</div>
))}
</div>
)}
{}
<h4 className="mt-2 mb-1" style={{color: 'var(--warning)'}}>Futurs Besoins Prévus (Tâches non finies)</h4>
{plannedTaskInventory.length === 0 ? (
<p style={{color: 'var(--gray-dark)', fontStyle: 'italic', fontSize: '0.9rem'}}>Aucun besoin prévu dans les tâches actuelles.</p>
) : (
<div style={{border: '1px solid var(--border-color)', borderRadius: '8px', overflow: 'hidden'}}>
{plannedTaskInventory.map(item => (
<div key={item.id} className="flex justify-between items-center" style={{padding: '10px 15px', borderBottom: '1px solid var(--border-color)', background: 'var(--bg-color)', opacity: 0.8}}>
<div className="flex items-center gap-1 flex-wrap">
<span>{item.label}</span>
<span className="badge" style={{background: 'var(--gray-light)', color:'var(--gray-dark)', fontSize: '0.75rem'}}>Pour : {item.taskTitle}</span>
</div>
<span className="badge" style={{background: 'var(--warning)', color:'black', fontSize: '1rem', padding: '4px 8px'}}>
x{item.quantity}
</span>
</div>
))}
</div>
)}
</div>
);
}
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 (
<div style={{ display: 'flex', gap: '20px', overflowX: 'auto', paddingBottom: '20px' }}>
{columns.map(col => (
<div key={col.id} style={{ flex: '1 1 280px', minWidth: '280px', background: 'var(--gray-light)', padding: '15px', borderRadius: '8px' }}>
<h3 className="mb-2 text-center" style={{color: 'var(--text-color)'}}>{col.title} ({col.tasks.length})</h3>
<div className="task-list mt-0">
{col.tasks.map((task) => <TaskCard key={task.id} task={task} user={user} navigate={navigate} navigateView={navigateView} assignTask={assignTask} toggleInProgress={toggleInProgress} startFocus={startFocus} completeTask={completeTask} undoTask={undoTask} toggleBlockTask={toggleBlockTask} approveTask={approveTask} rejectTask={rejectTask} locked={false} index={0} totalTasks={1} onOpenDiscussion={onOpenDiscussion} onOpenSubtasks={onOpenSubtasks} openProfile={openProfile} enableReview={enableReview} />)}
</div>
</div>
))}
</div>
);
}
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 (
<div>
{}
{suggestedTasks.length > 0 && (
<div className="admin-card mb-2" style={{borderLeft: '4px solid var(--primary)', background: 'color-mix(in srgb, var(--primary) 5%, var(--card-bg))'}}>
<h4 style={{color: 'var(--primary)', marginBottom: '10px'}} className="flex items-center gap-1">
<Icons.Lightbulb /> Suggestion selon vos compétences
</h4>
<div className="task-list mt-0">
<TaskCard
task={suggestedTasks[0]}
locked={false}
{...commonTaskProps}
/>
</div>
</div>
)}
{}
{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 (
<div key={stage.id} className={stageClasses}>
{}
{!isSingleStage && (
<div className="flex justify-between items-center mb-1">
<h3 className="flex items-center gap-1" style={{margin:0}}>
{locked && <Icons.Lock />} Étape {stage.order} : {stage.name}
</h3>
</div>
)}
{}
<div className="task-list mt-0">
{activeTasks.length === 0 && doneTasks.length === 0 &&
<p style={{color:'var(--gray-dark)', fontSize:'0.9rem', fontStyle:'italic'}}>
Aucune tâche.
</p>
}
{activeTasks.map((task, idx) =>
<TaskCard
key={task.id}
task={task}
locked={locked}
index={idx}
totalTasks={activeTasks.length}
{...commonTaskProps}
/>
)}
</div>
{}
{doneTasks.length > 0 && (
<details className="mt-2 details-btn" style={{cursor: 'pointer'}}>
<summary className="btn btn-outline w-full text-center">
Tâches terminées ({doneTasks.length})
</summary>
<div className="task-list mt-1">
{doneTasks.map((task, idx) =>
<TaskCard
key={task.id}
task={task}
locked={locked}
index={idx}
totalTasks={doneTasks.length}
{...commonTaskProps}
/>
)}
</div>
</details>
)}
</div>
);
})}
</div>
);
}
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 (
<div className="flex gap-2 flex-wrap">
{}
<div className="admin-card flex-1" style={{ minWidth: '300px' }}>
<h4 className="mb-2 flex items-center gap-1 text-primary">
<Icons.Users /> Temps Total par Membre
</h4>
{!hasUserTimes ? (
<p style={{ color: 'var(--gray-dark)', fontStyle: 'italic' }}>
Aucun temps enregistré pour le moment.
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{Object.entries(userTimes)
.sort((a, b) => b[1] - a[1]) // Tri du plus gros bosseur au plus petit
.map(([u, seconds]) => (
<div key={u} className="flex justify-between items-center" style={{ padding: '12px 15px', background: 'var(--bg-color)', borderRadius: '8px', border: '1px solid var(--border-color)' }}>
<strong className="flex items-center gap-1">
<Icons.User /> {u}
</strong>
<span className="badge" style={{ background: 'var(--primary)', color: 'white', fontSize: '1rem', padding: '5px 10px' }}>
{formatTime(seconds)}
</span>
</div>
))}
</div>
)}
</div>
{}
<div className="admin-card flex-1" style={{ minWidth: '300px' }}>
<h4 className="mb-2 flex items-center gap-1 text-primary">
<Icons.Target /> Tâches les plus longues
</h4>
{tasksWithTime.length === 0 ? (
<p style={{ color: 'var(--gray-dark)', fontStyle: 'italic' }}>
Aucune tâche n'a été chronométrée.
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column' }}>
{tasksWithTime.slice(0, 10).map((t, index) => ( // On limite au top 10 pour l'UI
<div key={t.id} className="flex justify-between items-center" style={{ padding: '12px 0', borderBottom: index === Math.min(tasksWithTime.length, 10) - 1 ? 'none' : '1px solid var(--border-color)' }}>
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', paddingRight: '15px', flex: 1 }} title={t.title}>
{t.title}
</div>
<strong style={{ color: 'var(--gray-dark)', flexShrink: 0 }}>
{formatTime(t.timeSpent)}
</strong>
</div>
))}
</div>
)}
</div>
</div>
);
};
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 <div className="admin-card text-center" style={{color: 'var(--danger)'}}>{error}</div>;
if (!stats) return (
<div className="admin-card text-center" style={{ padding: '40px' }}>
<p style={{ color: 'var(--gray-dark)', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px' }}>
<Icons.Activity /> Calcul des statistiques en cours...
</p>
</div>
);
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 (
<div>
{}
<div className="flex gap-2 flex-wrap mb-2">
{}
<div className="stat-card flex-1" style={{ minWidth: '250px', background: 'var(--card-bg)', padding: '20px', borderRadius: '12px', border: '1px solid var(--border-color)' }}>
<h4 style={{ color: 'var(--gray-dark)', margin: '0 0 10px 0', textTransform: 'uppercase', fontSize: '0.85rem' }}>Avancement Projet</h4>
<div className="stat-number" style={{ fontSize: '2.5rem', fontWeight: 'bold', marginBottom: '10px' }}>{stats.progress}%</div>
<div className="stat-bar-container" style={{ width: '100%', height: '8px', background: 'var(--gray-light)', borderRadius: '4px', overflow: 'hidden' }}>
<div className="stat-bar-fill" style={{ width: `${stats.progress}%`, background: 'var(--primary)', height: '100%' }}></div>
</div>
</div>
{}
<div className="stat-card flex-1" style={{ minWidth: '250px', background: 'var(--card-bg)', padding: '20px', borderRadius: '12px', border: '1px solid var(--border-color)' }}>
<h4 style={{ color: 'var(--gray-dark)', margin: '0 0 10px 0', textTransform: 'uppercase', fontSize: '0.85rem' }}>Budget Utilisé</h4>
<div className="stat-number" style={{ fontSize: '2.5rem', fontWeight: 'bold' }}>{stats.totalSpent} €</div>
</div>
</div>
{}
<div className="admin-card mb-2">
<h3 className="flex items-center gap-1 mb-2 text-primary" style={{ margin: '0 0 15px 0' }}>
<Icons.Sun /> Énergie de l'équipe
</h3>
{totalMoods === 0 ? (
<p style={{ color: 'var(--gray-dark)', fontStyle: 'italic', margin: 0 }}>
Personne n'a encore partagé sa météo aujourd'hui.
</p>
) : (
<div>
{}
<div style={{ width: '100%', height: '16px', borderRadius: '8px', overflow: 'hidden', display: 'flex', marginBottom: '15px', background: 'var(--gray-light)', boxShadow: 'inset 0 1px 3px rgba(0,0,0,0.1)' }}>
{happyPct > 0 && <div style={{ width: `${happyPct}%`, background: 'var(--success)', transition: 'width 0.5s' }} title={`${teamMood['😊']} En pleine forme`} />}
{neutralPct > 0 && <div style={{ width: `${neutralPct}%`, background: 'var(--warning)', transition: 'width 0.5s' }} title={`${teamMood['😐']} Normal`} />}
{tiredPct > 0 && <div style={{ width: `${tiredPct}%`, background: 'var(--danger)', transition: 'width 0.5s' }} title={`${teamMood['😴']} Fatigué(e)`} />}
</div>
{}
<div className="flex justify-between flex-wrap gap-1" style={{ fontSize: '0.7rem' }}>
<div className="flex items-center gap-1">
<span style={{ fontSize: '0.8rem' }}><Icons.EmojiGood/></span>
<strong>{teamMood['😊'] || 0}</strong> <span style={{color: 'var(--gray-dark)'}}>En forme</span>
</div>
<div className="flex items-center gap-1">
<span style={{ fontSize: '0.8rem' }}><Icons.EmojiMid/></span>
<strong>{teamMood['😐'] || 0}</strong> <span style={{color: 'var(--gray-dark)'}}>Normal</span>
</div>
<div className="flex items-center gap-1">
<span style={{ fontSize: '0.8rem' }}><Icons.EmojiBad/></span>
<strong>{teamMood['😴'] || 0}</strong> <span style={{color: 'var(--gray-dark)'}}>Fatigué(e)</span>
</div>
</div>
{}
{tiredPct >= 50 && (
<div style={{ marginTop: '15px', padding: '10px', background: 'color-mix(in srgb, var(--danger) 10%, transparent)', color: 'var(--danger)', borderRadius: '8px', fontSize: '0.85rem', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Icons.AlertTriangle />
<strong>Attention :</strong>
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.
</div>
)}
{happyPct >= 50 && (
<div style={{ marginTop: '15px', padding: '10px', background: 'color-mix(in srgb, var(--success) 10%, transparent)', color: 'var(--success)', borderRadius: '8px', fontSize: '0.85rem', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Icons.CheckCircle />
<strong>Super dynamique :</strong>
L'équipe est en pleine forme aujourd'hui ! C'est le bon moment pour s'attaquer aux tâches complexes.
</div>
)}
</div>
)}
</div>
{}
<div className="admin-card mb-2">
<h3 className="flex items-center gap-1 mb-2 text-primary">
<Icons.Activity /> Charge de travail
</h3>
{!hasWorkload ? (
<p style={{ color: 'var(--gray-dark)', fontStyle: 'italic', margin: 0 }}>
Personne n'a de tâche en cours de traitement.
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px', marginTop: '15px' }}>
{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 (
<div key={uid}>
<div className="flex justify-between items-center" style={{ fontSize: '1rem', marginBottom: '8px' }}>
<strong className="flex items-center gap-1">
<Icons.User /> {data['username']}
</strong>
<span className="badge" style={{ background: 'var(--bg-color)', color: 'var(--text-color)', border: '1px solid var(--border-color)' }}>
{data['count']} tâche(s) en cours
</span>
</div>
<div className="stat-bar-container" style={{ width: '100%', height: '12px', background: 'var(--gray-light)', borderRadius: '6px', overflow: 'hidden' }}>
<div
className="stat-bar-fill"
style={{
width: `${fillPercentage}%`,
background: barColor,
height: '100%',
transition: 'width 0.5s ease',
borderRadius: '6px'
}}
></div>
</div>
</div>
);
})}
</div>
)}
</div>
{}
<h3 className="mt-2 mb-2 flex items-center gap-1 text-primary"><Icons.Clock /> Détails du Suivi du Temps</h3>
<TimeTrackingView data={data} Icons={Icons} formatTime={formatTime} />
</div>
);
}
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 (
<div className="admin-card">
<h3 className="flex items-center gap-1 mb-2"><Icons.Users /> Équipe du projet</h3>
{
activeUsers.length === 0
? <p style={{color: 'var(--gray-dark)'}}>Personne n'est encore assigné à une tâche sur ce projet.</p>
: (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))', gap: '15px' }}>
{activeUsers.map((user, idx) => (
<div
key={idx}
style={{
background: 'var(--bg-color)',
padding: '15px',
borderRadius: '8px',
border: '1px solid var(--border-color)',
display: 'flex',
flexDirection: 'column',
gap: '10px'
}}>
<div className="flex items-center gap-1">
<div style={{width:'40px', height:'40px', borderRadius:'50%', background:'var(--primary)', color:'var(--btn-text-primary)', display:'flex', alignItems:'center', justifyContent:'center', fontSize:'1.2rem', fontWeight:'bold'}}>
{user.username.charAt(0).toUpperCase()}
</div>
<strong style={{fontSize: '1.2rem'}}>{user.username}</strong>
</div>
<div style={{ fontSize: '0.95rem', color: 'var(--text-color)' }}>
<div className="flex justify-between"><span>Tâches totales :</span> <strong>{userStats[user.uid]?.total || 0}</strong></div>
<div className="flex justify-between"><span>Tâches terminées :</span> <strong style={{color: 'var(--success)'}}>{userStats[user.uid]?.done || 0}</strong></div>
<div className="flex justify-between"><span>Tâches en cours/val. :</span> <strong style={{color: 'var(--warning)'}}>{userStats[user.uid]?.inProgress || 0}</strong></div>
</div>
{openProfile && <button className="btn btn-outline w-full mt-1" onClick={() => openProfile(user.uid)}>Voir le profil complet</button>}
</div>
))}
</div>
)
}
</div>
);
}
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 (
<div style={{ position: 'relative', width: '100%' }}>
<input
ref={inputRef}
value={value}
onChange={handleInput}
onKeyDown={onKeyDown}
placeholder={placeholder}
autoComplete="off"
style={{ width: '100%', padding: '10px', borderRadius: '8px', border: '1px solid var(--border-color)', background: 'var(--bg-color)', color: 'var(--text-color)' }}
/>
{showMenu && filteredUsers.length > 0 && (
<ul className="mention-dropdown" style={{
position: 'absolute',
bottom: '100%',
left: 0,
marginBottom: '5px',
background: 'var(--card-bg)',
border: '1px solid var(--border-color)',
borderRadius: '8px',
padding: '5px 0',
listStyle: 'none',
margin: 0,
boxShadow: '0 -5px 15px rgba(0,0,0,0.1)',
zIndex: 1000,
width: '100%',
maxHeight: '150px',
overflowY: 'auto'
}}>
{filteredUsers.map(u => (
<li
key={u}
onClick={() => 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'}
>
<Icons.User /> {u}
</li>
))}
</ul>
)}
</div>
);
}
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 (
<div style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.6)', zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px' }}>
{}
<div style={{ position: 'absolute', inset: 0 }} onClick={onClose}></div>
<div style={{
width: '100%',
maxWidth: '550px',
background: 'var(--card-bg)',
borderRadius: '12px',
display: 'flex',
flexDirection: 'column',
height: '85vh',
maxHeight: '800px',
margin: 'auto',
boxShadow: '0 20px 40px rgba(0,0,0,0.3)',
position: 'relative',
zIndex: 10
}}>
{}
<div className="flex justify-between items-center" style={{ padding: '15px 20px', borderBottom: '1px solid var(--border-color)' }}>
<h3 style={{ margin: 0, display: 'flex', alignItems: 'center', gap: '8px' }}>
<Icons.MessageSquare /> Discussion
</h3>
<button className="btn-icon" onClick={onClose} title="Fermer">
<Icons.X />
</button>
</div>
{}
<div style={{ padding: '20px', overflowY: 'auto', flex: 1, display: 'flex', flexDirection: 'column', gap: '15px', background: 'var(--bg-color)' }}>
{comments.length === 0 ? (
<p style={{ color: 'var(--gray-dark)', textAlign: 'center', marginTop: 'auto', marginBottom: 'auto' }}>
Aucun message pour le moment.<br/>Soyez le premier à lancer la discussion !
</p>
) : (
comments.map(c => {
const isMyMessage = c.author === user;
// Coloration des mentions (@pseudo)
const renderedText = c.text.split(/(@\w+)/g).map((part, i) =>
part.startsWith('@')
? <strong key={i} style={{ color: 'var(--primary)', background: 'color-mix(in srgb, var(--primary) 15%, transparent)', padding: '2px 4px', borderRadius: '4px' }}>{part}</strong>
: part
);
return (
<div key={c.id} style={{
alignSelf: isMyMessage ? 'flex-end' : 'flex-start',
maxWidth: '85%',
background: isMyMessage ? 'color-mix(in srgb, var(--primary) 10%, transparent)' : 'var(--card-bg)',
padding: '12px 15px',
borderRadius: '12px',
borderBottomRightRadius: isMyMessage ? '2px' : '12px',
borderBottomLeftRadius: !isMyMessage ? '2px' : '12px',
boxShadow: '0 2px 5px rgba(0,0,0,0.05)',
border: isMyMessage ? '1px solid color-mix(in srgb, var(--primary) 20%, transparent)' : '1px solid var(--border-color)'
}}>
<div className="flex justify-between mb-1 items-center gap-2" style={{ fontSize: '0.8rem', color: 'var(--gray-dark)' }}>
<span><strong>{isMyMessage ? 'Vous' : c.author}</strong> • {new Date(c.date).toLocaleString([], {timeStyle:'short', dateStyle:'short'})}</span>
{(isMyMessage || isAdmin) && (
<button
className="btn-icon"
style={{ width:'24px', height:'24px', color:'var(--danger)', background: 'transparent' }}
onClick={() => handleDeleteComment(c.id)}
title="Supprimer le message"
>
<Icons.Trash />
</button>
)}
</div>
<div style={{ fontSize: '0.95rem', lineHeight: '1.4', wordBreak: 'break-word', whiteSpace: 'pre-wrap' }}>
{renderedText}
</div>
</div>
);
})
)}
{}
<div ref={messagesEndRef} />
</div>
{}
{user ? (
<div style={{ padding: '15px 20px', borderTop: '1px solid var(--border-color)', display: 'flex', gap: '10px', alignItems: 'center', background: 'var(--card-bg)', borderBottomLeftRadius: '12px', borderBottomRightRadius: '12px' }}>
<MentionInput
value={text}
onChange={(e) => 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}
/>
<button
className="btn btn-primary btn-icon flex-shrink-0"
style={{ height: '42px', width: '42px', borderRadius: '50%' }}
onClick={handleSubmit}
disabled={!text.trim()}
title="Envoyer"
>
<Icons.Check />
</button>
</div>
) : (
<div style={{ padding: '15px', textAlign: 'center', color: 'var(--gray-dark)' }}>
Vous devez être connecté pour participer à la discussion.
</div>
)}
</div>
</div>
);
}
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 (
<div className={overlayClass} style={{ position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '20px', transition: 'background-color 0.5s ease' }}>
{}
<div className="badge mb-2" style={{ backgroundColor: 'white', color: themeColor, fontSize: '1.2rem', padding: '10px 20px', boxShadow: '0 4px 15px rgba(0,0,0,0.1)' }}>
{isResting ? (
<><Icons.Coffee /> Pause bien méritée</>
) : (
<><Icons.Activity /> Mode Focus</>
)}
</div>
{}
<div className="pomodoro-container">
<svg className="pomodoro-svg svg-circle" viewBox="0 0 280 280">
<circle
className="pomodoro-bg"
cx="140" cy="140" r={circleRadius}
/>
<circle
className="pomodoro-progress"
cx="140" cy="140" r={circleRadius}
strokeDasharray={circleCircumference}
strokeDashoffset={strokeDashoffset}
/>
</svg>
<div className="pomodoro-time">
{formatChrono(phaseTimeLeft)}
</div>
</div>
{}
<div className="flex gap-1 justify-center mb-2" style={{ marginTop: '20px' }}>
<button
className="btn btn-icon"
style={{ width: '60px', height: '60px', borderRadius: '30px', backgroundColor: 'white', color: themeColor, boxShadow: '0 4px 15px rgba(0,0,0,0.2)' }}
onClick={() => setIsRunning(!isRunning)}
title={isRunning ? "Mettre en pause" : "Reprendre"}
>
{isRunning ? <Icons.Pause /> : <Icons.Play />}
</button>
</div>
{}
<h1 style={{ marginTop: '10px', fontSize: '2rem', color: 'white', textAlign: 'center' }}>
{task.title}
</h1>
<p style={{ opacity: 0.8, fontSize: '1.1rem', color: 'white', textAlign: 'center', maxWidth: '600px' }}>
{task.description}
</p>
<p style={{ opacity: 0.6, fontSize: '0.9rem', marginTop: '5px', color: 'white' }}>
Temps total cumulé : {formatTime(totalWorkElapsed)}
</p>
{}
<div className="flex gap-2" style={{ flexWrap: 'wrap', justifyContent: 'center', marginTop: '40px' }}>
<button
className="btn btn-outline"
style={{ borderColor: 'rgba(255,255,255,0.4)', color: '#fff', padding: '15px 30px', fontSize: '1.1rem', background: 'transparent' }}
onClick={() => handleStop(false)}
>
<Icons.Stop /> Arrêter & Sauvegarder
</button>
<button
className="btn btn-success"
style={{ padding: '15px 30px', fontSize: '1.1rem', boxShadow: '0 4px 15px rgba(0,0,0,0.3)' }}
onClick={() => handleStop(true)}
>
{enableReview ? <><Icons.Eye /> Envoyer en validation</> : <><Icons.Check /> Valider la tâche</>}
</button>
</div>
</div>
);
}
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 (
<div style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.6)', zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px' }}>
{}
<div style={{ position: 'absolute', inset: 0 }} onClick={onClose}></div>
<div style={{ width: '100%', maxWidth: '450px', background: 'var(--card-bg)', borderRadius: '12px', display: 'flex', flexDirection: 'column', maxHeight: '85vh', margin: 'auto', boxShadow: '0 20px 40px rgba(0,0,0,0.3)', position: 'relative', zIndex: 10 }}>
{}
<div className="flex justify-between items-center" style={{ padding: '15px 20px', borderBottom: '1px solid var(--border-color)' }}>
<h3 style={{ margin: 0, display: 'flex', alignItems: 'center', gap: '8px' }}>
<Icons.CheckSquare /> Étapes : {task.title}
</h3>
<button className="btn-icon" onClick={onClose} title="Fermer">
<Icons.X />
</button>
</div>
{}
{totalSubs > 0 && (
<div style={{ padding: '15px 20px', background: 'var(--bg-color)', borderBottom: '1px solid var(--border-color)' }}>
<div className="flex justify-between items-center mb-1" style={{ fontSize: '0.85rem', color: 'var(--gray-dark)', fontWeight: 'bold' }}>
<span>Avancement</span>
<span style={{ color: progressPercent === 100 ? 'var(--success)' : 'var(--primary)' }}>
{doneSubs} / {totalSubs} ({progressPercent}%)
</span>
</div>
<div style={{ width: '100%', height: '8px', background: 'var(--gray-light)', borderRadius: '4px', overflow: 'hidden' }}>
<div
style={{
width: `${progressPercent}%`,
background: progressPercent === 100 ? 'var(--success)' : 'var(--primary)',
height: '100%',
transition: 'width 0.4s cubic-bezier(0.4, 0, 0.2, 1), background-color 0.4s'
}}
/>
</div>
</div>
)}
{}
<div style={{ padding: '20px', overflowY: 'auto', flex: 1 }}>
{totalSubs === 0 ? (
<p style={{ color: 'var(--gray-dark)', fontStyle: 'italic', textAlign: 'center', margin: '20px 0' }}>
Aucune étape définie pour cette tâche.
</p>
) : (
<div style={{ border: '1px solid var(--border-color)', borderRadius: '8px', overflow: 'hidden', background: 'var(--bg-color)' }}>
{subtasks.map((s, index) => (
<div
key={s.id}
className={`subtask-item ${s.done ? 'done' : ''}`}
onClick={() => 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'
}}
>
<div style={{ color: s.done ? 'var(--success)' : 'var(--gray-dark)', display: 'flex', alignItems: 'center' }}>
{s.done ? <Icons.CheckSquare /> : <Icons.Square />}
</div>
<span style={{
textDecoration: s.done ? 'line-through' : 'none',
color: s.done ? 'var(--gray-dark)' : 'var(--text-color)',
flex: 1,
lineHeight: '1.4'
}}>
{s.text}
</span>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}
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 (
<div style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.6)', zIndex: 3000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px' }}>
{}
<div style={{ position: 'absolute', inset: 0 }} onClick={onClose}></div>
<div style={{ width: '100%', maxWidth: '450px', background: 'var(--card-bg)', borderRadius: '12px', display: 'flex', flexDirection: 'column', boxShadow: '0 10px 25px rgba(0,0,0,0.5)', maxHeight: '90vh', position: 'relative', zIndex: 10 }}>
{}
<div className="flex justify-between items-center" style={{ padding: '15px 20px', borderBottom: '1px solid var(--border-color)' }}>
<h3 style={{ margin:0, display:'flex', alignItems:'center', gap:'8px' }}>
<Icons.User /> Mood : {mood ? <span title="Humeur du jour">[{mood}]</span> : "Aucun."}
</h3>
<button className="btn-icon" onClick={onClose} title="Fermer"><Icons.X /></button>
</div>
{}
<div style={{ padding: '25px 20px', textAlign: 'center', borderBottom: '1px solid var(--border-color)', background: 'var(--bg-color)' }}>
<div style={{ width: '80px', height: '80px', borderRadius: '50%', background: 'var(--primary)', color: 'white', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '2.5rem', margin: '0 auto 10px auto', fontWeight: 'bold' }}>
{(profile?.username || "Anonyme").charAt(0).toUpperCase()}
</div>
<h2 style={{ margin: '0 0 5px 0' }}>{profile?.username || "Anonyme"}</h2>
<p style={{ color: 'var(--gray-dark)', fontSize: '0.95rem', margin: 0 }}>
<strong style={{color: 'var(--primary)'}}>{tasks.length}</strong> tâche(s) assignée(s)
</p>
</div>
{}
<div style={{ overflowY: 'auto', flex: 1 }}>
{(loading || !profile?.username) ? (
<div style={{ padding: '40px', textAlign: 'center', color: 'var(--gray-dark)' }}>
Chargement...
</div>
) : isEditing
? (
<div style={{ padding: '20px', textAlign: 'left' }}>
{}
<h4 className="mb-1 text-danger flex items-center gap-1"><Icons.User /> Identité</h4>
<div className="form-group mb-2">
<label style={{ fontSize: '0.85rem', color: 'var(--gray-dark)' }}>Pseudo affiché (Display Name)</label>
<input
value={profile.username}
onChange={e => setProfile({...profile, username: e.target.value})}
placeholder="Ex: Jean Dupont"
/>
<small style={{ color: 'var(--gray-dark)' }}>C'est ce nom qui apparaîtra sur vos tâches.</small>
</div>
<hr style={{ borderColor: 'var(--border-color)', margin: '30px 0 20px 0' }} />
{}
<h4 className="mb-1 text-danger flex items-center gap-1"><Icons.Lock /> Sécurité & Mot de passe</h4>
<div style={{ background: 'var(--bg-color)', padding: '15px', borderRadius: '8px', border: '1px solid var(--border-color)' }}>
<div className="form-group mb-1">
<input
type="password"
placeholder="Ancien mot de passe"
value={pwdData.old}
onChange={e => setPwdData({...pwdData, old: e.target.value})} />
</div>
<div className="form-group mb-1">
<input
type="password"
placeholder="Nouveau mot de passe"
value={pwdData.new}
onChange={e => setPwdData({...pwdData, new: e.target.value})} />
</div>
<div className="form-group mb-2">
<input
type="password"
placeholder="Confirmer le nouveau"
value={pwdData.confirm}
onChange={e => setPwdData({...pwdData, confirm: e.target.value})} />
</div>
{pwdStatus.error && <div style={{ color: 'var(--danger)', fontSize: '0.85rem', marginBottom: '10px' }}>{pwdStatus.error}</div>}
{pwdStatus.success && <div style={{ color: 'var(--success)', fontSize: '0.85rem', marginBottom: '10px' }}>{pwdStatus.success}</div>}
<button
className="btn btn-danger w-full"
onClick={handleChangePassword}
disabled={!pwdData.old || !pwdData.new || !pwdData.confirm}
>
Mettre à jour le mot de passe
</button>
</div>
<hr style={{ borderColor: 'var(--border-color)', margin: '30px 0 20px 0' }} />
<h4 className="mb-1 text-danger flex items-center gap-1">Coordonnées</h4>
<div className="form-group mb-1"><input value={profile.email||''} onChange={e=>setProfile({...profile, email:e.target.value})} placeholder="Email (ex: contact@...)" /></div>
<div className="form-group mb-1"><input value={profile.phone||''} onChange={e=>setProfile({...profile, phone:e.target.value})} placeholder="Téléphone (ex: +33...)" /></div>
<div className="form-group mb-2"><input value={profile.matrix||''} onChange={e=>setProfile({...profile, matrix:e.target.value})} placeholder="Matrix / Discord / Autre" /></div>
<h4 className="mb-1 text-danger flex items-center gap-1">Domaines de compétences</h4>
<div className="flex flex-wrap gap-1 mb-2">
{GLOBAL_CATEGORIES.map(c => (
<label key={c} className="badge" style={{ cursor: 'pointer', background: (profile.skills||[]).includes(c) ? 'var(--primary)' : 'var(--gray-light)', color: (profile.skills||[]).includes(c) ? 'white' : 'var(--text-color)', border: '1px solid var(--border-color)' }}>
<input type="checkbox" style={{ display: 'none' }} checked={(profile.skills||[]).includes(c)} onChange={() => toggleSkill(c)} />
{c}
</label>
))}
</div>
<hr style={{ borderColor: 'var(--border-color)', margin: '30px 0 20px 0' }} />
<div style={{ background: 'var(--bg-color)', padding: '15px', borderRadius: '8px', marginBottom: '20px' }}>
<h4 className="mb-1 flex items-center gap-1"><Icons.Clock /> Temps de Focus (Chrono)</h4>
<div className="flex gap-2">
<div className="flex-1">
<label style={{ fontSize: '0.8rem', color: 'var(--gray-dark)' }}>Travail (min)</label>
<input type="number" min="1" max="120" value={profile.focusPrefs?.work || 25} onChange={e => setProfile({...profile, focusPrefs: {...(profile.focusPrefs||{}), work: parseInt(e.target.value)||25}})} />
</div>
<div className="flex-1">
<label style={{ fontSize: '0.8rem', color: 'var(--gray-dark)' }}>Repos (min)</label>
<input type="number" min="1" max="60" value={profile.focusPrefs?.rest || 5} onChange={e => setProfile({...profile, focusPrefs: {...(profile.focusPrefs||{}), rest: parseInt(e.target.value)||5}})} />
</div>
</div>
</div>
<hr style={{ borderColor: 'var(--border-color)', margin: '30px 0 20px 0' }} />
<div style={{ background: 'var(--bg-color)', padding: '15px', borderRadius: '8px', marginBottom: '20px' }}>
<h4 className="mb-1 flex items-center gap-1"><Icons.Bell /> Notifications</h4>
<div className="mb-1" style={{ padding: '10px', background: 'var(--card-bg)', borderRadius: '6px', border: '1px solid var(--border-color)' }}>
<label className="checkbox-label" style={{marginBottom: '5px'}}><input type="checkbox" checked={profile.notifPrefs?.sound !== false} onChange={e => setProfile({...profile, notifPrefs: {...(profile.notifPrefs||{}), sound: e.target.checked}})} /> <strong>Alerte Sonore (Bip)</strong></label>
<div className="flex gap-1 items-center">
<label className="checkbox-label mb-0" style={{flex: 1}}><input type="checkbox" checked={profile.notifPrefs?.push !== false} onChange={e => setProfile({...profile, notifPrefs: {...(profile.notifPrefs||{}), push: e.target.checked}})} /> <strong>Push (Navigateur)</strong></label>
<button className="btn btn-outline" style={{ padding: '4px 8px', fontSize: '0.75rem' }} onClick={requestPushPermission}>Autoriser</button>
</div>
</div>
<div>
<label className="checkbox-label mb-0"><input type="checkbox" checked={profile.notifPrefs?.mentions !== false} onChange={e => setProfile({...profile, notifPrefs: {...(profile.notifPrefs||{}), mentions: e.target.checked}})} /> On me mentionne</label>
<label className="checkbox-label mb-0"><input type="checkbox" checked={profile.notifPrefs?.assigned !== false} onChange={e => setProfile({...profile, notifPrefs: {...(profile.notifPrefs||{}), assigned: e.target.checked}})} /> On m'assigne</label>
<label className="checkbox-label mb-0"><input type="checkbox" checked={profile.notifPrefs?.comments !== false} onChange={e => setProfile({...profile, notifPrefs: {...(profile.notifPrefs||{}), comments: e.target.checked}})} /> Nouveaux coms</label>
<label className="checkbox-label mb-0"><input type="checkbox" checked={profile.notifPrefs?.review !== false} onChange={e => setProfile({...profile, notifPrefs: {...(profile.notifPrefs||{}), review: e.target.checked}})} /> Tâches "À valider"</label>
<label className="checkbox-label mb-0"><input type="checkbox" checked={profile.notifPrefs?.started !== false} onChange={e => setProfile({...profile, notifPrefs: {...(profile.notifPrefs||{}), started: e.target.checked}})} /> Mises en cours</label>
<label className="checkbox-label mb-0"><input type="checkbox" checked={profile.notifPrefs?.done !== false} onChange={e => setProfile({...profile, notifPrefs: {...(profile.notifPrefs||{}), done: e.target.checked}})} /> Tâches terminées</label>
<label className="checkbox-label mb-0"><input type="checkbox" checked={profile.notifPrefs?.files !== false} onChange={e => setProfile({...profile, notifPrefs: {...(profile.notifPrefs||{}), files: e.target.checked}})} /> Nouveaux fichiers</label>
</div>
</div>
<button className="btn btn-success w-full mt-2" onClick={handleSaveProfile}>Enregistrer le profil</button>
<button className="btn btn-outline w-full mt-1" onClick={() => setIsEditing(false)}>Annuler</button>
</div>
) : (
<div style={{ padding: '20px' }}>
<div style={{ textAlign: 'left', background: 'var(--bg-color)', padding: '20px', borderRadius: '8px', marginBottom: '20px', border: '1px solid var(--border-color)' }}>
<h4 className="mb-2" style={{ borderBottom: '1px solid var(--border-color)', paddingBottom: '8px', color: 'var(--primary)' }}> Coordonnées</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{profile.email && <div className="flex items-center gap-2"><Icons.Mail /> <a href={`mailto:${profile.email}`} style={{ color: 'var(--primary)', textDecoration: 'none' }}>{profile.email}</a></div>}
{profile.phone && <div className="flex items-center gap-2"><Icons.Phone /> <a href={`tel:${profile.phone}`} style={{ color: 'var(--text-color)', textDecoration: 'none' }}>{profile.phone}</a></div>}
{profile.matrix && <div className="flex items-center gap-2"><Icons.Hash /> <span>{profile.matrix}</span></div>}
{!profile.email && !profile.phone && !profile.matrix && <p style={{ fontSize: '0.9rem', color: 'var(--gray-dark)', fontStyle: 'italic', margin: 0 }}>Aucune coordonnée renseignée.</p>}
</div>
<h4 className="mt-2 mb-2" style={{ borderBottom: '1px solid var(--border-color)', paddingBottom: '8px', color: 'var(--primary)' }}> Compétences</h4>
<div className="flex flex-wrap gap-1">
{profile.skills && profile.skills.length > 0
? profile.skills.map(s => <span key={s} className="badge" style={{ background: 'var(--card-bg)', border: '1px solid var(--border-color)' }}>{s}</span>)
: <span style={{ fontSize: '0.9rem', color: 'var(--gray-dark)', fontStyle: 'italic' }}>Aucune compétence spécifiée.</span>
}
</div>
</div>
{}
{isMe && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
<button className="btn btn-outline w-full" onClick={() => setIsEditing(true)}>
<Icons.Settings /> Modifier mon profil & préférences
</button>
{onLogout && (
<button className="btn btn-danger w-full" onClick={onLogout}>
<Icons.Lock /> Se déconnecter
</button>
)}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}