Session 2026-05-29: profile layout 2-col, file browser copy/paste, fbq-proxy/backfill fns, migrations, UI updates
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@ import { supabase } from '../lib/supabase';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { deleteCompanyData } from '../lib/deleteHelpers';
|
||||
import { readPageCache, writePageCache } from '../lib/pageCache';
|
||||
import { createClientFolder, backfillClientFolders } from '../lib/filebrowserFolders';
|
||||
import { createClientFolder, backfillClientFolders, createTeamMemberFolder } from '../lib/filebrowserFolders';
|
||||
|
||||
// ── Team view ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -113,6 +113,9 @@ function TeamCompanies() {
|
||||
const errBody = error?.context ? await error.context.json().catch(() => null) : null;
|
||||
const errMsg = errBody?.error || data?.error || error?.message;
|
||||
if (errMsg) { setUserError(errMsg); return; }
|
||||
if (userForm.role === 'team' || userForm.role === 'external') {
|
||||
createTeamMemberFolder(userForm.name.trim()).catch(() => {});
|
||||
}
|
||||
setShowNewUser(false);
|
||||
setUserForm({ name: '', email: '', password: '', company_id: '', role: 'client' });
|
||||
load();
|
||||
|
||||
@@ -6,8 +6,8 @@ import { supabase } from '../lib/supabase';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { serviceTypes } from '../data/mockData';
|
||||
import { cleanupTaskStorage, deleteCompanyData } from '../lib/deleteHelpers';
|
||||
import { renameClientFolder, backfillClientFolders } from '../lib/filebrowserFolders';
|
||||
import { logActivity } from '../lib/activityLog';
|
||||
import { createProjectFolder } from '../lib/filebrowserFolders';
|
||||
|
||||
export default function CompanyDetail() {
|
||||
const { id } = useParams();
|
||||
@@ -73,8 +73,6 @@ export default function CompanyDetail() {
|
||||
setSavingName(true);
|
||||
const oldName = company.name;
|
||||
await supabase.from('companies').update({ name: nameVal.trim() }).eq('id', id);
|
||||
renameClientFolder(oldName, nameVal.trim()).catch(() => {});
|
||||
backfillClientFolders().catch(() => {});
|
||||
setCompany(c => ({ ...c, name: nameVal.trim() }));
|
||||
setEditingName(false);
|
||||
setSavingName(false);
|
||||
@@ -176,19 +174,10 @@ export default function CompanyDetail() {
|
||||
}).select().single();
|
||||
if (data) {
|
||||
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'project_created', projectId: data.id, projectName: data.name });
|
||||
createProjectFolder(company.name, data.name).catch(() => {});
|
||||
setProjects(prev => [data, ...prev]);
|
||||
setNewProjectName('');
|
||||
setShowNewProject(false);
|
||||
// Fire-and-forget: create project folder in FileBrowser
|
||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||
if (session?.access_token && company?.name) {
|
||||
fetch('/api/sync-project-folder', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.access_token}` },
|
||||
body: JSON.stringify({ type: 'INSERT', record: { name: data.name, company_name: company.name } }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
setSavingProject(false);
|
||||
};
|
||||
|
||||
+1108
-6
File diff suppressed because it is too large
Load Diff
Executable → Regular
@@ -2,16 +2,15 @@ import { useState, useEffect } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import Layout from '../components/Layout';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import FileBrowser from '../components/FileBrowser';
|
||||
import SortTh from '../components/SortTh';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { serviceTypes } from '../data/mockData';
|
||||
import { addDaysToDateOnly, getTodayDateOnlyEST } from '../lib/dates';
|
||||
import { logActivity } from '../lib/activityLog';
|
||||
import { createTaskFolder } from '../lib/filebrowserFolders';
|
||||
import { useSortable } from '../hooks/useSortable';
|
||||
|
||||
const safeFbName = v => String(v || '').trim().replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-').replace(/\s+/g, ' ').replace(/^-+|-+$/g, '');
|
||||
|
||||
const rLabel = (v) => 'R' + String(v || 0).padStart(2, '0');
|
||||
const emptyJobForm = () => ({ title: '', serviceType: '', deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3), description: '', requestedBy: '', isHot: false });
|
||||
@@ -149,6 +148,7 @@ export default function ProjectDetailPage() {
|
||||
if (task) {
|
||||
await supabase.from('submissions').insert({ task_id: task.id, version_number: 0, type: 'initial', is_hot: jobForm.isHot, service_type: jobForm.serviceType, deadline: jobForm.deadline || null, description: jobForm.description.trim() || null, submitted_by: requestor.id, submitted_by_name: requestor.name.replace(' (You)', '') });
|
||||
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'task_created', taskId: task.id, taskTitle: task.title, projectId: id, projectName: project?.name });
|
||||
createTaskFolder(company?.name, project?.name, task.title).catch(() => {});
|
||||
setTasks(prev => [task, ...prev]);
|
||||
setJobForm(emptyJobForm());
|
||||
setShowAddJob(false);
|
||||
@@ -315,20 +315,6 @@ export default function ProjectDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Project Folder (FileBrowser) — team + client */}
|
||||
{!isExternal && company?.name && project?.name && (() => {
|
||||
const co = safeFbName(company.name);
|
||||
const proj = safeFbName(project.name);
|
||||
const fbRoot = isClient
|
||||
? `/${co}/Projects/${proj}/00 Project Files`
|
||||
: `/Clients/${co}/Projects/${proj}/00 Project Files`;
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div className="card-title">Project Files</div>
|
||||
<FileBrowser initialPath={fbRoot} rootPath={fbRoot} />
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Client: mine/all filter */}
|
||||
{isClient && (
|
||||
|
||||
@@ -10,7 +10,7 @@ import { sendEmail } from '../lib/email';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { serviceTypes } from '../data/mockData';
|
||||
import { addDaysToDateOnly, formatDateEST, getTodayDateOnlyEST } from '../lib/dates';
|
||||
import { uploadFilesToRequestInfo } from '../lib/filebrowserFolders';
|
||||
import { uploadFilesToRequestInfo, createProjectFolder } from '../lib/filebrowserFolders';
|
||||
import { logActivity } from '../lib/activityLog';
|
||||
|
||||
const rLabel = (v) => 'R' + String(v || 0).padStart(2, '0');
|
||||
@@ -426,6 +426,7 @@ export default function RequestDetail() {
|
||||
try {
|
||||
const { data: newProj, error } = await supabase.from('projects').insert({ company_id: company.id, name: newProjectName.trim(), status: 'active' }).select().single();
|
||||
if (error) throw new Error(error.message);
|
||||
createProjectFolder(company.name, newProj.name).catch(() => {});
|
||||
await supabase.from('tasks').update({ project_id: newProj.id }).eq('id', id);
|
||||
setTask(t => ({ ...t, project_id: newProj.id }));
|
||||
setProject(p => ({ ...p, id: newProj.id, name: newProj.name }));
|
||||
|
||||
@@ -12,7 +12,7 @@ import { getCurrentVersionForTask, getDeadlineSourceSubmission } from '../lib/ta
|
||||
import { formatDateOnly, fmtShortDate } from '../lib/dates';
|
||||
import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../lib/requestSubmission';
|
||||
import { sendEmail } from '../lib/email';
|
||||
import { uploadFilesToRequestInfo } from '../lib/filebrowserFolders';
|
||||
import { uploadFilesToRequestInfo, createTaskFolder } from '../lib/filebrowserFolders';
|
||||
import SortTh from '../components/SortTh';
|
||||
import { useSortable } from '../hooks/useSortable';
|
||||
import FilterDropdown from '../components/FilterDropdown';
|
||||
@@ -185,6 +185,8 @@ export default function RequestsPage() {
|
||||
if (!projects.some(p => p.id === resolvedProject.id)) setProjects(prev => [...prev, resolvedProject]);
|
||||
const { task } = await createTaskForRequest({ projectId: resolvedProject.id, title: formData.title.trim(), requestKey: addRequestKey });
|
||||
if (!task) throw new Error('Failed to create task.');
|
||||
const taskCompany = companies?.find(c => c.id === formData.companyId);
|
||||
createTaskFolder(taskCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
|
||||
const { submission: sub } = await createInitialSubmissionForRequest({
|
||||
taskId: task.id, requestKey: addRequestKey, isHot: formData.isHot,
|
||||
serviceType: formData.serviceType, deadline: formData.deadline,
|
||||
@@ -213,6 +215,7 @@ export default function RequestsPage() {
|
||||
const resolvedProject = await findOrCreateProject(formData.companyId, formData.project.trim(), existingProjects);
|
||||
const { task } = await createTaskForRequest({ projectId: resolvedProject.id, title: formData.title.trim(), requestKey: addRequestKey });
|
||||
if (!task) throw new Error('Failed to create task.');
|
||||
createTaskFolder(selectedCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
|
||||
const { submission } = await createInitialSubmissionForRequest({
|
||||
taskId: task.id, requestKey: addRequestKey, isHot: formData.isHot,
|
||||
serviceType: formData.serviceType, deadline: formData.deadline,
|
||||
|
||||
Executable → Regular
+372
-140
@@ -1,8 +1,49 @@
|
||||
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 (
|
||||
<svg width="100%" height={H} viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" style={{ display: 'block', overflow: 'hidden' }}>
|
||||
<defs>
|
||||
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={color} stopOpacity="0.3" />
|
||||
<stop offset="95%" stopColor={color} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={area} fill={`url(#${gradId})`} />
|
||||
<path d={line} fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -18,15 +59,15 @@ export default function ProfilePage() {
|
||||
const [editError, setEditError] = useState('');
|
||||
const [editForm, setEditForm] = useState({
|
||||
name: '',
|
||||
title: '',
|
||||
|
||||
email: '',
|
||||
website: '',
|
||||
linkedin: '',
|
||||
instagram: '',
|
||||
twitter: '',
|
||||
});
|
||||
const [avatarUploading, setAvatarUploading] = useState(false);
|
||||
const [activityItems, setActivityItems] = useState([]);
|
||||
const [profileStats, setProfileStats] = useState(null);
|
||||
const [profileTaskItems, setProfileTaskItems] = useState([]);
|
||||
|
||||
const isSelfView = !profileId || profileId === currentUser?.id;
|
||||
|
||||
@@ -43,17 +84,8 @@ export default function ProfilePage() {
|
||||
setLoadingProfile(true);
|
||||
setProfileError('');
|
||||
try {
|
||||
const [{ data: profile, error }, { data: assignedTasks }] = await Promise.all([
|
||||
supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', profileId)
|
||||
.single(),
|
||||
supabase
|
||||
.from('tasks')
|
||||
.select('id, title, deadline, status')
|
||||
.eq('assigned_to', profileId)
|
||||
.not('deadline', 'is', null),
|
||||
const [{ data: profile, error }] = await Promise.all([
|
||||
supabase.from('profiles').select('*').eq('id', profileId).single(),
|
||||
]);
|
||||
if (error || !profile) {
|
||||
setProfileError('Unable to load profile.');
|
||||
@@ -82,16 +114,6 @@ export default function ProfilePage() {
|
||||
setViewedProfile(profile);
|
||||
setViewedCompanies([...names]);
|
||||
setPrimaryCompanyAddress(primaryAddress);
|
||||
setCalendarItems(
|
||||
(assignedTasks || []).map((t) => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
deadline: t.deadline,
|
||||
isDone: ['client_approved', 'invoiced', 'paid'].includes(t.status),
|
||||
isOverdue: !!t.deadline && !['client_approved', 'invoiced', 'paid'].includes(t.status) && new Date(t.deadline) < new Date(),
|
||||
isHot: t.status === 'revision_requested',
|
||||
}))
|
||||
);
|
||||
} catch (err) {
|
||||
if (!cancelled) setProfileError('Unable to load profile.');
|
||||
} finally {
|
||||
@@ -110,24 +132,49 @@ export default function ProfilePage() {
|
||||
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('id', { count: 'exact', head: true }).eq('assigned_to', uid).eq('status', 'revision_requested'),
|
||||
supabase.from('submissions').select('id', { count: 'exact', head: true }).eq('submitted_by', uid),
|
||||
]).then(([completed, members, revisions, submissions]) => {
|
||||
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 fetchActiveProjects = projectIds.length > 0
|
||||
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 });
|
||||
fetchActiveProjects.then(active => {
|
||||
fetchActive.then(active => {
|
||||
if (cancelled) return;
|
||||
setProfileStats({
|
||||
tasksCompleted: completed.count ?? 0,
|
||||
tasksCompleted: completedTotal.count ?? 0,
|
||||
tasksChart: weekBuckets,
|
||||
activeProjects: active.count ?? 0,
|
||||
revisionRequests: revisions.count ?? 0,
|
||||
submissions: submissions.count ?? 0,
|
||||
projectsChart: monthBuckets,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -138,23 +185,69 @@ export default function ProfilePage() {
|
||||
let cancelled = false;
|
||||
const uid = isSelfView ? currentUser?.id : profileId;
|
||||
if (!uid) return;
|
||||
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(10)
|
||||
.then(({ data }) => {
|
||||
if (cancelled) return;
|
||||
setActivityItems(data || []);
|
||||
});
|
||||
|
||||
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?.title, currentUser?.email, currentUser?.role, currentUser?.website, currentUser?.linkedin, currentUser?.instagram, currentUser?.twitter, viewedProfile]
|
||||
[isSelfView, currentUser?.id, currentUser?.name, currentUser?.email, currentUser?.role, currentUser?.website, currentUser?.linkedin, currentUser?.avatar_url, viewedProfile]
|
||||
);
|
||||
const companyNames = useMemo(() => {
|
||||
if (isSelfView) {
|
||||
@@ -168,6 +261,12 @@ export default function ProfilePage() {
|
||||
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;
|
||||
@@ -176,18 +275,23 @@ export default function ProfilePage() {
|
||||
: null;
|
||||
}, [profile?.created_at]);
|
||||
|
||||
const socialLinks = useMemo(() => {
|
||||
if (!profile) return [];
|
||||
const candidates = [
|
||||
{ key: 'website', label: 'Website' },
|
||||
{ key: 'linkedin', label: 'LinkedIn' },
|
||||
{ key: 'instagram', label: 'Instagram' },
|
||||
{ key: 'twitter', label: 'X / Twitter' },
|
||||
];
|
||||
return candidates
|
||||
.map(({ key, label }) => ({ label, value: profile[key] }))
|
||||
.filter((item) => typeof item.value === 'string' && item.value.trim().length > 0);
|
||||
}, [profile]);
|
||||
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)',
|
||||
@@ -208,16 +312,14 @@ export default function ProfilePage() {
|
||||
if (!profile) return;
|
||||
setEditForm({
|
||||
name: profile.name || '',
|
||||
title: profile.title || '',
|
||||
|
||||
email: profile.email || '',
|
||||
website: profile.website || '',
|
||||
linkedin: profile.linkedin || '',
|
||||
instagram: profile.instagram || '',
|
||||
twitter: profile.twitter || '',
|
||||
});
|
||||
setEditError('');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [profile?.name, profile?.title, profile?.email, profile?.website, profile?.linkedin, profile?.instagram, profile?.twitter]);
|
||||
}, [profile?.name, profile?.email, profile?.website, profile?.linkedin]);
|
||||
|
||||
const setEditField = (field) => (e) => setEditForm((prev) => ({ ...prev, [field]: e.target.value }));
|
||||
|
||||
@@ -229,12 +331,10 @@ export default function ProfilePage() {
|
||||
try {
|
||||
const payload = {
|
||||
name: editForm.name.trim(),
|
||||
title: editForm.title.trim(),
|
||||
|
||||
email: editForm.email.trim(),
|
||||
website: editForm.website.trim(),
|
||||
linkedin: editForm.linkedin.trim(),
|
||||
instagram: editForm.instagram.trim(),
|
||||
twitter: editForm.twitter.trim(),
|
||||
};
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
@@ -254,10 +354,111 @@ export default function ProfilePage() {
|
||||
};
|
||||
|
||||
|
||||
const ACTION_ICON = {
|
||||
task_started: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> },
|
||||
task_resumed: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> },
|
||||
task_submitted: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: <><line x1="12" y1="19" x2="12" y2="5" strokeWidth="2" strokeLinecap="round"/><polyline points="5,12 12,5 19,12" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/></> },
|
||||
task_approved: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: <polyline points="4,13 9,18 20,7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/> },
|
||||
task_on_hold: { bg: 'rgba(239,68,68,0.15)', color: '#ef4444', path: <><rect x="6" y="4" width="4" height="16" rx="1" fill="currentColor"/><rect x="14" y="4" width="4" height="16" rx="1" fill="currentColor"/></> },
|
||||
task_created: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: <><line x1="12" y1="5" x2="12" y2="19" strokeWidth="2" strokeLinecap="round"/><line x1="5" y1="12" x2="19" y2="12" strokeWidth="2" strokeLinecap="round"/></> },
|
||||
project_created: { bg: 'rgba(245,165,35,0.15)', color: '#F5A523', path: <><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" fill="none" strokeWidth="1.5"/></> },
|
||||
request_submitted: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: <><rect x="5" y="3" width="14" height="18" rx="2" fill="none" strokeWidth="1.5"/><line x1="9" y1="8" x2="15" y2="8" strokeWidth="1.5" strokeLinecap="round"/><line x1="9" y1="12" x2="15" y2="12" strokeWidth="1.5" strokeLinecap="round"/><line x1="9" y1="16" x2="12" y2="16" strokeWidth="1.5" strokeLinecap="round"/></> },
|
||||
revision_requested: { bg: 'rgba(245,158,11,0.15)', color: '#f59e0b', path: <><path d="M4 12a8 8 0 018-8v0a8 8 0 018 8" fill="none" strokeWidth="1.5" strokeLinecap="round"/><polyline points="18,8 20,12 16,12" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none"/></> },
|
||||
};
|
||||
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: <circle cx="12" cy="12" r="3" fill="currentColor"/> };
|
||||
return (
|
||||
<div style={{ width: size, height: size, borderRadius: '50%', background: cfg.bg, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" stroke={cfg.color} fill="none" style={{ color: cfg.color }}>{cfg.path}</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ACTION_LABEL = {
|
||||
task_created: 'created', task_started: 'started', task_on_hold: 'put on hold',
|
||||
task_resumed: 'resumed', task_submitted: 'submitted', task_approved: 'approved',
|
||||
project_created: 'created project', request_submitted: 'submitted', revision_requested: 'requested revision on',
|
||||
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 (
|
||||
<div style={{ ...dashCardStyle, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ marginBottom: rows.length > 0 ? 14 : 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Tasks In Progress</span>
|
||||
{rows.length > 5 && (
|
||||
<button type="button" className="dashboard-inline-link" style={{ fontSize: 11, fontWeight: 500, letterSpacing: 0.4, textTransform: 'uppercase' }} onClick={() => setShowAll((value) => !value)}>
|
||||
{showAll ? 'Show less' : 'Show all'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{rows.length === 0 ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No tasks in progress</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '65%' }} />
|
||||
<col style={{ width: '35%' }} />
|
||||
</colgroup>
|
||||
<thead style={{ background: 'transparent' }}>
|
||||
<tr style={{ background: 'transparent' }}>
|
||||
<SortTh col="task" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'left', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' }}>
|
||||
Task
|
||||
</SortTh>
|
||||
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'right', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 5px 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>
|
||||
Status
|
||||
</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleRows.map((task) => (
|
||||
<tr key={task.id} style={{ background: 'transparent' }}>
|
||||
<td style={{ fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'left' }}>
|
||||
<button type="button" className="dashboard-inline-link" onClick={() => navigate(`/requests/${task.id}`)}>{task.title}</button>
|
||||
</td>
|
||||
<td style={{ fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'right' }}>
|
||||
{formatTaskStatus(task.status)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ProfileActivityFeed = ({ items }) => (
|
||||
@@ -269,8 +470,9 @@ export default function ProfilePage() {
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 14 }}>No recent activity</div>
|
||||
) : items.map((e, i) => (
|
||||
<div key={e.id} style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: i > 0 ? 10 : 0 }}>
|
||||
<ActionIcon actionKey={e.action} />
|
||||
<div style={{ flex: 1, minWidth: 0, fontSize: 13, lineHeight: 1.4 }}>
|
||||
<span style={{ color: 'var(--text-muted)' }}>{ACTION_LABEL[e.action] || e.action}</span>
|
||||
<span style={{ color: 'var(--text-muted)' }}>{toSentenceTitle(ACTION_LABEL[e.action] || e.action)}</span>
|
||||
{e.task_title && e.task_id && (
|
||||
<><span style={{ color: 'var(--text-muted)' }}> </span>
|
||||
<button type="button" className="dashboard-inline-link" onClick={() => navigate(`/requests/${e.task_id}`)}>{e.task_title}</button></>
|
||||
@@ -294,89 +496,114 @@ export default function ProfilePage() {
|
||||
<div className="profile-top-grid">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<div style={{ ...dashCardStyle, display: 'flex', alignItems: 'flex-start', gap: 20, position: 'relative' }}>
|
||||
{isSelfView && (
|
||||
<div style={{ position: 'absolute', top: 18, right: 21, bottom: 18, display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'space-between' }}>
|
||||
<div style={{ position: 'absolute', top: 18, right: 21, bottom: 18, display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'space-between' }}>
|
||||
{isSelfView && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => setEditOpen(true)}
|
||||
style={{ borderRadius: 8, height: 30, padding: '0 12px', fontSize: 12 }}
|
||||
>
|
||||
Edit Profile
|
||||
</button>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 10 }}>
|
||||
{memberSince && (
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: 10, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6 }}>Member Since</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-primary)', marginTop: 2 }}>{memberSince}</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 10, marginTop: 'auto' }}>
|
||||
{memberSince && (
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={metaLabelStyle}>Member Since</div>
|
||||
<div style={metaValueStyle}>{memberSince}</div>
|
||||
</div>
|
||||
)}
|
||||
{profile?.role && (
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={metaLabelStyle}>Role</div>
|
||||
<div style={metaValueStyle}>
|
||||
{profile.role === 'team' ? 'Team' : profile.role === 'external' ? 'Subcontractor' : profile.role === 'client' ? 'Client' : profile.role}
|
||||
</div>
|
||||
)}
|
||||
{profile?.role && (
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: 10, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6 }}>Role</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-primary)', marginTop: 2, textTransform: 'capitalize' }}>{profile.role}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ width: 120, height: 120, flexShrink: 0, borderRadius: '50%', background: 'var(--card-bg-2)', border: '2px solid #111', outline: '2px solid var(--accent)', outlineOffset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 36, color: 'var(--text-primary)', fontWeight: 500, lineHeight: 1 }}>
|
||||
{initials || '?'}
|
||||
</div>
|
||||
<div style={{ width: 140, height: 140, flexShrink: 0, borderRadius: '50%', background: 'var(--card-bg-2)', border: '2px solid #111', outline: '2px solid var(--accent)', outlineOffset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 36, color: 'var(--text-primary)', fontWeight: 500, lineHeight: 1, overflow: 'hidden' }}>
|
||||
{profile?.avatar_url
|
||||
? <img src={profile.avatar_url} alt={profile.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: (initials || '?')}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 500, fontSize: 18, color: 'var(--text-primary)' }}>{profile?.name || '—'}</div>
|
||||
{profile?.title && <div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 3 }}>{profile.title}</div>}
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: 3 }}>
|
||||
{companyNames.length > 0 ? companyNames.join(', ') : '—'}
|
||||
<div style={profileTitleStyle}>{profile?.name || '—'}</div>
|
||||
<div style={profileSubStyle}>
|
||||
{companyDisplayName}
|
||||
</div>
|
||||
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{companyAddress && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><path d="M12 22s-8-4.5-8-11.8A8 8 0 0112 2a8 8 0 018 8.2c0 7.3-8 11.8-8 11.8z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
<span style={{ color: 'var(--text-primary)' }}>{companyAddress}</span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0, position: 'relative', top: -2 }}><path d="M12 22s-8-4.5-8-11.8A8 8 0 0112 2a8 8 0 018 8.2c0 7.3-8 11.8-8 11.8z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
<span style={profileBodyStyle}>{companyAddress}</span>
|
||||
</div>
|
||||
)}
|
||||
{profile?.email && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="2,4 12,13 22,4"/></svg>
|
||||
<span style={{ color: 'var(--text-primary)' }}>{profile.email}</span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0, position: 'relative', top: -2 }}><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="2,4 12,13 22,4"/></svg>
|
||||
<span style={profileBodyStyle}>{profile.email}</span>
|
||||
</div>
|
||||
)}
|
||||
{profile?.website && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0, position: 'relative', top: -2 }}><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 010 20M12 2a15.3 15.3 0 000 20"/></svg>
|
||||
<a href={profile.website.startsWith('http') ? profile.website : `https://${profile.website}`} target="_blank" rel="noreferrer" style={{ ...profileBodyStyle, textDecoration: 'none' }}>{profile.website.replace(/^https?:\/\/(www\.)?/, '')}</a>
|
||||
</div>
|
||||
)}
|
||||
{profile?.linkedin && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="var(--text-muted)" style={{ flexShrink: 0 }}><path d="M16 8a6 6 0 016 6v7h-4v-7a2 2 0 00-2-2 2 2 0 00-2 2v7h-4v-7a6 6 0 016-6z"/><rect x="2" y="9" width="4" height="12"/><circle cx="4" cy="4" r="2"/></svg>
|
||||
<a href={profile.linkedin.startsWith('http') ? profile.linkedin : `https://${profile.linkedin}`} target="_blank" rel="noreferrer" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: 13 }}>{profile.linkedin.replace(/^https?:\/\/(www\.)?/, '')}</a>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="var(--text-muted)" style={{ flexShrink: 0, position: 'relative', top: -2 }}><path d="M16 8a6 6 0 016 6v7h-4v-7a2 2 0 00-2-2 2 2 0 00-2 2v7h-4v-7a6 6 0 016-6z"/><rect x="2" y="9" width="4" height="12"/><circle cx="4" cy="4" r="2"/></svg>
|
||||
<a href={profile.linkedin.startsWith('http') ? profile.linkedin : `https://${profile.linkedin}`} target="_blank" rel="noreferrer" style={{ ...profileBodyStyle, textDecoration: 'none' }}>{profile.linkedin.replace(/^https?:\/\/(www\.)?/, '')}</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{profileStats && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 24 }}>
|
||||
{[
|
||||
{ label: 'Tasks Completed', value: profileStats.tasksCompleted, iconBg: 'rgba(74,222,128,0.15)', iconColor: '#4ade80', iconPath: '<polyline points="4,13 9,18 20,7"/>' },
|
||||
{ label: 'Active Projects', value: profileStats.activeProjects, iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: '<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" fill="none"/>' },
|
||||
{ label: 'Revision Requests',value: profileStats.revisionRequests, iconBg: 'rgba(239,68,68,0.15)', iconColor: '#f87171', iconPath: '<path d="M4 12a8 8 0 018-8v0a8 8 0 018 8" fill="none" stroke-linecap="round"/><polyline points="18,8 20,12 16,12" fill="none"/>' },
|
||||
{ label: 'Submissions', value: profileStats.submissions, iconBg: 'rgba(96,165,250,0.15)', iconColor: '#60a5fa', iconPath: '<line x1="12" y1="19" x2="12" y2="5" stroke-linecap="round"/><polyline points="5,12 12,5 19,12" fill="none"/>' },
|
||||
].map(({ label, value, iconBg, iconColor, iconPath }) => (
|
||||
<div key={label} style={{ ...dashCardStyle, display: 'flex', alignItems: 'stretch', gap: 21, minHeight: 120 }}>
|
||||
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', flex: 1 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
|
||||
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: iconPath }} />
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24 }}>
|
||||
{/* Tasks Completed — weekly */}
|
||||
<div style={{ ...dashCardStyle, display: 'flex', flexDirection: 'column', minHeight: 120 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'stretch', gap: 21 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>Tasks Completed</div>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
|
||||
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{profileStats.tasksCompleted}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||
<div style={{ width: 27, height: 27, borderRadius: '50%', background: 'rgba(74,222,128,0.15)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#4ade80" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: '<polyline points="4,13 9,18 20,7"/>' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MiniAreaChart data={profileStats.tasksChart} color="#4ade80" gradId="tcGrad" />
|
||||
</div>
|
||||
{/* Active Projects — monthly */}
|
||||
<div style={{ ...dashCardStyle, display: 'flex', flexDirection: 'column', minHeight: 120 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'stretch', gap: 21 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>Active Projects</div>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
|
||||
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{profileStats.activeProjects}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||
<div style={{ width: 27, height: 27, borderRadius: '50%', background: 'rgba(245,165,35,0.15)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#F5A523" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: '<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" fill="none"/>' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MiniAreaChart data={profileStats.projectsChart} color="#F5A523" gradId="apGrad" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ProfileActivityFeed items={activityItems} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<ProfileTasksInProgressCard tasks={profileTaskItems} />
|
||||
<ProfileActivityFeed items={activityItems} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -399,48 +626,53 @@ export default function ProfilePage() {
|
||||
<div
|
||||
style={{
|
||||
...dashCardStyle,
|
||||
width: 'min(620px, 100%)',
|
||||
width: 'min(434px, 100%)',
|
||||
maxHeight: 'calc(100vh - 48px)',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ fontSize: 18, fontWeight: 500, marginBottom: 14, color: 'var(--text-primary)' }}>Edit Profile</div>
|
||||
<div style={{ ...metaLabelStyle, marginBottom: 14 }}>Edit Profile</div>
|
||||
<form onSubmit={handleProfileSave}>
|
||||
<div className="form-group">
|
||||
<label>Name</label>
|
||||
<input value={editForm.name} onChange={setEditField('name')} />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 20 }}>
|
||||
<div style={{ width: 72, height: 72, borderRadius: '50%', background: 'var(--card-bg-2)', border: '2px solid #111', outline: '2px solid var(--accent)', outlineOffset: 0, flexShrink: 0, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 24, color: 'var(--text-primary)', fontWeight: 500 }}>
|
||||
{profile?.avatar_url
|
||||
? <img src={profile.avatar_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: (initials || '?')}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="avatar-upload" style={{ cursor: 'pointer', display: 'inline-block' }}>
|
||||
<span className="btn btn-outline" style={{ ...modalButtonStyle, display: 'inline-flex', alignItems: 'center' }}>
|
||||
{avatarUploading ? 'Uploading…' : 'Change Photo'}
|
||||
</span>
|
||||
</label>
|
||||
<input id="avatar-upload" type="file" accept="image/*" style={{ display: 'none' }} onChange={handleAvatarUpload} disabled={avatarUploading} />
|
||||
<div style={modalHelperStyle}>JPG, PNG or WebP</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Title</label>
|
||||
<input value={editForm.title} onChange={setEditField('title')} placeholder="e.g. Creative Director" />
|
||||
<label style={modalLabelStyle}>Name</label>
|
||||
<input type="text" value={editForm.name} onChange={setEditField('name')} style={modalInputStyle} />
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label style={modalLabelStyle}>Email</label>
|
||||
<input type="email" value={editForm.email} onChange={setEditField('email')} style={modalInputStyle} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Email</label>
|
||||
<input type="email" value={editForm.email} onChange={setEditField('email')} />
|
||||
<label style={modalLabelStyle}>Website</label>
|
||||
<input type="text" value={editForm.website} onChange={setEditField('website')} placeholder="example.com" style={modalInputStyle} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Website</label>
|
||||
<input value={editForm.website} onChange={setEditField('website')} placeholder="example.com" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>LinkedIn</label>
|
||||
<input value={editForm.linkedin} onChange={setEditField('linkedin')} placeholder="linkedin.com/in/username" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Instagram</label>
|
||||
<input value={editForm.instagram} onChange={setEditField('instagram')} placeholder="instagram.com/username" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>X / Twitter</label>
|
||||
<input value={editForm.twitter} onChange={setEditField('twitter')} placeholder="x.com/username" />
|
||||
<label style={modalLabelStyle}>LinkedIn</label>
|
||||
<input type="text" value={editForm.linkedin} onChange={setEditField('linkedin')} placeholder="linkedin.com/in/username" style={modalInputStyle} />
|
||||
</div>
|
||||
{editError && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>{editError}</div>}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 6 }}>
|
||||
<button type="button" className="btn btn-outline" onClick={() => setEditOpen(false)} disabled={savingProfile}>
|
||||
<button type="button" className="btn btn-outline" onClick={() => setEditOpen(false)} disabled={savingProfile} style={modalButtonStyle}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={savingProfile}>
|
||||
<button type="submit" className="btn btn-outline" disabled={savingProfile} style={modalButtonStyle}>
|
||||
{savingProfile ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Executable → Regular
@@ -29,6 +29,12 @@ const buildRevisionItemDescription = (revision) => {
|
||||
return `${projectName} • ${taskTitle} • Revision ${versionLabel}`;
|
||||
};
|
||||
|
||||
const getRevisionChargeQuantity = (revision) => {
|
||||
const version = Number(revision?.version_number || 0);
|
||||
// Incremental billing: R01 is free, each uninvoiced revision from R02+ is one charge unit.
|
||||
return version >= 2 ? 1 : 0;
|
||||
};
|
||||
|
||||
export default function CreateInvoice() {
|
||||
const navigate = useNavigate();
|
||||
const { currentUser } = useAuth();
|
||||
@@ -90,23 +96,22 @@ export default function CreateInvoice() {
|
||||
setInvoiceEmail(recipients[0]?.email || companies.find(c => c.id === selectedCompanyId)?.contact_email || '');
|
||||
if (projects && projects.length > 0) {
|
||||
const projectIds = projects.map(p => p.id);
|
||||
const [{ data: tasks }, { data: revisions }] = await Promise.all([
|
||||
supabase
|
||||
.from('tasks')
|
||||
.select('*, project:projects(name), submissions(service_type, type, version_number)')
|
||||
.in('project_id', projectIds)
|
||||
.eq('invoiced', false)
|
||||
.eq('status', 'client_approved'),
|
||||
supabase
|
||||
const { data: tasks } = await supabase
|
||||
.from('tasks')
|
||||
.select('*, project:projects(name), submissions(service_type, type, version_number)')
|
||||
.in('project_id', projectIds)
|
||||
.eq('invoiced', false)
|
||||
.eq('status', 'client_approved');
|
||||
const approvedTaskIds = (tasks || []).map((t) => t.id).filter(Boolean);
|
||||
const { data: revisions } = approvedTaskIds.length > 0
|
||||
? await supabase
|
||||
.from('submissions')
|
||||
.select('*, task:tasks(id, title, project:projects(name), submissions(service_type, type))')
|
||||
.eq('type', 'revision')
|
||||
.or('revision_type.eq.client_revision,revision_type.is.null')
|
||||
.eq('invoiced', false)
|
||||
.in('task_id',
|
||||
(await supabase.from('tasks').select('id').in('project_id', projectIds)).data?.map(t => t.id) || []
|
||||
),
|
||||
]);
|
||||
.in('task_id', approvedTaskIds)
|
||||
: { data: [] };
|
||||
const tasksWithService = (tasks || []).map(t => {
|
||||
const initial = (t.submissions || []).find(s => s.type === 'initial') || (t.submissions || [])[0];
|
||||
return { ...t, service_type: initial?.service_type || t.title };
|
||||
@@ -141,11 +146,14 @@ export default function CreateInvoice() {
|
||||
const serviceLabel = getRevisionServiceType(revision);
|
||||
const description = buildRevisionItemDescription(revision);
|
||||
const price = priceList.find(p => p.service_type === serviceLabel && p.price_type === 'revision');
|
||||
const revisionChargeQty = getRevisionChargeQuantity(revision);
|
||||
const quantity = revisionChargeQty > 0 ? revisionChargeQty : 1;
|
||||
const unitPrice = revisionChargeQty > 0 ? (price?.price || '') : 0;
|
||||
setItems(prev => {
|
||||
if (prev.length === 1 && !prev[0].description && !prev[0].unit_price) {
|
||||
return [newItem(description, price?.price || '', 1, revision.task_id, revision.id)];
|
||||
return [newItem(description, unitPrice, quantity, revision.task_id, revision.id)];
|
||||
}
|
||||
return [...prev, newItem(description, price?.price || '', 1, revision.task_id, revision.id)];
|
||||
return [...prev, newItem(description, unitPrice, quantity, revision.task_id, revision.id)];
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -319,6 +319,60 @@ function MiniCalendar({ items = [] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function TasksInProgressCard({ tasks = [] }) {
|
||||
const navigate = useNavigate();
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('task');
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const rows = sort(tasks.slice(0, 10), (t, key) => {
|
||||
if (key === 'task') return t.title || '';
|
||||
if (key === 'assigned_to') return t.assigned_name || '';
|
||||
return '';
|
||||
});
|
||||
const visibleRows = showAll ? rows : rows.slice(0, 5);
|
||||
return (
|
||||
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ marginBottom: rows.length > 0 ? 14 : 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Tasks In Progress</span>
|
||||
{rows.length > 5 && (
|
||||
<button type="button" className="dashboard-inline-link" style={{ fontSize: 11, fontWeight: 500, letterSpacing: 0.4, textTransform: 'uppercase' }} onClick={() => setShowAll((v) => !v)}>
|
||||
{showAll ? 'Show less' : 'Show all'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{rows.length === 0 ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No tasks in progress</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '55%' }} />
|
||||
<col style={{ width: '45%' }} />
|
||||
</colgroup>
|
||||
<thead style={{ background: 'transparent' }}>
|
||||
<tr style={{ background: 'transparent' }}>
|
||||
<SortTh col="task" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'left', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Task</SortTh>
|
||||
<SortTh col="assigned_to" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'right', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 5px 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Assigned To</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleRows.map((t) => (
|
||||
<tr key={t.id} style={{ background: 'transparent' }}>
|
||||
<td style={{ fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'left' }}>
|
||||
<button type="button" className="dashboard-inline-link" onClick={() => navigate('/requests/' + t.id)}>{t.title}</button>
|
||||
</td>
|
||||
<td style={{ fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'right' }}>
|
||||
{t.assigned_to
|
||||
? <button type="button" className="dashboard-inline-link" onClick={() => navigate(profilePath(t.assigned_to, t.assignee_role))}>{t.assigned_name || 'Profile'}</button>
|
||||
: (t.assigned_name || '—')}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HotItemsCard({ submissions, tasks }) {
|
||||
const navigate = useNavigate();
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('deadline');
|
||||
@@ -344,10 +398,10 @@ function HotItemsCard({ submissions, tasks }) {
|
||||
return (
|
||||
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ marginBottom: hotItems.length > 0 ? 14 : 0, flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Hot Items</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Hot Tasks</span>
|
||||
</div>
|
||||
{hotItems.length === 0 ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No hot items</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No hot tasks</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
@@ -467,7 +521,13 @@ function ClientHighlightCard({ highlights }) {
|
||||
);
|
||||
}
|
||||
|
||||
function TeamPerformanceCard({ tasks }) {
|
||||
function TeamPerformanceCard({ tasks, profiles }) {
|
||||
const navigate = useNavigate();
|
||||
const profileMap = useMemo(() => {
|
||||
const m = new Map();
|
||||
(profiles || []).forEach(p => m.set(p.id, p));
|
||||
return m;
|
||||
}, [profiles]);
|
||||
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
|
||||
const allMonthOpts = useMemo(() => {
|
||||
const opts = [];
|
||||
@@ -495,7 +555,7 @@ function TeamPerformanceCard({ tasks }) {
|
||||
const d = new Date(t.completed_at);
|
||||
if (d.getFullYear() !== year || d.getMonth() + 1 !== month) return;
|
||||
if (!t.assigned_name) return;
|
||||
const entry = map.get(t.assigned_name) || { name: t.assigned_name, newCount: 0, revCount: 0 };
|
||||
const entry = map.get(t.assigned_name) || { name: t.assigned_name, id: t.assigned_to || null, newCount: 0, revCount: 0 };
|
||||
if ((t.current_version || 0) === 0) entry.newCount += 1;
|
||||
else entry.revCount += 1;
|
||||
map.set(t.assigned_name, entry);
|
||||
@@ -524,10 +584,10 @@ function TeamPerformanceCard({ tasks }) {
|
||||
const tone = iconTone(p.name);
|
||||
return (
|
||||
<div key={p.name} style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: i > 0 ? 10 : 0 }}>
|
||||
<Avatar name={p.name} />
|
||||
{(() => { const prof = p.id ? profileMap.get(p.id) : null; const tone = iconTone(p.name); return prof?.avatar_url ? <div style={{ width: 27, height: 27, borderRadius: '50%', flexShrink: 0, overflow: 'hidden' }}><img src={prof.avatar_url} alt={p.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div> : <Avatar name={p.name} />; })()}
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-primary)', fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
|
||||
{p.id ? <button type="button" className="dashboard-inline-link" style={{ fontSize: 13, fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} onClick={() => navigate(`/profile/${p.id}`)}>{p.name}</button> : <span style={{ fontSize: 13, color: 'var(--text-primary)', fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>}
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', flexShrink: 0, marginLeft: 8 }}>{p.newCount} new · {p.revCount} revision</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
@@ -562,6 +622,7 @@ export default function TeamDashboard() {
|
||||
const [clientProfiles, setClientProfiles] = useState(() => cached?.clientProfiles || []);
|
||||
const [companyMemberships, setCompanyMemberships] = useState(() => cached?.companyMemberships || []);
|
||||
const [activityLog, setActivityLog] = useState(() => cached?.activityLog || []);
|
||||
const [allProfiles, setAllProfiles] = useState(() => cached?.allProfiles || []);
|
||||
const [teamInvoices, setTeamInvoices] = useState(() => cached?.teamInvoices || []);
|
||||
const [teamExpenses, setTeamExpenses] = useState([]);
|
||||
const [loading, setLoading] = useState(!cached);
|
||||
@@ -586,7 +647,7 @@ export default function TeamDashboard() {
|
||||
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at').gte('submitted_at', cutoffStr).order('submitted_at', { ascending: false }),
|
||||
supabase.from('projects').select('id, name, status, company_id'),
|
||||
supabase.from('submissions').select('task_id, version_number, deadline, type, submitted_by, submitted_by_name, is_hot, delivery:deliveries(sent_by, sent_at)').gte('submitted_at', cutoffStr).order('version_number', { ascending: false }),
|
||||
supabase.from('profiles').select('id, role, name, email, company_id, brand_book_rate'),
|
||||
supabase.from('profiles').select('id, role, name, email, company_id, brand_book_rate, avatar_url'),
|
||||
supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action, task_id, task_title, project_name, project_id').order('created_at', { ascending: false }).limit(20),
|
||||
supabase.from('companies').select('id, name').order('name'),
|
||||
supabase.from('company_members').select('company_id, profile_id'),
|
||||
@@ -613,6 +674,7 @@ export default function TeamDashboard() {
|
||||
setProjects(p || []);
|
||||
setSubmissions(subsWithRole);
|
||||
setClientProfiles(clients);
|
||||
setAllProfiles(profiles || []);
|
||||
setAllCompanies(cos || []);
|
||||
setCompanyMemberships(memRows || []);
|
||||
setActivityLog(activity || []);
|
||||
@@ -662,6 +724,7 @@ export default function TeamDashboard() {
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
|
||||
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
|
||||
const inProgressTasks = tasks.filter(t => t.status === 'in_progress');
|
||||
const activeTasks = tasks.filter(t => !doneStatuses.includes(t.status));
|
||||
const activeProjects = projects.filter(p => p.status !== 'completed' && p.status !== 'cancelled');
|
||||
const hotTaskIds = new Set((submissions || []).filter(s => s.is_hot).map(s => s.task_id));
|
||||
@@ -696,13 +759,14 @@ export default function TeamDashboard() {
|
||||
<DashStatCard label="Net Profit" value={fmtMoney(dashRevenue - dashExpensesTotal)} sub={`${fmtMoney(dashExpensesTotal)} expenses`} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={DASH_ICONS.profit} />
|
||||
<DashStatCard label="Revenue" value={fmtMoney(dashRevenue)} sub={`${fmtMoney(dashOutstanding)} outstanding`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={DASH_ICONS.revenue} chartData={revenueByMonth} />
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 280px', gap: 24, marginTop: 24 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 280px', gap: 24, marginTop: 24 }}>
|
||||
<ActivityFeed events={activityEvents} />
|
||||
<TasksInProgressCard tasks={inProgressTasks} />
|
||||
<MiniCalendar items={calendarItems} />
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}>
|
||||
<HotItemsCard submissions={submissions} tasks={tasks} />
|
||||
<TeamPerformanceCard tasks={tasks} />
|
||||
<TeamPerformanceCard tasks={tasks} profiles={allProfiles} />
|
||||
</div>
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<ClientHighlightCard highlights={teamHighlights} />
|
||||
|
||||
Reference in New Issue
Block a user