redesign: Layout2 design-system + dashboard/tasks rework + perf
Design system (Layout2): - Solid token-driven theme (no animated grid); single-source tokens for bg, cards (bg/border/radius), popups, fonts (Inter), and full semantic + data-viz color palette. Swept ~168 hardcoded colors to tokens. Dashboard: - Reflow: To Do + Calendar row (fixed right rail) over Activity / In-Progress / Team Performance; CSS height-match (To Do = Calendar); responsive stacking. - 'Tasks Not Started' -> 'To Do' card with R#/Assigned columns; compact money fmt. Tasks page: - Combined tabs into Tasks + Completed with a Status column. - Column-header sort + filter (Status, R#/new-vs-revision, Project, Task Type, Company) via portaled, scrollable FilterDropdown; removed toolbar filters and the projects side panel; headers stay visible when empty; renamed to 'Tasks'. Perf: - FK/filter index migration; removed redundant client/external scope waterfall (rely on RLS); parallel follow-up queries. Mobile: - Responsive grids; mobile topbar = hamburger only, blends with bg, proper offset. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -103,7 +103,7 @@ export default function FileAttachment({ files, onChange }) {
|
||||
{files.map((file, i) => (
|
||||
<div key={i} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '7px 12px', background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)',
|
||||
padding: '7px 12px', background: 'var(--card-bg)', borderRadius: 4, border: 'var(--card-border)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span>📄</span>
|
||||
|
||||
@@ -1,33 +1,71 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
const FilterIcon = () => (
|
||||
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M2 4h12M4 8h8M6 12h4"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function FilterDropdown({ value, onChange, options }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
const [pos, setPos] = useState(null);
|
||||
const btnRef = useRef(null);
|
||||
const menuRef = useRef(null);
|
||||
const active = value && value !== 'all';
|
||||
|
||||
const place = useCallback(() => {
|
||||
const r = btnRef.current?.getBoundingClientRect();
|
||||
if (!r) return;
|
||||
const left = Math.min(r.left, window.innerWidth - 176);
|
||||
setPos({ top: r.bottom + 4, left: Math.max(8, left) });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handler(e) { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
place();
|
||||
function onDown(e) {
|
||||
if (btnRef.current?.contains(e.target) || menuRef.current?.contains(e.target)) return;
|
||||
setOpen(false);
|
||||
}
|
||||
function onScroll(e) {
|
||||
// Don't close when scrolling inside the menu itself.
|
||||
if (menuRef.current && menuRef.current.contains(e.target)) return;
|
||||
setOpen(false);
|
||||
}
|
||||
document.addEventListener('mousedown', onDown);
|
||||
window.addEventListener('scroll', onScroll, true);
|
||||
window.addEventListener('resize', onScroll);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
window.removeEventListener('scroll', onScroll, true);
|
||||
window.removeEventListener('resize', onScroll);
|
||||
};
|
||||
}, [open, place]);
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
|
||||
<>
|
||||
<button
|
||||
className="btn btn-outline"
|
||||
ref={btnRef}
|
||||
type="button"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
|
||||
aria-label="Filter"
|
||||
title="Filter"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'none', border: 'none', padding: 0, cursor: 'pointer',
|
||||
color: active ? 'var(--accent)' : 'var(--text-primary)',
|
||||
opacity: active || open ? 0.9 : 0.35,
|
||||
}}
|
||||
>
|
||||
<FilterIcon />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="site-header-avatar-menu" style={{ top: 'calc(100% + 4px)', minWidth: 160 }}>
|
||||
{open && pos && createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="site-header-avatar-menu"
|
||||
style={{ position: 'fixed', top: pos.top, left: pos.left, right: 'auto', width: 'max-content', minWidth: 160, maxHeight: `calc(100vh - ${pos.top}px - 16px)`, overflowY: 'auto', zIndex: 4000 }}
|
||||
>
|
||||
{options.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
@@ -38,8 +76,9 @@ export default function FilterDropdown({ value, onChange, options }) {
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function InvoiceDetailPopup({
|
||||
return (
|
||||
<div style={popupOverlayStyle} onClick={() => { if (!blockClose) onClose?.(); }}>
|
||||
<div
|
||||
style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }}
|
||||
style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
|
||||
@@ -169,7 +169,7 @@ export default function Layout({ children, loading = false }) {
|
||||
location.pathname.startsWith('/my-invoices-sub/');
|
||||
const isReportsRoute = location.pathname === '/reports';
|
||||
const financeHeaderTitle = currentUser?.role === 'team' ? 'Finances' : 'Invoices';
|
||||
const headerTitle = isProfileRoute ? 'Profile' : isFinancesRoute ? financeHeaderTitle : isReportsRoute ? 'Reports' : isRequestsRoute && !isTaskDetailRoute ? 'Tasks & Projects' : !isTaskDetailRoute && !isInvoiceDetailRoute ? `Good ${timeOfDay}${firstName ? `, ${firstName}` : ''}` : null;
|
||||
const headerTitle = isProfileRoute ? 'Profile' : isFinancesRoute ? financeHeaderTitle : isReportsRoute ? 'Reports' : isRequestsRoute && !isTaskDetailRoute ? 'Tasks' : !isTaskDetailRoute && !isInvoiceDetailRoute ? `Good ${timeOfDay}${firstName ? `, ${firstName}` : ''}` : null;
|
||||
const headerSubtitle = isProfileRoute
|
||||
? 'Account details and security settings.'
|
||||
: isFinancesRoute
|
||||
@@ -190,7 +190,7 @@ export default function Layout({ children, loading = false }) {
|
||||
: currentUser?.role === 'client'
|
||||
? '/client-invoices'
|
||||
: '/subs-invoices';
|
||||
const detailBackLabel = isTaskDetailRoute ? 'Tasks & Projects' : financeHeaderTitle;
|
||||
const detailBackLabel = isTaskDetailRoute ? 'Tasks' : financeHeaderTitle;
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
@@ -272,7 +272,6 @@ export default function Layout({ children, loading = false }) {
|
||||
<button className="hamburger" onClick={() => setMenuOpen(o => !o)} aria-label="Menu">
|
||||
<span /><span /><span />
|
||||
</button>
|
||||
<img className="brand-logo brand-logo-mobile" src="/fourge-logo.png" alt="Fourge Branding" />
|
||||
</div>
|
||||
|
||||
<main className="main-content">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { supabase } from '../lib/supabase';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { sendEmail } from '../lib/email';
|
||||
import { isCompletedVersionEligible } from '../lib/invoiceVersionRules';
|
||||
import { useActionLock } from '../hooks/useActionLock';
|
||||
|
||||
const INVOICE_TODAY = new Date().toISOString().split('T')[0];
|
||||
const REVISION_RATE = 30;
|
||||
@@ -94,6 +95,7 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
|
||||
const [items, setItems] = useState([newItem()]);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const guard = useActionLock();
|
||||
const [error, setError] = useState('');
|
||||
const [lineItemsPulse, setLineItemsPulse] = useState(false);
|
||||
const dragItem = useRef(null);
|
||||
@@ -241,7 +243,7 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
|
||||
|
||||
const total = items.reduce((sum, item) => sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0), 0);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const handleSubmit = guard('sub-invoice-submit', async () => {
|
||||
const valid = items.filter((item) => String(item.description).trim());
|
||||
if (!valid.length) {
|
||||
setError('Add at least one line item.');
|
||||
@@ -294,7 +296,7 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
|
||||
setError(err?.message || 'Failed to submit invoice.');
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, gap: 0 }}>
|
||||
|
||||
Reference in New Issue
Block a user