redesign: rename Project→Client, finance single-card + filters, mobile tasks, sticky headers

- Rename "Project" → "Client" across UI (labels + /projects→/clients routes with redirect); normalize company-meaning labels to "Company"
- Finance tabs: merge dual cards into one per tab, add header column filters (expenses/subs/invoices), overview as 3-section card, move +Expense/+Invoice to card right edge
- Tasks: responsive mobile stats grid, progressive column hiding (min Status+Name+Assigned), sidebar mobile footer (profile/theme/signout)
- Hide Company column for single-company users; +Task shows disabled Company field instead of hiding
- Sticky table headers site-wide with opaque card background
- Pin dev server to port 5173

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Krao Hasanee
2026-07-02 09:39:54 -04:00
parent c91e292066
commit 91045a1897
19 changed files with 432 additions and 278 deletions
+8 -2
View File
@@ -64,7 +64,12 @@ const ClientMyInvoices = lazy(() => import('./pages/client/ClientMyInvoices'));
function RedirectProjectDetail() { function RedirectProjectDetail() {
const { id } = useParams(); const { id } = useParams();
return <Navigate to={`/projects/${id}`} replace />; return <Navigate to={`/clients/${id}`} replace />;
}
function RedirectClientDetail() {
const { id } = useParams();
return <Navigate to={`/clients/${id}`} replace />;
} }
function RedirectToTask() { function RedirectToTask() {
@@ -99,7 +104,8 @@ export default function App() {
<Route path="/" element={<Login />} /> <Route path="/" element={<Login />} />
<Route path="/dashboard" element={<ProtectedRoute role={['team', 'external', 'client']}><TeamDashboard /></ProtectedRoute>} /> <Route path="/dashboard" element={<ProtectedRoute role={['team', 'external', 'client']}><TeamDashboard /></ProtectedRoute>} />
<Route path="/projects/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><ProjectDetailPage /></ProtectedRoute>} /> <Route path="/clients/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><ProjectDetailPage /></ProtectedRoute>} />
<Route path="/projects/:id" element={<RedirectClientDetail />} />
<Route path="/tasks/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><TaskDetail /></ProtectedRoute>} /> <Route path="/tasks/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><TaskDetail /></ProtectedRoute>} />
<Route path="/company" element={<ProtectedRoute role={['team', 'client']}><CompaniesPage /></ProtectedRoute>} /> <Route path="/company" element={<ProtectedRoute role={['team', 'client']}><CompaniesPage /></ProtectedRoute>} />
<Route path="/company/:id" element={<ProtectedRoute role={['team', 'client']}><CompanyDetail /></ProtectedRoute>} /> <Route path="/company/:id" element={<ProtectedRoute role={['team', 'client']}><CompanyDetail /></ProtectedRoute>} />
+28 -7
View File
@@ -42,7 +42,7 @@ function TeamNav({ onNav }) {
const isCompaniesActive = location.pathname === '/company' && !location.search.includes('tab=users'); const isCompaniesActive = location.pathname === '/company' && !location.search.includes('tab=users');
const isUsersActive = location.pathname === '/company' && location.search.includes('tab=users'); const isUsersActive = location.pathname === '/company' && location.search.includes('tab=users');
const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/requests/'); const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/clients/') || location.pathname.startsWith('/requests/');
return ( return (
<div className="sidebar-section"> <div className="sidebar-section">
@@ -70,7 +70,7 @@ function TeamNav({ onNav }) {
function ClientNav({ onNav }) { function ClientNav({ onNav }) {
const location = useLocation(); const location = useLocation();
const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/requests/'); const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/clients/') || location.pathname.startsWith('/requests/');
const links = [ const links = [
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard }, { to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
{ to: '/tasks', label: 'Tasks', icon: ICONS.requests }, { to: '/tasks', label: 'Tasks', icon: ICONS.requests },
@@ -90,7 +90,7 @@ function ClientNav({ onNav }) {
function ExternalNav({ onNav }) { function ExternalNav({ onNav }) {
const location = useLocation(); const location = useLocation();
const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/requests/'); const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/clients/') || location.pathname.startsWith('/requests/');
const links = [ const links = [
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard }, { to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
{ to: '/tasks', label: 'Tasks', icon: ICONS.requests }, { to: '/tasks', label: 'Tasks', icon: ICONS.requests },
@@ -143,7 +143,7 @@ export default function Layout({ children, loading = false }) {
const timeOfDay = hour < 12 ? 'morning' : hour < 17 ? 'afternoon' : 'evening'; const timeOfDay = hour < 12 ? 'morning' : hour < 17 ? 'afternoon' : 'evening';
const firstName = currentUser?.name?.split(' ')[0] || ''; const firstName = currentUser?.name?.split(' ')[0] || '';
const isProfileRoute = location.pathname === '/profile' || location.pathname.startsWith('/profile/'); const isProfileRoute = location.pathname === '/profile' || location.pathname.startsWith('/profile/');
const isTaskDetailRoute = location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/requests/'); const isTaskDetailRoute = location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/clients/') || location.pathname.startsWith('/requests/');
const isInvoiceDetailRoute = const isInvoiceDetailRoute =
location.pathname.startsWith('/finances/') || location.pathname.startsWith('/finances/') ||
location.pathname.startsWith('/invoices/') || location.pathname.startsWith('/invoices/') ||
@@ -181,7 +181,7 @@ export default function Layout({ children, loading = false }) {
: isReportsRoute : isReportsRoute
? 'Track task versions, billing state and exportable summaries.' ? 'Track task versions, billing state and exportable summaries.'
: isRequestsRoute && !isTaskDetailRoute : isRequestsRoute && !isTaskDetailRoute
? 'Browse and manage all tasks and projects.' ? 'Browse and manage all tasks and clients.'
: isTaskDetailRoute || isInvoiceDetailRoute ? null : "Here's what's happening today."; : isTaskDetailRoute || isInvoiceDetailRoute ? null : "Here's what's happening today.";
const detailBackTarget = isTaskDetailRoute const detailBackTarget = isTaskDetailRoute
? '/tasks' ? '/tasks'
@@ -265,6 +265,27 @@ export default function Layout({ children, loading = false }) {
: <ClientNav onNav={() => setMenuOpen(false)} /> : <ClientNav onNav={() => setMenuOpen(false)} />
} }
<div className="sidebar-mobile-footer">
<button className="sidebar-link" onClick={() => { navigate('/profile'); setMenuOpen(false); }} title="Profile">
<ProfileAvatar profile={currentUser} name={currentUser?.name} size={26} fontSize={10} />
<span className="nav-label">Profile</span>
</button>
<button className="sidebar-link" onClick={toggleTheme} title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}>
<span className="nav-icon">
{theme === 'dark'
? <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="8" cy="8" r="3"/><line x1="8" y1="1" x2="8" y2="2.5"/><line x1="8" y1="13.5" x2="8" y2="15"/><line x1="1" y1="8" x2="2.5" y2="8"/><line x1="13.5" y1="8" x2="15" y2="8"/><line x1="3" y1="3" x2="4.1" y2="4.1"/><line x1="11.9" y1="11.9" x2="13" y2="13"/><line x1="13" y1="3" x2="11.9" y2="4.1"/><line x1="4.1" y1="11.9" x2="3" y2="13"/></svg>
: <svg viewBox="0 0 16 16" fill="currentColor" stroke="none"><path d="M14 8.53A6 6 0 1 1 7.47 2 4.67 4.67 0 0 0 14 8.53Z"/></svg>}
</span>
<span className="nav-label">{theme === 'dark' ? 'Light mode' : 'Dark mode'}</span>
</button>
<button className="sidebar-link" onClick={handleLogout} title="Sign Out">
<span className="nav-icon">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M6 14H3a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1h3"/><polyline points="10 11 13 8 10 5"/><line x1="13" y1="8" x2="6" y2="8"/></svg>
</span>
<span className="nav-label">Sign Out</span>
</button>
</div>
</aside> </aside>
<div className="main-wrapper"> <div className="main-wrapper">
@@ -313,9 +334,9 @@ export default function Layout({ children, loading = false }) {
{!hasResults && <div className="site-header-dropdown-empty">No results for "{searchQuery}"</div>} {!hasResults && <div className="site-header-dropdown-empty">No results for "{searchQuery}"</div>}
{searchResults.projects.length > 0 && ( {searchResults.projects.length > 0 && (
<> <>
<div className="site-header-dropdown-group">Projects</div> <div className="site-header-dropdown-group">Clients</div>
{searchResults.projects.map(p => ( {searchResults.projects.map(p => (
<div key={p.id} className="site-header-dropdown-item" onMouseDown={() => { navigate(`/projects/${p.id}`); setSearchQuery(''); setSearchOpen(false); }}> <div key={p.id} className="site-header-dropdown-item" onMouseDown={() => { navigate(`/clients/${p.id}`); setSearchQuery(''); setSearchOpen(false); }}>
<span className="nav-icon" style={{ opacity: 0.6 }}>{ICONS.projects}</span> <span className="nav-icon" style={{ opacity: 0.6 }}>{ICONS.projects}</span>
{p.name} {p.name}
</div> </div>
+14 -2
View File
@@ -85,6 +85,13 @@ export default function RequestForm({
const companyId = form.companyId || initialCompanyId; const companyId = form.companyId || initialCompanyId;
// Single-company user: lock selection to their one company.
useEffect(() => {
if (companies.length === 1 && !form.companyId) {
setForm(f => ({ ...f, companyId: companies[0].id }));
}
}, [companies, form.companyId]);
useEffect(() => { useEffect(() => {
if (!companyId) { setExistingProjects([]); setCompanyUsers([]); return; } if (!companyId) { setExistingProjects([]); setCompanyUsers([]); return; }
Promise.all([ Promise.all([
@@ -228,14 +235,19 @@ export default function RequestForm({
{companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)} {companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
</select> </select>
</div> </div>
) : companies.length === 1 ? (
<div className="form-group">
<label style={modalLabelStyle}>Company</label>
<input value={companies[0].name} disabled style={{ ...modalInputStyle, opacity: 0.5, cursor: 'not-allowed' }} />
</div>
) : <div />} ) : <div />}
<div className="form-group"> <div className="form-group">
<label style={modalLabelStyle}>Project {!lockedFields.includes('project') && '*'}</label> <label style={modalLabelStyle}>Client {!lockedFields.includes('project') && '*'}</label>
{lockedFields.includes('project') ? ( {lockedFields.includes('project') ? (
<input value={form.project} disabled style={{ ...modalInputStyle, opacity: 0.5, cursor: 'not-allowed' }} /> <input value={form.project} disabled style={{ ...modalInputStyle, opacity: 0.5, cursor: 'not-allowed' }} />
) : isTypingProject ? ( ) : isTypingProject ? (
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<input type="text" placeholder="Enter project name..." value={newProjectName} onChange={e => setNewProjectName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAddProject(); } }} autoFocus style={{ ...modalInputStyle, flex: 1 }} /> <input type="text" placeholder="Enter client name..." value={newProjectName} onChange={e => setNewProjectName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAddProject(); } }} autoFocus style={{ ...modalInputStyle, flex: 1 }} />
<button type="button" className="btn btn-outline" onClick={handleAddProject} disabled={!newProjectName.trim()}>Add</button> <button type="button" className="btn btn-outline" onClick={handleAddProject} disabled={!newProjectName.trim()}>Add</button>
<button type="button" className="btn btn-outline" onClick={() => { setIsTypingProject(false); setNewProjectName(''); }}>Cancel</button> <button type="button" className="btn btn-outline" onClick={() => { setIsTypingProject(false); setNewProjectName(''); }}>Cancel</button>
</div> </div>
+2 -1
View File
@@ -1,7 +1,8 @@
export default function SortTh({ col, children, sortKey, sortDir, onSort, style }) { export default function SortTh({ col, children, sortKey, sortDir, onSort, style, className }) {
const active = sortKey === col; const active = sortKey === col;
return ( return (
<th <th
className={className}
onClick={() => onSort(col)} onClick={() => onSort(col)}
style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap', ...style }} style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap', ...style }}
> >
+25 -1
View File
@@ -30,6 +30,7 @@
--accent-hover: #e09510; --accent-hover: #e09510;
/* Layout2: solid, no transparency. bg 90% K, cards 88% K */ /* Layout2: solid, no transparency. bg 90% K, cards 88% K */
--bg: #1a1a1a; --bg: #1a1a1a;
--page-bg-solid: #1a1a1a;
--card-bg: #1f1f1f; --card-bg: #1f1f1f;
--card-bg-2: #262626; --card-bg-2: #262626;
/* Single source of truth for all card frames across the site. /* Single source of truth for all card frames across the site.
@@ -135,6 +136,7 @@
--bg: --bg:
radial-gradient(560px circle at 58% -6%, rgba(78,78,78,0.42) 0%, rgba(78,78,78,0.29) 24%, rgba(78,78,78,0.16) 48%, rgba(78,78,78,0.06) 72%, rgba(78,78,78,0) 100%), radial-gradient(560px circle at 58% -6%, rgba(78,78,78,0.42) 0%, rgba(78,78,78,0.29) 24%, rgba(78,78,78,0.16) 48%, rgba(78,78,78,0.06) 72%, rgba(78,78,78,0) 100%),
linear-gradient(180deg, #ffffff 0%, #ffffff 42%, #ffffff 100%); linear-gradient(180deg, #ffffff 0%, #ffffff 42%, #ffffff 100%);
--page-bg-solid: #ffffff;
--card-bg: rgba(0,0,0,0.02); --card-bg: rgba(0,0,0,0.02);
--card-bg-2: rgba(0,0,0,0.08); --card-bg-2: rgba(0,0,0,0.08);
--popup-bg: rgba(255,255,255,0.92); --popup-bg: rgba(255,255,255,0.92);
@@ -374,6 +376,8 @@ body::before, body::after { display: none; }
.grid-card:hover { background: var(--card-bg-2); } .grid-card:hover { background: var(--card-bg-2); }
[data-theme="light"] .grid-card:hover { background: #fafafa; } [data-theme="light"] .grid-card:hover { background: #fafafa; }
.sidebar-mobile-footer { display: none; }
.sidebar-bottom { .sidebar-bottom {
margin-top: auto; padding: 16px 8px 0; margin-top: auto; padding: 16px 8px 0;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
@@ -1082,7 +1086,7 @@ tbody tr:hover .table-link {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 3; z-index: 3;
background: transparent !important; background: linear-gradient(var(--card-bg), var(--card-bg)), var(--page-bg-solid) !important;
} }
.table-scroll-fade { .table-scroll-fade {
position: static; position: static;
@@ -1659,6 +1663,13 @@ select option { background: #222; color: #fff; }
} }
.sidebar.sidebar-open { left: 0; } .sidebar.sidebar-open { left: 0; }
/* Account actions pinned to bottom of mobile sidebar */
.sidebar-mobile-footer {
display: flex; flex-direction: column; gap: 4px;
margin-top: auto; padding: 12px 8px;
border-top: 1px solid var(--border);
}
/* Show overlay when menu open */ /* Show overlay when menu open */
.sidebar-overlay { display: block; } .sidebar-overlay { display: block; }
@@ -1808,3 +1819,16 @@ button.section-tab-btn:focus-visible {
@media (max-width: 560px) { @media (max-width: 560px) {
.dash-stat-grid { grid-template-columns: 1fr; } .dash-stat-grid { grid-template-columns: 1fr; }
} }
/* Tasks stats row — responsive 6→3→2→1 */
.tasks-stat-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 24px; margin-bottom: 24px; }
@media (max-width: 1200px) { .tasks-stat-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; } }
@media (max-width: 768px) { .tasks-stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; } }
@media (max-width: 480px) { .tasks-stat-grid { grid-template-columns: 1fr; } }
/* Tasks table progressive column hiding — keep Status, Name, Assigned at smallest */
@media (max-width: 1200px) { .tcol-company { display: none; } }
@media (max-width: 1040px) { .tcol-due { display: none; } }
@media (max-width: 920px) { .tcol-rev { display: none; } }
@media (max-width: 820px) { .tcol-type { display: none; } }
@media (max-width: 720px) { .tcol-project { display: none; } }
+8 -8
View File
@@ -452,7 +452,7 @@ export default function BrandBook() {
const handleSave = async () => { const handleSave = async () => {
if (!bookInfo.clientName.trim()) { if (!bookInfo.clientName.trim()) {
setNotification({ type: 'error', msg: 'Please select a client.' }); setNotification({ type: 'error', msg: 'Please select a company.' });
return; return;
} }
setSaving(true); setSaving(true);
@@ -614,7 +614,7 @@ export default function BrandBook() {
const handleGenerate = async () => { const handleGenerate = async () => {
if (!bookInfo.clientName.trim()) { if (!bookInfo.clientName.trim()) {
setNotification({ type: 'error', msg: 'Please select a client.' }); setNotification({ type: 'error', msg: 'Please select a company.' });
return; return;
} }
setGenerating(true); setGenerating(true);
@@ -682,7 +682,7 @@ export default function BrandBook() {
const handleClientLogoUpload = async (e) => { const handleClientLogoUpload = async (e) => {
const file = e.target.files[0]; const file = e.target.files[0];
if (!file) return; if (!file) return;
if (!bookInfo.clientId) { setNotification({ type: 'error', msg: 'Select a client first.' }); return; } if (!bookInfo.clientId) { setNotification({ type: 'error', msg: 'Select a company first.' }); return; }
setUploadingClientLogo(true); setUploadingClientLogo(true);
const ext = file.name.split('.').pop().toLowerCase(); const ext = file.name.split('.').pop().toLowerCase();
const path = `${bookInfo.clientId}/logo.${ext}`; const path = `${bookInfo.clientId}/logo.${ext}`;
@@ -801,13 +801,13 @@ export default function BrandBook() {
</div> </div>
) : ( ) : (
<div className="table-wrapper"> <div className="table-wrapper">
<table> <table className="table-sticky-head">
<thead> <thead>
<tr> <tr>
<SortTh col="project_name" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Name</SortTh> <SortTh col="project_name" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Name</SortTh>
<SortTh col="revision" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Revision</SortTh> <SortTh col="revision" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Revision</SortTh>
<SortTh col="sign_count" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Sign Count</SortTh> <SortTh col="sign_count" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Sign Count</SortTh>
<SortTh col="client" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Client</SortTh> <SortTh col="client" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Company</SortTh>
<SortTh col="updated_at" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Updated</SortTh> <SortTh col="updated_at" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Updated</SortTh>
<th></th> <th></th>
</tr> </tr>
@@ -893,7 +893,7 @@ export default function BrandBook() {
<div className="card-title">Brand Book Info</div> <div className="card-title">Brand Book Info</div>
<div className="grid-2"> <div className="grid-2">
<div className="form-group"> <div className="form-group">
<label>Client *</label> <label>Company *</label>
<select value={bookInfo.clientId} onChange={handleClientChange}> <select value={bookInfo.clientId} onChange={handleClientChange}>
<option value=""> Select client </option> <option value=""> Select client </option>
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)} {clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
@@ -1048,7 +1048,7 @@ export default function BrandBook() {
{/* Client info (saved per company) */} {/* Client info (saved per company) */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div> <div>
<div style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)' }}>Client Info</div> <div style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)' }}>Company Info</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>Logo and contact saved to company reused across all brand books.</div> <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>Logo and contact saved to company reused across all brand books.</div>
</div> </div>
<button className="btn btn-outline btn-sm" onClick={handleSaveClientInfo} disabled={savingClientInfo || !bookInfo.clientId}> <button className="btn btn-outline btn-sm" onClick={handleSaveClientInfo} disabled={savingClientInfo || !bookInfo.clientId}>
@@ -1057,7 +1057,7 @@ export default function BrandBook() {
</div> </div>
<div className="form-group"> <div className="form-group">
<label>Client Logo <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(3.5"×1.5" area, bottom right)</span></label> <label>Company Logo <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(3.5"×1.5" area, bottom right)</span></label>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{bookInfo.clientLogoUrl && ( {bookInfo.clientLogoUrl && (
<img src={bookInfo.clientLogoUrl} alt="Client logo" style={{ maxHeight: 44, maxWidth: 130, objectFit: 'contain', border: '1px solid var(--border)', borderRadius: 4, padding: 4, background: '#fff' }} /> <img src={bookInfo.clientLogoUrl} alt="Client logo" style={{ maxHeight: 44, maxWidth: 130, objectFit: 'contain', border: '1px solid var(--border)', borderRadius: 4, padding: 4, background: '#fff' }} />
+7 -7
View File
@@ -76,7 +76,7 @@ function TeamCompanies() {
}; };
const handleDeleteCompany = async (company) => { const handleDeleteCompany = async (company) => {
if (!window.confirm(`Delete "${company.name}"? This will permanently delete all projects, jobs, files, and data for this company. This cannot be undone.`)) return; if (!window.confirm(`Delete "${company.name}"? This will permanently delete all clients, jobs, files, and data for this company. This cannot be undone.`)) return;
await deleteCompanyData(company.id); await deleteCompanyData(company.id);
setCompanies(prev => prev.filter(c => c.id !== company.id)); setCompanies(prev => prev.filter(c => c.id !== company.id));
load(); load();
@@ -191,7 +191,7 @@ function TeamCompanies() {
</div> </div>
{tab === 'companies' && ( {tab === 'companies' && (
<button className="btn btn-primary btn-sm" onClick={() => { setShowNew(v => !v); setShowNewUser(false); }}> <button className="btn btn-primary btn-sm" onClick={() => { setShowNew(v => !v); setShowNewUser(false); }}>
{showNew ? 'Cancel' : '+ New Client'} {showNew ? 'Cancel' : '+ New Company'}
</button> </button>
)} )}
{tab === 'users' && ( {tab === 'users' && (
@@ -230,7 +230,7 @@ function TeamCompanies() {
</div> </div>
<div className="action-buttons"> <div className="action-buttons">
<button type="submit" className="btn btn-primary" disabled={saving || !newForm.name.trim()}> <button type="submit" className="btn btn-primary" disabled={saving || !newForm.name.trim()}>
{saving ? 'Creating...' : 'Create Client'} {saving ? 'Creating...' : 'Create Company'}
</button> </button>
<button type="button" className="btn btn-outline" onClick={() => setShowNew(false)}>Cancel</button> <button type="button" className="btn btn-outline" onClick={() => setShowNew(false)}>Cancel</button>
</div> </div>
@@ -242,7 +242,7 @@ function TeamCompanies() {
<div className="card-empty-center">No clients</div> <div className="card-empty-center">No clients</div>
) : ( ) : (
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}> <div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table> <table className="table-sticky-head">
<thead> <thead>
<tr> <tr>
<SortTh col="name" sortKey={coSortKey} sortDir={coSortDir} onSort={coToggle}>Company</SortTh> <SortTh col="name" sortKey={coSortKey} sortDir={coSortDir} onSort={coToggle}>Company</SortTh>
@@ -396,7 +396,7 @@ function TeamCompanies() {
<div className="card-empty-center">No users</div> <div className="card-empty-center">No users</div>
) : ( ) : (
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}> <div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table> <table className="table-sticky-head">
<thead> <thead>
<tr> <tr>
<SortTh col="name" sortKey={clSortKey} sortDir={clSortDir} onSort={clToggle}>Name</SortTh> <SortTh col="name" sortKey={clSortKey} sortDir={clSortDir} onSort={clToggle}>Name</SortTh>
@@ -450,7 +450,7 @@ function TeamCompanies() {
<div className="card-empty-center">No subcontractors</div> <div className="card-empty-center">No subcontractors</div>
) : ( ) : (
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}> <div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table> <table className="table-sticky-head">
<thead> <thead>
<tr> <tr>
<SortTh col="name" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Name</SortTh> <SortTh col="name" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Name</SortTh>
@@ -517,7 +517,7 @@ function ClientCompanyList() {
<div className="card-empty-center">No companies</div> <div className="card-empty-center">No companies</div>
) : ( ) : (
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}> <div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table> <table className="table-sticky-head">
<thead> <thead>
<tr> <tr>
<SortTh col="name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Company</SortTh> <SortTh col="name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Company</SortTh>
+8 -8
View File
@@ -154,7 +154,7 @@ export default function CompanyDetail() {
}; };
const handleDeleteCompany = async () => { const handleDeleteCompany = async () => {
if (!window.confirm(`Delete "${company.name}"? This will permanently delete all projects, jobs, files, and data. This cannot be undone.`)) return; if (!window.confirm(`Delete "${company.name}"? This will permanently delete all clients, jobs, files, and data. This cannot be undone.`)) return;
await deleteCompanyData(id); await deleteCompanyData(id);
navigate('/company'); navigate('/company');
}; };
@@ -309,7 +309,7 @@ export default function CompanyDetail() {
<div className="stat-card"> <div className="stat-card">
<div className="stat-icon">📁</div> <div className="stat-icon">📁</div>
<div className="stat-value">{projects.length}</div> <div className="stat-value">{projects.length}</div>
<div className="stat-label">Projects</div> <div className="stat-label">Clients</div>
</div> </div>
<div className="stat-card"> <div className="stat-card">
<div className="stat-icon"></div> <div className="stat-icon"></div>
@@ -477,16 +477,16 @@ export default function CompanyDetail() {
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{isTeam && <div style={{ display: 'flex', justifyContent: 'flex-end' }}> {isTeam && <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button className="btn btn-primary btn-sm" onClick={() => setShowNewProject(s => !s)}> <button className="btn btn-primary btn-sm" onClick={() => setShowNewProject(s => !s)}>
{showNewProject ? 'Cancel' : '+ New Project'} {showNewProject ? 'Cancel' : '+ New Client'}
</button> </button>
</div>} </div>}
{showNewProject && ( {showNewProject && (
<div className="card"> <div className="card">
<div className="card-title">New Project</div> <div className="card-title">New Client</div>
<form onSubmit={handleCreateProject} style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}> <form onSubmit={handleCreateProject} style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}>
<div className="form-group" style={{ flex: 1, marginBottom: 0 }}> <div className="form-group" style={{ flex: 1, marginBottom: 0 }}>
<label>Project Name *</label> <label>Client Name *</label>
<input <input
type="text" type="text"
placeholder="e.g. Brand Identity 2026" placeholder="e.g. Brand Identity 2026"
@@ -504,7 +504,7 @@ export default function CompanyDetail() {
)} )}
{projects.length === 0 ? ( {projects.length === 0 ? (
<div className="card card-empty-center">No projects</div> <div className="card card-empty-center">No clients</div>
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{projects.map(project => { {projects.map(project => {
@@ -513,7 +513,7 @@ export default function CompanyDetail() {
const done = projectTasks.filter(t => t.status === 'client_approved').length; const done = projectTasks.filter(t => t.status === 'client_approved').length;
return ( return (
<div key={project.id} className="interactive-surface" style={{ display: 'flex', alignItems: 'center', background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 4, overflow: 'hidden' }}> <div key={project.id} className="interactive-surface" style={{ display: 'flex', alignItems: 'center', background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 4, overflow: 'hidden' }}>
<Link to={`/projects/${project.id}`} className="interactive-row" style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', textDecoration: 'none', cursor: 'pointer' }}> <Link to={`/clients/${project.id}`} className="interactive-row" style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', textDecoration: 'none', cursor: 'pointer' }}>
<div> <div>
<div style={{ fontWeight: 400, fontSize: 14, color: 'var(--text-primary)' }}>{project.name}</div> <div style={{ fontWeight: 400, fontSize: 14, color: 'var(--text-primary)' }}>{project.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 3 }}> <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 3 }}>
@@ -529,7 +529,7 @@ export default function CompanyDetail() {
type="button" type="button"
onClick={() => handleDeleteProject(project)} onClick={() => handleDeleteProject(project)}
style={{ background: 'none', border: 'none', borderLeft: '1px solid var(--border)', color: 'var(--danger, var(--danger-strong))', cursor: 'pointer', fontSize: 16, padding: '0 14px', alignSelf: 'stretch', display: 'flex', alignItems: 'center' }} style={{ background: 'none', border: 'none', borderLeft: '1px solid var(--border)', color: 'var(--danger, var(--danger-strong))', cursor: 'pointer', fontSize: 16, padding: '0 14px', alignSelf: 'stretch', display: 'flex', alignItems: 'center' }}
title="Delete project" title="Delete client"
></button>} ></button>}
</div> </div>
); );
+3 -3
View File
@@ -392,7 +392,7 @@ export default function ProfilePage() {
const ACTION_LABEL = { const ACTION_LABEL = {
task_created: 'Task created', task_started: 'Task started', task_on_hold: 'Task put on hold', task_created: 'Task created', task_started: 'Task started', task_on_hold: 'Task put on hold',
task_resumed: 'Task resumed', task_submitted: 'Task submitted', task_approved: 'Task approved', task_resumed: 'Task resumed', task_submitted: 'Task submitted', task_approved: 'Task approved',
project_created: 'Project created', request_submitted: 'Request submitted', revision_requested: 'Task rejected', project_created: 'Client created', request_submitted: 'Request submitted', revision_requested: 'Task rejected',
}; };
const toSentenceTitle = (value = '') => { const toSentenceTitle = (value = '') => {
@@ -443,7 +443,7 @@ export default function ProfilePage() {
{rows.length === 0 ? ( {rows.length === 0 ? (
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No tasks in progress</div> <div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No tasks in progress</div>
) : ( ) : (
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}> <table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup> <colgroup>
<col style={{ width: '65%' }} /> <col style={{ width: '65%' }} />
<col style={{ width: '35%' }} /> <col style={{ width: '35%' }} />
@@ -616,7 +616,7 @@ export default function ProfilePage() {
<div style={{ ...dashCardStyle, display: 'flex', flexDirection: 'column', minHeight: 120 }}> <div style={{ ...dashCardStyle, display: 'flex', flexDirection: 'column', minHeight: 120 }}>
<div style={{ display: 'flex', alignItems: 'stretch', gap: 21 }}> <div style={{ display: 'flex', alignItems: 'stretch', gap: 21 }}>
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>Active Projects</div> <div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>Active Clients</div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}> <div style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{profileStats.activeProjects}</div> <div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{profileStats.activeProjects}</div>
</div> </div>
+92 -47
View File
@@ -5,6 +5,7 @@ import PageLoader from '../components/PageLoader';
import ProfileAvatar from '../components/ProfileAvatar'; import ProfileAvatar from '../components/ProfileAvatar';
import StatusBadge from '../components/StatusBadge'; import StatusBadge from '../components/StatusBadge';
import SortTh from '../components/SortTh'; import SortTh from '../components/SortTh';
import FilterDropdown from '../components/FilterDropdown';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { logActivity } from '../lib/activityLog'; import { logActivity } from '../lib/activityLog';
@@ -49,7 +50,11 @@ export default function ProjectDetailPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [submissions, setSubmissions] = useState([]); const [submissions, setSubmissions] = useState([]);
const [deliveries, setDeliveries] = useState([]); const [deliveries, setDeliveries] = useState([]);
const [activeTab, setActiveTab] = useState('all'); const [activeTab, setActiveTab] = useState('tasks');
const [filterStatus, setFilterStatus] = useState('all');
const [filterRevision, setFilterRevision] = useState('all');
const [filterType, setFilterType] = useState('all');
const [filterAssigned, setFilterAssigned] = useState('all');
const [editingName, setEditingName] = useState(false); const [editingName, setEditingName] = useState(false);
const [nameVal, setNameVal] = useState(''); const [nameVal, setNameVal] = useState('');
@@ -227,7 +232,7 @@ export default function ProjectDetailPage() {
}; };
if (loading) return <Layout><PageLoader /></Layout>; if (loading) return <Layout><PageLoader /></Layout>;
if (!project) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Project not found.</p></Layout>; if (!project) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Client not found.</p></Layout>;
const rows = tasks.map(task => { const rows = tasks.map(task => {
const derived = getTaskDerivedState(task, submissions, deliveries); const derived = getTaskDerivedState(task, submissions, deliveries);
@@ -254,21 +259,51 @@ export default function ProjectDetailPage() {
const doneStatuses = new Set(['client_approved', 'invoiced', 'paid']); const doneStatuses = new Set(['client_approved', 'invoiced', 'paid']);
const taskTabs = [ const taskTabs = [
{ id: 'all', label: 'All Tasks' }, { id: 'tasks', label: 'Tasks' },
{ id: 'new_requests', label: 'New Requests' },
{ id: 'revisions', label: 'Revisions' },
{ id: 'in_progress', label: 'In Progress' },
{ id: 'on_hold', label: 'On Hold' },
{ id: 'client_review', label: 'In Review' },
{ id: 'completed', label: 'Completed' }, { id: 'completed', label: 'Completed' },
...(isTeam ? [{ id: 'members', label: 'Subcontractors' }] : []), ...(isTeam ? [{ id: 'members', label: 'Subcontractors' }] : []),
]; ];
const visibleRows = activeTab === 'all' ? sortedRows const tabBaseRows = activeTab === 'completed'
: activeTab === 'completed' ? sortedRows.filter(r => doneStatuses.has(r.status)) ? sortedRows.filter(r => doneStatuses.has(r.status))
: activeTab === 'new_requests' ? sortedRows.filter(r => r.status === 'not_started' && Number(r.version || 0) === 0) : sortedRows.filter(r => !doneStatuses.has(r.status));
: activeTab === 'revisions' ? sortedRows.filter(r => r.status === 'not_started' && Number(r.version || 0) >= 1) let visibleRows = tabBaseRows;
: activeTab === 'members' ? [] if (filterStatus !== 'all') visibleRows = visibleRows.filter(r => r.status === filterStatus);
: sortedRows.filter(r => r.status === activeTab); if (filterRevision === 'new') visibleRows = visibleRows.filter(r => Number(r.version || 0) === 0);
else if (filterRevision === 'revision') visibleRows = visibleRows.filter(r => Number(r.version || 0) >= 1);
if (filterType !== 'all') visibleRows = visibleRows.filter(r => (r.serviceType || '') === filterType);
if (filterAssigned === 'unassigned') visibleRows = visibleRows.filter(r => !r.assignedTo);
else if (filterAssigned !== 'all') visibleRows = visibleRows.filter(r => r.assignedTo === filterAssigned);
const STATUS_FILTER_OPTIONS = activeTab === 'completed'
? [
{ value: 'all', label: 'All Statuses' },
{ value: 'client_approved', label: 'Approved' },
{ value: 'invoiced', label: 'Invoiced' },
{ value: 'paid', label: 'Paid' },
]
: [
{ value: 'all', label: 'All Statuses' },
{ value: 'not_started', label: 'Not Started' },
{ value: 'in_progress', label: 'In Progress' },
{ value: 'on_hold', label: 'On Hold' },
{ value: 'client_review', label: 'In Review' },
];
const REVISION_FILTER_OPTIONS = [
{ value: 'all', label: 'All' },
{ value: 'new', label: 'New (R00)' },
{ value: 'revision', label: 'Revisions (R1+)' },
];
const TYPE_FILTER_OPTIONS = [
{ value: 'all', label: 'All Types' },
...[...new Set(tabBaseRows.map(r => r.serviceType).filter(Boolean))].sort((a, b) => a.localeCompare(b)).map(t => ({ value: t, label: t })),
];
const ASSIGNED_FILTER_OPTIONS = [
{ value: 'all', label: 'All Assignees' },
{ value: 'unassigned', label: 'Unassigned' },
...[...new Map(tabBaseRows.filter(r => r.assignedTo).map(r => [r.assignedTo, r.assignedName || '—'])).entries()]
.sort((a, b) => a[1].localeCompare(b[1]))
.map(([id, name]) => ({ value: id, label: name })),
];
const fmtDate = (d) => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'; const fmtDate = (d) => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
@@ -345,13 +380,8 @@ export default function ProjectDetailPage() {
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
{(() => { {(() => {
const tabCounts = { const tabCounts = {
all: rows.length, tasks: rows.filter(r => !doneStatuses.has(r.status)).length,
new_requests: rows.filter(r => r.status === 'not_started' && Number(r.version || 0) === 0).length, completed: rows.filter(r => doneStatuses.has(r.status)).length,
revisions: rows.filter(r => r.status === 'not_started' && Number(r.version || 0) >= 1).length,
in_progress: rows.filter(r => r.status === 'in_progress').length,
on_hold: rows.filter(r => r.status === 'on_hold').length,
client_review: rows.filter(r => r.status === 'client_review').length,
completed: rows.filter(r => ['client_approved','invoiced','paid'].includes(r.status)).length,
members: members.filter(m => m.profile?.role === 'external').length, members: members.filter(m => m.profile?.role === 'external').length,
}; };
return ( return (
@@ -360,7 +390,7 @@ export default function ProjectDetailPage() {
const count = tabCounts[tab.id] ?? 0; const count = tabCounts[tab.id] ?? 0;
const active = activeTab === tab.id; const active = activeTab === tab.id;
return ( return (
<button key={tab.id} onClick={() => setActiveTab(tab.id)} className={`section-tab-btn${active ? ' is-active' : ''}`}> <button key={tab.id} onClick={() => { setActiveTab(tab.id); setFilterStatus('all'); setFilterRevision('all'); setFilterType('all'); setFilterAssigned('all'); }} className={`section-tab-btn${active ? ' is-active' : ''}`}>
{tab.label} {tab.label}
{tab.id !== 'all' && count > 0 && <span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: active ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}>{count}</span>} {tab.id !== 'all' && count > 0 && <span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: active ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}>{count}</span>}
</button> </button>
@@ -373,37 +403,56 @@ export default function ProjectDetailPage() {
); );
})()} })()}
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, overflowY: 'auto' }}> <div className="card" style={{ ...CARD, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{activeTab !== 'members' && ( {activeTab !== 'members' && (
visibleRows.length === 0 <div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
? <div className="card-empty-center">No tasks</div>
: <div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ overflowY: 'auto' }}>
<table className="table-sticky-head" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}> <table className="table-sticky-head" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
<colgroup> <colgroup>
<col style={{ width: '5%' }} /> <col style={{ width: '14%' }} />
<col style={{ width: '28%' }} /> <col style={{ width: '10%' }} />
<col style={{ width: '13%' }} /> <col style={{ width: '30%' }} />
<col style={{ width: '8%' }} /> <col style={{ width: '12%' }} />
<col style={{ width: '18%' }} />
<col style={{ width: '16%' }} /> <col style={{ width: '16%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '15%' }} />
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
<SortTh col="revision" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>R#</SortTh> <th style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => toggle('status')} style={{ cursor: 'pointer', userSelect: 'none' }}>Status<span style={{ marginLeft: 4, opacity: sortKey === 'status' ? 0.85 : 0.2, fontSize: 9 }}>{sortKey === 'status' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span></span>
<FilterDropdown value={filterStatus} onChange={setFilterStatus} options={STATUS_FILTER_OPTIONS} />
</span>
</th>
<th style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => toggle('revision')} style={{ cursor: 'pointer', userSelect: 'none' }}>R#<span style={{ marginLeft: 4, opacity: sortKey === 'revision' ? 0.85 : 0.2, fontSize: 9 }}>{sortKey === 'revision' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span></span>
<FilterDropdown value={filterRevision} onChange={setFilterRevision} options={REVISION_FILTER_OPTIONS} />
</span>
</th>
<SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>Name</SortTh> <SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>Name</SortTh>
<SortTh col="assigned" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Assigned</SortTh> <th style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center', whiteSpace: 'nowrap' }}>
<SortTh col="priority" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Priority</SortTh> <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<SortTh col="serviceType" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Task Type</SortTh> <span onClick={() => toggle('assigned')} style={{ cursor: 'pointer', userSelect: 'none' }}>Assigned<span style={{ marginLeft: 4, opacity: sortKey === 'assigned' ? 0.85 : 0.2, fontSize: 9 }}>{sortKey === 'assigned' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span></span>
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Deadline</SortTh> <FilterDropdown value={filterAssigned} onChange={setFilterAssigned} options={ASSIGNED_FILTER_OPTIONS} />
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Status</SortTh> </span>
</th>
<th style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center', whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => toggle('serviceType')} style={{ cursor: 'pointer', userSelect: 'none' }}>Task Type<span style={{ marginLeft: 4, opacity: sortKey === 'serviceType' ? 0.85 : 0.2, fontSize: 9 }}>{sortKey === 'serviceType' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span></span>
<FilterDropdown value={filterType} onChange={setFilterType} options={TYPE_FILTER_OPTIONS} />
</span>
</th>
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Due</SortTh>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{visibleRows.map(row => { {visibleRows.length === 0 ? (
<tr><td colSpan={6} style={{ textAlign: 'center', padding: '40px 0', color: 'var(--text-muted)', fontSize: 13 }}>No tasks</td></tr>
) : visibleRows.map(row => {
const avatarStyle = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: '50%', overflow: 'hidden', verticalAlign: 'middle', flexShrink: 0 }; const avatarStyle = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: '50%', overflow: 'hidden', verticalAlign: 'middle', flexShrink: 0 };
return ( return (
<tr key={row.rowKey}> <tr key={row.rowKey}>
<td style={{ ...TASK_TABLE_TD_BASE }}><StatusBadge status={row.status} /></td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}> <td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>
<Link to={`/tasks/${row.id}`} className="table-link">{`R${String(row.version).padStart(2, '0')}`}</Link> <Link to={`/tasks/${row.id}`} className="table-link">{`R${String(row.version).padStart(2, '0')}`}</Link>
</td> </td>
@@ -418,12 +467,8 @@ export default function ProjectDetailPage() {
: <div title="Unassigned" style={{ ...avatarStyle, background: 'rgba(255,255,255,0.08)' }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.3)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg></div> : <div title="Unassigned" style={{ ...avatarStyle, background: 'rgba(255,255,255,0.08)' }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.3)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg></div>
} }
</td> </td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 11, textAlign: 'center', color: row.isHot ? 'var(--danger)' : 'var(--text-primary)', textTransform: 'uppercase', letterSpacing: 0.5 }}>
{row.isHot ? 'HOT' : 'NO'}
</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>{row.serviceType}</td> <td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>{row.serviceType}</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>{fmtShortDate(row.deadline, 'Not specified')}</td> <td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>{fmtShortDate(row.deadline, 'Not specified')}</td>
<td style={{ ...TASK_TABLE_TD_BASE, textAlign: 'center' }}><StatusBadge status={row.status} /></td>
</tr> </tr>
); );
})} })}
@@ -433,7 +478,7 @@ export default function ProjectDetailPage() {
)} )}
{activeTab === 'members' && isTeam && ( {activeTab === 'members' && isTeam && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 12 }}>
{(() => { {(() => {
const subs = members.filter(m => m.profile?.role === 'external'); const subs = members.filter(m => m.profile?.role === 'external');
if (subs.length === 0) return <div className="card-empty-center">No subcontractors</div>; if (subs.length === 0) return <div className="card-empty-center">No subcontractors</div>;
@@ -448,7 +493,7 @@ export default function ProjectDetailPage() {
return subSortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av); return subSortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
}); });
return ( return (
<table style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}> <table className="table-sticky-head" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
<colgroup> <colgroup>
<col style={{ width: '5%' }} /> <col style={{ width: '5%' }} />
<col style={{ width: '30%' }} /> <col style={{ width: '30%' }} />
@@ -461,7 +506,7 @@ export default function ProjectDetailPage() {
<th style={{ ...TASK_TABLE_TH_STYLE, padding: '0 0 12px' }} /> <th style={{ ...TASK_TABLE_TH_STYLE, padding: '0 0 12px' }} />
<SortTh col="name" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TASK_TABLE_TH_STYLE}>Name</SortTh> <SortTh col="name" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TASK_TABLE_TH_STYLE}>Name</SortTh>
<SortTh col="email" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TASK_TABLE_TH_STYLE}>Email</SortTh> <SortTh col="email" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TASK_TABLE_TH_STYLE}>Email</SortTh>
<SortTh col="projects" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Projects</SortTh> <SortTh col="projects" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Clients</SortTh>
<th style={{ ...TASK_TABLE_TH_STYLE, padding: '0 0 12px' }} /> <th style={{ ...TASK_TABLE_TH_STYLE, padding: '0 0 12px' }} />
</tr> </tr>
</thead> </thead>
@@ -582,7 +627,7 @@ export default function ProjectDetailPage() {
<div style={{ flex: 1, overflowY: 'auto', padding: '0 21px' }}> <div style={{ flex: 1, overflowY: 'auto', padding: '0 21px' }}>
{externalProfiles.length === 0 {externalProfiles.length === 0
? <div className="card-empty-center">No subcontractors</div> ? <div className="card-empty-center">No subcontractors</div>
: <table style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}> : <table className="table-sticky-head" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
<colgroup> <colgroup>
<col style={{ width: '5%' }} /> <col style={{ width: '5%' }} />
<col style={{ width: '36%' }} /> <col style={{ width: '36%' }} />
+2 -2
View File
@@ -757,9 +757,9 @@ export default function TaskDetail() {
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
<div> <div>
<div style={CARD_META_LABEL}>Project</div> <div style={CARD_META_LABEL}>Client</div>
{project {project
? <Link to={`/projects/${project.id}`} className="table-link" style={{ fontSize: 13 }}>{project.name}</Link> ? <Link to={`/clients/${project.id}`} className="table-link" style={{ fontSize: 13 }}>{project.name}</Link>
: <div style={{ fontSize: 13, color: 'var(--text-muted)' }}></div>} : <div style={{ fontSize: 13, color: 'var(--text-muted)' }}></div>}
</div> </div>
</div> </div>
+57 -30
View File
@@ -68,7 +68,7 @@ function TasksStatsRow({ tasks = [] }) {
const open = Math.max(total - completed, 0); const open = Math.max(total - completed, 0);
const pct = (count) => total > 0 ? `${Math.round((count / total) * 100)}% of total` : '0% of total'; const pct = (count) => total > 0 ? `${Math.round((count / total) * 100)}% of total` : '0% of total';
return ( return (
<div className="dash-stat-grid" style={{ gridTemplateColumns: '1fr 1fr 1fr 1fr 1fr 1fr' }}> <div className="tasks-stat-grid">
<TaskStatCard label="To Do" value={toDo} sub={pct(toDo)} iconBg="color-mix(in srgb, var(--violet) 15%, transparent)" iconColor="var(--violet)" iconPath={TASK_STAT_ICONS.todo} /> <TaskStatCard label="To Do" value={toDo} sub={pct(toDo)} iconBg="color-mix(in srgb, var(--violet) 15%, transparent)" iconColor="var(--violet)" iconPath={TASK_STAT_ICONS.todo} />
<TaskStatCard label="In Progress" value={inProgress} sub={pct(inProgress)} iconBg="color-mix(in srgb, var(--accent) 15%, transparent)" iconColor="var(--accent)" iconPath={TASK_STAT_ICONS.progress} /> <TaskStatCard label="In Progress" value={inProgress} sub={pct(inProgress)} iconBg="color-mix(in srgb, var(--accent) 15%, transparent)" iconColor="var(--accent)" iconPath={TASK_STAT_ICONS.progress} />
<TaskStatCard label="On Hold" value={onHold} sub={pct(onHold)} iconBg="color-mix(in srgb, var(--danger) 15%, transparent)" iconColor="var(--danger)" iconPath={TASK_STAT_ICONS.hold} /> <TaskStatCard label="On Hold" value={onHold} sub={pct(onHold)} iconBg="color-mix(in srgb, var(--danger) 15%, transparent)" iconColor="var(--danger)" iconPath={TASK_STAT_ICONS.hold} />
@@ -124,10 +124,10 @@ function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, c
return 35; return 35;
}; };
return ( return (
<div style={{ minWidth: 0, flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}> <div className="tasks-shell" style={{ minWidth: 0, flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<TasksStatsRow tasks={statsTasks} /> <TasksStatsRow tasks={statsTasks} />
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}> <div className="tasks-body-row" style={{ display: 'flex', flex: 1, minHeight: 0 }}>
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}> <div className="tasks-body-col" style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{children} {children}
</div> </div>
</div> </div>
@@ -140,6 +140,8 @@ export default function RequestsPage() {
const isTeam = currentUser?.role === 'team'; const isTeam = currentUser?.role === 'team';
const isExternal = currentUser?.role === 'external'; const isExternal = currentUser?.role === 'external';
const isClient = currentUser?.role === 'client'; const isClient = currentUser?.role === 'client';
// Company column only meaningful when the user spans multiple companies (team sees all).
const showCompanyCol = isTeam || (currentUser?.companies?.length || 0) > 1;
const { refreshKey } = useLiveRefresh(['tasks', 'projects', 'submissions']); const { refreshKey } = useLiveRefresh(['tasks', 'projects', 'submissions']);
@@ -163,6 +165,7 @@ export default function RequestsPage() {
const [filterStatus, setFilterStatus] = useState('all'); const [filterStatus, setFilterStatus] = useState('all');
const [filterRevision, setFilterRevision] = useState('all'); const [filterRevision, setFilterRevision] = useState('all');
const [filterType, setFilterType] = useState('all'); const [filterType, setFilterType] = useState('all');
const [filterAssigned, setFilterAssigned] = useState('all');
const [filterProject, setFilterProject] = useState(''); const [filterProject, setFilterProject] = useState('');
const { sortKey, sortDir, toggle, sort } = useSortable('status', 'asc'); const { sortKey, sortDir, toggle, sort } = useSortable('status', 'asc');
@@ -432,7 +435,7 @@ export default function RequestsPage() {
setAddProjectSaving(true); setAddProjectError(''); setAddProjectSaving(true); setAddProjectError('');
try { try {
const name = addProjectForm.name.trim(); const name = addProjectForm.name.trim();
if (!name) throw new Error('Project name required.'); if (!name) throw new Error('Client name required.');
if (!addProjectForm.companyId) throw new Error('Company required.'); if (!addProjectForm.companyId) throw new Error('Company required.');
const { data: newProject, error: insertError } = await supabase const { data: newProject, error: insertError } = await supabase
.from('projects').insert({ name, company_id: addProjectForm.companyId, status: 'active' }) .from('projects').insert({ name, company_id: addProjectForm.companyId, status: 'active' })
@@ -517,6 +520,8 @@ export default function RequestsPage() {
if (filterRevision === 'new') tabRows = tabRows.filter(r => Number(r.version || 0) === 0); if (filterRevision === 'new') tabRows = tabRows.filter(r => Number(r.version || 0) === 0);
else if (filterRevision === 'revision') tabRows = tabRows.filter(r => Number(r.version || 0) >= 1); else if (filterRevision === 'revision') tabRows = tabRows.filter(r => Number(r.version || 0) >= 1);
if (filterType !== 'all') tabRows = tabRows.filter(r => (r.serviceType || '') === filterType); if (filterType !== 'all') tabRows = tabRows.filter(r => (r.serviceType || '') === filterType);
if (filterAssigned === 'unassigned') tabRows = tabRows.filter(r => !r.assignedTo);
else if (filterAssigned !== 'all') tabRows = tabRows.filter(r => r.assignedTo === filterAssigned);
const REVISION_FILTER_OPTIONS = [ const REVISION_FILTER_OPTIONS = [
{ value: 'all', label: 'All' }, { value: 'all', label: 'All' },
@@ -525,7 +530,7 @@ export default function RequestsPage() {
]; ];
const PROJECT_FILTER_OPTIONS = [ const PROJECT_FILTER_OPTIONS = [
{ value: '', label: 'All Projects' }, { value: '', label: 'All Clients' },
...projects ...projects
.filter(p => !filterCompany || p.company_id === filterCompany) .filter(p => !filterCompany || p.company_id === filterCompany)
.slice() .slice()
@@ -545,6 +550,14 @@ export default function RequestsPage() {
...filterableCompanies.map(c => ({ value: c.id, label: c.name })), ...filterableCompanies.map(c => ({ value: c.id, label: c.name })),
]; ];
const ASSIGNED_FILTER_OPTIONS = [
{ value: 'all', label: 'All Assignees' },
{ value: 'unassigned', label: 'Unassigned' },
...[...new Map(tabRowsBase.filter(r => r.assignedTo).map(r => [r.assignedTo, r.assignedName || '—'])).entries()]
.sort((a, b) => a[1].localeCompare(b[1]))
.map(([id, name]) => ({ value: id, label: name })),
];
const STATUS_FILTER_OPTIONS = activeTab === 'completed' const STATUS_FILTER_OPTIONS = activeTab === 'completed'
? [ ? [
{ value: 'all', label: 'All Statuses' }, { value: 'all', label: 'All Statuses' },
@@ -589,14 +602,14 @@ export default function RequestsPage() {
<td style={{ ...TASK_TABLE_TD_BASE }}> <td style={{ ...TASK_TABLE_TD_BASE }}>
<StatusBadge status={row.status} /> <StatusBadge status={row.status} />
</td> </td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}> <td className="tcol-rev" style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>
{`R${String(row.version).padStart(2, '0')}`} {`R${String(row.version).padStart(2, '0')}`}
</td> </td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}> <td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>
<Link to={`/tasks/${row.id}`} className="table-link">{row.title || '—'}</Link> <Link to={`/tasks/${row.id}`} className="table-link">{row.title || '—'}</Link>
</td> </td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}> <td className="tcol-project" style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>
{project ? <Link to={`/projects/${project.id}`} className="table-link">{project.name}</Link> : '—'} {project ? <Link to={`/clients/${project.id}`} className="table-link">{project.name}</Link> : '—'}
</td> </td>
<td style={{ ...TASK_TABLE_TD_BASE, textAlign: 'center' }}> <td style={{ ...TASK_TABLE_TD_BASE, textAlign: 'center' }}>
{(() => { {(() => {
@@ -607,15 +620,17 @@ export default function RequestsPage() {
return <div title="Unassigned" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: '50%', overflow: 'hidden', verticalAlign: 'middle', flexShrink: 0, background: 'rgba(255,255,255,0.08)' }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.3)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg></div>; return <div title="Unassigned" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: '50%', overflow: 'hidden', verticalAlign: 'middle', flexShrink: 0, background: 'rgba(255,255,255,0.08)' }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.3)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg></div>;
})()} })()}
</td> </td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}> <td className="tcol-type" style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>
{row.serviceType} {row.serviceType}
</td> </td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}> <td className="tcol-due" style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>
{fmtShortDate(row.deadline, 'Not specified')} {fmtShortDate(row.deadline, 'Not specified')}
</td> </td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)' }}> {showCompanyCol && (
<td className="tcol-company" style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)' }}>
{project?.company?.name || '—'} {project?.company?.name || '—'}
</td> </td>
)}
</tr> </tr>
); );
}; };
@@ -641,7 +656,7 @@ export default function RequestsPage() {
<div style={popupOverlayStyle} onClick={() => { setShowAddProjectForm(false); setAddProjectForm({ name: '', companyId: '' }); setAddProjectError(''); }}> <div style={popupOverlayStyle} onClick={() => { setShowAddProjectForm(false); setAddProjectForm({ name: '', companyId: '' }); setAddProjectError(''); }}>
<div style={PROJECT_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}> <div style={PROJECT_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 14 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 14 }}>
<div style={TASK_MODAL_TITLE_STYLE}>Add Project</div> <div style={TASK_MODAL_TITLE_STYLE}>Add Client</div>
</div> </div>
<form onSubmit={handleAddProject}> <form onSubmit={handleAddProject}>
<div className="form-group"> <div className="form-group">
@@ -652,7 +667,7 @@ export default function RequestsPage() {
</select> </select>
</div> </div>
<div className="form-group"> <div className="form-group">
<label style={TASK_MODAL_LABEL_STYLE}>Project Name *</label> <label style={TASK_MODAL_LABEL_STYLE}>Client Name *</label>
<input type="text" placeholder="e.g. Brand Identity 2026" value={addProjectForm.name} onChange={e => setAddProjectForm(f => ({ ...f, name: e.target.value }))} required autoFocus style={TASK_MODAL_INPUT_STYLE} /> <input type="text" placeholder="e.g. Brand Identity 2026" value={addProjectForm.name} onChange={e => setAddProjectForm(f => ({ ...f, name: e.target.value }))} required autoFocus style={TASK_MODAL_INPUT_STYLE} />
</div> </div>
{addProjectError && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>{addProjectError}</div>} {addProjectError && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>{addProjectError}</div>}
@@ -689,9 +704,9 @@ export default function RequestsPage() {
)} )}
{/* Controls bar: tabs left, filters + actions right */} {/* Controls bar: tabs left, filters + actions right */}
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10, flexShrink: 0 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 4, rowGap: 8, marginBottom: 10, flexShrink: 0, flexWrap: 'wrap' }}>
{tabs.map(t => ( {tabs.map(t => (
<button key={t.id} onClick={() => { setActiveTab(t.id); setFilterStatus('all'); setFilterRevision('all'); setFilterType('all'); }} className={`section-tab-btn${activeTab === t.id ? ' is-active' : ''}`}> <button key={t.id} onClick={() => { setActiveTab(t.id); setFilterStatus('all'); setFilterRevision('all'); setFilterType('all'); setFilterAssigned('all'); }} className={`section-tab-btn${activeTab === t.id ? ' is-active' : ''}`}>
{t.label} {t.label}
{t.id !== 'all' && tabCounts[t.id] > 0 && ( {t.id !== 'all' && tabCounts[t.id] > 0 && (
<span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: activeTab === t.id ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}> <span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: activeTab === t.id ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}>
@@ -708,18 +723,18 @@ export default function RequestsPage() {
</div> </div>
{/* Task table */} {/* Task table */}
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', background: 'var(--card-bg)', borderRadius: 8, border: 'var(--card-border)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', padding: '18px 21px' }}> <div className="tasks-table-card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', background: 'var(--card-bg)', borderRadius: 8, border: 'var(--card-border)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', padding: '18px 21px' }}>
<div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}> <div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden tasks-table-scroll" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head table-no-row-hover" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}> <table className="table-sticky-head table-no-row-hover" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
<colgroup> <colgroup>
<col style={{ width: '11%' }} /> <col style={{ width: '11%' }} />
<col style={{ width: '7%' }} /> <col className="tcol-rev" style={{ width: '7%' }} />
<col style={{ width: '24%' }} /> <col style={{ width: '24%' }} />
<col style={{ width: '15%' }} /> <col className="tcol-project" style={{ width: '15%' }} />
<col style={{ width: '9%' }} /> <col style={{ width: '9%' }} />
<col style={{ width: '12%' }} /> <col className="tcol-type" style={{ width: '12%' }} />
<col style={{ width: '10%' }} /> <col className="tcol-due" style={{ width: '10%' }} />
<col style={{ width: '12%' }} /> {showCompanyCol && <col className="tcol-company" style={{ width: '12%' }} />}
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
@@ -734,7 +749,7 @@ export default function RequestsPage() {
<FilterDropdown value={filterStatus} onChange={setFilterStatus} options={STATUS_FILTER_OPTIONS} /> <FilterDropdown value={filterStatus} onChange={setFilterStatus} options={STATUS_FILTER_OPTIONS} />
</span> </span>
</th> </th>
<th style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}> <th className="tcol-rev" style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}> <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => toggle('revision')} style={{ cursor: 'pointer', userSelect: 'none' }}> <span onClick={() => toggle('revision')} style={{ cursor: 'pointer', userSelect: 'none' }}>
R# R#
@@ -746,10 +761,10 @@ export default function RequestsPage() {
</span> </span>
</th> </th>
<SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>Name</SortTh> <SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>Name</SortTh>
<th style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}> <th className="tcol-project" style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}> <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => toggle('project')} style={{ cursor: 'pointer', userSelect: 'none' }}> <span onClick={() => toggle('project')} style={{ cursor: 'pointer', userSelect: 'none' }}>
Project Client
<span style={{ marginLeft: 4, opacity: sortKey === 'project' ? 0.85 : 0.2, fontSize: 9 }}> <span style={{ marginLeft: 4, opacity: sortKey === 'project' ? 0.85 : 0.2, fontSize: 9 }}>
{sortKey === 'project' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'} {sortKey === 'project' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}
</span> </span>
@@ -757,8 +772,18 @@ export default function RequestsPage() {
<FilterDropdown value={filterProject} onChange={setFilterProject} options={PROJECT_FILTER_OPTIONS} /> <FilterDropdown value={filterProject} onChange={setFilterProject} options={PROJECT_FILTER_OPTIONS} />
</span> </span>
</th> </th>
<SortTh col="assigned" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Assigned</SortTh>
<th style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center', whiteSpace: 'nowrap' }}> <th style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center', whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => toggle('assigned')} style={{ cursor: 'pointer', userSelect: 'none' }}>
Assigned
<span style={{ marginLeft: 4, opacity: sortKey === 'assigned' ? 0.85 : 0.2, fontSize: 9 }}>
{sortKey === 'assigned' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}
</span>
</span>
<FilterDropdown value={filterAssigned} onChange={setFilterAssigned} options={ASSIGNED_FILTER_OPTIONS} />
</span>
</th>
<th className="tcol-type" style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center', whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}> <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => toggle('serviceType')} style={{ cursor: 'pointer', userSelect: 'none' }}> <span onClick={() => toggle('serviceType')} style={{ cursor: 'pointer', userSelect: 'none' }}>
Task Type Task Type
@@ -769,8 +794,9 @@ export default function RequestsPage() {
<FilterDropdown value={filterType} onChange={setFilterType} options={TYPE_FILTER_OPTIONS} /> <FilterDropdown value={filterType} onChange={setFilterType} options={TYPE_FILTER_OPTIONS} />
</span> </span>
</th> </th>
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Due</SortTh> <SortTh col="deadline" className="tcol-due" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Due</SortTh>
<th style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}> {showCompanyCol && (
<th className="tcol-company" style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}> <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => toggle('company')} style={{ cursor: 'pointer', userSelect: 'none' }}> <span onClick={() => toggle('company')} style={{ cursor: 'pointer', userSelect: 'none' }}>
Company Company
@@ -781,11 +807,12 @@ export default function RequestsPage() {
<FilterDropdown value={filterCompany} onChange={(v) => { setFilterCompany(v); setFilterProject(''); }} options={COMPANY_FILTER_OPTIONS} /> <FilterDropdown value={filterCompany} onChange={(v) => { setFilterCompany(v); setFilterProject(''); }} options={COMPANY_FILTER_OPTIONS} />
</span> </span>
</th> </th>
)}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{sortedRows.length === 0 ? ( {sortedRows.length === 0 ? (
<tr><td colSpan={8} style={{ textAlign: 'center', padding: '40px 0', color: 'var(--text-muted)', fontSize: 13 }}>No tasks</td></tr> <tr><td colSpan={showCompanyCol ? 8 : 7} style={{ textAlign: 'center', padding: '40px 0', color: 'var(--text-muted)', fontSize: 13 }}>No tasks</td></tr>
) : sortedRows.map(renderRow)} ) : sortedRows.map(renderRow)}
</tbody> </tbody>
</table> </table>
+3 -3
View File
@@ -208,9 +208,9 @@ export default function CreateSubcontractorPO() {
</select> </select>
</div> </div>
<div className="form-group" style={{ marginBottom: 0 }}> <div className="form-group" style={{ marginBottom: 0 }}>
<label>Project</label> <label>Client</label>
<select value={form.project_id} onChange={e => handleProjectChange(e.target.value)}> <select value={form.project_id} onChange={e => handleProjectChange(e.target.value)}>
<option value="">No project</option> <option value="">No client</option>
{projects.map(project => ( {projects.map(project => (
<option key={project.id} value={project.id}> <option key={project.id} value={project.id}>
{project.name}{project.company?.name ? ` · ${project.company.name}` : ''} {project.name}{project.company?.name ? ` · ${project.company.name}` : ''}
@@ -224,7 +224,7 @@ export default function CreateSubcontractorPO() {
{form.project_id && ( {form.project_id && (
<div className="card" style={{ marginBottom: 24 }}> <div className="card" style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<div className="card-title" style={{ marginBottom: 0 }}>Project Tasks</div> <div className="card-title" style={{ marginBottom: 0 }}>Client Tasks</div>
{availableTasks.length > 0 && ( {availableTasks.length > 0 && (
<button className="btn btn-outline btn-sm" onClick={() => availableTasks.forEach(task => addTaskAsLineItem(task))}> <button className="btn btn-outline btn-sm" onClick={() => availableTasks.forEach(task => addTaskAsLineItem(task))}>
+ Add All + Add All
+4 -4
View File
@@ -312,7 +312,7 @@ function TasksInProgressCard({ tasks = [] }) {
{rows.length === 0 ? ( {rows.length === 0 ? (
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No tasks in progress</div> <div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No tasks in progress</div>
) : ( ) : (
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}> <table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup> <colgroup>
<col style={{ width: '55%' }} /> <col style={{ width: '55%' }} />
<col style={{ width: '45%' }} /> <col style={{ width: '45%' }} />
@@ -402,7 +402,7 @@ function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false
</div> </div>
) : ( ) : (
<div className="table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}> <div className="table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}> <table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
{isExternal ? ( {isExternal ? (
<> <>
<colgroup> <colgroup>
@@ -414,7 +414,7 @@ function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false
<thead style={{ background: 'transparent' }}> <thead style={{ background: 'transparent' }}>
<tr style={{ background: 'transparent' }}> <tr style={{ background: 'transparent' }}>
<SortTh col="task" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'left', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Task</SortTh> <SortTh col="task" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'left', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Task</SortTh>
<SortTh col="project" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'left', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Project</SortTh> <SortTh col="project" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'left', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Client</SortTh>
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Status</SortTh> <SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Status</SortTh>
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Due By</SortTh> <SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Due By</SortTh>
</tr> </tr>
@@ -854,7 +854,7 @@ export default function TeamDashboard() {
<Layout> <Layout>
<div className="dash-stat-grid" style={{ marginBottom: 0 }}> <div className="dash-stat-grid" style={{ marginBottom: 0 }}>
<DashStatCard label="Open Tasks" value={activeTasks.length} sub="not complete" iconBg="color-mix(in srgb, var(--violet) 15%, transparent)" iconColor="var(--violet)" iconPath={DASH_ICONS.tasks} /> <DashStatCard label="Open Tasks" value={activeTasks.length} sub="not complete" iconBg="color-mix(in srgb, var(--violet) 15%, transparent)" iconColor="var(--violet)" iconPath={DASH_ICONS.tasks} />
<DashStatCard label="Active Projects" value={activeProjects.length} sub={`${projects.length} total`} iconBg="color-mix(in srgb, var(--info) 15%, transparent)" iconColor="var(--info)" iconPath={DASH_ICONS.projects} /> <DashStatCard label="Active Clients" value={activeProjects.length} sub={`${projects.length} total`} iconBg="color-mix(in srgb, var(--info) 15%, transparent)" iconColor="var(--info)" iconPath={DASH_ICONS.projects} />
<DashStatCard label={card3.label} value={card3.value} sub={card3.sub} iconBg={card3.iconBg} iconColor={card3.iconColor} iconPath={card3.iconPath} /> <DashStatCard label={card3.label} value={card3.value} sub={card3.sub} iconBg={card3.iconBg} iconColor={card3.iconColor} iconPath={card3.iconPath} />
<DashStatCard label={card4.label} value={card4.value} sub={card4.sub} iconBg={card4.iconBg} iconColor={card4.iconColor} iconPath={card4.iconPath} chartData={card4.chartData} /> <DashStatCard label={card4.label} value={card4.value} sub={card4.sub} iconBg={card4.iconBg} iconColor={card4.iconColor} iconPath={card4.iconPath} chartData={card4.chartData} />
</div> </div>
+1 -1
View File
@@ -574,7 +574,7 @@ export function TeamInvoiceDetailPanel({
<div className="card" style={{ marginBottom: 24 }}> <div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">Line Items</div> <div className="card-title">Line Items</div>
<div className="table-wrapper" style={{ border: 'none' }}> <div className="table-wrapper" style={{ border: 'none' }}>
<table> <table className="table-sticky-head">
<thead> <thead>
<tr> <tr>
<SortTh col="type" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ width: 100 }}>Type</SortTh> <SortTh col="type" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ width: 100 }}>Type</SortTh>
+99 -81
View File
@@ -4,6 +4,7 @@ import { DashboardBanner } from '../../lib/dashboardBanner';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout'; import Layout from '../../components/Layout';
import SortTh from '../../components/SortTh'; import SortTh from '../../components/SortTh';
import FilterDropdown from '../../components/FilterDropdown';
import StatusBadge from '../../components/StatusBadge'; import StatusBadge from '../../components/StatusBadge';
import { useSortable } from '../../hooks/useSortable'; import { useSortable } from '../../hooks/useSortable';
import { useActionLock } from '../../hooks/useActionLock'; import { useActionLock } from '../../hooks/useActionLock';
@@ -186,13 +187,13 @@ const invNewItem = (description = '', unit_price = '', quantity = 1, task_id = n
({ id: crypto.randomUUID(), description, unit_price, quantity, task_id, submission_id }); ({ id: crypto.randomUUID(), description, unit_price, quantity, task_id, submission_id });
const invBuildNewItemDescription = (task) => { const invBuildNewItemDescription = (task) => {
const projectName = task.project?.name || 'No Project'; const projectName = task.project?.name || 'No Client';
const taskTitle = task.title || task.service_type || 'Untitled'; const taskTitle = task.title || task.service_type || 'Untitled';
return `${projectName}${taskTitle}`; return `${projectName}${taskTitle}`;
}; };
const invBuildRevisionItemDescription = (revision) => { const invBuildRevisionItemDescription = (revision) => {
const projectName = revision.task?.project?.name || 'No Project'; const projectName = revision.task?.project?.name || 'No Client';
const taskTitle = revision.task?.title || revision.service_type || 'Revision'; const taskTitle = revision.task?.title || revision.service_type || 'Revision';
const versionLabel = 'R' + String(revision.version_number || 0).padStart(2, '0'); const versionLabel = 'R' + String(revision.version_number || 0).padStart(2, '0');
return `${projectName}${taskTitle} • Revision ${versionLabel}`; return `${projectName}${taskTitle} • Revision ${versionLabel}`;
@@ -266,12 +267,11 @@ export default function Invoices() {
const [expensePreviewUrl, setExpensePreviewUrl] = useState(null); const [expensePreviewUrl, setExpensePreviewUrl] = useState(null);
const [expenseDetailEditing, setExpenseDetailEditing] = useState(false); const [expenseDetailEditing, setExpenseDetailEditing] = useState(false);
const [expYearFilter, setExpYearFilter] = useState(String(new Date().getFullYear())); const [expYearFilter, setExpYearFilter] = useState(String(new Date().getFullYear()));
const [expYearMenuOpen, setExpYearMenuOpen] = useState(false);
const expYearMenuRef = useRef(null);
const [invYearFilter, setInvYearFilter] = useState(String(new Date().getFullYear())); const [invYearFilter, setInvYearFilter] = useState(String(new Date().getFullYear()));
const [invCompanyFilter, setInvCompanyFilter] = useState('all'); const [invCompanyFilter, setInvCompanyFilter] = useState('all');
const [invYearMenuOpen, setInvYearMenuOpen] = useState(false); const [subWhoFilter, setSubWhoFilter] = useState('all');
const invYearMenuRef = useRef(null); const [subStatusFilter, setSubStatusFilter] = useState('all');
const [subYearFilter, setSubYearFilter] = useState('all');
const [subcontractorPOs, setSubcontractorPOs] = useState([]); const [subcontractorPOs, setSubcontractorPOs] = useState([]);
const [subcontractorLoading, setSubcontractorLoading] = useState(true); const [subcontractorLoading, setSubcontractorLoading] = useState(true);
const [subcontractorError, setSubcontractorError] = useState(''); const [subcontractorError, setSubcontractorError] = useState('');
@@ -485,20 +485,6 @@ export default function Invoices() {
loadExpenses(); loadExpenses();
}, []); }, []);
useEffect(() => {
if (!expYearMenuOpen) return;
const handler = e => { if (expYearMenuRef.current && !expYearMenuRef.current.contains(e.target)) setExpYearMenuOpen(false); };
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [expYearMenuOpen]);
useEffect(() => {
if (!invYearMenuOpen) return;
const handler = e => { if (invYearMenuRef.current && !invYearMenuRef.current.contains(e.target)) setInvYearMenuOpen(false); };
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [invYearMenuOpen]);
useEffect(() => { useEffect(() => {
setExpensePreviewUrl(null); setExpensePreviewUrl(null);
setExpenseDetailEditing(false); setExpenseDetailEditing(false);
@@ -921,8 +907,7 @@ export default function Invoices() {
); );
})()} })()}
<div style={{ display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 24, marginTop: 24, marginBottom: 10, flexShrink: 0, alignItems: 'center' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 24, marginBottom: 10, flexShrink: 0, minHeight: 'var(--btn-height)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, minHeight: 'var(--btn-height)' }}>
{[ {[
{ id: 'overview', label: 'Overview' }, { id: 'overview', label: 'Overview' },
{ id: 'expenses', label: 'Expenses' }, { id: 'expenses', label: 'Expenses' },
@@ -939,49 +924,14 @@ export default function Invoices() {
{financeTab === 'expenses' && ( {financeTab === 'expenses' && (
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
<button className="btn btn-outline" onClick={() => { setEditingExpenseId(''); setNewExpense(blankExpense()); setExpensesError(''); setShowExpenseForm(true); }}>+ Expense</button> <button className="btn btn-outline" onClick={() => { setEditingExpenseId(''); setNewExpense(blankExpense()); setExpensesError(''); setShowExpenseForm(true); }}>+ Expense</button>
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }} ref={expYearMenuRef}>
<button className="btn btn-outline" onClick={() => setExpYearMenuOpen(o => !o)} style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }} title="Filter by year">
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M2 4h12M4 8h8M6 12h4" /></svg>
</button>
{expYearMenuOpen && (
<div className="site-header-avatar-menu" style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 220, minWidth: 130 }}>
<button onClick={() => { setExpYearFilter('all'); setExpYearMenuOpen(false); }} className="site-header-avatar-item" style={{ background: expYearFilter === 'all' ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', color: expYearFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Years</button>
{expenseYears.map(y => (
<button key={y} onClick={() => { setExpYearFilter(y); setExpYearMenuOpen(false); }} className="site-header-avatar-item" style={{ background: expYearFilter === y ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', color: expYearFilter === y ? 'var(--accent)' : 'var(--text-primary)' }}>{y}</button>
))}
</div>
)}
</div>
</div> </div>
)} )}
{financeTab === 'invoices' && ( {financeTab === 'invoices' && (
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
<button className="btn btn-outline" onClick={() => setShowInvoiceForm(true)}>+ Invoice</button> <button className="btn btn-outline" onClick={() => setShowInvoiceForm(true)}>+ Invoice</button>
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }} ref={invYearMenuRef}>
<button className="btn btn-outline" onClick={() => setInvYearMenuOpen(o => !o)} style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }} title="Filter by year">
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M2 4h12M4 8h8M6 12h4" /></svg>
</button>
{invYearMenuOpen && (
<div className="site-header-avatar-menu" style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 220, minWidth: 150 }}>
<div style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-muted)', padding: '6px 14px 4px' }}>Year</div>
<button onClick={() => setInvYearFilter('all')} className="site-header-avatar-item" style={{ background: invYearFilter === 'all' ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', color: invYearFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Years</button>
{[...new Set(invoices.map(i => i.invoice_date?.slice(0,4)).filter(Boolean))].sort((a,b) => b-a).map(y => (
<button key={y} onClick={() => setInvYearFilter(y)} className="site-header-avatar-item" style={{ background: invYearFilter === y ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', color: invYearFilter === y ? 'var(--accent)' : 'var(--text-primary)' }}>{y}</button>
))}
<div style={{ borderTop: '1px solid var(--border)', margin: '4px 0' }} />
<div style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-muted)', padding: '4px 14px 4px' }}>Company</div>
<button onClick={() => setInvCompanyFilter('all')} className="site-header-avatar-item" style={{ background: invCompanyFilter === 'all' ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', color: invCompanyFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Companies</button>
{[...new Set(invoices.map(i => i.company?.name || i.bill_to).filter(Boolean))].sort().map(c => (
<button key={c} onClick={() => setInvCompanyFilter(c)} className="site-header-avatar-item" style={{ background: invCompanyFilter === c ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', color: invCompanyFilter === c ? 'var(--accent)' : 'var(--text-primary)' }}>{c}</button>
))}
</div> </div>
)} )}
</div> </div>
</div>
)}
</div>
<div />
</div>
{financeTab === 'overview' && (() => { {financeTab === 'overview' && (() => {
const CARD = { background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }; const CARD = { background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' };
@@ -1071,9 +1021,9 @@ export default function Invoices() {
); );
return ( return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 24, flex: 1, minHeight: 0 }}> <div style={{ ...CARD, display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 0, flex: 1, minHeight: 0 }}>
{/* Card 1: This Month Expenses by category */} {/* Section 1: This Month Expenses by category */}
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto' }}> <div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto', paddingRight: 21 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5, flexShrink: 0 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5, flexShrink: 0 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{MONTHS[m1]} {y1 !== curY ? y1 : ''} Expenses</span> <span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{MONTHS[m1]} {y1 !== curY ? y1 : ''} Expenses</span>
<MonthNav m={ovMonth1} setter={setOvMonth1} /> <MonthNav m={ovMonth1} setter={setOvMonth1} />
@@ -1099,8 +1049,8 @@ export default function Invoices() {
{thisCatTotals.length > 0 && <PieLegend data={thisCatTotals.map(c => ({ name: c.cat, value: c.total }))} colors={PIE_COLORS} />} {thisCatTotals.length > 0 && <PieLegend data={thisCatTotals.map(c => ({ name: c.cat, value: c.total }))} colors={PIE_COLORS} />}
</div> </div>
{/* Card 2: Pending Sub Payments by subcontractor */} {/* Section 2: Pending Sub Payments by subcontractor */}
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto' }}> <div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto', borderLeft: '1px solid var(--border)', paddingLeft: 21, paddingRight: 21 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5, flexShrink: 0 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5, flexShrink: 0 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{MONTHS[m2]} {y2 !== curY ? y2 : ''} Sub Payments</span> <span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{MONTHS[m2]} {y2 !== curY ? y2 : ''} Sub Payments</span>
<MonthNav m={ovMonth2} setter={setOvMonth2} /> <MonthNav m={ovMonth2} setter={setOvMonth2} />
@@ -1126,8 +1076,8 @@ export default function Invoices() {
{subPieData.length > 0 && <PieLegend data={subPieData} colors={PIE_COLORS} />} {subPieData.length > 0 && <PieLegend data={subPieData} colors={PIE_COLORS} />}
</div> </div>
{/* Card 3: Invoiced this month by company */} {/* Section 3: Invoiced this month by company */}
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto' }}> <div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto', borderLeft: '1px solid var(--border)', paddingLeft: 21 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5, flexShrink: 0 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5, flexShrink: 0 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{MONTHS[m3]} {y3 !== curY ? y3 : ''} Invoiced</span> <span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{MONTHS[m3]} {y3 !== curY ? y3 : ''} Invoiced</span>
<MonthNav m={ovMonth3} setter={setOvMonth3} /> <MonthNav m={ovMonth3} setter={setOvMonth3} />
@@ -1171,11 +1121,13 @@ export default function Invoices() {
.map(cat => ({ cat, total: filteredExpenses.filter(e => e.category === cat).reduce((s, e) => s + Number(e.amount || 0), 0) })) .map(cat => ({ cat, total: filteredExpenses.filter(e => e.category === cat).reduce((s, e) => s + Number(e.amount || 0), 0) }))
.sort((a, b) => b.total - a.total); .sort((a, b) => b.total - a.total);
const grandTotal = filteredExpenses.reduce((s, e) => s + Number(e.amount || 0), 0); const grandTotal = filteredExpenses.reduce((s, e) => s + Number(e.amount || 0), 0);
const yearOptions = [{ value: 'all', label: 'All Years' }, ...expenseYears.map(y => ({ value: y, label: y }))];
const categoryOptions = [{ value: 'all', label: 'All' }, ...CATEGORIES.map(c => ({ value: c, label: c }))];
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, flex: 1, minHeight: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 0, flex: 1, minHeight: 0 }}>
<div style={{ display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 24, flex: 1, minHeight: 0 }}> <div style={{ ...CARD, display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 0, flex: 1, minHeight: 0 }}>
{/* Left: expense list */} {/* Left: expense list */}
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, paddingRight: 21 }}>
{expenses.length === 0 ? <div className="card-empty-center">No expenses</div> : ( {expenses.length === 0 ? <div className="card-empty-center">No expenses</div> : (
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}> <div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}> <table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
@@ -1187,9 +1139,25 @@ export default function Invoices() {
<col style={{ width: '12%' }} /> <col style={{ width: '12%' }} />
</colgroup> </colgroup>
<thead><tr> <thead><tr>
<SortTh col="date" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={TH}>Date</SortTh> <th style={{ ...TH, whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => expToggle('date')} style={{ cursor: 'pointer', userSelect: 'none' }}>
Date
<span style={{ marginLeft: 4, opacity: expSortKey === 'date' ? 0.85 : 0.2, fontSize: 9 }}>{expSortKey === 'date' ? (expSortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span>
</span>
<FilterDropdown value={expYearFilter} onChange={setExpYearFilter} options={yearOptions} />
</span>
</th>
<SortTh col="description" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={TH}>Description</SortTh> <SortTh col="description" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={TH}>Description</SortTh>
<SortTh col="category" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={TH}>Category</SortTh> <th style={{ ...TH, whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => expToggle('category')} style={{ cursor: 'pointer', userSelect: 'none' }}>
Category
<span style={{ marginLeft: 4, opacity: expSortKey === 'category' ? 0.85 : 0.2, fontSize: 9 }}>{expSortKey === 'category' ? (expSortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span>
</span>
<FilterDropdown value={expenseFilter} onChange={setExpenseFilter} options={categoryOptions} />
</span>
</th>
<SortTh col="notes" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={TH}>Notes</SortTh> <SortTh col="notes" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={TH}>Notes</SortTh>
<SortTh col="amount" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={{ ...TH, textAlign: 'right' }}>Amount</SortTh> <SortTh col="amount" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={{ ...TH, textAlign: 'right' }}>Amount</SortTh>
</tr></thead> </tr></thead>
@@ -1211,7 +1179,7 @@ export default function Invoices() {
)} )}
</div> </div>
{/* Right: category breakdown */} {/* Right: category breakdown */}
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, borderLeft: '1px solid var(--border)', paddingLeft: 21 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5, flexShrink: 0 }}>Category</div> <div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5, flexShrink: 0 }}>Category</div>
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: 'var(--danger)', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div> <div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: 'var(--danger)', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div>
{categoryTotals.length === 0 ? <div className="card-empty-center">No expenses</div> : ( {categoryTotals.length === 0 ? <div className="card-empty-center">No expenses</div> : (
@@ -1249,7 +1217,15 @@ export default function Invoices() {
const subInvoiceStatusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' }; const subInvoiceStatusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
const invTotal = inv => (inv.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0); const invTotal = inv => (inv.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
const yearFiltered = subInvoices; const yearFiltered = subInvoices.filter(inv => {
if (subWhoFilter !== 'all' && (inv.profile?.name || 'Unknown') !== subWhoFilter) return false;
if (subStatusFilter !== 'all' && inv.status !== subStatusFilter) return false;
if (subYearFilter !== 'all' && (inv.submitted_at ? String(new Date(inv.submitted_at).getFullYear()) : '') !== subYearFilter) return false;
return true;
});
const subWhoOptions = [{ value: 'all', label: 'All' }, ...[...new Set(subInvoices.map(i => i.profile?.name || 'Unknown'))].sort().map(n => ({ value: n, label: n }))];
const subStatusOptions = [{ value: 'all', label: 'All' }, ...[...new Set(subInvoices.map(i => i.status).filter(Boolean))].sort().map(s => ({ value: s, label: s.charAt(0).toUpperCase() + s.slice(1) }))];
const subYearOptions = [{ value: 'all', label: 'All Years' }, ...[...new Set(subInvoices.map(i => i.submitted_at ? String(new Date(i.submitted_at).getFullYear()) : null).filter(Boolean))].sort((a,b) => b-a).map(y => ({ value: y, label: y }))];
const sortedInvoices = subSort(yearFiltered, (inv, key) => { const sortedInvoices = subSort(yearFiltered, (inv, key) => {
if (key === 'total') return invTotal(inv); if (key === 'total') return invTotal(inv);
@@ -1275,9 +1251,9 @@ export default function Invoices() {
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, flex: 1, minHeight: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 0, flex: 1, minHeight: 0 }}>
<div style={{ display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 24, flex: 1, minHeight: 0 }}> <div style={{ ...CARD, display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 0, flex: 1, minHeight: 0 }}>
{/* Left: invoice list */} {/* Left: invoice list */}
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, paddingRight: 21 }}>
{subInvoicesLoading ? ( {subInvoicesLoading ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading</div> <div style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading</div>
) : subInvoicesError ? ( ) : subInvoicesError ? (
@@ -1297,10 +1273,34 @@ export default function Invoices() {
</colgroup> </colgroup>
<thead><tr> <thead><tr>
<SortTh col="invoice_number" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Invoice #</SortTh> <SortTh col="invoice_number" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Invoice #</SortTh>
<SortTh col="subcontractor" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Subcontractor</SortTh> <th style={{ ...TH, whiteSpace: 'nowrap' }}>
<SortTh col="submitted_at" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Submitted</SortTh> <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => subToggle('subcontractor')} style={{ cursor: 'pointer', userSelect: 'none' }}>
Subcontractor
<span style={{ marginLeft: 4, opacity: subSortKey === 'subcontractor' ? 0.85 : 0.2, fontSize: 9 }}>{subSortKey === 'subcontractor' ? (subSortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span>
</span>
<FilterDropdown value={subWhoFilter} onChange={setSubWhoFilter} options={subWhoOptions} />
</span>
</th>
<th style={{ ...TH, whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => subToggle('submitted_at')} style={{ cursor: 'pointer', userSelect: 'none' }}>
Submitted
<span style={{ marginLeft: 4, opacity: subSortKey === 'submitted_at' ? 0.85 : 0.2, fontSize: 9 }}>{subSortKey === 'submitted_at' ? (subSortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span>
</span>
<FilterDropdown value={subYearFilter} onChange={setSubYearFilter} options={subYearOptions} />
</span>
</th>
<SortTh col="paid_at" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Paid</SortTh> <SortTh col="paid_at" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Paid</SortTh>
<SortTh col="status" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Status</SortTh> <th style={{ ...TH, whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => subToggle('status')} style={{ cursor: 'pointer', userSelect: 'none' }}>
Status
<span style={{ marginLeft: 4, opacity: subSortKey === 'status' ? 0.85 : 0.2, fontSize: 9 }}>{subSortKey === 'status' ? (subSortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span>
</span>
<FilterDropdown value={subStatusFilter} onChange={setSubStatusFilter} options={subStatusOptions} />
</span>
</th>
<SortTh col="total" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={{ ...TH, textAlign: 'right' }}>Amount</SortTh> <SortTh col="total" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={{ ...TH, textAlign: 'right' }}>Amount</SortTh>
</tr></thead> </tr></thead>
<tbody>{sortedInvoices.map(inv => { <tbody>{sortedInvoices.map(inv => {
@@ -1332,7 +1332,7 @@ export default function Invoices() {
)} )}
</div> </div>
{/* Right: by subcontractor */} {/* Right: by subcontractor */}
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, borderLeft: '1px solid var(--border)', paddingLeft: 21 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5, flexShrink: 0 }}>Subcontractors</div> <div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5, flexShrink: 0 }}>Subcontractors</div>
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: 'var(--accent)', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div> <div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: 'var(--accent)', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div>
{bySubcontractor.length === 0 ? <div className="card-empty-center">No data</div> : ( {bySubcontractor.length === 0 ? <div className="card-empty-center">No data</div> : (
@@ -1395,11 +1395,13 @@ export default function Invoices() {
}, {})).sort((a, b) => b.total - a.total); }, {})).sort((a, b) => b.total - a.total);
const grandTotal = byCompany.reduce((s, g) => s + g.total, 0); const grandTotal = byCompany.reduce((s, g) => s + g.total, 0);
const invYearOptions = [{ value: 'all', label: 'All Years' }, ...[...new Set(invoices.map(i => i.invoice_date?.slice(0,4)).filter(Boolean))].sort((a,b) => b-a).map(y => ({ value: y, label: y }))];
const invCompanyOptions = [{ value: 'all', label: 'All Companies' }, ...[...new Set(invoices.map(i => i.company?.name || i.bill_to).filter(Boolean))].sort().map(c => ({ value: c, label: c }))];
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, flex: 1, minHeight: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 0, flex: 1, minHeight: 0 }}>
<div style={{ display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 24, flex: 1, minHeight: 0 }}> <div style={{ ...CARD, display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 0, flex: 1, minHeight: 0 }}>
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, paddingRight: 21 }}>
{loading ? ( {loading ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading</div> <div style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading</div>
) : invoices.length === 0 ? ( ) : invoices.length === 0 ? (
@@ -1418,8 +1420,24 @@ export default function Invoices() {
</colgroup> </colgroup>
<thead><tr> <thead><tr>
<SortTh col="invoice_number" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={TH}>Invoice #</SortTh> <SortTh col="invoice_number" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={TH}>Invoice #</SortTh>
<SortTh col="company" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={TH}>Company</SortTh> <th style={{ ...TH, whiteSpace: 'nowrap' }}>
<SortTh col="invoice_date" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={TH}>Date</SortTh> <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => invToggle('company')} style={{ cursor: 'pointer', userSelect: 'none' }}>
Company
<span style={{ marginLeft: 4, opacity: invSortKey === 'company' ? 0.85 : 0.2, fontSize: 9 }}>{invSortKey === 'company' ? (invSortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span>
</span>
<FilterDropdown value={invCompanyFilter} onChange={setInvCompanyFilter} options={invCompanyOptions} />
</span>
</th>
<th style={{ ...TH, whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => invToggle('invoice_date')} style={{ cursor: 'pointer', userSelect: 'none' }}>
Date
<span style={{ marginLeft: 4, opacity: invSortKey === 'invoice_date' ? 0.85 : 0.2, fontSize: 9 }}>{invSortKey === 'invoice_date' ? (invSortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span>
</span>
<FilterDropdown value={invYearFilter} onChange={setInvYearFilter} options={invYearOptions} />
</span>
</th>
<SortTh col="due_date" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={TH}>Due</SortTh> <SortTh col="due_date" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={TH}>Due</SortTh>
<SortTh col="status" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={{ ...TH, textAlign: 'center' }}>Status</SortTh> <SortTh col="status" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={{ ...TH, textAlign: 'center' }}>Status</SortTh>
<SortTh col="stripe_fee" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={{ ...TH, textAlign: 'right' }}>Fees</SortTh> <SortTh col="stripe_fee" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={{ ...TH, textAlign: 'right' }}>Fees</SortTh>
@@ -1450,7 +1468,7 @@ export default function Invoices() {
</div> </div>
)} )}
</div> </div>
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, borderLeft: '1px solid var(--border)', paddingLeft: 21 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5, flexShrink: 0 }}>Companies</div> <div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5, flexShrink: 0 }}>Companies</div>
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: 'var(--positive)', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div> <div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: 'var(--positive)', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div>
{byCompany.length === 0 ? <div className="card-empty-center">No data</div> : ( {byCompany.length === 0 ? <div className="card-empty-center">No data</div> : (
+5 -5
View File
@@ -274,7 +274,7 @@ export default function TeamReports() {
autoTable(doc, { autoTable(doc, {
startY: 78, startY: 78,
head: [['Company', 'Project', 'Task', 'Version', 'Type', 'Version Status', 'Client Billing', 'Sub Billing']], head: [['Company', 'Client', 'Task', 'Version', 'Type', 'Version Status', 'Client Billing', 'Sub Billing']],
body: sortedRows.map((row) => [ body: sortedRows.map((row) => [
row.company_name, row.company_name,
row.project_name, row.project_name,
@@ -325,9 +325,9 @@ export default function TeamReports() {
</select> </select>
</div> </div>
<div> <div>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 4 }}>Project</div> <div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 4 }}>Client</div>
<select value={projectFilter} onChange={(event) => setProjectFilter(event.target.value)}> <select value={projectFilter} onChange={(event) => setProjectFilter(event.target.value)}>
<option value="all">All Projects</option> <option value="all">All Clients</option>
{filteredProjects.map((project) => ( {filteredProjects.map((project) => (
<option key={project.id} value={project.id}>{project.name}</option> <option key={project.id} value={project.id}>{project.name}</option>
))} ))}
@@ -367,7 +367,7 @@ export default function TeamReports() {
) : sortedRows.length === 0 ? ( ) : sortedRows.length === 0 ? (
<div style={{ padding: 24, fontSize: 13, color: 'var(--text-muted)', textAlign: 'center' }}>No report rows for the selected filters.</div> <div style={{ padding: 24, fontSize: 13, color: 'var(--text-muted)', textAlign: 'center' }}>No report rows for the selected filters.</div>
) : ( ) : (
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}> <table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup> <colgroup>
<col style={{ width: '15%' }} /> <col style={{ width: '15%' }} />
<col style={{ width: '15%' }} /> <col style={{ width: '15%' }} />
@@ -381,7 +381,7 @@ export default function TeamReports() {
<thead> <thead>
<tr> <tr>
<SortTh col="company_name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Company</SortTh> <SortTh col="company_name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Company</SortTh>
<SortTh col="project_name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Project</SortTh> <SortTh col="project_name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Client</SortTh>
<SortTh col="task_title" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Task Name</SortTh> <SortTh col="task_title" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Task Name</SortTh>
<SortTh col="version_number" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>R#</SortTh> <SortTh col="version_number" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>R#</SortTh>
<SortTh col="version_type" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Type</SortTh> <SortTh col="version_type" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Type</SortTh>
+5 -5
View File
@@ -158,8 +158,8 @@ export default function SubcontractorPODetail() {
<div className="detail-item"><label>PO Date</label><p>{new Date(po.date).toLocaleDateString()}</p></div> <div className="detail-item"><label>PO Date</label><p>{new Date(po.date).toLocaleDateString()}</p></div>
<div className="detail-item"><label>Due Date</label><p>{po.due_date ? new Date(po.due_date).toLocaleDateString() : '—'}</p></div> <div className="detail-item"><label>Due Date</label><p>{po.due_date ? new Date(po.due_date).toLocaleDateString() : '—'}</p></div>
<div className="detail-item"><label>Terms</label><p>{po.terms || 'Net 15'}</p></div> <div className="detail-item"><label>Terms</label><p>{po.terms || 'Net 15'}</p></div>
<div className="detail-item"><label>Project</label><p>{po.project?.name || 'No project'}</p></div> <div className="detail-item"><label>Client</label><p>{po.project?.name || 'No client'}</p></div>
<div className="detail-item"><label>Client</label><p>{po.project?.company?.name || '—'}</p></div> <div className="detail-item"><label>Company</label><p>{po.project?.company?.name || '—'}</p></div>
<div className="detail-item"><label>Total</label><p style={{ fontSize: 18, fontWeight: 400, color: 'var(--accent)' }}>${Number(po.amount).toFixed(2)}</p></div> <div className="detail-item"><label>Total</label><p style={{ fontSize: 18, fontWeight: 400, color: 'var(--accent)' }}>${Number(po.amount).toFixed(2)}</p></div>
{po.paid_at && <div className="detail-item"><label>Paid On</label><p>{new Date(po.paid_at).toLocaleDateString()}</p></div>} {po.paid_at && <div className="detail-item"><label>Paid On</label><p>{new Date(po.paid_at).toLocaleDateString()}</p></div>}
</div> </div>
@@ -169,10 +169,10 @@ export default function SubcontractorPODetail() {
<div className="card" style={{ marginBottom: 24 }}> <div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">Line Items</div> <div className="card-title">Line Items</div>
<div className="table-wrapper" style={{ border: 'none' }}> <div className="table-wrapper" style={{ border: 'none' }}>
<table> <table className="table-sticky-head">
<thead> <thead>
<tr> <tr>
<SortTh col="project" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Project</SortTh> <SortTh col="project" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Client</SortTh>
<SortTh col="task" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Task</SortTh> <SortTh col="task" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Task</SortTh>
<SortTh col="description" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Description</SortTh> <SortTh col="description" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Description</SortTh>
<SortTh col="amount" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Amount</SortTh> <SortTh col="amount" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Amount</SortTh>
@@ -181,7 +181,7 @@ export default function SubcontractorPODetail() {
<tbody> <tbody>
{tableItems.map(item => ( {tableItems.map(item => (
<tr key={item.id}> <tr key={item.id}>
<td>{po.project?.name || 'No project'}</td> <td>{po.project?.name || 'No client'}</td>
<td>{item.task?.title || '—'}</td> <td>{item.task?.title || '—'}</td>
<td>{item.description}</td> <td>{item.description}</td>
<td style={{ textAlign: 'right', fontWeight: 400 }}>${Number(item.amount).toFixed(2)}</td> <td style={{ textAlign: 'right', fontWeight: 400 }}>${Number(item.amount).toFixed(2)}</td>
+1 -1
View File
@@ -4,7 +4,7 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { port: 4173 }, server: { port: 5173 },
build: { build: {
rollupOptions: { rollupOptions: {
output: { output: {