Add task columns to client dashboard
Two-column layout below stats: Awaiting Your Review (left) + In Progress (right). Each row links to the request detail page. Project name shown as subtitle. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,112 +4,152 @@ import Layout from '../../components/Layout';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { withTimeout } from '../../lib/withTimeout';
|
||||
|
||||
function TaskRow({ task, project }) {
|
||||
return (
|
||||
<Link
|
||||
to={`/my-requests/${task.id}`}
|
||||
className="interactive-row"
|
||||
style={{
|
||||
display: 'flex', flexDirection: 'column', gap: 4,
|
||||
padding: '10px 14px', borderBottom: '1px solid var(--border)',
|
||||
textDecoration: 'none', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{task.title}</span>
|
||||
<StatusBadge status={task.status} />
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{project?.name || '—'}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskColumn({ title, tasks, projects, emptyMessage }) {
|
||||
return (
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
<div style={{ padding: '12px 14px', borderBottom: tasks.length > 0 ? '1px solid var(--border)' : 'none', background: 'var(--card-bg-2)' }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>{title}</span>
|
||||
{tasks.length > 0 && (
|
||||
<span style={{ marginLeft: 8, fontSize: 11, fontWeight: 600, padding: '1px 7px', borderRadius: 20, background: 'var(--accent)', color: '#1a1a1a' }}>
|
||||
{tasks.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{tasks.length === 0 ? (
|
||||
<div style={{ padding: '14px', fontSize: 13, color: 'var(--text-muted)' }}>{emptyMessage}</div>
|
||||
) : (
|
||||
tasks.map(task => (
|
||||
<TaskRow key={task.id} task={task} project={projects.find(p => p.id === task.project_id)} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ClientDashboard() {
|
||||
const { currentUser } = useAuth();
|
||||
const companyId = currentUser?.company_id || currentUser?.company?.id;
|
||||
const [company, setCompany] = useState(currentUser?.company || null);
|
||||
const hasCompany = Boolean(currentUser?.company_id || currentUser?.company?.id || currentUser?.companies?.length);
|
||||
|
||||
const [stats, setStats] = useState({ notStarted: 0, inProgress: 0, awaitingReview: 0, outstandingInvoices: 0 });
|
||||
const [reviewTasks, setReviewTasks] = useState([]);
|
||||
const [inProgressTasks, setInProgressTasks] = useState([]);
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loading, setLoading] = useState(hasCompany);
|
||||
|
||||
useEffect(() => {
|
||||
if (!companyId) { setLoading(false); return; }
|
||||
async function load() {
|
||||
if (!company) {
|
||||
const { data: co } = await supabase.from('companies').select('*').eq('id', companyId).single();
|
||||
if (co) setCompany(co);
|
||||
}
|
||||
const { data: p } = await supabase
|
||||
.from('projects').select('*').eq('company_id', companyId).order('created_at', { ascending: false });
|
||||
const projectList = p || [];
|
||||
setProjects(projectList);
|
||||
if (!hasCompany) { setLoading(false); return; }
|
||||
|
||||
if (projectList.length > 0) {
|
||||
const { data: t } = await supabase
|
||||
.from('tasks').select('*')
|
||||
.in('project_id', projectList.map(pr => pr.id));
|
||||
setTasks(t || []);
|
||||
async function load() {
|
||||
try {
|
||||
const [
|
||||
{ count: notStartedCount },
|
||||
{ count: inProgressCount },
|
||||
{ count: reviewCount },
|
||||
{ data: sentInvoices },
|
||||
{ data: activeTasks },
|
||||
] = await withTimeout(Promise.all([
|
||||
supabase.from('tasks').select('id', { count: 'exact', head: true }).eq('status', 'not_started'),
|
||||
supabase.from('tasks').select('id', { count: 'exact', head: true }).in('status', ['in_progress', 'on_hold']),
|
||||
supabase.from('tasks').select('id', { count: 'exact', head: true }).eq('status', 'client_review'),
|
||||
supabase.from('invoices').select('total').eq('status', 'sent'),
|
||||
supabase.from('tasks').select('id, title, status, project_id').in('status', ['client_review', 'in_progress', 'on_hold', 'not_started']).order('submitted_at', { ascending: false }),
|
||||
]), 12000, 'Client dashboard load');
|
||||
|
||||
setStats({
|
||||
notStarted: notStartedCount || 0,
|
||||
inProgress: inProgressCount || 0,
|
||||
awaitingReview: reviewCount || 0,
|
||||
outstandingInvoices: (sentInvoices || []).reduce((sum, inv) => sum + Number(inv.total || 0), 0),
|
||||
});
|
||||
|
||||
const tasks = activeTasks || [];
|
||||
const review = tasks.filter(t => t.status === 'client_review');
|
||||
const inProg = tasks.filter(t => ['in_progress', 'on_hold', 'not_started'].includes(t.status));
|
||||
setReviewTasks(review);
|
||||
setInProgressTasks(inProg);
|
||||
|
||||
if (tasks.length > 0) {
|
||||
const projectIds = [...new Set(tasks.map(t => t.project_id).filter(Boolean))];
|
||||
const { data: proj } = await supabase.from('projects').select('id, name').in('id', projectIds);
|
||||
setProjects(proj || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('ClientDashboard load failed:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
load();
|
||||
}, [companyId]);
|
||||
}, [hasCompany]);
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
|
||||
const notStarted = tasks.filter(t => t.status === 'not_started').length;
|
||||
const inProgress = tasks.filter(t => ['in_progress', 'on_hold'].includes(t.status)).length;
|
||||
const awaitingReview = tasks.filter(t => t.status === 'client_review').length;
|
||||
const completed = tasks.filter(t => t.status === 'client_approved').length;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Dashboard</div>
|
||||
<div className="page-subtitle">Welcome back, {currentUser?.name?.split(' ')[0]}.</div>
|
||||
<div className="page-title">Welcome back, {currentUser?.name?.split(' ')[0]}</div>
|
||||
<div className="page-subtitle">Track active work and the items that need your attention.</div>
|
||||
</div>
|
||||
<Link to="/new-request" className="btn btn-primary">+ New Request</Link>
|
||||
</div>
|
||||
|
||||
<div className="stats-grid" style={{ marginBottom: 28 }}>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{notStarted}</div>
|
||||
<div className="stat-label">Not Started</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{inProgress}</div>
|
||||
<div className="stat-label">In Progress</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ color: awaitingReview > 0 ? 'var(--accent)' : undefined }}>{awaitingReview}</div>
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card stat-card-highlight">
|
||||
<div className="stat-value" style={{ color: stats.awaitingReview > 0 ? 'var(--accent)' : undefined }}>{stats.awaitingReview}</div>
|
||||
<div className="stat-label">Awaiting Review</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{completed}</div>
|
||||
<div className="stat-label">Completed</div>
|
||||
<div className="stat-value">{stats.inProgress}</div>
|
||||
<div className="stat-label">In Progress</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.notStarted}</div>
|
||||
<div className="stat-label">Not Started</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ fontSize: 22 }}>${stats.outstandingInvoices.toFixed(2)}</div>
|
||||
<div className="stat-label">Outstanding Invoices</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No projects yet</h3>
|
||||
<p>Submit a request to get started.</p>
|
||||
<Link to="/new-request" className="btn btn-primary" style={{ marginTop: 16 }}>+ New Request</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="card-title" style={{ marginBottom: 12 }}>Projects</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{projects.map(project => {
|
||||
const projectTasks = tasks.filter(t => t.project_id === project.id);
|
||||
const pendingReview = projectTasks.filter(t => t.status === 'client_review').length;
|
||||
const active = projectTasks.filter(t => ['in_progress', 'on_hold', 'not_started'].includes(t.status)).length;
|
||||
return (
|
||||
<Link key={project.id} to={`/my-projects/${project.id}`} style={{ textDecoration: 'none' }}>
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
border: `1px solid ${pendingReview > 0 ? 'var(--accent)' : 'var(--border)'}`,
|
||||
borderRadius: 8, background: 'var(--card-bg)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>{project.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 3 }}>
|
||||
{projectTasks.length} job{projectTasks.length !== 1 ? 's' : ''}
|
||||
{active > 0 && <> · {active} active</>}
|
||||
{pendingReview > 0 && <span style={{ color: 'var(--accent)', fontWeight: 600 }}> · {pendingReview} awaiting review</span>}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={project.status} />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="grid-2" style={{ marginTop: 24 }}>
|
||||
<TaskColumn
|
||||
title="Awaiting Your Review"
|
||||
tasks={reviewTasks}
|
||||
projects={projects}
|
||||
emptyMessage="No items need your review."
|
||||
/>
|
||||
<TaskColumn
|
||||
title="In Progress"
|
||||
tasks={inProgressTasks}
|
||||
projects={projects}
|
||||
emptyMessage="No items currently in progress."
|
||||
/>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user