Add company tabs to client dashboard
Tabs appear below stats. All four stat cards + both task columns filter to the selected company. Stats recompute client-side from fetched data. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -51,49 +51,31 @@ function TaskColumn({ title, tasks, projects, emptyMessage }) {
|
|||||||
export default function ClientDashboard() {
|
export default function ClientDashboard() {
|
||||||
const { currentUser } = useAuth();
|
const { currentUser } = useAuth();
|
||||||
const hasCompany = Boolean(currentUser?.company_id || currentUser?.company?.id || currentUser?.companies?.length);
|
const hasCompany = Boolean(currentUser?.company_id || currentUser?.company?.id || currentUser?.companies?.length);
|
||||||
|
const companies = (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : [])).slice().sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
const [activeCompanyId, setActiveCompanyId] = useState(companies[0]?.id || null);
|
||||||
|
|
||||||
const [stats, setStats] = useState({ notStarted: 0, inProgress: 0, awaitingReview: 0, outstandingInvoices: 0 });
|
const [allTasks, setAllTasks] = useState([]);
|
||||||
const [reviewTasks, setReviewTasks] = useState([]);
|
const [allProjects, setAllProjects] = useState([]);
|
||||||
const [inProgressTasks, setInProgressTasks] = useState([]);
|
const [allInvoices, setAllInvoices] = useState([]);
|
||||||
const [projects, setProjects] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(hasCompany);
|
const [loading, setLoading] = useState(hasCompany);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasCompany) { setLoading(false); return; }
|
if (!hasCompany) { setLoading(false); return; }
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const [
|
const [{ data: activeTasks }, { data: invoices }] = await withTimeout(Promise.all([
|
||||||
{ 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 }),
|
supabase.from('tasks').select('id, title, status, project_id').in('status', ['client_review', 'in_progress', 'on_hold', 'not_started']).order('submitted_at', { ascending: false }),
|
||||||
|
supabase.from('invoices').select('total, status, company_id').eq('status', 'sent'),
|
||||||
]), 12000, 'Client dashboard load');
|
]), 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 tasks = activeTasks || [];
|
||||||
const review = tasks.filter(t => t.status === 'client_review');
|
setAllTasks(tasks);
|
||||||
const inProg = tasks.filter(t => ['in_progress', 'on_hold', 'not_started'].includes(t.status));
|
setAllInvoices(invoices || []);
|
||||||
setReviewTasks(review);
|
|
||||||
setInProgressTasks(inProg);
|
|
||||||
|
|
||||||
if (tasks.length > 0) {
|
if (tasks.length > 0) {
|
||||||
const projectIds = [...new Set(tasks.map(t => t.project_id).filter(Boolean))];
|
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);
|
const { data: proj } = await supabase.from('projects').select('id, name, company_id').in('id', projectIds);
|
||||||
setProjects(proj || []);
|
setAllProjects(proj || []);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('ClientDashboard load failed:', error);
|
console.error('ClientDashboard load failed:', error);
|
||||||
@@ -101,10 +83,29 @@ export default function ClientDashboard() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
load();
|
load();
|
||||||
}, [hasCompany]);
|
}, [hasCompany]);
|
||||||
|
|
||||||
|
const filterByCompany = (tasks) => {
|
||||||
|
if (companies.length <= 1 || !activeCompanyId) return tasks;
|
||||||
|
return tasks.filter(t => {
|
||||||
|
const proj = allProjects.find(p => p.id === t.project_id);
|
||||||
|
return proj?.company_id === activeCompanyId;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const visibleTasks = filterByCompany(allTasks);
|
||||||
|
const visibleProjects = companies.length <= 1
|
||||||
|
? allProjects
|
||||||
|
: allProjects.filter(p => p.company_id === activeCompanyId);
|
||||||
|
const visibleInvoices = companies.length <= 1 || !activeCompanyId
|
||||||
|
? allInvoices
|
||||||
|
: allInvoices.filter(i => i.company_id === activeCompanyId);
|
||||||
|
|
||||||
|
const reviewTasks = visibleTasks.filter(t => t.status === 'client_review');
|
||||||
|
const inProgressTasks = visibleTasks.filter(t => ['in_progress', 'on_hold', 'not_started'].includes(t.status));
|
||||||
|
const outstandingInvoices = visibleInvoices.reduce((sum, inv) => sum + Number(inv.total || 0), 0);
|
||||||
|
|
||||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -119,34 +120,55 @@ export default function ClientDashboard() {
|
|||||||
|
|
||||||
<div className="stats-grid">
|
<div className="stats-grid">
|
||||||
<div className="stat-card stat-card-highlight">
|
<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-value" style={{ color: reviewTasks.length > 0 ? 'var(--accent)' : undefined }}>{reviewTasks.length}</div>
|
||||||
<div className="stat-label">Awaiting Review</div>
|
<div className="stat-label">Awaiting Review</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="stat-value">{stats.inProgress}</div>
|
<div className="stat-value">{inProgressTasks.filter(t => ['in_progress', 'on_hold'].includes(t.status)).length}</div>
|
||||||
<div className="stat-label">In Progress</div>
|
<div className="stat-label">In Progress</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="stat-value">{stats.notStarted}</div>
|
<div className="stat-value">{inProgressTasks.filter(t => t.status === 'not_started').length}</div>
|
||||||
<div className="stat-label">Not Started</div>
|
<div className="stat-label">Not Started</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="stat-value" style={{ fontSize: 22 }}>${stats.outstandingInvoices.toFixed(2)}</div>
|
<div className="stat-value" style={{ fontSize: 22 }}>${outstandingInvoices.toFixed(2)}</div>
|
||||||
<div className="stat-label">Outstanding Invoices</div>
|
<div className="stat-label">Outstanding Invoices</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid-2" style={{ marginTop: 24 }}>
|
{companies.length > 1 && (
|
||||||
|
<div style={{ marginTop: 20, marginBottom: 4, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }} className="card-title">
|
||||||
|
{companies.map((company, index) => (
|
||||||
|
<span key={company.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
{index > 0 && <span style={{ color: 'var(--text-muted)' }}>|</span>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveCompanyId(company.id)}
|
||||||
|
style={{
|
||||||
|
background: 'none', border: 'none', padding: 0, margin: 0,
|
||||||
|
cursor: 'pointer', font: 'inherit', textTransform: 'inherit', letterSpacing: 'inherit',
|
||||||
|
color: activeCompanyId === company.id ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{company.name}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid-2" style={{ marginTop: 16 }}>
|
||||||
<TaskColumn
|
<TaskColumn
|
||||||
title="Awaiting Your Review"
|
title="Awaiting Your Review"
|
||||||
tasks={reviewTasks}
|
tasks={reviewTasks}
|
||||||
projects={projects}
|
projects={visibleProjects}
|
||||||
emptyMessage="No items need your review."
|
emptyMessage="No items need your review."
|
||||||
/>
|
/>
|
||||||
<TaskColumn
|
<TaskColumn
|
||||||
title="In Progress"
|
title="In Progress"
|
||||||
tasks={inProgressTasks}
|
tasks={inProgressTasks}
|
||||||
projects={projects}
|
projects={visibleProjects}
|
||||||
emptyMessage="No items currently in progress."
|
emptyMessage="No items currently in progress."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user