import { useEffect, useMemo, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import Layout from '../components/Layout'; import SortTh from '../components/SortTh'; import { supabase } from '../lib/supabase'; import { useAuth } from '../context/AuthContext'; import { useSortable } from '../hooks/useSortable'; function smoothCurve(pts) { if (pts.length < 2) return ''; let d = `M${pts[0][0].toFixed(1)},${pts[0][1].toFixed(1)}`; for (let i = 1; i < pts.length; i++) { const [x0, y0] = pts[i - 1]; const [x1, y1] = pts[i]; const cpX = (x0 + x1) / 2; d += ` C${cpX.toFixed(1)},${y0.toFixed(1)} ${cpX.toFixed(1)},${y1.toFixed(1)} ${x1.toFixed(1)},${y1.toFixed(1)}`; } return d; } function MiniAreaChart({ data, color = '#F5A523', gradId = 'ag1' }) { const W = 90, H = 42; if (!data || data.length < 2) return null; const max = Math.max(...data, 1); const pts = data.map((v, i) => [(i / (data.length - 1)) * W, 4 + (1 - v / max) * (H - 8)]); const line = smoothCurve(pts); const area = `${line} L${pts[pts.length-1][0].toFixed(1)},${H} L${pts[0][0].toFixed(1)},${H} Z`; return ( ); } function formatTaskStatus(status) { if (status === 'in_progress') return 'In Progress'; if (status === 'on_hold') return 'On Hold'; if (status === 'client_review') return 'In Review'; return status ? status.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) : '—'; } export default function ProfilePage() { const { currentUser } = useAuth(); const navigate = useNavigate(); const { id: profileId } = useParams(); const [loadingProfile, setLoadingProfile] = useState(false); const [profileError, setProfileError] = useState(''); const [viewedProfile, setViewedProfile] = useState(null); const [viewedCompanies, setViewedCompanies] = useState([]); const [primaryCompanyAddress, setPrimaryCompanyAddress] = useState(''); const [editOpen, setEditOpen] = useState(false); const [savingProfile, setSavingProfile] = useState(false); const [editError, setEditError] = useState(''); const [editForm, setEditForm] = useState({ name: '', email: '', website: '', linkedin: '', }); const [avatarUploading, setAvatarUploading] = useState(false); const [activityItems, setActivityItems] = useState([]); const [profileStats, setProfileStats] = useState(null); const [profileTaskItems, setProfileTaskItems] = useState([]); const isSelfView = !profileId || profileId === currentUser?.id; useEffect(() => { let cancelled = false; async function loadViewedProfile() { if (!profileId || profileId === currentUser?.id) { setViewedProfile(null); setViewedCompanies([]); setPrimaryCompanyAddress(''); setProfileError(''); return; } setLoadingProfile(true); setProfileError(''); try { const [{ data: profile, error }] = await Promise.all([ supabase.from('profiles').select('*').eq('id', profileId).single(), ]); if (error || !profile) { setProfileError('Unable to load profile.'); return; } const [{ data: primaryCompany }, { data: memberships }] = await Promise.all([ profile.company_id ? supabase.from('companies').select('id, name, address').eq('id', profile.company_id).maybeSingle() : Promise.resolve({ data: null }), supabase .from('company_members') .select('company_id, company:companies(id, name, address)') .eq('profile_id', profileId), ]); if (cancelled) return; const names = new Set(); if (primaryCompany?.name) names.add(primaryCompany.name); const primaryAddress = primaryCompany?.address || ''; (memberships || []).forEach((row) => { const n = row?.company?.name; if (n) names.add(n); }); setViewedProfile(profile); setViewedCompanies([...names]); setPrimaryCompanyAddress(primaryAddress); } catch (err) { if (!cancelled) setProfileError('Unable to load profile.'); } finally { if (!cancelled) setLoadingProfile(false); } } loadViewedProfile(); return () => { cancelled = true; }; }, [profileId, currentUser?.id]); useEffect(() => { let cancelled = false; const uid = isSelfView ? currentUser?.id : profileId; if (!uid) return; const doneStatuses = ['client_approved', 'invoiced', 'paid']; const now = new Date(); const weeksBack = 10; const monthsBack = 8; const weekStart = new Date(now); weekStart.setDate(weekStart.getDate() - weeksBack * 7); const monthStart = new Date(now); monthStart.setMonth(monthStart.getMonth() - monthsBack); Promise.all([ supabase.from('tasks').select('id', { count: 'exact', head: true }).eq('assigned_to', uid).in('status', doneStatuses), supabase.from('tasks').select('completed_at').eq('assigned_to', uid).in('status', doneStatuses).not('completed_at', 'is', null).gte('completed_at', weekStart.toISOString()), supabase.from('project_members').select('project_id').eq('profile_id', uid), supabase.from('tasks').select('project_id, submitted_at').eq('assigned_to', uid).not('project_id', 'is', null).gte('submitted_at', monthStart.toISOString()), ]).then(([completedTotal, completedRecent, members, tasksByMonth]) => { if (cancelled) return; // weekly chart: count completed tasks per week (oldest → newest) const weekBuckets = Array(weeksBack).fill(0); (completedRecent.data || []).forEach(t => { const msAgo = now - new Date(t.completed_at); const weekIdx = weeksBack - 1 - Math.floor(msAgo / (7 * 86400000)); if (weekIdx >= 0 && weekIdx < weeksBack) weekBuckets[weekIdx]++; }); // monthly chart: distinct active project_ids per month const monthBuckets = Array(monthsBack).fill(0); const monthSets = Array.from({ length: monthsBack }, () => new Set()); (tasksByMonth.data || []).forEach(t => { const msAgo = now - new Date(t.submitted_at); const mIdx = monthsBack - 1 - Math.floor(msAgo / (30.44 * 86400000)); if (mIdx >= 0 && mIdx < monthsBack) monthSets[mIdx].add(t.project_id); }); monthSets.forEach((s, i) => { monthBuckets[i] = s.size; }); const projectIds = (members.data || []).map(m => m.project_id); const fetchActive = projectIds.length > 0 ? supabase.from('projects').select('id', { count: 'exact', head: true }).in('id', projectIds).not('status', 'in', '("completed","cancelled")') : Promise.resolve({ count: 0 }); fetchActive.then(active => { if (cancelled) return; setProfileStats({ tasksCompleted: completedTotal.count ?? 0, tasksChart: weekBuckets, activeProjects: active.count ?? 0, projectsChart: monthBuckets, }); }); }); return () => { cancelled = true; }; }, [isSelfView, currentUser?.id, profileId]); useEffect(() => { let cancelled = false; const uid = isSelfView ? currentUser?.id : profileId; if (!uid) return; async function loadProfileTasks() { const { data } = await supabase .from('tasks') .select('id, title, status, submitted_at') .eq('assigned_to', uid) .in('status', ['in_progress', 'on_hold', 'client_review']) .order('submitted_at', { ascending: false }) .limit(20); if (!cancelled) setProfileTaskItems(data || []); } loadProfileTasks(); return () => { cancelled = true; }; }, [isSelfView, currentUser?.id, profileId]); useEffect(() => { let cancelled = false; const uid = isSelfView ? currentUser?.id : profileId; if (!uid) return; async function loadActivity() { const [{ data: actorLogs }, { data: assignedTasks }] = await Promise.all([ supabase .from('activity_log') .select('id, created_at, action, task_id, task_title, project_name, project_id') .eq('actor_id', uid) .order('created_at', { ascending: false }) .limit(20), supabase .from('tasks') .select('id') .eq('assigned_to', uid), ]); const assignedTaskIds = (assignedTasks || []).map((t) => t.id).filter(Boolean); let outcomeLogs = []; if (assignedTaskIds.length > 0) { const { data } = await supabase .from('activity_log') .select('id, created_at, action, task_id, task_title, project_name, project_id') .in('action', ['task_approved', 'revision_requested']) .in('task_id', assignedTaskIds) .order('created_at', { ascending: false }) .limit(20); outcomeLogs = data || []; } if (cancelled) return; const merged = [...(actorLogs || []), ...outcomeLogs]; const deduped = Array.from(new Map(merged.map((item) => [item.id, item])).values()); deduped.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); setActivityItems(deduped.slice(0, 10)); } loadActivity(); return () => { cancelled = true; }; }, [isSelfView, currentUser?.id, profileId]); const profile = useMemo( () => isSelfView ? { ...(currentUser || {}), ...(viewedProfile || {}) } : viewedProfile, // eslint-disable-next-line react-hooks/exhaustive-deps [isSelfView, currentUser?.id, currentUser?.name, currentUser?.email, currentUser?.role, currentUser?.website, currentUser?.linkedin, currentUser?.avatar_url, viewedProfile] ); const companyNames = useMemo(() => { if (isSelfView) { const names = new Set((currentUser?.companies || []).map((c) => c?.company?.name).filter(Boolean)); if (currentUser?.company?.name) names.add(currentUser.company.name); return [...names]; } return viewedCompanies; }, [isSelfView, currentUser, viewedCompanies]); const companyAddress = useMemo(() => { if (isSelfView) return currentUser?.company?.address || currentUser?.companies?.[0]?.company?.address || ''; return primaryCompanyAddress || ''; }, [isSelfView, currentUser, primaryCompanyAddress]); const companyDisplayName = useMemo(() => { const role = profile?.role; if (role === 'team' || role === 'external') return 'Fourge Branding'; if (companyNames.length > 0) return companyNames.join(', '); return 'No Company'; }, [companyNames, profile?.role]); const memberSince = useMemo(() => { const d = profile?.created_at ? new Date(profile.created_at) : null; return d && !Number.isNaN(d.getTime()) ? d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : null; }, [profile?.created_at]); const handleAvatarUpload = async (e) => { const file = e.target.files?.[0]; if (!file || !currentUser?.id) return; setAvatarUploading(true); try { const ext = file.name.split('.').pop(); const path = `${currentUser.id}/avatar.${ext}`; const { error: upErr } = await supabase.storage.from('avatars').upload(path, file, { upsert: true }); if (upErr) { setEditError(upErr.message); return; } const { data: { publicUrl } } = supabase.storage.from('avatars').getPublicUrl(path); const { data, error: dbErr } = await supabase.from('profiles').update({ avatar_url: publicUrl }).eq('id', currentUser.id).select('*').single(); if (dbErr) { setEditError(dbErr.message); return; } setViewedProfile(data); } finally { setAvatarUploading(false); } }; const dashCardStyle = { background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', }; const initials = (profile?.name || '') .split(' ') .map(n => n[0]) .join('') .toUpperCase() .slice(0, 2); useEffect(() => { if (!profile) return; setEditForm({ name: profile.name || '', email: profile.email || '', website: profile.website || '', linkedin: profile.linkedin || '', }); setEditError(''); // eslint-disable-next-line react-hooks/exhaustive-deps }, [profile?.name, profile?.email, profile?.website, profile?.linkedin]); const setEditField = (field) => (e) => setEditForm((prev) => ({ ...prev, [field]: e.target.value })); const handleProfileSave = async (e) => { e.preventDefault(); if (!currentUser?.id) return; setSavingProfile(true); setEditError(''); try { const payload = { name: editForm.name.trim(), email: editForm.email.trim(), website: editForm.website.trim(), linkedin: editForm.linkedin.trim(), }; const { data, error } = await supabase .from('profiles') .update(payload) .eq('id', currentUser.id) .select('*') .single(); if (error) { setEditError(error.message || 'Unable to update profile.'); return; } setViewedProfile(data || null); setEditOpen(false); } finally { setSavingProfile(false); } }; const ACTION_ICON = { task_started: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: }, task_resumed: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: }, task_submitted: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: <> }, task_approved: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: }, task_on_hold: { bg: 'rgba(239,68,68,0.15)', color: '#ef4444', path: <> }, task_created: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: <> }, project_created: { bg: 'rgba(245,165,35,0.15)', color: '#F5A523', path: <> }, request_submitted: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: <> }, revision_requested: { bg: 'rgba(245,158,11,0.15)', color: '#f59e0b', path: <> }, }; const ActionIcon = ({ actionKey, size = 27 }) => { const cfg = ACTION_ICON[actionKey] || { bg: 'rgba(255,255,255,0.08)', color: 'rgba(255,255,255,0.4)', path: }; return (
{cfg.path}
); }; const ACTION_LABEL = { task_created: 'Task created', task_started: 'Task started', task_on_hold: 'Task put on hold', task_resumed: 'Task resumed', task_submitted: 'Task submitted', task_approved: 'Task approved', project_created: 'Project created', request_submitted: 'Request submitted', revision_requested: 'Task rejected', }; const toSentenceTitle = (value = '') => { if (!value) return ''; return value.charAt(0).toUpperCase() + value.slice(1); }; const profileTitleStyle = { fontSize: 18, fontWeight: 500, color: 'var(--text-primary)', lineHeight: 1.2 }; const profileSubStyle = { fontSize: 13, color: 'var(--text-secondary)', marginTop: 3 }; const profileBodyStyle = { fontSize: 13, color: 'var(--text-primary)' }; const metaLabelStyle = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8 }; const metaValueStyle = { fontSize: 13, color: 'var(--text-primary)', marginTop: 2 }; const modalLabelStyle = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 6 }; const modalInputStyle = { fontSize: 13, padding: '0 5px', textAlign: 'left', lineHeight: 1, }; const modalHelperStyle = { fontSize: 12, color: 'var(--text-muted)', marginTop: 6 }; const modalButtonStyle = { lineHeight: 1, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', }; const ProfileTasksInProgressCard = ({ tasks = [] }) => { const { sortKey, sortDir, toggle, sort } = useSortable('task'); const [showAll, setShowAll] = useState(false); const rows = sort(tasks.slice(0, 10), (task, key) => { if (key === 'task') return task.title || ''; if (key === 'status') return formatTaskStatus(task.status); return ''; }); const visibleRows = showAll ? rows : rows.slice(0, 5); return (
0 ? 14 : 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}> Tasks In Progress {rows.length > 5 && ( )}
{rows.length === 0 ? (
No tasks in progress
) : ( Task Status {visibleRows.map((task) => ( ))}
{formatTaskStatus(task.status)}
)}
); }; const ProfileActivityFeed = ({ items }) => (
0 ? 14 : 0 }}> Recent Activity
{items.length === 0 ? (
No recent activity
) : items.map((e, i) => (
0 ? 10 : 0 }}>
{toSentenceTitle(ACTION_LABEL[e.action] || e.action)} {e.task_title && e.task_id && ( <> )} {e.task_title && !e.task_id && {e.task_title}}
{new Date(e.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
))}
); return (
{loadingProfile &&
Loading profile...
} {!loadingProfile && profileError &&
{profileError}
} {!loadingProfile && !profileError && (
{isSelfView && ( )}
{memberSince && (
Member Since
{memberSince}
)} {profile?.role && (
Role
{profile.role === 'team' ? 'Team' : profile.role === 'external' ? 'Subcontractor' : profile.role === 'client' ? 'Client' : profile.role}
)}
{profile?.avatar_url ? {profile.name} : (initials || '?')}
{profile?.name || '—'}
{companyDisplayName}
{companyAddress && (
{companyAddress}
)} {profile?.email && (
{profile.email}
)} {profile?.website && ( )} {profile?.linkedin && ( )}
{profileStats && (
{/* Tasks Completed — weekly */}
Tasks Completed
{profileStats.tasksCompleted}
' }} />
{/* Active Projects — monthly */}
Active Projects
{profileStats.activeProjects}
' }} />
)}
)}
{isSelfView && editOpen && (
{ if (!savingProfile) setEditOpen(false); }} >
e.stopPropagation()} >
Edit Profile
{profile?.avatar_url ? : (initials || '?')}
JPG, PNG or WebP
{editError &&
{editError}
}
)}
); }