Session 2026-05-29: tasks page redesign, projects sidebar, invoicing fix
- Tasks page: tabs (All/To Do/In Progress/In Review/Completed) in header, removed grid view, 70/30 split with projects card - Projects card: sortable table, dynamic height fills viewport - Removed Projects page and nav links; redirects → /tasks - Invoice dedup: prevent double-charge when multiple submissions per revision version - Dashboard table style applied to tasks table (10px headers, 5px cell padding, transparent cells) - stat cards: removed "Tasks" header, moved Total Tasks to far right Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+68
-52
@@ -27,8 +27,7 @@ function TeamNav({ onNav }) {
|
||||
|
||||
const primaryLinks = [
|
||||
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
|
||||
{ to: '/team/tasks', label: 'Tasks', icon: ICONS.requests },
|
||||
{ to: '/projects', label: 'Projects', icon: ICONS.projects },
|
||||
{ to: '/tasks', label: 'Tasks', icon: ICONS.requests },
|
||||
{ to: '/invoices', label: 'Finances', icon: ICONS.invoices },
|
||||
{ to: '/file-sharing', label: 'File Sharing', icon: ICONS.fileSharing },
|
||||
];
|
||||
@@ -50,10 +49,8 @@ function TeamNav({ onNav }) {
|
||||
<NI icon={icon} /><span className="nav-label">{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
<div className="sidebar-tools-divider" style={{ height: 1, margin: '10px 12px', background: 'var(--border)' }} />
|
||||
<div className="sidebar-tools-label" style={{ padding: '0 12px 8px', fontSize: 11, fontWeight: 400, letterSpacing: 0.8, textTransform: 'uppercase', color: 'var(--text-muted)' }}>
|
||||
Tools
|
||||
</div>
|
||||
<div className="sidebar-tools-divider" />
|
||||
<div className="sidebar-tools-label">Tools</div>
|
||||
{utilityLinks.map(({ to, label, icon }) => (
|
||||
<NavLink key={to} to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
|
||||
<NI icon={icon} /><span className="nav-label">{label}</span>
|
||||
@@ -72,8 +69,7 @@ function TeamNav({ onNav }) {
|
||||
function ClientNav({ onNav }) {
|
||||
const links = [
|
||||
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
|
||||
{ to: '/requests', label: 'Requests', icon: ICONS.requests },
|
||||
{ to: '/projects', label: 'Projects', icon: ICONS.projects },
|
||||
{ to: '/tasks', label: 'Tasks', icon: ICONS.requests },
|
||||
{ to: '/file-sharing', label: 'File Sharing', icon: ICONS.fileSharing },
|
||||
{ to: '/my-invoices', label: 'Invoices', icon: ICONS.invoices },
|
||||
];
|
||||
@@ -92,8 +88,7 @@ function ClientNav({ onNav }) {
|
||||
function ExternalNav({ onNav }) {
|
||||
const links = [
|
||||
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
|
||||
{ to: '/requests', label: 'Requests', icon: ICONS.requests },
|
||||
{ to: '/projects', label: 'Projects', icon: ICONS.projects },
|
||||
{ to: '/tasks', label: 'Tasks', icon: ICONS.requests },
|
||||
{ to: '/my-invoices-sub', label: 'Invoices', icon: ICONS.invoices },
|
||||
{ to: '/file-sharing', label: 'File Sharing', icon: ICONS.fileSharing },
|
||||
{ to: '/survey-maker', label: 'Survey Maker', icon: ICONS.survey },
|
||||
@@ -107,10 +102,8 @@ function ExternalNav({ onNav }) {
|
||||
<div key={to}>
|
||||
{index === 4 && (
|
||||
<>
|
||||
<div className="sidebar-tools-divider" style={{ height: 1, margin: '10px 12px', background: 'var(--border)' }} />
|
||||
<div className="sidebar-tools-label" style={{ padding: '0 12px 8px', fontSize: 11, fontWeight: 400, letterSpacing: 0.8, textTransform: 'uppercase', color: 'var(--text-muted)' }}>
|
||||
Tools
|
||||
</div>
|
||||
<div className="sidebar-tools-divider" />
|
||||
<div className="sidebar-tools-label">Tools</div>
|
||||
</>
|
||||
)}
|
||||
<NavLink to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
|
||||
@@ -122,16 +115,22 @@ function ExternalNav({ onNav }) {
|
||||
);
|
||||
}
|
||||
|
||||
function getInitialTheme() {
|
||||
if (typeof document !== 'undefined') {
|
||||
return document.documentElement.getAttribute('data-theme') || localStorage.getItem('theme') || 'dark';
|
||||
}
|
||||
return 'dark';
|
||||
}
|
||||
|
||||
export default function Layout({ children }) {
|
||||
const { currentUser, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [theme, setTheme] = useState(() => localStorage.getItem('theme') || 'dark');
|
||||
const [theme, setTheme] = useState(getInitialTheme);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState({ projects: [], tasks: [] });
|
||||
const [searchResults, setSearchResults] = useState({ projects: [], tasks: [], companies: [], people: [] });
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [searchExpanded, setSearchExpanded] = useState(false);
|
||||
const searchInputRef = useRef(null);
|
||||
const [avatarOpen, setAvatarOpen] = useState(false);
|
||||
const avatarRef = useRef(null);
|
||||
@@ -141,33 +140,33 @@ export default function Layout({ children }) {
|
||||
const firstName = currentUser?.name?.split(' ')[0] || '';
|
||||
const isProfileRoute = location.pathname === '/profile' || location.pathname.startsWith('/profile/');
|
||||
const isFileSharingRoute = location.pathname === '/file-sharing';
|
||||
const isRequestsRoute = location.pathname === '/requests';
|
||||
const isTasksRoute = location.pathname === '/team/tasks';
|
||||
const headerTitle = isProfileRoute ? 'Profile' : isFileSharingRoute ? 'File Sharing' : isTasksRoute ? 'Tasks & Projects' : isRequestsRoute ? 'Requests' : `Good ${timeOfDay}${firstName ? `, ${firstName}` : ''}`;
|
||||
const isRequestsRoute = location.pathname === '/tasks' || location.pathname === '/requests' || location.pathname === '/team/tasks';
|
||||
const headerTitle = isProfileRoute ? 'Profile' : isFileSharingRoute ? 'File Sharing' : isRequestsRoute ? 'Tasks & Projects' : `Good ${timeOfDay}${firstName ? `, ${firstName}` : ''}`;
|
||||
const headerSubtitle = isProfileRoute
|
||||
? 'Account details and security settings.'
|
||||
: isFileSharingRoute
|
||||
? 'Browse, share and manage files.'
|
||||
: isTasksRoute
|
||||
: isRequestsRoute
|
||||
? 'Browse and manage all tasks and projects.'
|
||||
: isRequestsRoute
|
||||
? 'Browse and manage all your requests.'
|
||||
: "Here's what's happening today.";
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
document.documentElement.style.colorScheme = theme === 'light' ? 'light' : 'dark';
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchQuery.trim()) { setSearchResults({ projects: [], tasks: [] }); return; }
|
||||
if (!searchQuery.trim()) { setSearchResults({ projects: [], tasks: [], companies: [], people: [] }); return; }
|
||||
const t = setTimeout(async () => {
|
||||
const { supabase } = await import('../lib/supabase');
|
||||
const [{ data: projects }, { data: tasks }] = await Promise.all([
|
||||
const [{ data: projects }, { data: tasks }, { data: companies }, { data: people }] = await Promise.all([
|
||||
supabase.from('projects').select('id, name').ilike('name', `%${searchQuery}%`).limit(6),
|
||||
supabase.from('tasks').select('id, title').ilike('title', `%${searchQuery}%`).limit(6),
|
||||
supabase.from('companies').select('id, name').ilike('name', `%${searchQuery}%`).limit(6),
|
||||
supabase.from('profiles').select('id, name').ilike('name', `%${searchQuery}%`).limit(6),
|
||||
]);
|
||||
setSearchResults({ projects: projects || [], tasks: tasks || [] });
|
||||
setSearchResults({ projects: projects || [], tasks: tasks || [], companies: companies || [], people: people || [] });
|
||||
}, 280);
|
||||
return () => clearTimeout(t);
|
||||
}, [searchQuery]);
|
||||
@@ -201,7 +200,11 @@ export default function Layout({ children }) {
|
||||
const toggleTheme = () => setTheme(t => t === 'dark' ? 'light' : 'dark');
|
||||
const handleLogout = async () => { await logout(); navigate('/'); };
|
||||
|
||||
const hasResults = searchResults.projects.length > 0 || searchResults.tasks.length > 0;
|
||||
const hasResults =
|
||||
searchResults.projects.length > 0 ||
|
||||
searchResults.tasks.length > 0 ||
|
||||
searchResults.companies.length > 0 ||
|
||||
searchResults.people.length > 0;
|
||||
|
||||
return (
|
||||
<div className="app-layout">
|
||||
@@ -238,29 +241,20 @@ export default function Layout({ children }) {
|
||||
</div>
|
||||
<div className="site-header-right">
|
||||
<div className="site-header-search-wrap">
|
||||
{!searchExpanded ? (
|
||||
<button
|
||||
className="site-header-search-btn"
|
||||
onClick={() => { setSearchExpanded(true); setTimeout(() => searchInputRef.current?.focus(), 50); }}
|
||||
aria-label="Search"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><circle cx="6.5" cy="6.5" r="4"/><line x1="10" y1="10" x2="14" y2="14"/></svg>
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
className="site-header-search"
|
||||
type="text"
|
||||
placeholder="Search projects & tasks..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
onFocus={() => setSearchOpen(true)}
|
||||
onBlur={() => setTimeout(() => { setSearchOpen(false); setSearchExpanded(false); setSearchQuery(''); }, 150)}
|
||||
onKeyDown={e => { if (e.key === 'Escape') { setSearchOpen(false); setSearchExpanded(false); setSearchQuery(''); } }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<span className="site-header-search-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><circle cx="6.5" cy="6.5" r="4"/><line x1="10" y1="10" x2="14" y2="14"/></svg>
|
||||
</span>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
className="site-header-search"
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
onFocus={() => setSearchOpen(true)}
|
||||
onBlur={() => setTimeout(() => { setSearchOpen(false); setSearchQuery(''); }, 150)}
|
||||
onKeyDown={e => { if (e.key === 'Escape') { setSearchOpen(false); setSearchQuery(''); } }}
|
||||
/>
|
||||
{searchOpen && searchQuery.trim() && (
|
||||
<div className="site-header-dropdown">
|
||||
{!hasResults && <div className="site-header-dropdown-empty">No results for "{searchQuery}"</div>}
|
||||
@@ -268,7 +262,7 @@ export default function Layout({ children }) {
|
||||
<>
|
||||
<div className="site-header-dropdown-group">Projects</div>
|
||||
{searchResults.projects.map(p => (
|
||||
<div key={p.id} className="site-header-dropdown-item" onMouseDown={() => { navigate(`/projects/${p.id}`); setSearchQuery(''); setSearchOpen(false); setSearchExpanded(false); }}>
|
||||
<div key={p.id} className="site-header-dropdown-item" onMouseDown={() => { navigate(`/projects/${p.id}`); setSearchQuery(''); setSearchOpen(false); }}>
|
||||
<span className="nav-icon" style={{ opacity: 0.6 }}>{ICONS.projects}</span>
|
||||
{p.name}
|
||||
</div>
|
||||
@@ -279,13 +273,35 @@ export default function Layout({ children }) {
|
||||
<>
|
||||
<div className="site-header-dropdown-group">Tasks</div>
|
||||
{searchResults.tasks.map(r => (
|
||||
<div key={r.id} className="site-header-dropdown-item" onMouseDown={() => { navigate(`/requests/${r.id}`); setSearchQuery(''); setSearchOpen(false); setSearchExpanded(false); }}>
|
||||
<div key={r.id} className="site-header-dropdown-item" onMouseDown={() => { navigate(`/requests/${r.id}`); setSearchQuery(''); setSearchOpen(false); }}>
|
||||
<span className="nav-icon" style={{ opacity: 0.6 }}>{ICONS.requests}</span>
|
||||
{r.title}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{searchResults.companies.length > 0 && (
|
||||
<>
|
||||
<div className="site-header-dropdown-group">Companies</div>
|
||||
{searchResults.companies.map(c => (
|
||||
<div key={c.id} className="site-header-dropdown-item" onMouseDown={() => { navigate(`/company/${c.id}`); setSearchQuery(''); setSearchOpen(false); }}>
|
||||
<span className="nav-icon" style={{ opacity: 0.6 }}>{ICONS.companies}</span>
|
||||
{c.name}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{searchResults.people.length > 0 && (
|
||||
<>
|
||||
<div className="site-header-dropdown-group">People</div>
|
||||
{searchResults.people.map(p => (
|
||||
<div key={p.id} className="site-header-dropdown-item" onMouseDown={() => { navigate(`/profile/${p.id}`); setSearchQuery(''); setSearchOpen(false); }}>
|
||||
<span className="nav-icon" style={{ opacity: 0.6 }}>{ICONS.users}</span>
|
||||
{p.name || 'Unknown User'}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user