Add client dashboard page, restore MyCompany to people+edit only
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ import CreateInvoice from './pages/team/CreateInvoice';
|
|||||||
import InvoiceDetail from './pages/team/InvoiceDetail';
|
import InvoiceDetail from './pages/team/InvoiceDetail';
|
||||||
|
|
||||||
import Settings from './pages/Settings';
|
import Settings from './pages/Settings';
|
||||||
|
import ClientDashboard from './pages/client/ClientDashboard';
|
||||||
import MyCompany from './pages/client/MyCompany';
|
import MyCompany from './pages/client/MyCompany';
|
||||||
import MyRequests from './pages/client/MyRequests';
|
import MyRequests from './pages/client/MyRequests';
|
||||||
import MyProjects from './pages/client/MyProjects';
|
import MyProjects from './pages/client/MyProjects';
|
||||||
@@ -46,6 +47,7 @@ export default function App() {
|
|||||||
|
|
||||||
<Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
|
<Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
|
||||||
|
|
||||||
|
<Route path="/my-dashboard" element={<ProtectedRoute role="client"><ClientDashboard /></ProtectedRoute>} />
|
||||||
<Route path="/my-company" element={<ProtectedRoute role="client"><MyCompany /></ProtectedRoute>} />
|
<Route path="/my-company" element={<ProtectedRoute role="client"><MyCompany /></ProtectedRoute>} />
|
||||||
<Route path="/my-requests" element={<ProtectedRoute role="client"><MyRequests /></ProtectedRoute>} />
|
<Route path="/my-requests" element={<ProtectedRoute role="client"><MyRequests /></ProtectedRoute>} />
|
||||||
<Route path="/my-requests/:id" element={<ProtectedRoute role="client"><RequestDetail /></ProtectedRoute>} />
|
<Route path="/my-requests/:id" element={<ProtectedRoute role="client"><RequestDetail /></ProtectedRoute>} />
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ function ClientNav({ onNav }) {
|
|||||||
<div className="sidebar-section">
|
<div className="sidebar-section">
|
||||||
<div className="sidebar-section-label">My Work</div>
|
<div className="sidebar-section-label">My Work</div>
|
||||||
{[
|
{[
|
||||||
|
{ to: '/my-dashboard', label: 'Dashboard' },
|
||||||
{ to: '/my-projects', label: 'Projects' },
|
{ to: '/my-projects', label: 'Projects' },
|
||||||
{ to: '/my-invoices', label: 'Invoices' },
|
{ to: '/my-invoices', label: 'Invoices' },
|
||||||
{ to: '/my-company', label: 'Company' },
|
{ to: '/my-company', label: 'Company' },
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export default function ProtectedRoute({ children, role }) {
|
|||||||
const { currentUser } = useAuth();
|
const { currentUser } = useAuth();
|
||||||
if (!currentUser) return <Navigate to="/" replace />;
|
if (!currentUser) return <Navigate to="/" replace />;
|
||||||
if (role && currentUser.role !== role) {
|
if (role && currentUser.role !== role) {
|
||||||
return <Navigate to={currentUser.role === 'team' ? '/dashboard' : '/my-requests'} replace />;
|
return <Navigate to={currentUser.role === 'team' ? '/dashboard' : '/my-dashboard'} replace />;
|
||||||
}
|
}
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ export default function Login() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentUser) {
|
if (currentUser) {
|
||||||
navigate(currentUser.role === 'team' ? '/dashboard' : '/my-company', { replace: true });
|
navigate(currentUser.role === 'team' ? '/dashboard' : '/my-dashboard', { replace: true });
|
||||||
}
|
}
|
||||||
}, [currentUser, navigate]);
|
}, [currentUser, navigate]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import Layout from '../../components/Layout';
|
||||||
|
import StatusBadge from '../../components/StatusBadge';
|
||||||
|
import { supabase } from '../../lib/supabase';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
|
||||||
|
export default function ClientDashboard() {
|
||||||
|
const { currentUser } = useAuth();
|
||||||
|
const company = currentUser?.company;
|
||||||
|
const [projects, setProjects] = useState([]);
|
||||||
|
const [tasks, setTasks] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!company?.id) { setLoading(false); return; }
|
||||||
|
async function load() {
|
||||||
|
const { data: p } = await supabase
|
||||||
|
.from('projects').select('*').eq('company_id', company.id).order('created_at', { ascending: false });
|
||||||
|
const projectList = p || [];
|
||||||
|
setProjects(projectList);
|
||||||
|
|
||||||
|
if (projectList.length > 0) {
|
||||||
|
const { data: t } = await supabase
|
||||||
|
.from('tasks').select('*')
|
||||||
|
.in('project_id', projectList.map(pr => pr.id));
|
||||||
|
setTasks(t || []);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
}, [company?.id]);
|
||||||
|
|
||||||
|
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>
|
||||||
|
<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="stat-label">Awaiting Review</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-value">{completed}</div>
|
||||||
|
<div className="stat-label">Completed</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" 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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import Layout from '../../components/Layout';
|
import Layout from '../../components/Layout';
|
||||||
import StatusBadge from '../../components/StatusBadge';
|
|
||||||
import { supabase } from '../../lib/supabase';
|
import { supabase } from '../../lib/supabase';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
|
||||||
@@ -9,110 +7,102 @@ export default function MyCompany() {
|
|||||||
const { currentUser } = useAuth();
|
const { currentUser } = useAuth();
|
||||||
const company = currentUser?.company;
|
const company = currentUser?.company;
|
||||||
const [members, setMembers] = useState([]);
|
const [members, setMembers] = useState([]);
|
||||||
const [projects, setProjects] = useState([]);
|
|
||||||
const [tasks, setTasks] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [form, setForm] = useState({ name: '', phone: '', address: '' });
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!company?.id) { setLoading(false); return; }
|
if (!company?.id) { setLoading(false); return; }
|
||||||
|
setForm({ name: company.name || '', phone: company.phone || '', address: company.address || '' });
|
||||||
async function load() {
|
async function load() {
|
||||||
const [{ data: m }, { data: p }] = await Promise.all([
|
const { data: m } = await supabase
|
||||||
supabase.from('profiles').select('id, name, email').eq('company_id', company.id).eq('role', 'client'),
|
.from('profiles').select('id, name, email').eq('company_id', company.id).eq('role', 'client');
|
||||||
supabase.from('projects').select('*').eq('company_id', company.id).order('created_at', { ascending: false }),
|
|
||||||
]);
|
|
||||||
setMembers(m || []);
|
setMembers(m || []);
|
||||||
const projectList = p || [];
|
|
||||||
setProjects(projectList);
|
|
||||||
|
|
||||||
if (projectList.length > 0) {
|
|
||||||
const { data: t } = await supabase
|
|
||||||
.from('tasks').select('*')
|
|
||||||
.in('project_id', projectList.map(pr => pr.id));
|
|
||||||
setTasks(t || []);
|
|
||||||
}
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
load();
|
load();
|
||||||
}, [company?.id]);
|
}, [company?.id]);
|
||||||
|
|
||||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
const handleSave = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
await supabase.from('companies').update({
|
||||||
|
name: form.name.trim(),
|
||||||
|
phone: form.phone.trim(),
|
||||||
|
address: form.address.trim(),
|
||||||
|
}).eq('id', company.id);
|
||||||
|
setSaving(false);
|
||||||
|
setEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
const notStarted = tasks.filter(t => t.status === 'not_started').length;
|
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||||
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 (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div>
|
<div>
|
||||||
<div className="page-title">{company?.name || 'Your Company'}</div>
|
<div className="page-title">{form.name || company?.name}</div>
|
||||||
<div className="page-subtitle">
|
<div className="page-subtitle">
|
||||||
{[company?.phone, company?.address].filter(Boolean).join(' · ') || 'No contact info on file'}
|
{[company?.phone, company?.address].filter(Boolean).join(' · ') || 'No contact info on file'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{!editing && (
|
||||||
|
<button className="btn btn-outline" onClick={() => setEditing(true)}>Edit Info</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{editing && (
|
||||||
<div className="stats-grid" style={{ marginBottom: 28 }}>
|
<div className="card" style={{ marginBottom: 24, maxWidth: 520 }}>
|
||||||
<div className="stat-card">
|
<div className="card-title">Edit Company Info</div>
|
||||||
<div className="stat-value">{notStarted}</div>
|
<form onSubmit={handleSave}>
|
||||||
<div className="stat-label">Not Started</div>
|
<div className="form-group">
|
||||||
</div>
|
<label>Company Name *</label>
|
||||||
<div className="stat-card">
|
<input
|
||||||
<div className="stat-value">{inProgress}</div>
|
type="text"
|
||||||
<div className="stat-label">In Progress</div>
|
value={form.name}
|
||||||
</div>
|
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
||||||
<div className="stat-card" style={{ position: 'relative' }}>
|
required
|
||||||
<div className="stat-value" style={{ color: awaitingReview > 0 ? 'var(--accent)' : undefined }}>{awaitingReview}</div>
|
/>
|
||||||
<div className="stat-label">Awaiting Review</div>
|
</div>
|
||||||
</div>
|
<div className="grid-2">
|
||||||
<div className="stat-card">
|
<div className="form-group">
|
||||||
<div className="stat-value">{completed}</div>
|
<label>Phone</label>
|
||||||
<div className="stat-label">Completed</div>
|
<input
|
||||||
</div>
|
type="text"
|
||||||
</div>
|
placeholder="+1 (555) 000-0000"
|
||||||
|
value={form.phone}
|
||||||
{/* Projects */}
|
onChange={e => setForm(f => ({ ...f, phone: e.target.value }))}
|
||||||
{projects.length > 0 && (
|
/>
|
||||||
<div style={{ marginBottom: 28 }}>
|
</div>
|
||||||
<div className="card-title" style={{ marginBottom: 12 }}>Projects</div>
|
<div className="form-group">
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<label>Address</label>
|
||||||
{projects.map(project => {
|
<input
|
||||||
const projectTasks = tasks.filter(t => t.project_id === project.id);
|
type="text"
|
||||||
const pendingReview = projectTasks.filter(t => t.status === 'client_review').length;
|
placeholder="123 Main St, City, State"
|
||||||
const active = projectTasks.filter(t => ['in_progress', 'on_hold', 'not_started'].includes(t.status)).length;
|
value={form.address}
|
||||||
return (
|
onChange={e => setForm(f => ({ ...f, address: e.target.value }))}
|
||||||
<Link
|
/>
|
||||||
key={project.id}
|
</div>
|
||||||
to={`/my-projects`}
|
</div>
|
||||||
style={{ textDecoration: 'none' }}
|
<div className="action-buttons">
|
||||||
>
|
<button type="submit" className="btn btn-primary" disabled={saving || !form.name.trim()}>
|
||||||
<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' }}>
|
{saving ? 'Saving...' : 'Save Changes'}
|
||||||
<div>
|
</button>
|
||||||
<div style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>{project.name}</div>
|
<button type="button" className="btn btn-outline" onClick={() => { setEditing(false); setForm({ name: company.name || '', phone: company.phone || '', address: company.address || '' }); }}>
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 3 }}>
|
Cancel
|
||||||
{projectTasks.length} job{projectTasks.length !== 1 ? 's' : ''}
|
</button>
|
||||||
{active > 0 && <> · {active} active</>}
|
</div>
|
||||||
{pendingReview > 0 && <span style={{ color: 'var(--accent)', fontWeight: 600 }}> · {pendingReview} awaiting review</span>}
|
</form>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<StatusBadge status={project.status} />
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Team members */}
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-title">People</div>
|
<div className="card-title">People</div>
|
||||||
{members.length === 0 ? (
|
{members.length === 0 ? (
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No members found.</p>
|
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No members found.</p>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
{members.map((member, i) => (
|
{members.map((member, i) => (
|
||||||
<div
|
<div
|
||||||
key={member.id}
|
key={member.id}
|
||||||
|
|||||||
Reference in New Issue
Block a user