fix: crash bugs in TeamInvoices + faster load

Bug fixes:
- TeamInvoices: add useAuth import/currentUser (invoice-create crash)
- TeamInvoices: setChartYear -> setExportYear (year-dropdown crash)
- TeamDashboard: drop redundant setState-in-effect (cascading renders)
- Companies: delete dead _UnusedClientCompanies (illegal hook calls)
- annotate intentional empty PDF-fallback catches

Load speed:
- preconnect/dns-prefetch to Supabase origin
- lazy-load heic-to in Converters: page chunk 2737KB -> 9KB
- split recharts into its own 'charts' vendor chunk

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Krao Hasanee
2026-06-08 12:59:11 -04:00
parent 85625f4d95
commit 04e0911e9f
101 changed files with 11786 additions and 7445 deletions
+5 -4
View File
@@ -793,10 +793,11 @@ export default function BrandBook() {
{loadingBooks ? (
<p style={{ padding: '24px 0', color: 'var(--text-muted)' }}>Loading...</p>
) : filteredBooks.length === 0 ? (
<div className="empty-state">
<h3>{savedBooks.length === 0 ? 'No brand books yet' : 'No matching brand books'}</h3>
<p>{savedBooks.length === 0 ? 'Create your first brand book to get started.' : 'Try clearing the current company filter.'}</p>
<button className="btn btn-primary" onClick={handleNew} style={{ marginTop: 16 }}>+ New Brand Book</button>
<div className="card" style={{ minHeight: 160, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12 }}>
<div style={{ fontSize: 13, color: 'var(--text-muted)', textAlign: 'center' }}>
{savedBooks.length === 0 ? 'No brand books' : 'No matching brand books'}
</div>
{savedBooks.length === 0 && <button className="btn btn-primary" onClick={handleNew}>+ New Brand Book</button>}
</div>
) : (
<div className="table-wrapper">
+41 -182
View File
@@ -1,13 +1,14 @@
import { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import Layout from '../components/Layout';
import PageLoader from '../components/PageLoader';
import SortTh from '../components/SortTh';
import { useSortable } from '../hooks/useSortable';
import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext';
import { deleteCompanyData } from '../lib/deleteHelpers';
import { readPageCache, writePageCache } from '../lib/pageCache';
import { createClientFolder, backfillClientFolders, createTeamMemberFolder } from '../lib/filebrowserFolders';
import { ensureCompanyFolder, ensureSubcontractorFolder, renameSubcontractorFolder } from '../lib/folderSync';
// ── Team view ─────────────────────────────────────────────────────────────────
@@ -63,8 +64,11 @@ function TeamCompanies() {
}).select().single();
setSaving(false);
if (data) {
createClientFolder(data.name).catch(() => {});
backfillClientFolders().catch(() => {});
try {
await ensureCompanyFolder({ companyId: data.id });
} catch (folderError) {
console.error('Company folder sync failed:', folderError);
}
setShowNew(false);
setNewForm({ name: '', phone: '', address: '' });
navigate(`/company/${data.id}`);
@@ -80,8 +84,18 @@ function TeamCompanies() {
const handleEditUserSave = async (userId) => {
if (!editUserVal.trim()) return;
await supabase.from('profiles').update({ name: editUserVal.trim() }).eq('id', userId);
setProfiles(prev => prev.map(u => u.id === userId ? { ...u, name: editUserVal.trim() } : u));
const existingUser = profiles.find(u => u.id === userId);
const oldName = existingUser?.name || '';
const newName = editUserVal.trim();
await supabase.from('profiles').update({ name: newName }).eq('id', userId);
if (existingUser?.role === 'external') {
try {
await renameSubcontractorFolder({ profileId: userId, oldName, newName });
} catch (folderError) {
console.error('Subcontractor folder rename failed:', folderError);
}
}
setProfiles(prev => prev.map(u => u.id === userId ? { ...u, name: newName } : u));
setEditingUserId(null);
};
@@ -113,15 +127,31 @@ function TeamCompanies() {
const errBody = error?.context ? await error.context.json().catch(() => null) : null;
const errMsg = errBody?.error || data?.error || error?.message;
if (errMsg) { setUserError(errMsg); return; }
if (userForm.role === 'external') {
try {
const { data: newProfile } = await supabase
.from('profiles')
.select('id')
.eq('email', userForm.email.trim())
.eq('role', 'external')
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (newProfile?.id) {
await ensureSubcontractorFolder({ profileId: newProfile.id });
}
} catch (folderError) {
console.error('Subcontractor folder sync failed:', folderError);
}
}
if (userForm.role === 'team' || userForm.role === 'external') {
createTeamMemberFolder(userForm.name.trim()).catch(() => {});
}
setShowNewUser(false);
setUserForm({ name: '', email: '', password: '', company_id: '', role: 'client' });
load();
};
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
if (loading) return <Layout><PageLoader /></Layout>;
const getProfileCompanyIds = (profile) => {
const ids = new Set(
@@ -209,7 +239,7 @@ function TeamCompanies() {
)}
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{companies.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>No clients yet.</div>
<div className="card-empty-center">No clients</div>
) : (
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table>
@@ -363,7 +393,7 @@ function TeamCompanies() {
</div>
)}
{clientProfiles.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>No users yet.</div>
<div className="card-empty-center">No users</div>
) : (
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table>
@@ -417,7 +447,7 @@ function TeamCompanies() {
{userSubTab === 'external' && <>
{subcontractors.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>No subcontractors yet.</div>
<div className="card-empty-center">No subcontractors</div>
) : (
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table>
@@ -484,7 +514,7 @@ function ClientCompanyList() {
</div>
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{companies.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>No companies linked to your account.</div>
<div className="card-empty-center">No companies</div>
) : (
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table>
@@ -512,177 +542,6 @@ function ClientCompanyList() {
);
}
// ── (removed old ClientCompanies dropdown — kept for reference only) ──────────
function _UnusedClientCompanies() {
const { currentUser } = useAuth();
const companies = currentUser?.companies || [];
const [selectedId, setSelectedId] = useState(companies[0]?.id || null);
const company = companies.find(c => c.id === selectedId) || companies[0] || null;
const [members, setMembers] = useState([]);
const [loading, setLoading] = useState(!!company?.id);
const [editing, setEditing] = useState(false);
const [form, setForm] = useState({ name: company?.name || '', phone: company?.phone || '', address: company?.address || '' });
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!company?.id) return;
setForm({ name: company.name || '', phone: company.phone || '', address: company.address || '' });
setEditing(false);
setLoading(true);
async function load() {
const [{ data: primaryMembers }, { data: memberRows }] = await Promise.all([
supabase.from('profiles').select('id, name, email').eq('company_id', company.id).in('role', ['client', 'external']),
supabase.from('company_members').select('profile:profiles(id, name, email)').eq('company_id', company.id),
]);
const memberMap = new Map();
(primaryMembers || []).forEach(m => memberMap.set(m.id, m));
(memberRows || []).forEach(row => { if (row.profile) memberMap.set(row.profile.id, row.profile); });
setMembers([...memberMap.values()]);
setLoading(false);
}
load();
}, [company?.id]);
const handleSave = async (e) => {
e.preventDefault();
setSaving(true);
const { error } = await supabase.from('companies').update({
name: form.name.trim(),
phone: form.phone.trim(),
address: form.address.trim(),
}).eq('id', company.id);
setSaving(false);
if (error) { alert('Failed to save. Please try again.'); return; }
setEditing(false);
};
if (!company) return (
<Layout>
<div className="page-header"><div className="page-title">My Company</div></div>
<p style={{ color: 'var(--text-muted)' }}>No company linked to your account.</p>
</Layout>
);
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
const companyDetails = [
{ label: 'Company Name', value: form.name || company.name || '—' },
{ label: 'Phone', value: company.phone || '—' },
{ label: 'Address', value: company.address || '—' },
{ label: 'Members', value: String(members.length) },
];
return (
<Layout>
<div className="page-header">
<div>
{companies.length > 1 ? (
<div style={{ marginBottom: 4 }}>
<select
value={selectedId}
onChange={e => setSelectedId(e.target.value)}
style={{
fontSize: 22, fontWeight: 400, background: 'var(--card-bg)',
border: '1px solid var(--border)', borderRadius: 4,
color: 'var(--text-primary)', cursor: 'pointer',
padding: '4px 8px', fontFamily: 'inherit',
}}
>
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
) : (
<div className="page-title">{form.name || company.name}</div>
)}
<div className="page-subtitle">
{[company.phone, company.address].filter(Boolean).join(' · ') || 'No contact info on file'}
</div>
</div>
{!editing && (
<button className="btn btn-outline" onClick={() => setEditing(true)}>Edit Info</button>
)}
</div>
<div className="stats-grid" style={{ marginBottom: 24 }}>
{companyDetails.map(detail => (
<div key={detail.label} className={`stat-card${detail.label === 'Members' ? ' stat-card-highlight' : ''}`}>
<div className="stat-value" style={{ fontSize: detail.label === 'Members' ? 28 : 18 }}>{detail.value}</div>
<div className="stat-label">{detail.label}</div>
</div>
))}
</div>
{editing && (
<div className="card" style={{ marginBottom: 24, maxWidth: 520 }}>
<div className="card-title">Edit Company Info</div>
<form onSubmit={handleSave}>
<div className="form-group">
<label>Company Name *</label>
<input type="text" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} required />
</div>
<div className="grid-2">
<div className="form-group">
<label>Phone</label>
<input type="text" placeholder="+1 (555) 000-0000" value={form.phone}
onChange={e => setForm(f => ({ ...f, phone: e.target.value }))} />
</div>
<div className="form-group">
<label>Address</label>
<input type="text" placeholder="123 Main St, City, State" value={form.address}
onChange={e => setForm(f => ({ ...f, address: e.target.value }))} />
</div>
</div>
<div className="action-buttons">
<button type="submit" className="btn btn-primary" disabled={saving || !form.name.trim()}>
{saving ? 'Saving...' : 'Save Changes'}
</button>
<button type="button" className="btn btn-outline" onClick={() => {
setEditing(false);
setForm({ name: company.name || '', phone: company.phone || '', address: company.address || '' });
}}>Cancel</button>
</div>
</form>
</div>
)}
<div className="card">
<div className="card-title">People</div>
{members.length === 0 ? (
<div className="card-empty-center">No members found.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column' }}>
{members.map((member, i) => (
<div key={member.id} style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '12px 0',
borderBottom: i < members.length - 1 ? '1px solid var(--border)' : 'none',
}}>
<div style={{
width: 36, height: 36, borderRadius: 4, background: 'var(--accent)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 13, fontWeight: 400, color: '#111', flexShrink: 0,
}}>
{member.name?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
</div>
<div>
<div style={{ fontWeight: 400, fontSize: 14, color: 'var(--text-primary)' }}>
{member.name}
{member.id === currentUser.id && (
<span style={{ marginLeft: 8, fontSize: 11, color: 'var(--accent)', fontWeight: 500 }}>You</span>
)}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{member.email || '—'}</div>
</div>
</div>
))}
</div>
)}
</div>
</Layout>
);
}
// ── Entry point ───────────────────────────────────────────────────────────────
export default function CompaniesPage() {
+21 -14
View File
@@ -1,13 +1,14 @@
import { useState, useEffect } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import Layout from '../components/Layout';
import PageLoader from '../components/PageLoader';
import StatusBadge from '../components/StatusBadge';
import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext';
import { serviceTypes } from '../data/mockData';
import { cleanupTaskStorage, deleteCompanyData } from '../lib/deleteHelpers';
import { logActivity } from '../lib/activityLog';
import { createProjectFolder } from '../lib/filebrowserFolders';
import { ensureProjectFolder, renameCompanyFolder } from '../lib/folderSync';
export default function CompanyDetail() {
const { id } = useParams();
@@ -78,8 +79,14 @@ export default function CompanyDetail() {
if (!nameVal.trim()) return;
setSavingName(true);
const oldName = company.name;
await supabase.from('companies').update({ name: nameVal.trim() }).eq('id', id);
setCompany(c => ({ ...c, name: nameVal.trim() }));
const newName = nameVal.trim();
await supabase.from('companies').update({ name: newName }).eq('id', id);
try {
await renameCompanyFolder({ companyId: id, oldName, newName });
} catch (folderError) {
console.error('Company folder rename failed:', folderError);
}
setCompany(c => ({ ...c, name: newName }));
setEditingName(false);
setSavingName(false);
};
@@ -179,8 +186,12 @@ export default function CompanyDetail() {
status: 'active',
}).select().single();
if (data) {
try {
await ensureProjectFolder({ projectId: data.id });
} catch (folderError) {
console.error('Project folder sync failed:', folderError);
}
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'project_created', projectId: data.id, projectName: data.name });
createProjectFolder(company.name, data.name).catch(() => {});
setProjects(prev => [data, ...prev]);
setNewProjectName('');
setShowNewProject(false);
@@ -248,7 +259,7 @@ export default function CompanyDetail() {
setSavingSignFamilyPrice(null);
};
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
if (loading) return <Layout><PageLoader /></Layout>;
if (!company) return <Layout><p>Company not found.</p></Layout>;
const activeTasks = tasks.filter(t => t.status !== 'client_approved');
@@ -318,15 +329,14 @@ export default function CompanyDetail() {
</div>
{/* Tabs */}
<div style={{ display: 'flex', gap: 4, marginBottom: 24, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: 4, marginBottom: 10, flexWrap: 'wrap', minHeight: 'var(--btn-height)' }}>
{(isTeam ? ['users', 'projects', 'pricing'] : ['users', 'projects']).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className={`tab-btn${tab === t ? ' active' : ''}`}
style={{ textTransform: 'capitalize' }}
className={`section-tab-btn${tab === t ? ' is-active' : ''}`}
>
{t}
{t.charAt(0).toUpperCase() + t.slice(1)}
{t === 'users' && availableUsers.length > 0 && (
<span style={{ marginLeft: 6, fontSize: 10, background: tab === t ? 'rgba(0,0,0,0.3)' : 'var(--danger)', color: 'white', padding: '1px 5px', borderRadius: 4, fontWeight: 400 }}>
{availableUsers.length}
@@ -342,7 +352,7 @@ export default function CompanyDetail() {
<div className="card">
<div className="card-title">Assigned Users</div>
{users.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No users assigned to this company yet.</p>
<div className="card-empty-center">No users</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{users.map((user, i) => (
@@ -494,10 +504,7 @@ export default function CompanyDetail() {
)}
{projects.length === 0 ? (
<div className="empty-state">
<h3>No projects yet</h3>
<p>Create a project to start adding jobs.</p>
</div>
<div className="card card-empty-center">No projects</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{projects.map(project => {
+13 -3
View File
@@ -1,6 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import JSZip from 'jszip';
import { heicTo, isHeic } from 'heic-to/csp';
import Layout from '../components/Layout';
import LoadingButton from '../components/LoadingButton';
@@ -13,6 +12,14 @@ const OUTPUT_FORMATS = [
const HEIC_EXTENSIONS = new Set(['heic', 'heif']);
const MAX_FILES = 100;
// Lazy-load heic-to (WASM, ~2.7MB) only when a conversion actually runs,
// so visiting the page doesn't pull the whole library up front.
let _heicLibPromise = null;
function getHeicLib() {
if (!_heicLibPromise) _heicLibPromise = import('heic-to/csp');
return _heicLibPromise;
}
function getExtension(name = '') {
return name.split('.').pop()?.toLowerCase() || '';
}
@@ -96,6 +103,7 @@ async function convertRasterBlob(blob, format, quality) {
}
async function convertHeicFile(file, format, quality) {
const { heicTo } = await getHeicLib();
if (format.mime === 'image/jpeg' || format.mime === 'image/png') {
try {
return await heicTo({ blob: file, type: format.mime, quality });
@@ -129,7 +137,9 @@ async function convertHeicFile(file, format, quality) {
async function convertFile(file, format, quality) {
const looksHeic = isHeicFile(file);
const confirmedHeic = looksHeic ? await isHeic(file).catch(() => false) : false;
const confirmedHeic = looksHeic
? await getHeicLib().then(({ isHeic }) => isHeic(file)).catch(() => false)
: false;
if (looksHeic || confirmedHeic) {
return convertHeicFile(file, format, quality);
}
@@ -368,7 +378,7 @@ export default function Converters() {
</div>
{!files.length ? (
<div style={{ color: 'var(--text-muted)', fontSize: 13 }}>No files added yet.</div>
<div className="card-empty-center">No files</div>
) : (
<div style={{ display: 'grid', gap: 12 }}>
{files.map((file, index) => {
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { useParams, useSearchParams } from 'react-router-dom';
import PageLoader from '../components/PageLoader';
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
@@ -74,15 +75,14 @@ export default function PayInvoice() {
return (
<div style={{ minHeight: '100vh', background: '#f5f5f5', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
{loading ? <PageLoader /> : null}
<div style={{ width: '100%', maxWidth: 480 }}>
{/* Logo */}
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<img src="/fourge-logo.png" alt="Fourge Branding" style={{ height: 36, filter: 'invert(1)' }} />
</div>
{loading ? (
<div style={{ textAlign: 'center', color: '#666' }}>Loading...</div>
) : !invoice ? (
{!invoice ? (
<div style={{ background: '#fff', color: '#141414', borderRadius: 4, padding: 32, textAlign: 'center', boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
<div style={{ fontSize: 18, fontWeight: 400, marginBottom: 8 }}>Invoice not found</div>
<div style={{ color: '#666' }}>This payment link may be invalid or expired.</div>
+1 -1
View File
@@ -689,7 +689,7 @@ export default function ProfilePage() {
<input type="text" value={editForm.linkedin} onChange={setEditField('linkedin')} placeholder="linkedin.com/in/username" style={modalInputStyle} />
</div>
{editError && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>{editError}</div>}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 6 }}>
<div className="modal-action-row" style={{ marginTop: 6 }}>
<button type="submit" className="btn btn-outline" disabled={savingProfile} style={modalButtonStyle}>
{savingProfile ? 'Saving...' : 'Save'}
</button>
+108 -166
View File
@@ -1,34 +1,35 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import Layout from '../components/Layout';
import PageLoader from '../components/PageLoader';
import ProfileAvatar from '../components/ProfileAvatar';
import StatusBadge from '../components/StatusBadge';
import SortTh from '../components/SortTh';
import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext';
import { logActivity } from '../lib/activityLog';
import { createTaskFolder, uploadFilesToRequestInfo } from '../lib/filebrowserFolders';
import { createInitialSubmissionForRequest, createTaskForRequest } from '../lib/requestSubmission';
import { ensureRequestFolder, renameProjectFolder, uploadRequestFileToSurvey } from '../lib/folderSync';
import { sendEmail } from '../lib/email';
import RequestForm from '../components/RequestForm';
import { useSortable } from '../hooks/useSortable';
import { serviceTypes } from '../data/mockData';
import { addDaysToDateOnly, getTodayDateOnlyEST, fmtShortDate } from '../lib/dates';
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
import { useRefetchOnFocus } from '../hooks/useRefetchOnFocus';
import { useRealtimeSubscription } from '../hooks/useRealtimeSubscription';
import { fmtShortDate } from '../lib/dates';
import { popupOverlayStyle } from '../lib/popupStyles';
import { useLiveRefresh } from '../hooks/useLiveRefresh';
import { getTaskDerivedState } from '../lib/taskVersions';
import {
TASK_TABLE_TH_STYLE,
TASK_TABLE_TD_BASE,
TASK_MODAL_TITLE_STYLE,
TASK_MODAL_SHELL_STYLE,
} from '../lib/taskUi';
const CARD = { padding: '18px 21px', borderRadius: 8 };
const LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 14 };
const META_LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 4 };
const CARD_META_LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 2 };
const MODAL_LBL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 6 };
const TH_STYLE = { fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.6, color: 'var(--text-muted)', textAlign: 'left', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' };
const TD_BASE = { padding: '5px', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
const MODAL_IN = { fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' };
const emptyJobForm = () => ({ title: '', serviceType: '', deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3), description: '', requestedBy: '' });
export default function ProjectDetailPage() {
const { id } = useParams();
const navigate = useNavigate();
@@ -38,28 +39,22 @@ export default function ProjectDetailPage() {
const isExternal = currentUser?.role === 'external';
const isTeam = currentUser?.role === 'team';
const [refreshKey, setRefreshKey] = useState(0);
const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
useRefetchOnFocus(refresh);
useRealtimeSubscription(['tasks', 'projects', 'project_members', 'profiles'], refresh);
const { refreshKey } = useLiveRefresh(['tasks', 'projects', 'project_members', 'profiles']);
const [project, setProject] = useState(null);
const [company, setCompany] = useState(null);
const [tasks, setTasks] = useState([]);
const [members, setMembers] = useState([]);
const [externalProfiles, setExtProfs] = useState([]);
const [companyUsers, setCompanyUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [submissions, setSubmissions] = useState([]);
const [deliveries, setDeliveries] = useState([]);
const [activeTab, setActiveTab] = useState('all');
const [editingName, setEditingName] = useState(false);
const [nameVal, setNameVal] = useState('');
const [savingName, setSavingName] = useState(false);
const [showAddJob, setShowAddJob] = useState(false);
const [jobForm, setJobForm] = useState(emptyJobForm());
const [savingJob, setSavingJob] = useState(false);
const [showClientTask, setShowClientTask] = useState(false);
const [clientTaskKey, setClientTaskKey] = useState(0);
const [clientTaskSaving, setClientTaskSaving] = useState(false);
@@ -93,13 +88,20 @@ export default function ProjectDetailPage() {
if (taskIds.length > 0) {
const { data: subs } = await supabase.from('submissions').select('id, task_id, type, version_number, service_type, deadline, is_hot, submitted_at').in('task_id', taskIds).order('submitted_at', { ascending: false });
setSubmissions(subs || []);
if ((subs || []).length > 0) {
const { data: delRows } = await supabase.from('deliveries').select('id, submission_id, version_number, sent_at').in('submission_id', (subs || []).map(sub => sub.id));
setDeliveries(delRows || []);
} else {
setDeliveries([]);
}
} else {
setDeliveries([]);
}
const [{ data: users }, { data: pm }, { data: viaMembers }] = await Promise.all([
supabase.from('profiles').select('id, name, avatar_url, email').eq('company_id', p.company_id).eq('role', 'client'),
supabase.from('project_members').select('*, profile:profiles(id, name, avatar_url, email, role)').eq('project_id', id),
supabase.from('company_members').select('profile:profiles(id, name, avatar_url, email, role)').eq('company_id', p.company_id),
]);
setCompanyUsers(users || []);
setMembers(pm || []);
const seen = new Set();
const merged = [];
@@ -128,8 +130,15 @@ export default function ProjectDetailPage() {
e.preventDefault();
if (!nameVal.trim()) return;
setSavingName(true);
await supabase.from('projects').update({ name: nameVal.trim() }).eq('id', id);
setProject(p => ({ ...p, name: nameVal.trim() }));
const oldName = project.name;
const newName = nameVal.trim();
await supabase.from('projects').update({ name: newName }).eq('id', id);
try {
await renameProjectFolder({ projectId: id, oldName, newName });
} catch (folderError) {
console.error('Project folder rename failed:', folderError);
}
setProject(p => ({ ...p, name: newName }));
setEditingName(false);
setSavingName(false);
};
@@ -151,23 +160,6 @@ export default function ProjectDetailPage() {
setTasks(prev => prev.filter(t => t.id !== taskId));
};
const handleAddJob = async (e) => {
e.preventDefault();
setSavingJob(true);
const requestor = requesterOptions.find(u => u.id === jobForm.requestedBy);
if (!requestor) { setSavingJob(false); return; }
const { data: task } = await supabase.from('tasks').insert({ project_id: id, title: jobForm.title.trim(), status: 'not_started', current_version: 0 }).select().single();
if (task) {
await supabase.from('submissions').insert({ task_id: task.id, version_number: 0, type: 'initial', service_type: jobForm.serviceType, deadline: jobForm.deadline || null, description: jobForm.description.trim() || null, submitted_by: requestor.id, submitted_by_name: requestor.name.replace(' (You)', '') });
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'task_created', taskId: task.id, taskTitle: task.title, projectId: id, projectName: project?.name });
createTaskFolder(company?.name, project?.name, task.title).catch(() => {});
setTasks(prev => [task, ...prev]);
setJobForm(emptyJobForm());
setShowAddJob(false);
}
setSavingJob(false);
};
const handleAddMember = async () => {
setSavingMembers(true);
const currentIds = new Set(members.filter(m => m.profile?.role === 'external').map(m => m.profile_id));
@@ -188,18 +180,12 @@ export default function ProjectDetailPage() {
setSavingMembers(false);
};
const handleRemoveMember = async (profileId) => {
await supabase.from('project_members').delete().eq('project_id', id).eq('profile_id', profileId);
setMembers(prev => prev.filter(m => m.profile_id !== profileId));
};
const handleClientTask = async (formData, files) => {
if (clientTaskSaving) return;
setClientTaskSaving(true); setClientTaskError('');
try {
const { task } = await createTaskForRequest({ projectId: project.id, title: formData.title.trim(), requestKey: clientTaskRequestKey });
if (!task) throw new Error('Failed to create task.');
createTaskFolder(company?.name, project.name, formData.title.trim()).catch(() => {});
const { submission } = await createInitialSubmissionForRequest({
taskId: task.id, requestKey: clientTaskRequestKey, isHot: formData.isHot,
serviceType: formData.serviceType, signFamily: formData.signFamily,
@@ -207,16 +193,32 @@ export default function ProjectDetailPage() {
deadline: formData.deadline, description: formData.description,
submittedBy: currentUser.id, submittedByName: currentUser.name,
});
try {
await ensureRequestFolder({ projectId: project.id, taskTitle: formData.title.trim() });
} catch (folderError) {
console.error('Request folder sync failed:', folderError);
}
if (submission && files.length > 0) {
for (const file of files) {
const path = `${task.id}/${Date.now()}_${file.name}`;
const path = `${task.id}/Survey/${Date.now()}_${file.name}`;
const { data: up } = await supabase.storage.from('submissions').upload(path, file);
if (up) await supabase.from('submission_files').insert({ submission_id: submission.id, name: file.name, storage_path: path, size: file.size });
if (up) {
await supabase.from('submission_files').insert({ submission_id: submission.id, name: file.name, storage_path: path, size: file.size });
try {
await uploadRequestFileToSurvey({
projectId: project.id,
taskTitle: formData.title.trim(),
storagePath: path,
fileName: file.name,
});
} catch (mirrorError) {
console.error('Survey folder mirror failed:', mirrorError);
}
}
}
uploadFilesToRequestInfo(files, company?.name, project.name, formData.title.trim()).catch(() => {});
}
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'request_submitted', taskId: task.id, taskTitle: task.title, projectId: project.id, projectName: project.name }).catch(() => {});
sendEmail('new_request', 'hello@fourgebranding.com', { clientName: currentUser.name, clientEmail: currentUser.email, company: company?.name || '', serviceType: formData.serviceType, projectName: project.name, deadline: formData.deadline, description: formData.description, taskId: task.id }).catch(() => {});
sendEmail('new_request', 'hello@fourgebranding.com', { clientName: currentUser.name, clientEmail: currentUser.email, company: company?.name || '', serviceType: formData.serviceType, projectName: project.name, projectId: project.id, deadline: formData.deadline, description: formData.description, taskId: task.id }).catch(() => {});
const { data: newTasks } = await supabase.from('tasks').select('*, assignee:profiles!assigned_to(avatar_url)').eq('project_id', id).order('submitted_at', { ascending: false });
setTasks(newTasks || []);
setShowClientTask(false); setClientTaskKey(k => k + 1); setClientTaskRequestKey(crypto.randomUUID());
@@ -227,19 +229,14 @@ export default function ProjectDetailPage() {
if (loading) return <Layout><PageLoader /></Layout>;
if (!project) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Project not found.</p></Layout>;
const requesterOptions = [
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)` }] : []),
...companyUsers.filter(u => u.id !== currentUser?.id),
];
const rows = tasks.map(task => {
const sub = submissions.find(s => s.task_id === task.id && s.type === 'initial') || submissions.find(s => s.task_id === task.id);
const derived = getTaskDerivedState(task, submissions, deliveries);
return {
id: task.id, title: task.title, status: task.status,
version: task.current_version || 0,
version: derived.currentVersion,
assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null, assignedTo: task.assigned_to || null,
serviceType: sub?.service_type || '—', deadline: sub?.deadline || null, isHot: sub?.is_hot || false,
submittedAt: task.submitted_at || '',
serviceType: derived.serviceType, deadline: derived.deadline, isHot: derived.isHot,
submittedAt: derived.latestActivityAt ? new Date(derived.latestActivityAt).toISOString() : (task.submitted_at || ''),
};
});
@@ -258,7 +255,8 @@ export default function ProjectDetailPage() {
const doneStatuses = new Set(['client_approved', 'invoiced', 'paid']);
const taskTabs = [
{ id: 'all', label: 'All Tasks' },
{ id: 'not_started', label: 'To Do' },
{ 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' },
@@ -267,6 +265,8 @@ export default function ProjectDetailPage() {
];
const visibleRows = activeTab === 'all' ? sortedRows
: activeTab === 'completed' ? sortedRows.filter(r => doneStatuses.has(r.status))
: activeTab === 'new_requests' ? sortedRows.filter(r => r.status === 'not_started' && Number(r.version || 0) === 0)
: activeTab === 'revisions' ? sortedRows.filter(r => r.status === 'not_started' && Number(r.version || 0) >= 1)
: activeTab === 'members' ? []
: sortedRows.filter(r => r.status === activeTab);
@@ -345,12 +345,13 @@ export default function ProjectDetailPage() {
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
{(() => {
const tabCounts = {
all: tasks.length,
not_started: tasks.filter(t => t.status === 'not_started').length,
in_progress: tasks.filter(t => t.status === 'in_progress').length,
on_hold: tasks.filter(t => t.status === 'on_hold').length,
client_review: tasks.filter(t => t.status === 'client_review').length,
completed: tasks.filter(t => ['client_approved','invoiced','paid'].includes(t.status)).length,
all: rows.length,
new_requests: rows.filter(r => r.status === 'not_started' && Number(r.version || 0) === 0).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,
};
return (
@@ -375,7 +376,7 @@ export default function ProjectDetailPage() {
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, overflowY: 'auto' }}>
{activeTab !== 'members' && (
visibleRows.length === 0
? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 1, minHeight: 120, color: 'var(--text-muted)', fontSize: 13 }}>No tasks yet.</div>
? <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%' }}>
<colgroup>
@@ -389,44 +390,40 @@ export default function ProjectDetailPage() {
</colgroup>
<thead>
<tr>
<SortTh col="revision" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TH_STYLE}>R#</SortTh>
<SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TH_STYLE}>Name</SortTh>
<SortTh col="assigned" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TH_STYLE, textAlign: 'center' }}>Assigned</SortTh>
<SortTh col="priority" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TH_STYLE, textAlign: 'center' }}>Priority</SortTh>
<SortTh col="serviceType" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TH_STYLE, textAlign: 'center' }}>Task Type</SortTh>
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TH_STYLE, textAlign: 'center' }}>Deadline</SortTh>
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TH_STYLE, textAlign: 'center' }}>Status</SortTh>
<SortTh col="revision" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>R#</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>
<SortTh col="priority" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Priority</SortTh>
<SortTh col="serviceType" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Task Type</SortTh>
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Deadline</SortTh>
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Status</SortTh>
</tr>
</thead>
<tbody>
{visibleRows.map(row => {
const initials = row.assignedName ? row.assignedName.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase() : null;
const avatarStyle = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: '50%', overflow: 'hidden', verticalAlign: 'middle', flexShrink: 0 };
return (
<tr key={row.id}>
<td style={{ ...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>
</td>
<td style={{ ...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>
</td>
<td style={{ ...TD_BASE, textAlign: 'center' }}>
<td style={{ ...TASK_TABLE_TD_BASE, textAlign: 'center' }}>
{row.assignedName && row.assignedTo
? <Link to={`/profile/${row.assignedTo}`} style={{ display: 'inline-flex' }}>
{row.assigneeAvatar
? <div title={row.assignedName} style={avatarStyle}><img src={row.assigneeAvatar} alt={row.assignedName} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div>
: <div title={row.assignedName} style={{ ...avatarStyle, background: 'var(--accent)' }}><span style={{ fontSize: 10, fontWeight: 600, color: '#000', lineHeight: 1 }}>{initials}</span></div>
}
<ProfileAvatar name={row.assignedName} avatarUrl={row.assigneeAvatar} size={26} fontSize={10} style={{ verticalAlign: 'middle' }} />
</Link>
: <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 style={{ ...TD_BASE, fontSize: 11, textAlign: 'center', color: row.isHot ? '#ef4444' : 'var(--text-primary)', textTransform: 'uppercase', letterSpacing: 0.5 }}>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 11, textAlign: 'center', color: row.isHot ? '#ef4444' : 'var(--text-primary)', textTransform: 'uppercase', letterSpacing: 0.5 }}>
{row.isHot ? 'HOT' : 'NO'}
</td>
<td style={{ ...TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>{row.serviceType}</td>
<td style={{ ...TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>{fmtShortDate(row.deadline, 'Not specified')}</td>
<td style={{ ...TD_BASE, textAlign: 'center' }}><StatusBadge status={row.status} /></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, textAlign: 'center' }}><StatusBadge status={row.status} /></td>
</tr>
);
})}
@@ -439,7 +436,7 @@ export default function ProjectDetailPage() {
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{(() => {
const subs = members.filter(m => m.profile?.role === 'external');
if (subs.length === 0) return <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 100, color: 'var(--text-muted)', fontSize: 13 }}>No subcontractors assigned.</div>;
if (subs.length === 0) return <div className="card-empty-center">No subcontractors</div>;
const sorted = [...subs].sort((a, b) => {
if (subSortKey === 'projects') {
const av = subProjectCounts[a.profile_id] || 0;
@@ -461,29 +458,25 @@ export default function ProjectDetailPage() {
</colgroup>
<thead>
<tr>
<th style={{ ...TH_STYLE, padding: '0 0 12px' }} />
<SortTh col="name" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TH_STYLE}>Name</SortTh>
<SortTh col="email" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TH_STYLE}>Email</SortTh>
<SortTh col="projects" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={{ ...TH_STYLE, textAlign: 'center' }}>Projects</SortTh>
<th style={{ ...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="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>
<th style={{ ...TASK_TABLE_TH_STYLE, padding: '0 0 12px' }} />
</tr>
</thead>
<tbody>
{sorted.map(m => {
const initials = (m.profile?.name || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
const projCount = subProjectCounts[m.profile_id] || 0;
return (
<tr key={m.id}>
<td style={{ ...TD_BASE }}>
{m.profile?.avatar_url
? <div style={{ width: 26, height: 26, borderRadius: '50%', overflow: 'hidden' }}><img src={m.profile.avatar_url} alt={m.profile.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div>
: <div style={{ width: 26, height: 26, borderRadius: '50%', background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><span style={{ fontSize: 10, fontWeight: 600, color: '#000', lineHeight: 1 }}>{initials}</span></div>
}
<td style={{ ...TASK_TABLE_TD_BASE }}>
<ProfileAvatar profile={m.profile} name={m.profile?.name} size={26} fontSize={10} />
</td>
<td style={{ ...TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>{m.profile?.name || '—'}</td>
<td style={{ ...TD_BASE, fontSize: 12, color: 'var(--text-muted)' }}>{m.profile?.email || '—'}</td>
<td style={{ ...TD_BASE, fontSize: 13, color: 'var(--text-primary)', textAlign: 'center', fontVariantNumeric: 'tabular-nums' }}>{projCount}</td>
<td style={{ ...TD_BASE }} />
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>{m.profile?.name || '—'}</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-muted)' }}>{m.profile?.email || '—'}</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)', textAlign: 'center', fontVariantNumeric: 'tabular-nums' }}>{projCount}</td>
<td style={{ ...TASK_TABLE_TD_BASE }} />
</tr>
);
})}
@@ -502,17 +495,13 @@ export default function ProjectDetailPage() {
<div className="card" style={CARD}>
<div style={LABEL}>Main Contact</div>
{companyClients.length === 0
? <div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No contacts found.</div>
? <div className="card-empty-center">No contacts</div>
: (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{companyClients.map(person => {
const initials = (person.name || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
return (
<div key={person.id} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{person.avatar_url
? <div style={{ width: 32, height: 32, borderRadius: '50%', overflow: 'hidden', flexShrink: 0 }}><img src={person.avatar_url} alt={person.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div>
: <div style={{ width: 32, height: 32, borderRadius: '50%', background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}><span style={{ fontSize: 12, fontWeight: 600, color: '#000', lineHeight: 1 }}>{initials}</span></div>
}
<ProfileAvatar profile={person} name={person.name} size={32} fontSize={12} />
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)' }}>{person.name}</div>
{person.email && <div style={{ fontSize: 11, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{person.email}</div>}
@@ -530,7 +519,7 @@ export default function ProjectDetailPage() {
{(() => {
const SHOW = { task_started: { label: 'started', color: '#60a5fa', bg: 'rgba(96,165,250,0.15)', path: 'M6 4l14 8-14 8V4z' }, task_on_hold: { label: 'placed on hold', color: '#ef4444', bg: 'rgba(239,68,68,0.15)', path: 'M6 4h4v16H6zM14 4h4v16h-4z' }, task_approved: { label: 'approved', color: '#4ade80', bg: 'rgba(74,222,128,0.15)', path: 'M4 13l5 5L20 7' } };
const filtered = activityLog;
if (filtered.length === 0) return <div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No activity yet.</div>;
if (filtered.length === 0) return <div className="card-empty-center">No activity</div>;
return (
<div className="scrollbar-thin-theme" style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 10 }}>
{filtered.map(e => {
@@ -561,51 +550,10 @@ export default function ProjectDetailPage() {
</div>
</div>
{/* Add Task modal — team only */}
{showAddJob && isTeam && (
<div style={popupOverlayStyle} onClick={() => { setShowAddJob(false); setJobForm(emptyJobForm()); }}>
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(520px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Add Task {project.name}</div>
<form onSubmit={handleAddJob} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<div style={MODAL_LBL}>Task Title *</div>
<input autoFocus required value={jobForm.title} onChange={e => setJobForm(f => ({ ...f, title: e.target.value }))} placeholder="e.g. Logo Design" style={MODAL_IN} />
</div>
<div>
<div style={MODAL_LBL}>Service Type *</div>
<select required value={jobForm.serviceType} onChange={e => setJobForm(f => ({ ...f, serviceType: e.target.value }))} style={MODAL_IN}>
<option value="">Select a service</option>
{serviceTypes.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
<div>
<div style={MODAL_LBL}>Deadline</div>
<input type="date" value={jobForm.deadline} onChange={e => setJobForm(f => ({ ...f, deadline: e.target.value }))} style={MODAL_IN} />
</div>
<div>
<div style={MODAL_LBL}>Requested By *</div>
<select required value={jobForm.requestedBy} onChange={e => setJobForm(f => ({ ...f, requestedBy: e.target.value }))} style={MODAL_IN}>
<option value="">Select requester</option>
{requesterOptions.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
</select>
</div>
<div>
<div style={MODAL_LBL}>Notes</div>
<textarea value={jobForm.description} onChange={e => setJobForm(f => ({ ...f, description: e.target.value }))} placeholder="Any details…" rows={3} style={{ ...MODAL_IN, resize: 'vertical' }} />
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', paddingTop: 4 }}>
<button type="submit" className="btn btn-outline" disabled={savingJob}>{savingJob ? 'Saving…' : 'Save'}</button>
<button type="button" className="btn btn-outline" onClick={() => { setShowAddJob(false); setJobForm(emptyJobForm()); }}>Cancel</button>
</div>
</form>
</div>
</div>
)}
{showClientTask && isClient && (
<div style={popupOverlayStyle} onClick={() => { setShowClientTask(false); setClientTaskKey(k => k + 1); setClientTaskError(''); }}>
<div style={{ ...popupSurfaceStyle, width: 'min(720px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto' }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 14 }}>New Task</div>
<div style={TASK_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}>
<div style={{ ...TASK_MODAL_TITLE_STYLE, marginBottom: 14 }}>New Task</div>
<RequestForm
key={clientTaskKey}
companies={company ? [company] : []}
@@ -626,14 +574,14 @@ export default function ProjectDetailPage() {
{addingMember && isTeam && (
<div style={popupOverlayStyle} onClick={() => { setAddingMember(false); setSelectedExts(new Set()); }}>
<div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', display: 'flex', flexDirection: 'column', gap: 16, padding: 0 }} onClick={e => e.stopPropagation()}>
<div style={{ ...TASK_MODAL_SHELL_STYLE, width: 'min(480px, 96vw)', display: 'flex', flexDirection: 'column', gap: 16, padding: 0 }} onClick={e => e.stopPropagation()}>
<div style={{ padding: '18px 21px 0' }}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 2 }}>Manage Subcontractors</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>{project.name}</div>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '0 21px' }}>
{externalProfiles.length === 0
? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 80, fontSize: 13, color: 'var(--text-muted)' }}>No subcontractors found.</div>
? <div className="card-empty-center">No subcontractors</div>
: <table style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
<colgroup>
<col style={{ width: '5%' }} />
@@ -643,10 +591,10 @@ export default function ProjectDetailPage() {
</colgroup>
<thead>
<tr>
<th style={{ ...TH_STYLE, padding: '0 0 12px' }} />
<SortTh col="name" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TH_STYLE}>Name</SortTh>
<SortTh col="email" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TH_STYLE}>Email</SortTh>
<th style={{ ...TH_STYLE, padding: '0 0 12px', textAlign: 'center' }}>Assigned</th>
<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="email" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TASK_TABLE_TH_STYLE}>Email</SortTh>
<th style={{ ...TASK_TABLE_TH_STYLE, padding: '0 0 12px', textAlign: 'center' }}>Assigned</th>
</tr>
</thead>
<tbody>
@@ -656,16 +604,10 @@ export default function ProjectDetailPage() {
return subSortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
}).map(p => {
const checked = selectedExts.has(p.id);
const initials = (p.name || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
return (
<tr key={p.id} onClick={() => setSelectedExts(prev => { const s = new Set(prev); s.has(p.id) ? s.delete(p.id) : s.add(p.id); return s; })} style={{ cursor: 'pointer' }}>
<td style={{ padding: '5px 0', border: 'none', background: 'transparent' }}>
{p.avatar_url
? <div style={{ width: 26, height: 26, borderRadius: '50%', overflow: 'hidden' }}><img src={p.avatar_url} alt={p.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div>
: <div style={{ width: 26, height: 26, borderRadius: '50%', background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span style={{ fontSize: 10, fontWeight: 600, color: '#000', lineHeight: 1 }}>{initials}</span>
</div>
}
<ProfileAvatar profile={p} name={p.name} size={26} fontSize={10} />
</td>
<td style={{ padding: '5px 5px 5px 12px', border: 'none', background: 'transparent', fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</td>
<td style={{ padding: '5px', border: 'none', background: 'transparent', fontSize: 12, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.email || '—'}</td>
@@ -681,7 +623,7 @@ export default function ProjectDetailPage() {
</table>
}
</div>
<div style={{ padding: '0 21px 18px', display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<div className="modal-action-row" style={{ padding: '0 21px 18px' }}>
<button className="btn btn-outline" disabled={savingMembers} onClick={handleAddMember}>{savingMembers ? 'Saving…' : 'Save'}</button>
<button className="btn btn-outline" onClick={() => { setAddingMember(false); setSelectedExts(new Set()); }}>Cancel</button>
</div>
+374 -163
View File
@@ -1,19 +1,20 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useParams, Link } from 'react-router-dom';
import Layout from '../components/Layout';
import PageLoader from '../components/PageLoader';
import FileBrowser from '../components/FileBrowser';
import ProfileAvatar from '../components/ProfileAvatar';
import StatusBadge from '../components/StatusBadge';
import { useAuth } from '../context/AuthContext';
import { supabase } from '../lib/supabase';
import { logActivity } from '../lib/activityLog';
import JSZip from 'jszip';
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
import { sendEmail } from '../lib/email';
import { uploadFilesToRequestInfo, safeName } from '../lib/filebrowserFolders';
import { useRefetchOnFocus } from '../hooks/useRefetchOnFocus';
import { useRealtimeSubscription } from '../hooks/useRealtimeSubscription';
import { useLiveRefresh } from '../hooks/useLiveRefresh';
import FileAttachment from '../components/FileAttachment';
import { sendTaskStatusUpdate } from '../lib/taskNotifications';
import { encodeRejectedNote, parseRejectedNote, isReviewShadowSubmission, REVIEW_SHADOW_PREFIX, getVisibleTaskSubmissions, getTaskDerivedState } from '../lib/taskVersions';
import { getCurrentVersionForTask } from '../lib/taskDeadlines';
import { getSubmissionDisplayName, mergeSubmissionDisplayNames } from '../lib/submissionDisplay';
const ACTION_LABEL = {
task_created: 'created this task', task_started: 'started this task', task_on_hold: 'placed task on hold',
@@ -63,8 +64,24 @@ const CARD = { padding: '18px 21px', borderRadius: 8 };
const LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 14 };
const META_LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 4 };
const CARD_META_LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 2 };
const MODAL_ACTION_ROW = { display: 'flex', gap: 8, justifyContent: 'flex-end', paddingTop: 4, flexWrap: 'nowrap' };
const MODAL_ACTION_BUTTON = { width: 132, justifyContent: 'center', flex: '0 0 132px' };
const TABS = ['Overview', 'Revisions', 'Submissions', 'Comments', 'Folder'];
const TABS = ['Overview', 'Revisions', 'Submissions', 'Comments'];
function RejectedNoteBlock({ note }) {
if (!note?.body) return null;
const d = note.created_at ? new Date(note.created_at) : null;
const dateStr = d ? d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
return (
<div style={{ padding: '12px 14px', borderRadius: 8, border: '1px solid color-mix(in srgb, var(--danger) 30%, var(--border))', background: 'color-mix(in srgb, var(--danger) 8%, var(--card-bg-2))' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, marginBottom: 6 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--danger)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Rejected Notes</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', flexShrink: 0 }}>{dateStr}</div>
</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{note.body}</div>
</div>
);
}
function TimelineCard({ task, activityLog, currentSubmission }) {
const status = task?.status;
@@ -135,19 +152,7 @@ function TimelineCard({ task, activityLog, currentSubmission }) {
}
function AssigneePortrait({ name, avatarUrl }) {
if (avatarUrl) {
return (
<div style={{ width: 32, height: 32, borderRadius: '50%', overflow: 'hidden', flexShrink: 0 }}>
<img src={avatarUrl} alt={name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</div>
);
}
const initials = (name || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
return (
<div style={{ width: 32, height: 32, borderRadius: '50%', background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: '#000', lineHeight: 1 }}>{initials}</span>
</div>
);
return <ProfileAvatar name={name} avatarUrl={avatarUrl} size={32} fontSize={12} />;
}
const fmtSize = (bytes) => {
@@ -194,13 +199,55 @@ function SubmissionFiles({ files, downloading, onDownloadAll }) {
);
}
function SubmissionSigns({ signs = [], signCount = null }) {
const normalizedCount = Number(signCount || 0);
if ((!signs || signs.length === 0) && normalizedCount <= 0) return null;
return (
<div>
<div style={META_LABEL}>Signs</div>
{signs && signs.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{signs
.slice()
.sort((a, b) => Number(a.sign_number || 0) - Number(b.sign_number || 0))
.map((sign) => (
<div key={sign.id || `${sign.sign_number}-${sign.sign_name}`} style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.5 }}>
<span style={{ color: 'var(--text-secondary)', marginRight: 8 }}>Sign {sign.sign_number || '—'}</span>
<span>{sign.sign_name || 'Unnamed sign'}</span>
</div>
{sign.sign_family ? (
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.4 }}>
{sign.sign_family}
</div>
) : null}
</div>
))}
</div>
) : (
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.5 }}>
N/A
</div>
)}
</div>
);
}
function normalizeSubmissionSigns(submission) {
return (Array.isArray(submission?.signs) ? submission.signs : [])
.map((sign, index) => ({
...sign,
sign_number: Number(sign?.sign_number || index + 1),
sign_name: String(sign?.sign_name || '').trim(),
sign_family: String(sign?.sign_family || '').trim(),
}))
.filter(sign => sign.sign_name || sign.sign_family || Number.isFinite(sign.sign_number));
}
export default function TaskDetail() {
const { id } = useParams();
const { currentUser } = useAuth();
const [refreshKey, setRefreshKey] = useState(0);
const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
useRefetchOnFocus(refresh);
useRealtimeSubscription(['tasks', 'submissions', 'activity_log', 'task_comments'], refresh);
const { refreshKey } = useLiveRefresh(['tasks', 'submissions', 'activity_log', 'task_comments']);
const [task, setTask] = useState(null);
const [submissions, setSubmissions] = useState([]);
const [activityLog, setActivityLog] = useState([]);
@@ -213,25 +260,55 @@ export default function TaskDetail() {
const [revisionFiles, setRevisionFiles] = useState([]);
const [revisionSaving, setRevisionSaving] = useState(false);
const revisionFileRef = useRef(null);
const [rejectModal, setRejectModal] = useState(false);
const [rejectNote, setRejectNote] = useState('');
const [rejectSaving, setRejectSaving] = useState(false);
const [amendModal, setAmendModal] = useState(false);
const [amendForm, setAmendForm] = useState({ description: '' });
const [amendFiles, setAmendFiles] = useState([]);
const [amendSaving, setAmendSaving] = useState(false);
const [amendDragging, setAmendDragging] = useState(false);
const amendDragCounter = useRef(0);
const amendFileRef = useRef(null);
const [reviewModal, setReviewModal] = useState(false);
const [reviewFiles, setReviewFiles] = useState([]);
const [reviewNotes, setReviewNotes] = useState('');
const [reviewSaving, setReviewSaving] = useState(false);
const [reviewDragging, setReviewDragging] = useState(false);
const reviewDragCounter = useRef(0);
const reviewFileRef = useRef(null);
const [comments, setComments] = useState([]);
const [commentBody, setCommentBody] = useState('');
const [commentSaving, setCommentSaving] = useState(false);
const [deliveries, setDeliveries] = useState([]);
const [dlProgress, setDlProgress] = useState({ active: false, label: '', percent: 0 });
const canonicalTaskServiceType = getTaskDerivedState(task, submissions, deliveries).serviceType;
const fetchTaskSubmissions = async (taskId) => {
const { data: subRows } = await supabase
.from('submissions')
.select('id, type, version_number, request_key, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, signs, files:submission_files(id, name, storage_path, size)')
.eq('task_id', taskId)
.order('submitted_at', { ascending: true });
const submitterIds = [...new Set((subRows || []).map((submission) => submission.submitted_by).filter(Boolean))];
if (submitterIds.length === 0) {
return (subRows || []).map(submissionRow => ({ ...submissionRow, signs: normalizeSubmissionSigns(submissionRow) }));
}
const { data: submitterProfiles } = await supabase
.from('profiles')
.select('id, name')
.in('id', submitterIds);
return mergeSubmissionDisplayNames(
(subRows || []).map(submissionRow => ({ ...submissionRow, signs: normalizeSubmissionSigns(submissionRow) })),
submitterProfiles || []
);
};
const reloadTaskRow = async () => {
const { data } = await supabase
.from('tasks')
.select('*, project:projects(id, name, company:companies(id, name)), assignee:profiles!assigned_to(id, name, avatar_url)')
.eq('id', id)
.single();
if (data) setTask(data);
};
useEffect(() => {
Promise.all([
@@ -240,11 +317,7 @@ export default function TaskDetail() {
.select('*, project:projects(id, name, company:companies(id, name)), assignee:profiles!assigned_to(id, name, avatar_url)')
.eq('id', id)
.single(),
supabase
.from('submissions')
.select('id, type, version_number, request_key, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, files:submission_files(id, name, storage_path, size)')
.eq('task_id', id)
.order('submitted_at', { ascending: true }),
fetchTaskSubmissions(id),
supabase
.from('activity_log')
.select('id, created_at, actor_id, actor_name, action, task_id, task_title')
@@ -256,7 +329,7 @@ export default function TaskDetail() {
.select('id, author_id, author_name, body, created_at')
.eq('task_id', id)
.order('created_at', { ascending: true }),
]).then(async ([{ data: taskData }, { data: subRows }, { data: actData }, { data: commentData }]) => {
]).then(async ([{ data: taskData }, subRows, { data: actData }, { data: commentData }]) => {
setTask(taskData);
setSubmissions(subRows || []);
setActivityLog(actData || []);
@@ -277,76 +350,225 @@ export default function TaskDetail() {
if (loading) return <Layout><PageLoader /></Layout>;
if (!task) return <Layout><p style={{ padding: 24 }}>Task not found.</p></Layout>;
const submission = submissions.find(s => s.type === 'initial') || submissions[0] || null;
const amendments = submissions.filter(s => s.type === 'amendment');
const revisions = submissions.filter(s => s.type === 'revision').sort((a, b) => a.version_number - b.version_number);
const visibleSubmissions = getVisibleTaskSubmissions(submissions);
const submission = visibleSubmissions.find(s => s.type === 'initial') || visibleSubmissions[0] || null;
const rejectedNoteSubmissions = visibleSubmissions
.filter(s => s.type === 'amendment' && parseRejectedNote(s.description))
.map(s => ({ ...s, rejected: parseRejectedNote(s.description) }));
const amendments = visibleSubmissions.filter(s => s.type === 'amendment' && !parseRejectedNote(s.description));
const revisions = visibleSubmissions.filter(s => s.type === 'revision').sort((a, b) => a.version_number - b.version_number);
const reviewSubmissions = deliveries;
const commentRows = comments;
const rejectedNoteByVersion = new Map(
rejectedNoteSubmissions.map(note => [note.rejected.versionNumber, { ...note, body: note.rejected.body, created_at: note.submitted_at }])
);
const displayCurrentVersion = getCurrentVersionForTask(task, submissions);
const assignee = task.assignee;
const assigneeName = assignee?.name || task.assigned_name || null;
const assigneeAvatar = assignee?.avatar_url || null;
const project = task.project;
const company = project?.company;
const requester = submission?.submitted_by_name || null;
const requester = getSubmissionDisplayName(submission) || null;
const role = currentUser?.role;
const isClient = role === 'client';
const isTeamOrSub = role === 'team' || role === 'external';
const status = task.status;
const notificationBase = {
taskId: id,
taskTitle: task?.title || '',
projectId: project?.id || '',
projectName: project?.name || '',
companyId: company?.id || '',
companyName: company?.name || '',
assignedProfileId: task?.assigned_to || currentUser?.id || '',
};
const actionBusy = statusSaving || reviewSaving || revisionSaving || rejectSaving || amendSaving;
const log = (action) => logActivity({ actorId: currentUser.id, actorName: currentUser.name, action, taskId: id, taskTitle: task?.title, projectId: task?.project?.id, projectName: task?.project?.name });
const updateStatus = async (newStatus, extra = {}, action = null) => {
setStatusSaving(true);
await supabase.from('tasks').update({ status: newStatus, ...extra }).eq('id', id);
const prevTask = task;
setTask(t => ({ ...t, status: newStatus, ...extra }));
if (action) {
await log(action);
const { data } = await supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action').eq('task_id', id).order('created_at', { ascending: false }).limit(20);
setActivityLog(data || []);
try {
const { error: taskUpdateError } = await supabase.from('tasks').update({ status: newStatus, ...extra }).eq('id', id);
if (taskUpdateError) throw taskUpdateError;
await reloadTaskRow();
if (action) {
await log(action);
const { data } = await supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action').eq('task_id', id).order('created_at', { ascending: false }).limit(20);
setActivityLog(data || []);
}
} catch (err) {
setTask(prevTask);
console.error('Status update failed:', err);
window.alert(err?.message || 'Failed to update status.');
throw err;
} finally {
setStatusSaving(false);
}
setStatusSaving(false);
};
const handleStart = () => updateStatus('in_progress', { assigned_to: currentUser.id, assigned_name: currentUser.name }, 'task_started');
const handleOnHold = () => updateStatus('on_hold', {}, 'task_on_hold');
const handleOnHold = async () => {
await updateStatus('on_hold', {}, 'task_on_hold');
sendTaskStatusUpdate({
...notificationBase,
statusLabel: 'On Hold',
headline: 'Task On Hold',
subject: `Task On Hold: ${task?.title || 'Task'}`,
message: `${currentUser.name} placed ${task?.title || 'this task'} on hold${project?.name ? ` for ${project.name}` : ''}.`,
includeTeam: true,
includeAssigned: true,
includeClient: true,
}).catch(() => {});
};
const handleResume = () => updateStatus('in_progress', {}, 'task_resumed');
const handleSendToReview = () => { setReviewFiles([]); setReviewNotes(''); setReviewModal(true); };
const handleRemoveFromReview = () => updateStatus('in_progress', {}, 'task_resumed');
const handleConfirmReview = async () => {
setReviewSaving(true);
try {
const briefId = submissions.find(s => s.type === 'initial')?.id || submissions[0]?.id;
const { data: newDel } = await supabase.from('deliveries').insert({ submission_id: briefId, sent_by: currentUser.name, message: reviewNotes.trim() || '', version_number: task.current_version || 0 }).select().single();
const targetVersion = task.current_version || 0;
const versionSubmissions = submissions.filter(s => (s.version_number || 0) === targetVersion && s.type !== 'amendment');
const usedSubmissionIds = new Set(
deliveries
.filter(d => (d.version_number || 0) === targetVersion)
.filter(d => versionSubmissions.some(s => s.id === d.submission_id))
.map(d => d.submission_id)
);
let targetSubmission = versionSubmissions.find(s => !usedSubmissionIds.has(s.id)) || null;
if (!targetSubmission) {
const { data: createdSubmission, error: createdSubmissionError } = await supabase
.from('submissions')
.insert({
task_id: id,
version_number: targetVersion,
type: targetVersion === 0 ? 'initial' : 'revision',
revision_type: targetVersion > 0 ? 'client_revision' : null,
service_type: canonicalTaskServiceType !== '—' ? canonicalTaskServiceType : null,
description: `${REVIEW_SHADOW_PREFIX}${targetVersion}`,
submitted_by: currentUser.id,
submitted_by_name: currentUser.name,
})
.select('id, type, version_number, request_key, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, signs')
.single();
if (createdSubmissionError) throw createdSubmissionError;
targetSubmission = createdSubmission || null;
}
if (!targetSubmission?.id) throw new Error('No submission found for current version.');
const { data: newDel, error: deliveryError } = await supabase
.from('deliveries')
.insert({ submission_id: targetSubmission.id, sent_by: currentUser.name, message: reviewNotes.trim() || '', version_number: targetVersion })
.select()
.single();
if (deliveryError) throw deliveryError;
if (!newDel?.id) throw new Error('Failed to create review submission.');
if (newDel && reviewFiles.length > 0) {
const uploaded = [];
for (const file of reviewFiles) {
const path = `${id}/${Date.now()}_${file.name}`;
const { data: up } = await supabase.storage.from('deliveries').upload(path, file);
const { data: up, error: uploadError } = await supabase.storage.from('deliveries').upload(path, file);
if (uploadError) throw uploadError;
if (up) uploaded.push({ name: file.name, storage_path: path, size: file.size, delivery_id: newDel.id });
}
if (uploaded.length > 0) await supabase.from('delivery_files').insert(uploaded);
if (uploaded.length > 0) {
const { error: deliveryFilesError } = await supabase.from('delivery_files').insert(uploaded);
if (deliveryFilesError) throw deliveryFilesError;
}
}
const subIds = submissions.map(s => s.id);
const refreshedSubRows = await fetchTaskSubmissions(id);
const allSubRows = refreshedSubRows || [];
setSubmissions(allSubRows);
const subIds = allSubRows.map(s => s.id);
const { data: delRows } = await supabase.from('deliveries').select('*, files:delivery_files(id, name, storage_path, size)').in('submission_id', subIds).order('sent_at', { ascending: false });
setDeliveries(delRows || []);
await updateStatus('client_review', {}, 'task_submitted');
sendTaskStatusUpdate({
...notificationBase,
statusLabel: 'In Review',
headline: 'Task Ready For Review',
subject: `In Review: ${task?.title || 'Task'}`,
message: `${currentUser.name} placed ${task?.title || 'this task'} in review${project?.name ? ` for ${project.name}` : ''}.${reviewNotes.trim() ? ` Notes: ${reviewNotes.trim()}` : ''}`,
includeTeam: true,
includeAssigned: true,
includeClient: true,
}).catch(() => {});
setReviewModal(false);
setReviewFiles([]);
setReviewNotes('');
} catch (err) {
console.error('Place in review failed:', err);
window.alert(err?.message || 'Failed to place task in review.');
} finally {
setReviewSaving(false);
}
};
const handleClientApprove = () => updateStatus('client_approved', { completed_at: new Date().toISOString() }, 'task_approved');
const handleClientReject = () => updateStatus('not_started', { current_version: (task.current_version || 0) + 1, assigned_to: null, assigned_name: null }, 'revision_requested');
const handleClientApprove = async () => {
await updateStatus('client_approved', { completed_at: new Date().toISOString() }, 'task_approved');
sendTaskStatusUpdate({
...notificationBase,
statusLabel: 'Approved',
headline: 'Task Approved',
subject: `Approved: ${task?.title || 'Task'}`,
message: `${currentUser.name} approved ${task?.title || 'this task'}${project?.name ? ` for ${project.name}` : ''}.`,
includeTeam: true,
includeAssigned: true,
includeClient: true,
}).catch(() => {});
};
const handleClientReject = () => { setRejectNote(''); setRejectModal(true); };
const handleSubmitReject = async () => {
if (!rejectNote.trim()) return;
setRejectSaving(true);
try {
const rejectedVersion = task.current_version || 0;
const { data: rejectedNoteSubmission } = await supabase
.from('submissions')
.insert({
task_id: id,
version_number: rejectedVersion,
type: 'amendment',
service_type: canonicalTaskServiceType !== '—' ? canonicalTaskServiceType : null,
description: encodeRejectedNote(rejectedVersion, rejectNote.trim()),
submitted_by: currentUser.id,
submitted_by_name: currentUser.name,
})
.select()
.single();
if (rejectedNoteSubmission) setSubmissions(prev => [...prev, rejectedNoteSubmission]);
await updateStatus('in_progress', {}, 'revision_requested');
sendTaskStatusUpdate({
...notificationBase,
statusLabel: 'Rejected',
headline: 'Task Rejected',
subject: `Rejected: ${task?.title || 'Task'}`,
message: `${currentUser.name} rejected ${task?.title || 'this task'}${project?.name ? ` for ${project.name}` : ''}. Notes: ${rejectNote.trim()}`,
includeTeam: true,
includeAssigned: true,
includeClient: true,
}).catch(() => {});
setRejectModal(false);
setRejectNote('');
} finally {
setRejectSaving(false);
}
};
const handleRelease = () => updateStatus('not_started', { assigned_to: null, assigned_name: null }, 'task_released');
const handleSubmitRevision = async () => {
setRevisionSaving(true);
const baseline = Math.max(task.current_version || 0, ...submissions.map(s => s.version_number || 0));
const newVersion = baseline + 1;
await supabase.from('tasks').update({ status: 'not_started', current_version: newVersion, assigned_to: null, assigned_name: null }).eq('id', id);
const { data: newSub } = await supabase.from('submissions').insert({ task_id: id, version_number: newVersion, type: 'revision', revision_type: 'client_revision', service_type: task.title, deadline: revisionForm.deadline || null, description: revisionForm.description, submitted_by: currentUser.id, submitted_by_name: currentUser.name }).select().single();
await supabase.from('tasks').update({
status: 'not_started',
current_version: newVersion,
assigned_to: task?.assigned_to || null,
assigned_name: task?.assigned_name || task?.assignee?.name || null,
}).eq('id', id);
const { data: newSub } = await supabase.from('submissions').insert({ task_id: id, version_number: newVersion, type: 'revision', revision_type: 'client_revision', service_type: canonicalTaskServiceType !== '—' ? canonicalTaskServiceType : null, deadline: revisionForm.deadline || null, description: revisionForm.description, submitted_by: currentUser.id, submitted_by_name: currentUser.name }).select().single();
if (newSub && revisionFiles.length > 0) {
const uploadedFiles = [];
for (const file of revisionFiles) {
@@ -355,18 +577,27 @@ export default function TaskDetail() {
if (uploaded) uploadedFiles.push({ name: file.name, storage_path: path, size: file.size });
}
if (uploadedFiles.length > 0) await supabase.from('submission_files').insert(uploadedFiles.map(f => ({ ...f, submission_id: newSub.id })));
if (project?.name && task?.title) uploadFilesToRequestInfo(revisionFiles, company?.name, project.name, task.title, newVersion).catch(() => {});
}
await logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'revision_requested', taskId: id, taskTitle: task?.title, projectId: project?.id, projectName: project?.name });
sendEmail('revision_submitted', ['hello@fourgebranding.com'], { clientName: currentUser.name, serviceType: task.title, projectName: project?.name, version: `R${String(newVersion).padStart(2, '0')}`, deadline: revisionForm.deadline, description: revisionForm.description, taskId: id }).catch(() => {});
const [{ data: taskData }, { data: subRows }, { data: actData }] = await Promise.all([
const [{ data: taskData }, subRows, { data: actData }] = await Promise.all([
supabase.from('tasks').select('*, project:projects(id, name, company:companies(id, name)), assignee:profiles!assigned_to(id, name, avatar_url)').eq('id', id).single(),
supabase.from('submissions').select('id, type, version_number, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, files:submission_files(id, name, storage_path, size)').eq('task_id', id).order('submitted_at', { ascending: true }),
fetchTaskSubmissions(id),
supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action, task_id, task_title').eq('task_id', id).order('created_at', { ascending: false }).limit(20),
]);
setTask(taskData);
setSubmissions(subRows || []);
setActivityLog(actData || []);
sendTaskStatusUpdate({
...notificationBase,
assignedProfileId: task?.assigned_to || '',
statusLabel: `Revision Request ${`R${String(newVersion).padStart(2, '0')}`}`,
headline: 'Revision Requested',
subject: `Revision Request: ${task?.title || 'Task'} ${`R${String(newVersion).padStart(2, '0')}`}`,
message: `${currentUser.name} requested a revision for ${task?.title || 'this task'}${project?.name ? ` on ${project.name}` : ''}. ${revisionForm.description}`,
includeTeam: true,
includeAssigned: true,
includeClient: true,
}).catch(() => {});
setRevisionModal(false);
setRevisionForm({ description: '', deadline: '' });
setRevisionFiles([]);
@@ -378,7 +609,7 @@ export default function TaskDetail() {
setAmendSaving(true);
setAmendModal(false);
const baseline = Math.max(task.current_version || 0, ...submissions.map(s => s.version_number || 0));
const { data: newSub } = await supabase.from('submissions').insert({ task_id: id, version_number: baseline, type: 'amendment', service_type: task.title, description: amendForm.description, submitted_by: currentUser.id, submitted_by_name: currentUser.name }).select().single();
const { data: newSub } = await supabase.from('submissions').insert({ task_id: id, version_number: baseline, type: 'amendment', service_type: canonicalTaskServiceType !== '—' ? canonicalTaskServiceType : null, description: amendForm.description, submitted_by: currentUser.id, submitted_by_name: currentUser.name }).select().single();
if (newSub && amendFiles.length > 0) {
setDlProgress({ active: true, label: 'Uploading files…', percent: 0 });
const uploaded = [];
@@ -390,11 +621,20 @@ export default function TaskDetail() {
if (up) uploaded.push({ name: file.name, storage_path: path, size: file.size });
}
if (uploaded.length > 0) await supabase.from('submission_files').insert(uploaded.map(f => ({ ...f, submission_id: newSub.id })));
if (project?.name && task?.title) uploadFilesToRequestInfo(amendFiles, company?.name, project.name, task.title, baseline).catch(() => {});
setDlProgress({ active: false, label: '', percent: 0 });
}
const { data: subRows } = await supabase.from('submissions').select('id, type, version_number, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, files:submission_files(id, name, storage_path, size)').eq('task_id', id).order('submitted_at', { ascending: true });
const subRows = await fetchTaskSubmissions(id);
setSubmissions(subRows || []);
sendTaskStatusUpdate({
...notificationBase,
statusLabel: 'Request Amended',
headline: 'Request Amended',
subject: `Request Amended: ${task?.title || 'Task'}`,
message: `${currentUser.name} amended ${task?.title || 'this task'}${project?.name ? ` for ${project.name}` : ''}. Notes: ${amendForm.description.trim()}`,
includeTeam: true,
includeAssigned: true,
includeClient: true,
}).catch(() => {});
setAmendForm({ description: '' });
setAmendFiles([]);
setAmendSaving(false);
@@ -454,38 +694,38 @@ export default function TaskDetail() {
<div style={{ fontSize: 28, fontWeight: 500, color: 'var(--text-primary)', lineHeight: 1.2 }}>{task.title}</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
{isTeamOrSub && status === 'not_started' && (
<button className="btn btn-outline" disabled={statusSaving} onClick={handleStart}>Start Task</button>
<button className="btn btn-outline" disabled={actionBusy} onClick={handleStart}>Start Task</button>
)}
{isTeamOrSub && status === 'in_progress' && task.assigned_to === currentUser?.id && (
<>
<button className="btn btn-outline" disabled={statusSaving} onClick={handleRelease}>Release</button>
<button className="btn btn-outline" disabled={statusSaving} onClick={handleOnHold}>Place on Hold</button>
<button className="btn btn-primary" disabled={statusSaving} onClick={handleSendToReview}>Place in Review</button>
<button className="btn btn-outline" disabled={actionBusy} onClick={handleRelease}>Release</button>
<button className="btn btn-outline" disabled={actionBusy} onClick={handleOnHold}>Place on Hold</button>
<button className="btn btn-primary" disabled={actionBusy} onClick={handleSendToReview}>Place in Review</button>
</>
)}
{isTeamOrSub && status === 'client_review' && task.assigned_to === currentUser?.id && (
<button className="btn btn-outline" disabled={statusSaving} onClick={handleRemoveFromReview}>Remove from Review</button>
<button className="btn btn-outline" disabled={actionBusy} onClick={handleRemoveFromReview}>Remove from Review</button>
)}
{isTeamOrSub && status === 'on_hold' && task.assigned_to === currentUser?.id && (
<>
<button className="btn btn-outline" disabled={statusSaving} onClick={handleRelease}>Release</button>
<button className="btn btn-outline" disabled={statusSaving} onClick={handleResume}>Resume</button>
<button className="btn btn-outline" disabled={actionBusy} onClick={handleRelease}>Release</button>
<button className="btn btn-outline" disabled={actionBusy} onClick={handleResume}>Resume</button>
</>
)}
{isClient && status === 'client_review' && (
<>
<button className="btn btn-primary" disabled={statusSaving} onClick={handleClientApprove}>Approve</button>
<button className="btn btn-danger" disabled={statusSaving} onClick={handleClientReject}>Reject</button>
<button className="btn btn-primary" disabled={actionBusy} onClick={handleClientApprove}>Approve</button>
<button className="btn btn-danger" disabled={actionBusy} onClick={handleClientReject}>Reject</button>
</>
)}
{isClient && ['client_approved', 'invoiced', 'paid'].includes(status) && (
<button className="btn btn-outline" onClick={() => setRevisionModal(true)}>Request Revision</button>
<button className="btn btn-outline" disabled={actionBusy} onClick={() => setRevisionModal(true)}>Request Revision</button>
)}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 20 }}>
{submission?.service_type && (
<span className="badge badge-status badge-client">{submission.service_type}</span>
{canonicalTaskServiceType !== '—' && (
<span className="badge badge-status badge-client">{canonicalTaskServiceType}</span>
)}
<StatusBadge status={task.status || 'not_started'} />
</div>
@@ -501,7 +741,7 @@ export default function TaskDetail() {
<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 }}><polyline points="17,1 21,5 17,9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7,23 3,19 7,15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
<div>
<div style={CARD_META_LABEL}>Revision</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>{(task.current_version ?? 0) === 0 ? 'New' : String(task.current_version).padStart(2, '0')}</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>{displayCurrentVersion === 0 ? 'New' : String(displayCurrentVersion).padStart(2, '0')}</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
@@ -541,7 +781,7 @@ export default function TaskDetail() {
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10 }}>
{TABS.map(tab => {
const count = tab === 'Revisions' ? revisions.length : tab === 'Submissions' ? reviewSubmissions.length : tab === 'Comments' ? comments.length : 0;
const count = tab === 'Revisions' ? revisions.length : tab === 'Submissions' ? reviewSubmissions.length : tab === 'Comments' ? commentRows.length : 0;
return (
<button key={tab} onClick={() => setActiveTab(tab)} className={`section-tab-btn${activeTab === tab ? ' is-active' : ''}`}>
{tab}{count > 0 && <span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: activeTab === tab ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}>{count}</span>}
@@ -549,8 +789,8 @@ export default function TaskDetail() {
);
})}
</div>
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, overflowY: activeTab === 'Folder' ? 'hidden' : 'auto', padding: activeTab === 'Folder' ? 0 : CARD.padding, ...(activeTab === 'Folder' ? { display: 'flex', flexDirection: 'column' } : {}) }}>
<div style={{ fontSize: 13, ...(activeTab === 'Folder' ? { flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 } : {}) }}>
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, overflowY: 'auto' }}>
<div style={{ fontSize: 13 }}>
{activeTab === 'Overview' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 32 }}>
@@ -573,6 +813,11 @@ export default function TaskDetail() {
<button className="btn btn-outline" onClick={() => setAmendModal(true)}>Amend Request</button>
</div>
)}
{rejectedNoteByVersion.get(0) && (
<div style={{ marginLeft: 'auto', flex: '0 0 min(320px, 36%)' }}>
<RejectedNoteBlock note={rejectedNoteByVersion.get(0)} />
</div>
)}
</div>
{submission?.description && (
<div>
@@ -586,6 +831,7 @@ export default function TaskDetail() {
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{submission.sign_family}</div>
</div>
)}
<SubmissionSigns signs={submission?.signs} signCount={submission?.sign_count} />
<SubmissionFiles files={submission?.files} downloading={downloading} onDownloadAll={downloadAllFiles} />
{amendments.length > 0 && (
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 20, display: 'flex', flexDirection: 'column', gap: 20 }}>
@@ -609,7 +855,7 @@ export default function TaskDetail() {
)}
{activeTab === 'Revisions' && (
revisions.length === 0
? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 1, minHeight: 120, color: 'var(--text-muted)' }}>No revisions yet.</div>
? <div className="card-empty-center">No revisions</div>
: <div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{[...revisions].reverse().map((rev, i, arr) => {
const rNum = `R${String(rev.version_number).padStart(2, '0')}`;
@@ -621,7 +867,7 @@ export default function TaskDetail() {
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 32 }}>
<div>
<div style={META_LABEL}>Requested By</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{rev.submitted_by_name || '—'}</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{getSubmissionDisplayName(rev) || '—'}</div>
</div>
<div>
<div style={META_LABEL}>Requested</div>
@@ -640,6 +886,11 @@ export default function TaskDetail() {
<button className="btn btn-outline" onClick={() => setAmendModal(true)}>Amend</button>
</div>
)}
{rejectedNoteByVersion.get(rev.version_number) && (
<div style={{ marginLeft: 'auto', flex: '0 0 min(320px, 36%)' }}>
<RejectedNoteBlock note={rejectedNoteByVersion.get(rev.version_number)} />
</div>
)}
</div>
{rev.description && (
<div>
@@ -653,7 +904,8 @@ export default function TaskDetail() {
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{rev.sign_family}</div>
</div>
)}
{!rev.description && !rev.sign_family && (
<SubmissionSigns signs={rev.signs} signCount={rev.sign_count} />
{!rev.description && !rev.sign_family && !(rev.signs?.length > 0) && !(Number(rev.sign_count || 0) > 0) && (
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No notes provided.</div>
)}
<SubmissionFiles files={rev.files} downloading={downloading} onDownloadAll={(f) => downloadAllFiles(f, rNum)} />
@@ -664,7 +916,7 @@ export default function TaskDetail() {
)}
{activeTab === 'Submissions' && (
reviewSubmissions.length === 0
? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 1, minHeight: 120, color: 'var(--text-muted)' }}>No submissions yet.</div>
? <div className="card-empty-center">No submissions</div>
: <div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{reviewSubmissions.map((sub, i, arr) => {
const fmtDate = sub.sent_at
@@ -681,7 +933,7 @@ export default function TaskDetail() {
<div style={META_LABEL}>Date</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{fmtDate}</div>
</div>
<div style={{ marginLeft: 'auto' }}>
<div>
<div style={META_LABEL}>Version</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)', fontWeight: 500 }}>{`R${String(sub.version_number ?? 0).padStart(2, '0')}`}</div>
</div>
@@ -713,8 +965,8 @@ export default function TaskDetail() {
<button className="btn btn-outline" disabled={commentSaving || !commentBody.trim()} onClick={handlePostComment}>Post</button>
</div>
{comments.length === 0
? <div style={{ color: 'var(--text-muted)', fontSize: 13 }}>No comments yet.</div>
: comments.map((c, i) => {
? <div className="card-empty-center">No comments</div>
: commentRows.map((c, i) => {
const d = new Date(c.created_at);
const dateStr = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
const timeStr = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
@@ -737,22 +989,6 @@ export default function TaskDetail() {
}
</div>
)}
{activeTab === 'Folder' && company?.name && project?.name && (() => {
const fbInitial = role === 'external'
? `/Projects/${safeName(project.name)}/${safeName(task.title)}`
: role === 'client'
? `/${safeName(company.name)}/Projects/${safeName(project.name)}/${safeName(task.title)}`
: `/Clients/${safeName(company.name)}/Projects/${safeName(project.name)}/${safeName(task.title)}`;
const fbRoot = role === 'external'
? `/Projects/${safeName(project.name)}`
: role === 'client'
? `/${safeName(company.name)}/Projects/${safeName(project.name)}`
: `/Clients/${safeName(company.name)}/Projects/${safeName(project.name)}`;
return <FileBrowser initialPath={fbInitial} rootPath={fbRoot} />;
})()}
{activeTab === 'Folder' && (!company?.name || !project?.name) && (
<div style={{ color: 'var(--text-muted)' }}>No folder available task needs a project and company.</div>
)}
</div>
</div>
</div>
@@ -776,7 +1012,7 @@ export default function TaskDetail() {
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div style={LABEL}>Activity</div>
{activityLog.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No activity yet.</div>
<div className="card-empty-center">No activity</div>
) : (
<div className="scrollbar-thin-theme" style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 10 }}>
{activityLog.map(e => (
@@ -797,36 +1033,11 @@ export default function TaskDetail() {
<textarea rows={4} placeholder="Add notes for this submission…" value={reviewNotes} onChange={e => setReviewNotes(e.target.value)} style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box', resize: 'vertical' }} />
</div>
<div>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Attachments <span style={{ color: 'var(--text-muted)', fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}>(optional)</span></div>
<input ref={reviewFileRef} type="file" multiple id="review-file-input" style={{ display: 'none' }} onChange={e => { setReviewFiles(prev => [...prev, ...Array.from(e.target.files)]); e.target.value = ''; }} />
<div
onDragEnter={e => { e.preventDefault(); reviewDragCounter.current++; setReviewDragging(true); }}
onDragLeave={e => { e.preventDefault(); reviewDragCounter.current--; if (reviewDragCounter.current === 0) setReviewDragging(false); }}
onDragOver={e => e.preventDefault()}
onDrop={e => { e.preventDefault(); reviewDragCounter.current = 0; setReviewDragging(false); setReviewFiles(prev => [...prev, ...Array.from(e.dataTransfer.files)]); }}
onClick={() => reviewFileRef.current?.click()}
style={{ border: `2px dashed ${reviewDragging ? 'var(--accent)' : reviewFiles.length > 0 ? 'var(--accent)' : 'var(--border)'}`, borderRadius: 8, padding: '16px', textAlign: 'center', background: reviewDragging ? 'color-mix(in srgb, var(--accent) 8%, var(--bg))' : 'transparent', transition: 'all 160ms', cursor: 'pointer' }}
>
<div style={{ fontSize: 20, marginBottom: 4 }}>{reviewDragging ? '📂' : '📎'}</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>
{reviewDragging ? 'Drop files here' : reviewFiles.length > 0 ? `${reviewFiles.length} file${reviewFiles.length !== 1 ? 's' : ''} attached — click or drag to add more` : 'Click or drag files here'}
</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 2 }}>Any file type accepted</div>
</div>
{reviewFiles.length > 0 && (
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
{reviewFiles.map((f, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '5px 10px', background: 'var(--card-bg-2)', borderRadius: 6, border: '1px solid var(--border)' }}>
<span style={{ fontSize: 12, color: 'var(--text-primary)' }}>{f.name}</span>
<button type="button" className="btn btn-danger" onClick={() => setReviewFiles(prev => prev.filter((_, j) => j !== i))}>Remove</button>
</div>
))}
</div>
)}
<FileAttachment files={reviewFiles} onChange={setReviewFiles} />
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', paddingTop: 4 }}>
<button className="btn btn-outline" disabled={reviewSaving} onClick={handleConfirmReview}>{reviewSaving ? 'Submitting…' : 'Place in Review'}</button>
<button className="btn btn-outline" disabled={reviewSaving} onClick={() => { setReviewModal(false); setReviewFiles([]); setReviewNotes(''); }}>Cancel</button>
<div className="modal-action-row" style={MODAL_ACTION_ROW}>
<button className="btn btn-outline" style={MODAL_ACTION_BUTTON} disabled={reviewSaving} onClick={handleConfirmReview}>{reviewSaving ? 'Submitting…' : 'Place in Review'}</button>
<button className="btn btn-outline" style={MODAL_ACTION_BUTTON} disabled={reviewSaving} onClick={() => { setReviewModal(false); setReviewFiles([]); setReviewNotes(''); }}>Cancel</button>
</div>
</div>
</div>
@@ -858,9 +1069,9 @@ export default function TaskDetail() {
<div className="form-group">
<FileAttachment files={revisionFiles} onChange={setRevisionFiles} />
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button className="btn btn-outline" disabled={revisionSaving} onClick={() => { setRevisionModal(false); setRevisionForm({ description: '', deadline: '' }); setRevisionFiles([]); }}>Cancel</button>
<button className="btn btn-outline" style={{ color: 'var(--accent)', borderColor: 'var(--accent)' }} disabled={revisionSaving || !revisionForm.description.trim()} onClick={handleSubmitRevision}>
<div className="modal-action-row" style={MODAL_ACTION_ROW}>
<button className="btn btn-outline" style={MODAL_ACTION_BUTTON} disabled={revisionSaving} onClick={() => { setRevisionModal(false); setRevisionForm({ description: '', deadline: '' }); setRevisionFiles([]); }}>Cancel</button>
<button className="btn btn-outline" style={{ ...MODAL_ACTION_BUTTON, color: 'var(--accent)', borderColor: 'var(--accent)' }} disabled={revisionSaving || !revisionForm.description.trim()} onClick={handleSubmitRevision}>
{revisionSaving ? 'Submitting…' : 'Submit Revision'}
</button>
</div>
@@ -868,6 +1079,30 @@ export default function TaskDetail() {
</div>
)}
{rejectModal && (
<div style={popupOverlayStyle} onClick={() => { if (!rejectSaving) { setRejectModal(false); setRejectNote(''); } }}>
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', lineHeight: 1.1 }}>Reject Task</div>
<div>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Rejected Notes</div>
<textarea
rows={5}
placeholder="Tell the team what needs to change before approval…"
value={rejectNote}
onChange={e => setRejectNote(e.target.value)}
style={{ width: '100%', fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '8px 10px', resize: 'vertical', fontFamily: 'inherit', boxSizing: 'border-box' }}
/>
</div>
<div className="modal-action-row" style={MODAL_ACTION_ROW}>
<button className="btn btn-outline" style={MODAL_ACTION_BUTTON} disabled={rejectSaving} onClick={() => { setRejectModal(false); setRejectNote(''); }}>Cancel</button>
<button className="btn btn-danger" style={MODAL_ACTION_BUTTON} disabled={rejectSaving || !rejectNote.trim()} onClick={handleSubmitReject}>
{rejectSaving ? 'Saving…' : 'Reject'}
</button>
</div>
</div>
</div>
)}
{amendModal && (
<div style={popupOverlayStyle} onClick={() => { setAmendModal(false); setAmendForm({ description: '' }); setAmendFiles([]); }}>
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>
@@ -878,35 +1113,11 @@ export default function TaskDetail() {
</div>
<div>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Attach Files</div>
<input ref={amendFileRef} type="file" multiple id="amend-file-input" style={{ display: 'none' }} onChange={e => { setAmendFiles(prev => [...prev, ...Array.from(e.target.files)]); e.target.value = ''; }} />
<div
onDragEnter={e => { e.preventDefault(); amendDragCounter.current++; setAmendDragging(true); }}
onDragLeave={e => { e.preventDefault(); amendDragCounter.current--; if (amendDragCounter.current === 0) setAmendDragging(false); }}
onDragOver={e => e.preventDefault()}
onDrop={e => { e.preventDefault(); amendDragCounter.current = 0; setAmendDragging(false); setAmendFiles(prev => [...prev, ...Array.from(e.dataTransfer.files)]); }}
style={{ border: `2px dashed ${amendDragging ? 'var(--accent)' : amendFiles.length > 0 ? 'var(--accent)' : 'var(--border)'}`, borderRadius: 8, padding: '16px', textAlign: 'center', background: amendDragging ? 'color-mix(in srgb, var(--accent) 8%, var(--bg))' : 'transparent', transition: 'all 160ms', cursor: 'pointer' }}
onClick={() => amendFileRef.current?.click()}
>
<div style={{ fontSize: 20, marginBottom: 4 }}>{amendDragging ? '📂' : '📎'}</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>
{amendDragging ? 'Drop files here' : amendFiles.length > 0 ? `${amendFiles.length} file${amendFiles.length !== 1 ? 's' : ''} attached — click or drag to add more` : 'Click or drag files here'}
</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 2 }}>Any file type accepted</div>
</div>
{amendFiles.length > 0 && (
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
{amendFiles.map((f, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '5px 10px', background: 'var(--card-bg-2)', borderRadius: 6, border: '1px solid var(--border)' }}>
<span style={{ fontSize: 12, color: 'var(--text-primary)' }}>{f.name}</span>
<button type="button" className="btn btn-danger" onClick={() => setAmendFiles(prev => prev.filter((_, idx) => idx !== i))}>Remove</button>
</div>
))}
</div>
)}
<FileAttachment files={amendFiles} onChange={setAmendFiles} />
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', paddingTop: 4 }}>
<button className="btn btn-outline" disabled={amendSaving} onClick={handleSubmitAmendment}>{amendSaving ? 'Saving…' : 'Save'}</button>
<button className="btn btn-outline" disabled={amendSaving} onClick={() => { setAmendModal(false); setAmendForm({ description: '' }); setAmendFiles([]); }}>Cancel</button>
<div className="modal-action-row" style={MODAL_ACTION_ROW}>
<button className="btn btn-outline" style={MODAL_ACTION_BUTTON} disabled={amendSaving} onClick={handleSubmitAmendment}>{amendSaving ? 'Saving…' : 'Save'}</button>
<button className="btn btn-outline" style={MODAL_ACTION_BUTTON} disabled={amendSaving} onClick={() => { setAmendModal(false); setAmendForm({ description: '' }); setAmendFiles([]); }}>Cancel</button>
</div>
</div>
</div>
+238 -135
View File
@@ -1,23 +1,36 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { useState, useEffect, useRef, useMemo } from 'react';
import { Link } from 'react-router-dom';
import Layout from '../components/Layout';
import PageLoader from '../components/PageLoader';
import ProfileAvatar from '../components/ProfileAvatar';
import StatusBadge from '../components/StatusBadge';
import RequestForm from '../components/RequestForm';
import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext';
import { readPageCache, writePageCache } from '../lib/pageCache';
import { withTimeout } from '../lib/withTimeout';
import { getCurrentVersionForTask, getDeadlineSourceSubmission } from '../lib/taskDeadlines';
import { getTaskDerivedState } from '../lib/taskVersions';
import { logActivity } from '../lib/activityLog';
import { fmtShortDate } from '../lib/dates';
import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../lib/requestSubmission';
import { ensureProjectFolder, ensureRequestFolder, uploadRequestFileToSurvey } from '../lib/folderSync';
import { sendEmail } from '../lib/email';
import { uploadFilesToRequestInfo, createTaskFolder } from '../lib/filebrowserFolders';
import SortTh from '../components/SortTh';
import { useSortable } from '../hooks/useSortable';
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
import { useRefetchOnFocus } from '../hooks/useRefetchOnFocus';
import { useRealtimeSubscription } from '../hooks/useRealtimeSubscription';
import { popupOverlayStyle } from '../lib/popupStyles';
import { useLiveRefresh } from '../hooks/useLiveRefresh';
import { resolveScopedWorkIds } from '../lib/workScope';
import { mergeSubmissionDisplayNames } from '../lib/submissionDisplay';
import {
TASK_TABLE_TH_STYLE,
TASK_TABLE_TD_BASE,
TASK_MODAL_TITLE_STYLE,
TASK_MODAL_LABEL_STYLE,
TASK_MODAL_INPUT_STYLE,
TASK_MODAL_BUTTON_STYLE,
TASK_MODAL_SHELL_STYLE,
PROJECT_MODAL_SHELL_STYLE,
} from '../lib/taskUi';
const TASK_STAT_ICONS = {
total: '<rect x="3" y="4" width="18" height="16" rx="2"/><line x1="7" y1="9" x2="17" y2="9"/><line x1="7" y1="13" x2="17" y2="13"/>',
@@ -28,19 +41,11 @@ const TASK_STAT_ICONS = {
done: '<polyline points="4,12 9,17 20,6"/>',
};
const TASK_TABLE_TH_STYLE = { fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.6, color: 'var(--text-muted)', textAlign: 'left', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' };
const TASK_TABLE_TD_BASE = { padding: '5px', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
const TASK_MODAL_TITLE_STYLE = { fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' };
const TASK_MODAL_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 6 };
const TASK_MODAL_INPUT_STYLE = { fontSize: 13, padding: '0 5px', textAlign: 'left', lineHeight: 1 };
const TASK_MODAL_BUTTON_STYLE = { lineHeight: 1, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' };
const TASK_MODAL_SHELL_STYLE = { ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(720px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto' };
const PROJECT_MODAL_SHELL_STYLE = { ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(434px, 100%)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto' };
const TASK_STATUS_SORT_RANK = { not_started: 0, in_progress: 1, on_hold: 2, client_review: 3, client_approved: 4, invoiced: 5, paid: 6 };
function TaskStatCard({ label, value, iconBg, iconColor, iconPath }) {
function TaskStatCard({ label, value, sub, iconBg, iconColor, iconPath }) {
return (
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '14px 21px', minHeight: 86 }}>
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', minHeight: 120 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
@@ -48,6 +53,7 @@ function TaskStatCard({ label, value, iconBg, iconColor, iconPath }) {
</div>
</div>
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 5 }}>{sub}</div>
</div>
);
}
@@ -58,14 +64,17 @@ function TasksStatsRow({ tasks = [] }) {
const onHold = tasks.filter(t => t?.status === 'on_hold').length;
const inReview = tasks.filter(t => t?.status === 'client_review').length;
const completed = tasks.filter(t => ['client_approved', 'invoiced', 'paid'].includes(t?.status)).length;
const total = tasks.length;
const open = Math.max(total - completed, 0);
const pct = (count) => total > 0 ? `${Math.round((count / total) * 100)}% of total` : '0% of total';
return (
<div className="dash-stat-grid" style={{ gridTemplateColumns: '1fr 1fr 1fr 1fr 1fr 1fr' }}>
<TaskStatCard label="To Do" value={toDo} iconBg="rgba(167,139,250,0.15)" iconColor="#a78bfa" iconPath={TASK_STAT_ICONS.todo} />
<TaskStatCard label="In Progress" value={inProgress} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={TASK_STAT_ICONS.progress} />
<TaskStatCard label="On Hold" value={onHold} iconBg="rgba(239,68,68,0.15)" iconColor="#ef4444" iconPath={TASK_STAT_ICONS.hold} />
<TaskStatCard label="In Review" value={inReview} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={TASK_STAT_ICONS.review} />
<TaskStatCard label="Completed" value={completed} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={TASK_STAT_ICONS.done} />
<TaskStatCard label="Total Tasks" value={tasks.length} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={TASK_STAT_ICONS.total} />
<TaskStatCard label="To Do" value={toDo} sub={pct(toDo)} iconBg="rgba(167,139,250,0.15)" iconColor="#a78bfa" iconPath={TASK_STAT_ICONS.todo} />
<TaskStatCard label="In Progress" value={inProgress} sub={pct(inProgress)} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={TASK_STAT_ICONS.progress} />
<TaskStatCard label="On Hold" value={onHold} sub={pct(onHold)} iconBg="rgba(239,68,68,0.15)" iconColor="#ef4444" iconPath={TASK_STAT_ICONS.hold} />
<TaskStatCard label="In Review" value={inReview} sub={pct(inReview)} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={TASK_STAT_ICONS.review} />
<TaskStatCard label="Completed" value={completed} sub={pct(completed)} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={TASK_STAT_ICONS.done} />
<TaskStatCard label="Total Tasks" value={total} sub={`${open} still open`} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={TASK_STAT_ICONS.total} />
</div>
);
}
@@ -80,7 +89,7 @@ function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, c
};
const completedStatuses = new Set(['completed', 'cancelled', 'archived', 'done']);
const projectTabs = [
{ id: 'all', label: 'All Projects' },
{ id: 'all', label: 'All' },
{ id: 'active', label: 'Active' },
{ id: 'completed', label: 'Completed' },
];
@@ -89,6 +98,11 @@ function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, c
const done = completedStatuses.has((p?.status || '').toLowerCase());
return projTab === 'completed' ? done : !done;
});
const projectTabCounts = {
all: projects.length,
active: projects.filter((p) => !completedStatuses.has((p?.status || '').toLowerCase())).length,
completed: projects.filter((p) => completedStatuses.has((p?.status || '').toLowerCase())).length,
};
const sortedProjects = [...visibleProjects].sort((a, b) => {
if (projSortKey === 'status') {
const ra = completedStatuses.has((a.status || '').toLowerCase()) ? 1 : 0;
@@ -119,7 +133,14 @@ function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, c
<div style={{ flex: '0 0 calc(30% - 24px)', minWidth: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10, flexShrink: 0 }}>
{projectTabs.map(t => (
<button key={t.id} onClick={() => setProjTab(t.id)} className={`section-tab-btn${projTab === t.id ? ' is-active' : ''}`}>{t.label}</button>
<button key={t.id} onClick={() => setProjTab(t.id)} className={`section-tab-btn${projTab === t.id ? ' is-active' : ''}`}>
{t.label}
{projectTabCounts[t.id] > 0 && (
<span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: projTab === t.id ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}>
{projectTabCounts[t.id]}
</span>
)}
</button>
))}
{onAddProject && (
<div style={{ marginLeft: 'auto' }}>
@@ -128,39 +149,41 @@ function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, c
)}
</div>
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', height: '100%', boxSizing: 'border-box', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '60%' }} />
<col style={{ width: '40%' }} />
</colgroup>
<thead>
<tr>
<SortTh col="name" sortKey={projSortKey} sortDir={projSortDir} onSort={toggleProjSort} style={TASK_TABLE_TH_STYLE}>Name</SortTh>
<SortTh col="status" sortKey={projSortKey} sortDir={projSortDir} onSort={toggleProjSort} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Status</SortTh>
</tr>
</thead>
<tbody>
{sortedProjects.length === 0 ? (
<tr><td colSpan={2} style={{ fontSize: 13, color: 'var(--text-muted)', padding: '28px 5px', border: 'none', background: 'transparent', textAlign: 'center' }}>No projects.</td></tr>
) : sortedProjects.map(p => (
<tr key={p.id}>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)', textAlign: 'left' }}>
<Link to={`/projects/${p.id}`} className="table-link">{p.name}</Link>
</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-muted)', textAlign: 'center' }}>
<Link to={`/projects/${p.id}`} className="table-link" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6, width: '100%' }}>
<span style={{ width: 68, height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden', position: 'relative', flexShrink: 0 }}>
<span style={{ position: 'absolute', inset: 0, width: `${projectCompletionPct(p)}%`, background: 'var(--accent)', borderRadius: 2 }} />
</span>
<span style={{ minWidth: 28, textAlign: 'right', fontSize: 12, color: 'var(--text-secondary)' }}>{projectCompletionPct(p)}%</span>
</Link>
</td>
{sortedProjects.length === 0 ? (
<div className="card-empty-center">No projects</div>
) : (
<div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '60%' }} />
<col style={{ width: '40%' }} />
</colgroup>
<thead>
<tr>
<SortTh col="name" sortKey={projSortKey} sortDir={projSortDir} onSort={toggleProjSort} style={TASK_TABLE_TH_STYLE}>Name</SortTh>
<SortTh col="status" sortKey={projSortKey} sortDir={projSortDir} onSort={toggleProjSort} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Status</SortTh>
</tr>
))}
</tbody>
</table>
</div>
</thead>
<tbody>
{sortedProjects.map(p => (
<tr key={p.id}>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)', textAlign: 'left' }}>
<Link to={`/projects/${p.id}`} className="table-link">{p.name}</Link>
</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-muted)', textAlign: 'center' }}>
<Link to={`/projects/${p.id}`} className="table-link" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6, width: '100%' }}>
<span style={{ width: 68, height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden', position: 'relative', flexShrink: 0 }}>
<span style={{ position: 'absolute', inset: 0, width: `${projectCompletionPct(p)}%`, background: 'var(--accent)', borderRadius: 2 }} />
</span>
<span style={{ minWidth: 28, textAlign: 'right', fontSize: 12, color: 'var(--text-secondary)' }}>{projectCompletionPct(p)}%</span>
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
@@ -174,10 +197,7 @@ export default function RequestsPage() {
const isExternal = currentUser?.role === 'external';
const isClient = currentUser?.role === 'client';
const [refreshKey, setRefreshKey] = useState(0);
const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
useRefetchOnFocus(refresh);
useRealtimeSubscription(['tasks', 'projects', 'submissions'], refresh);
const { refreshKey } = useLiveRefresh(['tasks', 'projects', 'submissions']);
const teamCached = isTeam ? readPageCache('team_requests') : null;
const extCached = isExternal ? readPageCache(`ext-requests:${currentUser?.id}`, 3 * 60_000) : null;
@@ -185,6 +205,7 @@ export default function RequestsPage() {
const [projects, setProjects] = useState(() => teamCached?.projects || extCached?.projects || []);
const [tasks, setTasks] = useState(() => teamCached?.tasks || extCached?.tasks || []);
const [submissions, setSubmissions] = useState(() => teamCached?.submissions || extCached?.submissions || []);
const [deliveries, setDeliveries] = useState([]);
const [companies, setCompanies] = useState(() => teamCached?.companies || []);
const [loading, setLoading] = useState(() => {
if (isTeam) return !teamCached;
@@ -193,7 +214,7 @@ export default function RequestsPage() {
});
const [error, setError] = useState('');
const [loadError, setLoadError] = useState(false);
const [activeTab, setActiveTab] = useState('not_started');
const [activeTab, setActiveTab] = useState('new_requests');
const [filterCompany, setFilterCompany] = useState('');
const [filterProject, setFilterProject] = useState('');
const { sortKey, sortDir, toggle, sort } = useSortable('status', 'asc');
@@ -238,37 +259,11 @@ export default function RequestsPage() {
try {
setError(''); setLoadError(false);
let scopedProjectIds = null;
let scopedTaskIds = null;
if (isClient) {
const clientCos = currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : []);
const companyIds = clientCos.map(c => c.id).filter(Boolean);
if (companyIds.length > 0) {
const { data: projRows } = await withTimeout(
supabase.from('projects').select('id, tasks(id)').in('company_id', companyIds), 10000, 'Client project scope'
);
scopedProjectIds = (projRows || []).map(p => p.id);
scopedTaskIds = (projRows || []).flatMap(p => (p.tasks || []).map(t => t.id));
} else {
scopedProjectIds = []; scopedTaskIds = [];
}
}
if (isExternal) {
const { data: memberData } = await withTimeout(
supabase.from('project_members').select('project_id').eq('profile_id', currentUser.id), 10000, 'External project scope'
);
scopedProjectIds = (memberData || []).map(r => r.project_id).filter(Boolean);
if (scopedProjectIds.length > 0) {
const { data: taskRows } = await withTimeout(
supabase.from('tasks').select('id').in('project_id', scopedProjectIds), 10000, 'External task scope'
);
scopedTaskIds = (taskRows || []).map(r => r.id).filter(Boolean);
} else {
scopedTaskIds = [];
}
}
const { scopedProjectIds, scopedTaskIds } = await withTimeout(
resolveScopedWorkIds(currentUser, { isClient, isExternal }),
10000,
'Tasks scope'
);
const tasksQ = supabase.from('tasks')
.select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at, submitted_at, assignee:profiles!assigned_to(avatar_url)')
@@ -298,12 +293,38 @@ export default function RequestsPage() {
);
if (cancelled) return;
setProjects(p || []); setTasks(t || []); setSubmissions(subs || []); setCompanies(co || []);
const allSubs = subs || [];
const submitterIds = [...new Set(allSubs.map((submission) => submission.submitted_by).filter(Boolean))];
let submitterProfiles = [];
if (submitterIds.length > 0) {
const { data: profileRows } = await withTimeout(
supabase.from('profiles').select('id, name').in('id', submitterIds),
10000,
'Tasks submitter profiles load'
);
submitterProfiles = profileRows || [];
}
const hydratedSubs = mergeSubmissionDisplayNames(allSubs, submitterProfiles);
let deliveryRows = [];
if (hydratedSubs.length > 0) {
const { data: delRows } = await withTimeout(
supabase.from('deliveries').select('id, submission_id, version_number, sent_at').in('submission_id', hydratedSubs.map(sub => sub.id)),
10000,
'Tasks deliveries load'
);
deliveryRows = delRows || [];
}
setProjects(p || []);
setTasks(t || []);
setSubmissions(hydratedSubs);
setDeliveries(deliveryRows);
setCompanies(co || []);
if (isTeam) {
writePageCache('team_requests', { submissions: subs || [], tasks: t || [], projects: p || [], companies: co || [] });
writePageCache('team_requests', { submissions: hydratedSubs, tasks: t || [], projects: p || [], companies: co || [] });
} else if (isExternal) {
writePageCache(`ext-requests:${currentUser.id}`, { projects: p || [], tasks: t || [], submissions: subs || [] });
writePageCache(`ext-requests:${currentUser.id}`, { projects: p || [], tasks: t || [], submissions: hydratedSubs });
}
} catch (err) {
console.error('Tasks page load failed:', err);
@@ -317,7 +338,7 @@ export default function RequestsPage() {
return () => { cancelled = true; };
}, [isTeam, isExternal, isClient, currentUser?.id, refreshKey]); // eslint-disable-line react-hooks/exhaustive-deps
const handleAddRequest = async (formData, _files, existingProjects) => {
const handleAddRequest = async (formData, files, existingProjects) => {
if (addSaving) return;
setAddSaving(true); setAddError('');
try {
@@ -326,7 +347,6 @@ export default function RequestsPage() {
const { task } = await createTaskForRequest({ projectId: resolvedProject.id, title: formData.title.trim(), requestKey: addRequestKey });
if (!task) throw new Error('Failed to create task.');
const taskCompany = companies?.find(c => c.id === formData.companyId);
createTaskFolder(taskCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
const { submission: sub } = await createInitialSubmissionForRequest({
taskId: task.id, requestKey: addRequestKey, isHot: formData.isHot,
serviceType: formData.serviceType, signFamily: formData.signFamily,
@@ -335,12 +355,67 @@ export default function RequestsPage() {
description: formData.description, submittedBy: formData.requestedBy, submittedByName: formData.requestedByName,
});
if (!sub) throw new Error('Failed to create submission.');
try {
await ensureRequestFolder({ projectId: resolvedProject.id, taskTitle: formData.title.trim() });
} catch (folderError) {
console.error('Request folder sync failed:', folderError);
}
if (files.length > 0) {
const taskCompany = companies?.find(c => c.id === formData.companyId);
setShowAddForm(false);
setUploadProgress({ active: true, label: 'Uploading files…', percent: 0 });
for (let fi = 0; fi < files.length; fi++) {
const file = files[fi];
setUploadProgress({ active: true, label: file.name, percent: Math.round((fi / files.length) * 100) });
const path = `${task.id}/Survey/${Date.now()}_${file.name}`;
const { data: uploaded, error: uploadError } = await supabase.storage.from('submissions').upload(path, file);
if (uploadError) {
await supabase.from('tasks').delete().eq('id', task.id);
setUploadProgress({ active: false, label: '', percent: 0 });
throw new Error(`Upload failed: ${uploadError.message}`);
}
if (uploaded) {
const { error: fileErr } = await supabase.from('submission_files').insert({ submission_id: sub.id, name: file.name, storage_path: path, size: file.size });
if (fileErr) {
await supabase.from('tasks').delete().eq('id', task.id);
setUploadProgress({ active: false, label: '', percent: 0 });
throw new Error(`File record failed: ${fileErr.message}`);
}
try {
await uploadRequestFileToSurvey({
projectId: resolvedProject.id,
taskTitle: formData.title.trim(),
storagePath: path,
fileName: file.name,
});
} catch (mirrorError) {
console.error('Survey folder mirror failed:', mirrorError);
}
}
}
setUploadProgress({ active: false, label: '', percent: 0 });
}
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'request_submitted', taskId: task.id, taskTitle: task.title, projectId: resolvedProject.id, projectName: resolvedProject.name }).catch(() => {});
sendEmail('new_request', 'hello@fourgebranding.com', {
clientName: formData.requestedByName || currentUser.name,
clientEmail: currentUser.email || 'hello@fourgebranding.com',
company: taskCompany?.name || '',
serviceType: formData.serviceType,
projectName: resolvedProject.name,
projectId: resolvedProject.id,
deadline: formData.deadline,
description: formData.description,
taskId: task.id,
}).catch(() => {});
const [{ data: newSubs }, { data: newTasks }] = await Promise.all([
supabase.from('submissions').select('id, task_id, submitted_at, submitted_by, submitted_by_name, is_hot, service_type, deadline, version_number, type').order('submitted_at', { ascending: false }),
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at, submitted_at, assignee:profiles!assigned_to(avatar_url)').order('submitted_at', { ascending: false }),
]);
setSubmissions(newSubs || []); setTasks(newTasks || []);
const submitterIds = [...new Set((newSubs || []).map((submission) => submission.submitted_by).filter(Boolean))];
const { data: submitterProfiles } = submitterIds.length > 0
? await supabase.from('profiles').select('id, name').in('id', submitterIds)
: { data: [] };
setSubmissions(mergeSubmissionDisplayNames(newSubs || [], submitterProfiles || [])); setTasks(newTasks || []);
setShowAddForm(false); setAddFormKey(k => k + 1); setAddRequestKey(crypto.randomUUID());
} catch (err) { setAddError(err.message); }
finally { setAddSaving(false); }
@@ -355,7 +430,6 @@ export default function RequestsPage() {
const resolvedProject = await findOrCreateProject(formData.companyId, formData.project.trim(), existingProjects);
const { task } = await createTaskForRequest({ projectId: resolvedProject.id, title: formData.title.trim(), requestKey: addRequestKey });
if (!task) throw new Error('Failed to create task.');
createTaskFolder(selectedCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
const { submission } = await createInitialSubmissionForRequest({
taskId: task.id, requestKey: addRequestKey, isHot: formData.isHot,
serviceType: formData.serviceType, signFamily: formData.signFamily,
@@ -363,28 +437,42 @@ export default function RequestsPage() {
deadline: formData.deadline,
description: formData.description, submittedBy: currentUser.id, submittedByName: currentUser.name,
});
try {
await ensureRequestFolder({ projectId: resolvedProject.id, taskTitle: formData.title.trim() });
} catch (folderError) {
console.error('Request folder sync failed:', folderError);
}
if (submission && files.length > 0) {
setShowAddForm(false);
setUploadProgress({ active: true, label: 'Uploading files…', percent: 0 });
for (let fi = 0; fi < files.length; fi++) {
const file = files[fi];
setUploadProgress({ active: true, label: file.name, percent: Math.round((fi / files.length) * 100) });
const path = `${task.id}/${Date.now()}_${file.name}`;
const path = `${task.id}/Survey/${Date.now()}_${file.name}`;
const { data: uploaded, error: uploadError } = await supabase.storage.from('submissions').upload(path, file);
if (uploadError) { await supabase.from('tasks').delete().eq('id', task.id); setUploadProgress({ active: false, label: '', percent: 0 }); throw new Error(`Upload failed: ${uploadError.message}`); }
if (uploaded) {
const { error: fileErr } = await supabase.from('submission_files').insert({ submission_id: submission.id, name: file.name, storage_path: path, size: file.size });
if (fileErr) { await supabase.from('tasks').delete().eq('id', task.id); setUploadProgress({ active: false, label: '', percent: 0 }); throw new Error(`File record failed: ${fileErr.message}`); }
try {
await uploadRequestFileToSurvey({
projectId: resolvedProject.id,
taskTitle: formData.title.trim(),
storagePath: path,
fileName: file.name,
});
} catch (mirrorError) {
console.error('Survey folder mirror failed:', mirrorError);
}
}
}
uploadFilesToRequestInfo(files, selectedCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
setUploadProgress({ active: false, label: '', percent: 0 });
}
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'request_submitted', taskId: task.id, taskTitle: task.title, projectId: resolvedProject.id, projectName: resolvedProject.name }).catch(() => {});
sendEmail('new_request', 'hello@fourgebranding.com', {
clientName: currentUser.name, clientEmail: currentUser.email,
company: selectedCompany?.name || '', serviceType: formData.serviceType,
projectName: formData.project, deadline: formData.deadline,
projectName: formData.project, projectId: resolvedProject.id, deadline: formData.deadline,
description: formData.description, taskId: task.id,
}).catch(() => {});
const refreshCompanyIds = clientCos.map(c => c.id).filter(Boolean);
@@ -399,7 +487,11 @@ export default function RequestsPage() {
.select('id, task_id, submitted_at, submitted_by, submitted_by_name, is_hot, service_type, deadline, version_number, type')
.in('task_id', refreshTaskIds.length > 0 ? refreshTaskIds : ['__none__'])
.order('submitted_at', { ascending: false });
setProjects(refreshProjects || []); setTasks(newTasks || []); setSubmissions(newSubs || []);
const submitterIds = [...new Set((newSubs || []).map((submission) => submission.submitted_by).filter(Boolean))];
const { data: submitterProfiles } = submitterIds.length > 0
? await supabase.from('profiles').select('id, name').in('id', submitterIds)
: { data: [] };
setProjects(refreshProjects || []); setTasks(newTasks || []); setSubmissions(mergeSubmissionDisplayNames(newSubs || [], submitterProfiles || []));
setShowAddForm(false); setAddFormKey(k => k + 1); setAddRequestKey(crypto.randomUUID());
} catch (err) { setAddError(err.message); }
finally { setAddSaving(false); }
@@ -417,6 +509,11 @@ export default function RequestsPage() {
.from('projects').insert({ name, company_id: addProjectForm.companyId, status: 'active' })
.select('id, name, status, company_id, company:companies(id, name)').single();
if (insertError) throw insertError;
try {
await ensureProjectFolder({ projectId: newProject.id });
} catch (folderError) {
console.error('Project folder sync failed:', folderError);
}
setProjects(prev => [...prev, newProject]);
setShowAddProjectForm(false); setAddProjectForm({ name: '', companyId: '' });
} catch (err) { setAddProjectError(err.message || 'Failed to create project.'); }
@@ -436,37 +533,29 @@ export default function RequestsPage() {
const allRows = useMemo(() => {
if (isClient) {
return tasks.map(task => {
const sub = submissions.find(s => s.task_id === task.id && s.type === 'initial');
const derived = getTaskDerivedState(task, submissions, deliveries);
return {
id: task.id, title: task.title, status: task.status, projectId: task.project_id,
serviceType: sub?.service_type || '—', deadline: sub?.deadline || null,
version: task.current_version || 0, isHot: false,
serviceType: derived.serviceType, deadline: derived.initialSubmission?.deadline || derived.deadline || null,
version: derived.currentVersion, isHot: false,
assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null, assignedTo: task.assigned_to || null,
submittedAt: task.submitted_at ? new Date(task.submitted_at).getTime() : 0,
submittedAt: derived.latestActivityAt,
};
});
}
return tasks.map(task => {
const taskSubs = submissions.filter(s => s.task_id === task.id);
const deadlineSource = getDeadlineSourceSubmission(task, taskSubs);
if (!deadlineSource) return null;
const currentVersion = getCurrentVersionForTask(task, taskSubs);
const latestGroup = taskSubs.filter(s => s.version_number === currentVersion);
const serviceType = submissions.find(s => s.task_id === task.id && s.type === 'initial')?.service_type
|| submissions.find(s => s.task_id === task.id && s.service_type)?.service_type
|| deadlineSource.service_type || '—';
const derived = getTaskDerivedState(task, submissions, deliveries);
if (derived.visibleTaskSubs.length === 0 || !derived.deadlineSource) return null;
return {
id: task.id, title: task.title, status: task.status, projectId: task.project_id,
serviceType, deadline: deadlineSource.deadline,
version: deadlineSource.version_number ?? 0,
isHot: deadlineSource.is_hot || false,
serviceType: derived.serviceType, deadline: derived.deadline,
version: derived.currentVersion,
isHot: derived.isHot,
assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null, assignedTo: task.assigned_to || null,
submittedAt: latestGroup.length > 0
? Math.max(...latestGroup.map(s => new Date(s.submitted_at).getTime()))
: (task.submitted_at ? new Date(task.submitted_at).getTime() : 0),
submittedAt: derived.latestActivityAt,
};
}).filter(Boolean);
}, [tasks, submissions, isClient]); // eslint-disable-line react-hooks/exhaustive-deps
}, [tasks, submissions, deliveries, isClient]); // eslint-disable-line react-hooks/exhaustive-deps
const filteredRows = useMemo(() => {
return allRows
@@ -482,16 +571,29 @@ export default function RequestsPage() {
const doneStatuses = new Set(['client_approved', 'invoiced', 'paid']);
const tabs = [
{ id: 'all', label: 'All Tasks' },
{ id: 'not_started', label: 'To Do' },
{ id: 'all', label: 'All' },
{ 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' },
];
const tabRows = activeTab === 'all' ? filteredRows
const tabCounts = {
all: filteredRows.length,
new_requests: filteredRows.filter(r => r.status === 'not_started' && Number(r.version || 0) === 0).length,
revisions: filteredRows.filter(r => r.status === 'not_started' && Number(r.version || 0) >= 1).length,
in_progress: filteredRows.filter(r => r.status === 'in_progress').length,
on_hold: filteredRows.filter(r => r.status === 'on_hold').length,
client_review: filteredRows.filter(r => r.status === 'client_review').length,
completed: filteredRows.filter(r => doneStatuses.has(r.status)).length,
};
const tabRows = activeTab === 'all' ? filteredRows
: activeTab === 'completed' ? filteredRows.filter(r => doneStatuses.has(r.status))
: activeTab === 'new_requests' ? filteredRows.filter(r => r.status === 'not_started' && Number(r.version || 0) === 0)
: activeTab === 'revisions' ? filteredRows.filter(r => r.status === 'not_started' && Number(r.version || 0) >= 1)
: filteredRows.filter(r => r.status === activeTab);
const sortedRows = sort(tabRows, (row, key) => {
@@ -517,7 +619,6 @@ export default function RequestsPage() {
const renderRow = (row) => {
const project = projects.find(p => p.id === row.projectId);
const initials = row.assignedName ? row.assignedName.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase() : null;
return (
<tr key={row.id}>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>
@@ -531,14 +632,11 @@ export default function RequestsPage() {
</td>
<td style={{ ...TASK_TABLE_TD_BASE, textAlign: 'center' }}>
{(() => {
const avatarStyle = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: '50%', overflow: 'hidden', verticalAlign: 'middle', flexShrink: 0 };
if (row.assignedName && row.assignedTo) {
const portrait = row.assigneeAvatar
? <div title={row.assignedName} style={avatarStyle}><img src={row.assigneeAvatar} alt={row.assignedName} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div>
: <div title={row.assignedName} style={{ ...avatarStyle, background: 'var(--accent)' }}><span style={{ fontSize: 10, fontWeight: 600, color: '#000', lineHeight: 1 }}>{initials}</span></div>;
const portrait = <ProfileAvatar name={row.assignedName} avatarUrl={row.assigneeAvatar} size={26} fontSize={10} />;
return <Link to={`/profile/${row.assignedTo}`} style={{ display: 'inline-flex' }}>{portrait}</Link>;
}
return <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>;
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 style={{ ...TASK_TABLE_TD_BASE, fontSize: 11, textAlign: 'center', color: row.isHot ? '#ef4444' : 'var(--text-primary)', textTransform: 'uppercase', letterSpacing: 0.5 }}>
@@ -554,7 +652,7 @@ export default function RequestsPage() {
);
};
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
if (loading) return <Layout><PageLoader /></Layout>;
if (loadError) return <Layout><div style={{ padding: 24 }}><p style={{ color: 'var(--text-muted)', marginBottom: 12 }}>Failed to load. Check your connection and try again.</p><button className="btn btn-outline" onClick={() => window.location.reload()}>Retry</button></div></Layout>;
const filteredStatTasks = filteredRows.map(r => tasks.find(t => t.id === r.id)).filter(Boolean);
@@ -590,7 +688,7 @@ export default function RequestsPage() {
<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>
{addProjectError && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>{addProjectError}</div>}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 20 }}>
<div className="modal-action-row" style={{ marginTop: 20 }}>
<button type="submit" className="btn btn-outline" style={TASK_MODAL_BUTTON_STYLE} disabled={addProjectSaving}>{addProjectSaving ? 'Saving...' : 'Save'}</button>
<button type="button" className="btn btn-outline" style={TASK_MODAL_BUTTON_STYLE} onClick={() => { setShowAddProjectForm(false); setAddProjectForm({ name: '', companyId: '' }); setAddProjectError(''); }}>Cancel</button>
</div>
@@ -627,6 +725,11 @@ export default function RequestsPage() {
{tabs.map(t => (
<button key={t.id} onClick={() => setActiveTab(t.id)} className={`section-tab-btn${activeTab === t.id ? ' is-active' : ''}`}>
{t.label}
{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' }}>
{tabCounts[t.id]}
</span>
)}
</button>
))}
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }} ref={companyFilterMenuRef}>
@@ -692,9 +795,9 @@ export default function RequestsPage() {
{/* Task table */}
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', padding: '18px 21px' }}>
{allRows.length === 0 ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No tasks yet.</div>
<div className="card-empty-center">No tasks</div>
) : sortedRows.length === 0 ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No tasks.</div>
<div className="card-empty-center">No tasks</div>
) : (
<div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head table-no-row-hover" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
+356 -124
View File
@@ -1,169 +1,401 @@
import { useState, useEffect, useMemo } from 'react';
import { useState, useEffect, useMemo, useRef } from 'react';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { Link } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import LoadingButton from '../../components/LoadingButton';
import SortTh from '../../components/SortTh';
import StatusBadge from '../../components/StatusBadge';
import { supabase } from '../../lib/supabase';
import { generateInvoicePDF } from '../../lib/invoice';
import { generateInvoicePDF, generateReceiptPDF } from '../../lib/invoice';
import { useAuth } from '../../context/AuthContext';
import { useSortable } from '../../hooks/useSortable';
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
const invoiceStatusLabel = (status) => {
if (status === 'sent') return 'Invoiced';
return status ? status.charAt(0).toUpperCase() + status.slice(1) : '—';
};
function ClientFinanceStatCard({ label, value, sub, iconBg, iconColor, iconPath }) {
return (
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', minHeight: 120 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: iconPath }} />
</div>
</div>
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
{sub ? <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 5 }}>{sub}</div> : null}
</div>
);
}
function ClientFinanceTooltip({ active, payload, label, year }) {
if (!active || !payload?.length) return null;
return (
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '10px 14px', fontSize: 12 }}>
<div style={{ fontWeight: 600, marginBottom: 6, color: 'var(--text-primary)' }}>{label} {year}</div>
{payload.map(p => (
<div key={p.dataKey} style={{ color: p.color, marginBottom: 2 }}>{p.name}: <strong>${Number(p.value).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</strong></div>
))}
</div>
);
}
function ClientInvoiceModal({ invoice, onClose }) {
const navigate = useNavigate();
const [downloading, setDownloading] = useState('');
if (!invoice) return null;
const items = invoice.items || [];
const company = invoice.company || null;
const isOverdue = invoice.status !== 'paid' && invoice.due_date && new Date(invoice.due_date) < new Date();
const handleDownloadInvoice = async () => {
if (downloading) return;
setDownloading('invoice');
try {
await generateInvoicePDF(invoice, company, items);
} finally {
setDownloading('');
}
};
const handleDownloadReceipt = async () => {
if (downloading) return;
setDownloading('receipt');
try {
await generateReceiptPDF(invoice, company, items);
} finally {
setDownloading('');
}
};
const openPay = () => {
onClose?.();
navigate(`/pay/${encodeURIComponent(invoice.invoice_number)}`);
};
const sortedItems = [...items].sort((a, b) => {
const aOrder = Number(a.sort_order ?? 0);
const bOrder = Number(b.sort_order ?? 0);
return aOrder - bOrder;
});
return (
<div style={popupOverlayStyle} onClick={onClose}>
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', maxWidth: 1180, maxHeight: '86vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, paddingBottom: 18, marginBottom: 18, borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
<div>
<div style={{ fontSize: 28, fontWeight: 500, lineHeight: 1.2 }}>{invoice.invoice_number}</div>
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: 4 }}>
{company?.id ? (
<Link to={`/company/${company.id}`} className="dashboard-inline-link" onClick={onClose}>
{company.name}
</Link>
) : (
company?.name || invoice.bill_to || 'Invoice'
)}
</div>
</div>
<div className="modal-action-row" style={{ alignItems: 'center' }}>
<StatusBadge
status={statusColor[invoice.status] || 'not_started'}
label={`${invoiceStatusLabel(invoice.status)}${isOverdue ? ' · Overdue' : ''}`}
/>
{invoice.status === 'sent' && (
<button className="btn btn-outline" onClick={openPay}>Pay Invoice</button>
)}
<LoadingButton className="btn btn-outline" loading={downloading === 'invoice'} disabled={Boolean(downloading)} loadingText="Generating…" onClick={handleDownloadInvoice}>
Download Invoice
</LoadingButton>
{invoice.status === 'paid' && (
<LoadingButton className="btn btn-outline" loading={downloading === 'receipt'} disabled={Boolean(downloading)} loadingText="Generating…" onClick={handleDownloadReceipt}>
Download Receipt
</LoadingButton>
)}
<button className="btn btn-outline" onClick={onClose}>Close</button>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)', gap: 24, marginBottom: 24, flexShrink: 0 }}>
<div className="card" style={{ margin: 0 }}>
<div className="card-title">Bill To</div>
<div style={{ fontSize: 15, fontWeight: 400 }}>{invoice.bill_to || company?.name || '—'}</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 4 }}>
{company?.id ? (
<Link to={`/company/${company.id}`} className="dashboard-inline-link" onClick={onClose}>
{company.name}
</Link>
) : (
company?.name || 'Fourge Branding Client Invoice'
)}
</div>
</div>
<div className="card" style={{ margin: 0 }}>
<div className="card-title">Invoice Details</div>
<div className="detail-grid" style={{ marginBottom: 0 }}>
<div className="detail-item"><label>Invoice Date</label><p>{invoice.invoice_date ? new Date(invoice.invoice_date).toLocaleDateString() : '—'}</p></div>
<div className="detail-item"><label>Due Date</label><p style={{ color: isOverdue ? 'var(--danger)' : 'inherit' }}>{invoice.due_date ? new Date(invoice.due_date).toLocaleDateString() : '—'}</p></div>
<div className="detail-item"><label>Terms</label><p>Net 30</p></div>
<div className="detail-item"><label>Status</label><p><StatusBadge status={statusColor[invoice.status] || 'not_started'} label={invoiceStatusLabel(invoice.status)} /></p></div>
<div className="detail-item"><label>Total</label><p style={{ fontSize: 18, fontWeight: 400, color: 'var(--accent)' }}>${Number(invoice.total || 0).toFixed(2)}</p></div>
{invoice.paid_at && <div className="detail-item"><label>Paid On</label><p style={{ color: 'var(--success, #16a34a)', fontWeight: 400 }}>{new Date(invoice.paid_at).toLocaleDateString()}</p></div>}
</div>
</div>
</div>
<div className="card" style={{ margin: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div className="card-title">Line Items</div>
<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' }}>
<colgroup>
<col style={{ width: '12%' }} />
<col style={{ width: '46%' }} />
<col style={{ width: '14%' }} />
<col style={{ width: '14%' }} />
<col style={{ width: '14%' }} />
</colgroup>
<thead>
<tr>
<th>Type</th>
<th>Description</th>
<th style={{ textAlign: 'center' }}>Qty</th>
<th style={{ textAlign: 'right' }}>Unit Price</th>
<th style={{ textAlign: 'right' }}>Total</th>
</tr>
</thead>
<tbody>
{sortedItems.map(item => (
<tr key={item.id}>
<td style={{ padding: '5px 0' }}>
<span className={`badge ${item.submission_id ? 'badge-client_revision' : 'badge-initial'}`}>
{item.submission_id ? 'Revision' : 'New'}
</span>
</td>
<td style={{ padding: '5px 0', fontSize: 13 }}>{item.description}</td>
<td style={{ padding: '5px 0', fontSize: 13, textAlign: 'center' }}>{item.quantity}</td>
<td style={{ padding: '5px 0', fontSize: 13, textAlign: 'right' }}>${Number(item.unit_price || 0).toFixed(2)}</td>
<td style={{ padding: '5px 0', fontSize: 13, textAlign: 'right', fontWeight: 400 }}>${(Number(item.quantity || 0) * Number(item.unit_price || 0)).toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', paddingTop: 16, marginTop: 12, borderTop: '1px solid var(--border)', flexShrink: 0 }}>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
<div style={{ fontSize: 24, fontWeight: 400, color: 'var(--accent)' }}>${Number(invoice.total || 0).toFixed(2)}</div>
</div>
</div>
</div>
{invoice.notes ? (
<div className="card" style={{ margin: '24px 0 0 0', flexShrink: 0 }}>
<div className="card-title">Notes</div>
<p style={{ fontSize: 14, color: 'var(--text-secondary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{invoice.notes}</p>
</div>
) : null}
</div>
</div>
);
}
export default function MyInvoices() {
const { currentUser } = useAuth();
const companies = (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : [])).slice().sort((a, b) => a.name.localeCompare(b.name));
const [activeCompanyId, setActiveCompanyId] = useState(companies[0]?.id || null);
const companyIds = (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : []))
.map(company => company?.id)
.filter(Boolean);
const [invoices, setInvoices] = useState([]);
const [loading, setLoading] = useState(true);
const [generatingInvoiceId, setGeneratingInvoiceId] = useState('');
const { sortKey, sortDir, toggle, sort } = useSortable('invoice_date');
const [activeInvoice, setActiveInvoice] = useState(null);
const [yearFilter, setYearFilter] = useState(String(new Date().getFullYear()));
const [companyFilter, setCompanyFilter] = useState('all');
const [filterMenuOpen, setFilterMenuOpen] = useState(false);
const filterMenuRef = useRef(null);
const { sortKey, sortDir, toggle, sort } = useSortable('invoice_date', 'desc');
useEffect(() => {
async function load() {
if (companyIds.length === 0) {
setInvoices([]);
setLoading(false);
return;
}
const { data } = await supabase
.from('invoices')
.select('*, company:companies(name), items:invoice_items(*)')
.select('*, company:companies(id, name), items:invoice_items(*)')
.in('company_id', companyIds)
.order('created_at', { ascending: false });
setInvoices((data || []).filter(inv => inv.status !== 'draft'));
setLoading(false);
}
load();
}, []);
}, [currentUser?.id]); // eslint-disable-line react-hooks/exhaustive-deps
const handleDownload = async (invoice) => {
if (generatingInvoiceId) return;
setGeneratingInvoiceId(invoice.id);
try {
await generateInvoicePDF(invoice, invoice.company, invoice.items || []);
} finally {
setGeneratingInvoiceId('');
useEffect(() => {
if (!filterMenuOpen) return;
function onDocClick(e) {
if (filterMenuRef.current && !filterMenuRef.current.contains(e.target)) setFilterMenuOpen(false);
}
};
const visible = companies.length > 1 && activeCompanyId
? invoices.filter(inv => inv.company_id === activeCompanyId)
: invoices;
const outstanding = visible.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total), 0);
const paid = visible.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total), 0);
const overdueCount = visible.filter(inv => inv.status !== 'paid' && new Date(inv.due_date) < new Date()).length;
document.addEventListener('mousedown', onDocClick);
return () => document.removeEventListener('mousedown', onDocClick);
}, [filterMenuOpen]);
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const chartYear = new Date().getFullYear();
const invoiceYears = [...new Set(invoices.map(i => i.invoice_date?.slice(0, 4)).filter(Boolean))].sort((a, b) => b.localeCompare(a));
const availableCompanyOptions = (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : []))
.filter(company => company?.id && invoices.some(inv => inv.company_id === company.id))
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
const chartYear = Number(yearFilter) || new Date().getFullYear();
const filteredInvoices = invoices.filter(inv => {
if (yearFilter !== 'all' && inv.invoice_date?.slice(0, 4) !== yearFilter) return false;
if (companyFilter !== 'all' && inv.company_id !== companyFilter) return false;
return true;
});
const chartData = useMemo(() => MONTHS.map((month, mi) => {
const paidAmt = visible.filter(i => i.status === 'paid' && new Date(i.invoice_date).getFullYear() === chartYear && new Date(i.invoice_date).getMonth() === mi).reduce((s, i) => s + Number(i.total || 0), 0);
const outAmt = visible.filter(i => i.status === 'sent' && new Date(i.invoice_date).getFullYear() === chartYear && new Date(i.invoice_date).getMonth() === mi).reduce((s, i) => s + Number(i.total || 0), 0);
const paidAmt = filteredInvoices.filter(i => i.status === 'paid' && new Date(i.invoice_date).getFullYear() === chartYear && new Date(i.invoice_date).getMonth() === mi).reduce((s, i) => s + Number(i.total || 0), 0);
const outAmt = filteredInvoices.filter(i => i.status === 'sent' && new Date(i.invoice_date).getFullYear() === chartYear && new Date(i.invoice_date).getMonth() === mi).reduce((s, i) => s + Number(i.total || 0), 0);
return { month, Paid: +paidAmt.toFixed(2), Outstanding: +outAmt.toFixed(2) };
}), [visible, chartYear]);
const hasChartData = chartData.some(d => d.Paid > 0 || d.Outstanding > 0);
}), [filteredInvoices, chartYear]);
const sorted = sort(visible, (inv, key) => {
const outstanding = filteredInvoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total || 0), 0);
const paid = filteredInvoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
const overdueCount = filteredInvoices.filter(inv => inv.status !== 'paid' && inv.due_date && new Date(inv.due_date) < new Date()).length;
const paidCount = filteredInvoices.filter(i => i.status === 'paid').length;
const sortedInvoices = sort(filteredInvoices, (inv, key) => {
if (key === 'invoice_date' || key === 'due_date') return new Date(inv[key] || 0).getTime();
if (key === 'total') return Number(inv.total || 0);
if (key === 'company') return inv.company?.name || '';
return inv[key] || '';
});
const th = { sortKey, sortDir, onSort: toggle };
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">Invoices</div>
<div className="page-subtitle">{visible.length} invoice{visible.length !== 1 ? 's' : ''}</div>
<Layout loading={loading}>
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ display: 'grid', gridTemplateColumns: '3fr 2fr', gap: 24, flexShrink: 0 }}>
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Invoice Overview</span>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{chartYear}</span>
</div>
<ResponsiveContainer width="100%" height={160}>
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="clientPaidGrad" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#4ade80" stopOpacity={0.25}/><stop offset="95%" stopColor="#4ade80" stopOpacity={0}/></linearGradient>
<linearGradient id="clientOutGrad" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#60a5fa" stopOpacity={0.2}/><stop offset="95%" stopColor="#60a5fa" stopOpacity={0}/></linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
<XAxis dataKey="month" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} tickFormatter={v => `$${v >= 1000 ? (v / 1000).toFixed(0) + 'k' : v}`} width={45} />
<Tooltip content={<ClientFinanceTooltip year={chartYear} />} />
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} />
<Area type="monotone" dataKey="Paid" stroke="#4ade80" strokeWidth={2} fill="url(#clientPaidGrad)" dot={false} activeDot={{ r: 4 }} />
<Area type="monotone" dataKey="Outstanding" stroke="#60a5fa" strokeWidth={2} fill="url(#clientOutGrad)" dot={false} activeDot={{ r: 4 }} />
</AreaChart>
</ResponsiveContainer>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, alignContent: 'start' }}>
<ClientFinanceStatCard label="Paid" value={`$${paid.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} sub={`${paidCount} settled`} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={'<polyline points="4,12 9,17 20,6"/>'} />
<ClientFinanceStatCard label="Outstanding" value={`$${outstanding.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} sub="ready to pay" iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={'<circle cx="12" cy="12" r="8"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>'} />
<ClientFinanceStatCard label="Overdue" value={overdueCount} sub={overdueCount === 1 ? 'needs attention' : 'need attention'} iconBg="rgba(239,68,68,0.15)" iconColor="#ef4444" iconPath={'<path d="M12 9v4"/><path d="M12 16h.01"/><path d="M10.29 3.86l-7.5 13A1 1 0 0 0 3.66 18h16.68a1 1 0 0 0 .87-1.5l-7.5-13a1 1 0 0 0-1.74 0z"/>'} />
<ClientFinanceStatCard label="Invoices" value={invoices.length} sub={`${companyIds.length} companies`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={'<rect x="3" y="4" width="18" height="16" rx="2"/><line x1="7" y1="9" x2="17" y2="9"/><line x1="7" y1="13" x2="17" y2="13"/>'} />
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 24, marginBottom: 10, flexShrink: 0, minHeight: 'var(--btn-height)' }}>
<div style={{ marginLeft: 'auto', position: 'relative', display: 'flex', alignItems: 'center' }} ref={filterMenuRef}>
<button className="btn btn-outline" onClick={() => setFilterMenuOpen(o => !o)} style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }} title="Filter invoices">
<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>
{filterMenuOpen && (
<div className="site-header-avatar-menu" style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 220, minWidth: 160 }}>
<div style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-muted)', padding: '6px 14px 4px' }}>Year</div>
<button onClick={() => { setYearFilter('all'); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: yearFilter === 'all' ? 'rgba(245,165,35,0.08)' : 'transparent', color: yearFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Years</button>
{invoiceYears.map(year => (
<button key={year} onClick={() => { setYearFilter(year); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: yearFilter === year ? 'rgba(245,165,35,0.08)' : 'transparent', color: yearFilter === year ? 'var(--accent)' : 'var(--text-primary)' }}>{year}</button>
))}
{availableCompanyOptions.length > 1 && (
<>
<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={() => { setCompanyFilter('all'); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: companyFilter === 'all' ? 'rgba(245,165,35,0.08)' : 'transparent', color: companyFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Companies</button>
{availableCompanyOptions.map(company => (
<button key={company.id} onClick={() => { setCompanyFilter(company.id); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: companyFilter === company.id ? 'rgba(245,165,35,0.08)' : 'transparent', color: companyFilter === company.id ? 'var(--accent)' : 'var(--text-primary)' }}>{company.name}</button>
))}
</>
)}
</div>
)}
</div>
</div>
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{filteredInvoices.length === 0 ? (
<div className="card-empty-center">No invoices</div>
) : (
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head table-no-row-hover" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '15%' }} />
<col style={{ width: '20%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '15%' }} />
</colgroup>
<thead>
<tr>
<SortTh col="invoice_number" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Invoice #</SortTh>
<SortTh col="company" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Company</SortTh>
<SortTh col="invoice_date" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Issued</SortTh>
<SortTh col="due_date" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Due</SortTh>
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Status</SortTh>
<SortTh col="total" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Total</SortTh>
</tr>
</thead>
<tbody>
{sortedInvoices.map(inv => {
const isOverdue = inv.status !== 'paid' && inv.due_date && new Date(inv.due_date) < new Date();
return (
<tr key={inv.id}>
<td style={{ padding: '5px 0' }}>
<button type="button" className="table-link" onClick={() => setActiveInvoice(inv)}>{inv.invoice_number}</button>
</td>
<td style={{ padding: '5px 0', fontSize: 13 }}>
{inv.company?.id ? (
<Link to={`/company/${inv.company.id}`} className="table-link">
{inv.company?.name || inv.bill_to || '—'}
</Link>
) : (
inv.company?.name || inv.bill_to || '—'
)}
</td>
<td style={{ padding: '5px 0', fontSize: 12, color: 'var(--text-muted)' }}>{inv.invoice_date ? new Date(inv.invoice_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</td>
<td style={{ padding: '5px 0', fontSize: 12, color: isOverdue ? 'var(--danger)' : 'var(--text-muted)' }}>{inv.due_date ? new Date(inv.due_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</td>
<td style={{ padding: '5px 0' }}>
<StatusBadge status={statusColor[inv.status] || 'not_started'} label={invoiceStatusLabel(inv.status)} />
</td>
<td style={{ padding: '5px 0', textAlign: 'right', fontSize: 13, color: inv.status === 'paid' ? '#4ade80' : inv.status === 'sent' ? '#F5A523' : 'var(--text-primary)', fontWeight: 400 }}>${Number(inv.total || 0).toFixed(2)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
{hasChartData && (
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '16px 16px 8px', marginBottom: 18 }}>
<ResponsiveContainer width="100%" height={180}>
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="gradPaidC" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#4ade80" stopOpacity={0.25}/><stop offset="95%" stopColor="#4ade80" stopOpacity={0}/></linearGradient>
<linearGradient id="gradOutC" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#60a5fa" stopOpacity={0.2}/><stop offset="95%" stopColor="#60a5fa" stopOpacity={0}/></linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
<XAxis dataKey="month" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} tickFormatter={v => `$${v >= 1000 ? (v/1000).toFixed(0)+'k' : v}`} width={45} />
<Tooltip formatter={(v) => [`$${Number(v).toLocaleString('en-US', { minimumFractionDigits: 2 })}`, undefined]} contentStyle={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, fontSize: 12 }} />
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} />
<Area type="monotone" dataKey="Paid" stroke="#4ade80" strokeWidth={2} fill="url(#gradPaidC)" dot={false} activeDot={{ r: 4 }} />
<Area type="monotone" dataKey="Outstanding" stroke="#60a5fa" strokeWidth={2} fill="url(#gradOutC)" dot={false} activeDot={{ r: 4 }} />
</AreaChart>
</ResponsiveContainer>
</div>
)}
{companies.length > 1 && (
<div style={{ marginBottom: 16 }}>
<select className="filter-select" value={activeCompanyId || ''} onChange={e => setActiveCompanyId(e.target.value)}>
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
)}
{loading ? (
<p style={{ color: 'var(--text-muted)' }}>Loading...</p>
) : visible.length === 0 ? (
<div className="empty-state">
<h3>No invoices yet</h3>
<p>Your invoices will appear here once they are sent.</p>
</div>
) : (
<div className="card">
<div className="table-wrapper">
<table>
<thead>
<tr>
<SortTh col="invoice_number" {...th}>Invoice #</SortTh>
<SortTh col="invoice_date" {...th}>Issued</SortTh>
<SortTh col="due_date" {...th}>Due</SortTh>
<SortTh col="status" {...th}>Status</SortTh>
<SortTh col="total" {...th}>Total</SortTh>
<th></th>
</tr>
</thead>
<tbody>
{sorted.map(inv => {
const isOverdue = inv.status !== 'paid' && new Date(inv.due_date) < new Date();
return (
<tr key={inv.id}>
<td style={{ fontWeight: 400 }}>{inv.invoice_number}</td>
<td style={{ color: 'var(--text-muted)' }}>{new Date(inv.invoice_date).toLocaleDateString()}</td>
<td style={{ color: isOverdue ? 'var(--danger)' : 'var(--text-muted)' }}>
{inv.due_date ? new Date(inv.due_date).toLocaleDateString() : '—'}
{isOverdue && <span style={{ marginLeft: 6, fontSize: 11 }}>Overdue</span>}
</td>
<td>
<StatusBadge
status={statusColor[inv.status] || 'not_started'}
label={inv.status ? inv.status.charAt(0).toUpperCase() + inv.status.slice(1) : '—'}
/>
</td>
<td style={{ fontWeight: 400, color: 'var(--accent)' }}>${Number(inv.total).toFixed(2)}</td>
<td>
<LoadingButton
className="btn btn-outline btn-sm"
loading={generatingInvoiceId === inv.id}
disabled={Boolean(generatingInvoiceId)}
loadingText="Generating..."
onClick={() => handleDownload(inv)}
>
Download PDF
</LoadingButton>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
<ClientInvoiceModal invoice={activeInvoice} onClose={() => setActiveInvoice(null)} />
</Layout>
);
}
+2 -288
View File
@@ -1,296 +1,10 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import LoadingButton from '../../components/LoadingButton';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
import { sendEmail } from '../../lib/email';
const INVOICE_TODAY = new Date().toISOString().split('T')[0];
function newItem(description = '', unit_price = '', quantity = 1, taskId = null, isRevision = false) {
return { id: crypto.randomUUID(), description, unit_price, quantity, task_id: taskId, is_revision: isRevision };
}
function genNumber(count) {
const year = new Date().getFullYear();
return `INVSUB-${year}-${String(count + 1).padStart(3, '0')}`;
}
import SubcontractorInvoiceForm from '../../components/SubcontractorInvoiceForm';
export default function MyInvoiceCreate() {
const navigate = useNavigate();
const { currentUser } = useAuth();
const [invoiceNumber, setInvoiceNumber] = useState('');
const [completedTasks, setCompletedTasks] = useState([]);
const [loadingTasks, setLoadingTasks] = useState(true);
const [items, setItems] = useState([newItem()]);
const [notes, setNotes] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const dragItem = useRef(null);
const rate = Number(currentUser?.brand_book_rate || 60);
useEffect(() => {
async function load() {
const [{ data: tasks }, { data: existingInvoices }, { data: nextNum }] = await Promise.all([
supabase
.from('tasks')
.select('id, title, current_version, project:projects(name)')
.eq('status', 'client_approved')
.eq('assigned_to', currentUser.id)
.order('title'),
supabase
.from('subcontractor_invoices')
.select('id, status, items:subcontractor_invoice_items(task_id)')
.in('status', ['submitted', 'paid']),
supabase.rpc('get_next_sub_invoice_number'),
]);
setInvoiceNumber(nextNum || genNumber(0));
const invoicedTaskIds = new Set(
(existingInvoices || []).flatMap(inv => (inv.items || []).map(i => i.task_id).filter(Boolean))
);
setCompletedTasks((tasks || []).filter(t => !invoicedTaskIds.has(t.id)));
setLoadingTasks(false);
}
load();
}, []);
const addedTaskIds = useMemo(() => new Set(items.map(i => i.task_id).filter(Boolean)), [items]);
const addTask = (task) => {
if (addedTaskIds.has(task.id)) return;
const version = task.current_version || 0;
const baseDesc = task.project?.name ? `${task.project.name}${task.title}` : task.title;
const toAdd = [newItem(`${baseDesc} R00`, rate, 1, task.id, false)];
for (let v = 1; v <= version; v++) {
toAdd.push(newItem(`${baseDesc} R${String(v).padStart(2, '0')}`, 30, 1, task.id, true));
}
setItems(prev => {
if (prev.length === 1 && !prev[0].description && !prev[0].unit_price) return toAdd;
return [...prev, ...toAdd];
});
};
const updateItem = (id, field, value) => {
setItems(prev => prev.map(item => item.id === id ? { ...item, [field]: value } : item));
};
const removeItem = (id) => setItems(prev => prev.filter(item => item.id !== id));
const handleDrop = (targetIndex) => {
if (dragItem.current === null || dragItem.current === targetIndex) { dragItem.current = null; return; }
setItems(prev => {
const next = [...prev];
const [moved] = next.splice(dragItem.current, 1);
next.splice(targetIndex, 0, moved);
return next;
});
dragItem.current = null;
};
const total = items.reduce((s, i) => s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0), 0);
const handleSubmit = async () => {
const valid = items.filter(i => String(i.description).trim());
if (!valid.length) { setError('Add at least one line item.'); return; }
setSaving(true);
setError('');
try {
const { data: inv, error: invErr } = await supabase
.from('subcontractor_invoices')
.insert({
profile_id: currentUser.id,
invoice_number: invoiceNumber.trim(),
status: 'submitted',
notes: notes.trim(),
submitted_at: new Date().toISOString(),
})
.select()
.single();
if (invErr) throw invErr;
const { error: itemsErr } = await supabase.from('subcontractor_invoice_items').insert(
valid.map((item, idx) => ({
invoice_id: inv.id,
task_id: item.task_id || null,
description: String(item.description).trim(),
quantity: Number(item.quantity) || 1,
unit_price: Number(item.unit_price) || 0,
sort_order: idx,
}))
);
if (itemsErr) throw itemsErr;
const total = valid.reduce((s, i) => s + (Number(i.unit_price) || 0) * (Number(i.quantity) || 1), 0);
sendEmail('subcontractor_invoice_submitted', 'hello@fourgebranding.com', {
subName: currentUser.name,
invoiceNumber: inv.invoice_number,
total: total.toFixed(2),
invoiceId: inv.id,
}).catch(err => console.error('Sub invoice notification failed:', err));
navigate(`/my-invoices-sub/${inv.id}`);
} catch (err) {
setError(err.message);
setSaving(false);
}
};
return (
<Layout>
<button className="back-link" onClick={() => navigate('/my-invoices-sub')}> Back to Invoices</button>
<div className="page-header">
<div>
<div className="page-title">New Invoice</div>
<div className="page-subtitle">
Invoice date: {new Date(INVOICE_TODAY).toLocaleDateString()} · {invoiceNumber}
</div>
</div>
</div>
{error && <div className="notification notification-info" style={{ marginBottom: 16 }}>{error}</div>}
<div className="card" style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<div className="card-title" style={{ marginBottom: 0 }}>Completed Tasks</div>
{completedTasks.length > 0 && !loadingTasks && (
<button
className="btn btn-outline btn-sm"
onClick={() => completedTasks.forEach(task => { if (!addedTaskIds.has(task.id)) addTask(task); })}
>
+ Add All
</button>
)}
</div>
{loadingTasks ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading...</p>
) : completedTasks.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No completed tasks available to invoice.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{completedTasks.map(task => {
const alreadyAdded = addedTaskIds.has(task.id);
return (
<div key={task.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 400 }}>
{task.project?.name ? `${task.project.name}` : ''}{task.title}
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
{(() => {
const v = task.current_version || 0;
const parts = [`R00 New Book $${rate.toFixed(2)}`];
if (v > 0) parts.push(`+ ${v} Revision${v > 1 ? 's' : ''} @ $30ea`);
return parts.join(' · ');
})()}
</div>
</div>
<button
className={`btn btn-sm ${alreadyAdded ? 'btn-outline' : 'btn-primary'}`}
onClick={() => !alreadyAdded && addTask(task)}
disabled={alreadyAdded}
>
{alreadyAdded ? 'Added' : '+ Add'}
</button>
</div>
);
})}
</div>
)}
</div>
<div className="card" style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<div className="card-title" style={{ marginBottom: 0 }}>Line Items</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 80px 120px 120px 40px', gap: 8, marginBottom: 8 }}>
{['', 'Type', 'Description', 'Qty / Hrs', 'Rate', 'Total', ''].map((h, i) => (
<div key={i} style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: i > 3 ? 'right' : 'left' }}>{h}</div>
))}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{items.map((item, index) => (
<div
key={item.id}
onDragOver={e => e.preventDefault()}
onDrop={() => handleDrop(index)}
style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 80px 120px 120px 40px', gap: 8, alignItems: 'center' }}
>
<div
draggable
onDragStart={e => { dragItem.current = index; e.dataTransfer.effectAllowed = 'move'; }}
style={{ cursor: 'grab', color: 'var(--text-muted)', fontSize: 14, textAlign: 'center', userSelect: 'none' }}
></div>
<span className={`badge ${item.is_revision ? 'badge-client_revision' : 'badge-initial'}`}>
{item.is_revision ? 'Revision' : 'New'}
</span>
<input
type="text"
placeholder="Description..."
value={item.description}
onChange={e => updateItem(item.id, 'description', e.target.value)}
style={{ margin: 0 }}
/>
<input
type="number"
min="0.5"
step="0.5"
value={item.quantity}
onChange={e => updateItem(item.id, 'quantity', e.target.value)}
style={{ margin: 0, textAlign: 'center' }}
/>
<input
type="number"
min="0"
step="0.01"
placeholder="0.00"
value={item.unit_price}
onChange={e => updateItem(item.id, 'unit_price', e.target.value)}
style={{ margin: 0, textAlign: 'right' }}
/>
<div style={{ textAlign: 'right', fontSize: 14, fontWeight: 400, color: 'var(--text-primary)', paddingRight: 4 }}>
${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)}
</div>
<button onClick={() => removeItem(item.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: 16, padding: 4 }}></button>
</div>
))}
</div>
<button className="btn btn-outline btn-sm" style={{ marginTop: 12 }} onClick={() => setItems(prev => [...prev, newItem()])}>
+ Add Line Item
</button>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 20, paddingTop: 16, borderTop: '1px solid var(--border)' }}>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
<div style={{ fontSize: 26, fontWeight: 400, color: 'var(--accent)' }}>${total.toFixed(2)}</div>
</div>
</div>
</div>
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">Notes</div>
<textarea
placeholder="Additional notes for the team..."
value={notes}
onChange={e => setNotes(e.target.value)}
style={{ minHeight: 80 }}
/>
</div>
<div className="action-buttons">
<LoadingButton className="btn btn-primary" onClick={handleSubmit} loading={saving} loadingText="Submitting...">
Submit Invoice
</LoadingButton>
<button className="btn btn-outline" onClick={() => navigate('/my-invoices-sub')} disabled={saving}>Cancel</button>
</div>
<SubcontractorInvoiceForm />
</Layout>
);
}
+64 -186
View File
@@ -1,34 +1,27 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useParams } from 'react-router-dom';
import Layout from '../../components/Layout';
import PageLoader from '../../components/PageLoader';
import LoadingButton from '../../components/LoadingButton';
import SortTh from '../../components/SortTh';
import StatusBadge from '../../components/StatusBadge';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
import { generateSubcontractorPOPDF } from '../../lib/invoice';
import { useSortable } from '../../hooks/useSortable';
import SubcontractorInvoiceDetailView from '../../components/SubcontractorInvoiceDetailView';
const STATUS_COLOR = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
const STATUS_LABEL = { draft: 'Draft', submitted: 'Submitted', paid: 'Paid' };
function fmt(val) {
return `$${Number(val || 0).toFixed(2)}`;
}
function invoiceTotal(items) {
return (items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
return (items || []).reduce((sum, item) => sum + Number(item.unit_price || 0) * Number(item.quantity || 1), 0);
}
export default function MyInvoiceDetail() {
const { id } = useParams();
const navigate = useNavigate();
const { currentUser } = useAuth();
const [invoice, setInvoice] = useState(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [deleting, setDeleting] = useState(false);
const [downloading, setDownloading] = useState(false);
const [error, setError] = useState('');
const { sortKey, sortDir, toggle, sort } = useSortable('description');
@@ -40,7 +33,11 @@ export default function MyInvoiceDetail() {
.select('*, items:subcontractor_invoice_items(*)')
.eq('id', id)
.single();
if (err || !data) { setError('Invoice not found.'); setLoading(false); return; }
if (err || !data) {
setError('Invoice not found.');
setLoading(false);
return;
}
setInvoice(data);
setLoading(false);
}
@@ -49,24 +46,18 @@ export default function MyInvoiceDetail() {
async function handleSubmit() {
setSubmitting(true);
const submittedAt = new Date().toISOString();
const { error: err } = await supabase
.from('subcontractor_invoices')
.update({ status: 'submitted', submitted_at: new Date().toISOString() })
.update({ status: 'submitted', submitted_at: submittedAt })
.eq('id', id);
if (err) setError(err.message);
else setInvoice(i => ({ ...i, status: 'submitted', submitted_at: new Date().toISOString() }));
else setInvoice((current) => ({ ...current, status: 'submitted', submitted_at: submittedAt }));
setSubmitting(false);
}
async function handleDelete() {
if (!window.confirm(`Delete invoice ${invoice.invoice_number}? This cannot be undone.`)) return;
setDeleting(true);
const { error: err } = await supabase.from('subcontractor_invoices').delete().eq('id', id);
if (err) { setError(err.message); setDeleting(false); return; }
navigate('/my-invoices-sub');
}
async function handleDownload() {
if (!invoice) return;
setDownloading(true);
try {
const total = invoiceTotal(invoice.items);
@@ -92,13 +83,16 @@ export default function MyInvoiceDetail() {
setDownloading(false);
}
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
if (loading) return <Layout><PageLoader /></Layout>;
if (!invoice) return <Layout><p style={{ padding: 24 }}>{error || 'Invoice not found.'}</p></Layout>;
const total = invoiceTotal(invoice.items);
const sortedItems = [...(invoice.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
const tableItems = sort(sortedItems, (item, key) => {
if (key === 'type') return item.task_id ? 'Task' : 'Other';
const typeSort = /\bR(\d{2})\b/i.test(String(item.description || ''))
? Number(String(item.description).match(/\bR(\d{2})\b/i)?.[1] || 0)
: item.task_id ? 0 : 999;
if (key === 'type') return typeSort;
if (key === 'description') return item.description || '';
if (key === 'quantity') return Number(item.quantity || 0);
if (key === 'unit_price') return Number(item.unit_price || 0);
@@ -106,175 +100,59 @@ export default function MyInvoiceDetail() {
return '';
});
const headerActions = (
<>
{invoice.status === 'draft' ? (
<LoadingButton className="btn btn-outline" loading={submitting} loadingText="Submitting…" disabled={submitting} onClick={handleSubmit}>
Submit to Team
</LoadingButton>
) : null}
{invoice.status === 'paid' ? (
<LoadingButton className="btn btn-outline" loading={downloading} loadingText="Generating…" disabled={downloading} onClick={handleDownload}>
Download Receipt
</LoadingButton>
) : null}
</>
);
return (
<Layout>
<button className="back-link" onClick={() => navigate('/my-invoices-sub')}> Back to Invoices</button>
<div className="page-header">
<div>
<div className="page-title">{invoice.invoice_number}</div>
<div className="page-subtitle">Fourge Branding</div>
</div>
<div className="action-buttons">
<StatusBadge
status={STATUS_COLOR[invoice.status] || 'not_started'}
label={STATUS_LABEL[invoice.status] || invoice.status}
/>
{invoice.status === 'paid' && (
<LoadingButton
className="btn btn-primary"
loading={downloading}
loadingText="Generating PDF..."
disabled={downloading}
onClick={handleDownload}
>
Download Receipt
</LoadingButton>
)}
</div>
</div>
{error && <div className="notification notification-info" style={{ marginBottom: 16 }}>{error}</div>}
<div className="grid-2" style={{ marginBottom: 24 }}>
<div className="card">
<div className="card-title">From</div>
<div style={{ fontSize: 15, fontWeight: 400 }}>{currentUser?.name || 'Subcontractor'}</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 4 }}>{currentUser?.email}</div>
</div>
<div className="card">
<div className="card-title">Invoice Details</div>
<div className="detail-grid" style={{ marginBottom: 0 }}>
<div className="detail-item">
<label>Invoice #</label>
<p style={{ fontWeight: 400 }}>{invoice.invoice_number}</p>
</div>
<div className="detail-item">
<label>Created</label>
<p>{new Date(invoice.created_at).toLocaleDateString()}</p>
</div>
<div className="detail-item">
<label>Terms</label>
<p>NET 30 from client payment</p>
</div>
<div className="detail-item">
<label>Status</label>
<p>
<StatusBadge
status={STATUS_COLOR[invoice.status] || 'not_started'}
label={STATUS_LABEL[invoice.status] || invoice.status}
/>
</p>
</div>
{invoice.submitted_at && (
<div className="detail-item">
<label>Submitted</label>
<p>{new Date(invoice.submitted_at).toLocaleDateString()}</p>
</div>
)}
<div className="detail-item">
<label>Total</label>
<p style={{ fontSize: 18, fontWeight: 400, color: 'var(--accent)' }}>{fmt(total)}</p>
</div>
{invoice.paid_at && (
<div className="detail-item">
<SubcontractorInvoiceDetailView
error={error}
headerActions={headerActions}
leftCardTitle="From"
leftCardBody={(
<>
<div style={{ fontSize: 15, fontWeight: 400 }}>{currentUser?.name || 'Subcontractor'}</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 4 }}>{currentUser?.email || '—'}</div>
</>
)}
rightCardTitle="Invoice Details"
rightCardBody={(
<div className="invoice-detail-meta-grid">
<div className="invoice-detail-meta-item"><label>Invoice #</label><p>{invoice.invoice_number}</p></div>
<div className="invoice-detail-meta-item"><label>Created</label><p>{new Date(invoice.created_at).toLocaleDateString()}</p></div>
<div className="invoice-detail-meta-item"><label>Terms</label><p>NET 30 from client payment</p></div>
<div className="invoice-detail-meta-item"><label>Status</label><p>{STATUS_LABEL[invoice.status] || invoice.status}</p></div>
{invoice.submitted_at ? <div className="invoice-detail-meta-item"><label>Submitted</label><p>{new Date(invoice.submitted_at).toLocaleDateString()}</p></div> : null}
<div className="invoice-detail-meta-item"><label>Total</label><p style={{ fontSize: 18, color: 'var(--accent)' }}>${total.toFixed(2)}</p></div>
{invoice.paid_at ? (
<div className="invoice-detail-meta-item">
<label>Paid On</label>
<p style={{ color: 'var(--success, #16a34a)', fontWeight: 400 }}>
<p style={{ color: 'var(--success, #16a34a)' }}>
{new Date(invoice.paid_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
</p>
</div>
)}
) : null}
</div>
</div>
</div>
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">Line Items</div>
<div className="table-wrapper" style={{ border: 'none' }}>
<table>
<thead>
<tr>
<SortTh col="type" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ width: 100 }}>Type</SortTh>
<SortTh col="description" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Description</SortTh>
<SortTh col="quantity" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'center' }}>Qty / Hrs</SortTh>
<SortTh col="unit_price" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Rate</SortTh>
<SortTh col="line_total" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Total</SortTh>
</tr>
</thead>
<tbody>
{tableItems.map(item => (
<tr key={item.id}>
<td>
<span className={`badge ${item.task_id ? 'badge-in_progress' : 'badge-initial'}`}>
{item.task_id ? 'Task' : 'Other'}
</span>
</td>
<td>{item.description}</td>
<td style={{ textAlign: 'center' }}>{item.quantity}</td>
<td style={{ textAlign: 'right' }}>{fmt(item.unit_price)}</td>
<td style={{ textAlign: 'right', fontWeight: 400 }}>
{fmt(Number(item.unit_price) * Number(item.quantity || 1))}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '16px 16px 0', borderTop: '1px solid var(--border)', marginTop: 8 }}>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
<div style={{ fontSize: 24, fontWeight: 400, color: 'var(--accent)' }}>{fmt(total)}</div>
</div>
</div>
</div>
{invoice.notes && (
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">Notes</div>
<p style={{ fontSize: 14, color: 'var(--text-secondary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{invoice.notes}</p>
</div>
)}
<div className="card">
<div className="card-title">Actions</div>
<div className="action-buttons">
{invoice.status === 'draft' && (
<LoadingButton
className="btn btn-primary"
loading={submitting}
loadingText="Submitting..."
disabled={submitting || deleting}
onClick={handleSubmit}
>
Submit to Team
</LoadingButton>
)}
{invoice.status === 'paid' && (
<LoadingButton
className="btn btn-success"
loading={downloading}
loadingText="Generating PDF..."
disabled={downloading}
onClick={handleDownload}
>
Download Receipt
</LoadingButton>
)}
{invoice.status !== 'paid' && (
<LoadingButton
className="btn-icon btn-icon-danger"
loading={deleting}
loadingText="..."
disabled={submitting || deleting}
title="Delete Invoice"
onClick={handleDelete}
>
</LoadingButton>
)}
</div>
</div>
)}
sortKey={sortKey}
sortDir={sortDir}
onSort={toggle}
items={tableItems}
notes={invoice.notes}
total={total}
/>
</Layout>
);
}
+524 -99
View File
@@ -1,16 +1,27 @@
import { useEffect, useState, useMemo } from 'react';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import StatusBadge from '../../components/StatusBadge';
import SortTh from '../../components/SortTh';
import LoadingButton from '../../components/LoadingButton';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
import { readPageCache, writePageCache } from '../../lib/pageCache';
import { useSortable } from '../../hooks/useSortable';
import { isCompletedVersionEligible } from '../../lib/invoiceVersionRules';
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
import SubcontractorInvoiceForm from '../../components/SubcontractorInvoiceForm';
import { generateSubcontractorPOPDF } from '../../lib/invoice';
const STATUS_BADGE = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
const STATUS_LABEL = { draft: 'Draft', submitted: 'Submitted', paid: 'Paid' };
const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 4 };
const STAT_ICONS = {
newTasks: '<rect x="3" y="4" width="18" height="16" rx="2"/><line x1="7" y1="9" x2="17" y2="9"/><line x1="7" y1="13" x2="17" y2="13"/>',
revisions: '<path d="M4 12a8 8 0 018-8v0a8 8 0 018 8"/><polyline points="18,8 20,12 16,12"/>',
invoiced: '<path d="M5 3h11l3 3v15H5z"/><path d="M16 3v4h4"/><line x1="8" y1="11" x2="16" y2="11"/><line x1="8" y1="15" x2="16" y2="15"/>',
paid: '<polyline points="4,12 9,17 20,6"/>',
};
function fmt(val) {
return `$${Number(val || 0).toFixed(2)}`;
@@ -20,121 +31,535 @@ function invoiceTotal(items) {
return (items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
}
function TaskStatCard({ label, value, sub, iconBg, iconColor, iconPath }) {
return (
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', minHeight: 120 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: iconPath }} />
</div>
</div>
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 5 }}>{sub}</div>
</div>
);
}
function asArray(value) {
if (Array.isArray(value)) return value;
if (value == null) return [];
return typeof value === 'object' ? [value] : [];
}
function parseItemVersionNumber(item) {
if (Number.isFinite(Number(item?.version_number))) return Number(item.version_number);
const match = String(item?.description || '').match(/\bR(\d{2})\b/i);
return match ? Number(match[1]) : 0;
}
function itemVersionLabel(item) {
return `R${String(parseItemVersionNumber(item)).padStart(2, '0')}`;
}
function itemWorkLabel(item) {
return parseItemVersionNumber(item) > 0 ? 'Revision' : 'New';
}
function cleanItemDescription(item) {
return String(item?.description || '—').replace(/\s*[-]\s*R\d{2}\b/i, '').trim() || '—';
}
async function fetchTaskTypeMap(taskIds) {
const uniqueTaskIds = [...new Set((taskIds || []).filter(Boolean))];
if (uniqueTaskIds.length === 0) return {};
const { data, error } = await supabase
.from('tasks')
.select('id, title, submissions(service_type, type)')
.in('id', uniqueTaskIds);
if (error) throw error;
const next = {};
for (const task of (data || [])) {
const submissions = asArray(task.submissions);
const initial = submissions.find((submission) => submission?.type === 'initial' && submission?.service_type);
const fallback = submissions.find((submission) => submission?.service_type);
next[task.id] = initial?.service_type || fallback?.service_type || task.title || 'Other';
}
return next;
}
function buildPDFArgs(invoice, profile, total, statusOverride) {
return {
po_number: invoice.invoice_number,
status: statusOverride || invoice.status || 'draft',
profile,
project: { name: 'Subcontractor Invoice', company: { name: 'Fourge Branding' } },
date: invoice.created_at?.split('T')[0],
due_date: invoice.paid_at?.split('T')[0] || invoice.submitted_at?.split('T')[0] || invoice.created_at?.split('T')[0],
amount: total,
description: invoice.status === 'paid'
? 'Payment received for completed subcontractor work.'
: 'Subcontractor invoice for completed work.',
notes: invoice.notes,
items: (invoice.items || []).map((item, idx) => ({
description: item.description,
amount: Number(item.unit_price || 0) * Number(item.quantity || 1),
sort_order: item.sort_order ?? idx,
})),
};
}
export default function MyInvoices() {
const { currentUser } = useAuth();
const navigate = useNavigate();
const cacheKey = `ext-invoices:${currentUser?.id}`;
const cached = readPageCache(cacheKey, 3 * 60_000);
const [invoices, setInvoices] = useState(() => cached || []);
const [invoices, setInvoices] = useState(() => cached?.invoices || []);
const [stats, setStats] = useState(() => cached?.stats || { completedNewTasks: 0, completedRevisions: 0, invoicedUnits: 0, paidUnits: 0 });
const [loading, setLoading] = useState(() => !cached);
const [error, setError] = useState('');
const { sortKey, sortDir, toggle, sort } = useSortable('created_at');
const [showInvoiceForm, setShowInvoiceForm] = useState(false);
const { sortKey, sortDir, toggle, sort } = useSortable('created_at', 'desc');
const { sortKey: detailSortKey, sortDir: detailSortDir, toggle: toggleDetailSort, sort: sortDetailItems } = useSortable('description');
const [viewingInvoice, setViewingInvoice] = useState(null);
const [detailLoading, setDetailLoading] = useState(false);
const [detailError, setDetailError] = useState('');
const [detailTaskTypeMap, setDetailTaskTypeMap] = useState({});
const [submittingInvoice, setSubmittingInvoice] = useState(false);
const [downloadingInvoice, setDownloadingInvoice] = useState(false);
const [downloadingReceipt, setDownloadingReceipt] = useState(false);
useEffect(() => {
if (!currentUser?.id) { setLoading(false); return; }
supabase
.from('subcontractor_invoices')
.select('*, items:subcontractor_invoice_items(*)')
.order('created_at', { ascending: false })
.then(({ data, error: err }) => {
if (err) setError(err.message);
else { setInvoices(data || []); writePageCache(cacheKey, data || []); }
async function load() {
if (!currentUser?.id) {
setInvoices([]);
setStats({ completedNewTasks: 0, completedRevisions: 0, invoicedUnits: 0, paidUnits: 0 });
setLoading(false);
});
}, [currentUser?.id]); // eslint-disable-line react-hooks/exhaustive-deps
return;
}
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const chartYear = new Date().getFullYear();
const chartData = useMemo(() => MONTHS.map((month, mi) => {
const submittedAmt = invoices.filter(i => i.status === 'submitted' && new Date(i.created_at).getFullYear() === chartYear && new Date(i.created_at).getMonth() === mi).reduce((s, i) => s + invoiceTotal(i.items), 0);
const paidAmt = invoices.filter(i => i.status === 'paid' && new Date(i.created_at).getFullYear() === chartYear && new Date(i.created_at).getMonth() === mi).reduce((s, i) => s + invoiceTotal(i.items), 0);
return { month, Submitted: +submittedAmt.toFixed(2), Paid: +paidAmt.toFixed(2) };
}), [invoices, chartYear]);
const hasChartData = chartData.some(d => d.Submitted > 0 || d.Paid > 0);
const totalPaid = invoices.filter(i => i.status === 'paid').reduce((s, i) => s + invoiceTotal(i.items), 0);
const totalPending = invoices.filter(i => i.status === 'submitted').reduce((s, i) => s + invoiceTotal(i.items), 0);
setLoading(true);
setError('');
try {
const [{ data: invoiceRows, error: invoiceError }, { data: taskRows, error: taskError }] = await Promise.all([
supabase
.from('subcontractor_invoices')
.select('*, items:subcontractor_invoice_items(*)')
.order('created_at', { ascending: false }),
supabase
.from('tasks')
.select('id, title, status, current_version, assigned_to')
.in('status', ['not_started', 'in_progress', 'on_hold', 'client_review', 'client_approved', 'invoiced', 'paid'])
.eq('assigned_to', currentUser.id)
.order('title'),
]);
if (invoiceError) throw invoiceError;
if (taskError) throw taskError;
const tasks = taskRows || [];
const invoicesData = invoiceRows || [];
const taskIds = tasks.map((task) => task.id).filter(Boolean);
const { data: submissionRows, error: submissionsError } = taskIds.length > 0
? await supabase
.from('submissions')
.select('task_id, deliveries(version_number, sent_by, sent_at)')
.in('task_id', taskIds)
: { data: [], error: null };
if (submissionsError) throw submissionsError;
const deliveriesByTask = new Map();
for (const row of (submissionRows || [])) {
const taskId = row.task_id;
if (!taskId) continue;
const bucket = deliveriesByTask.get(taskId) || new Map();
for (const delivery of asArray(row.deliveries)) {
if ((delivery.sent_by || '').trim().toLowerCase() !== (currentUser.name || '').trim().toLowerCase()) continue;
const version = Number(delivery.version_number || 0);
if (!bucket.has(version)) bucket.set(version, delivery);
}
deliveriesByTask.set(taskId, bucket);
}
let completedNewTasks = 0;
let completedRevisions = 0;
for (const task of tasks) {
const versions = [...(deliveriesByTask.get(task.id)?.keys() || [])];
for (const version of versions) {
if (!isCompletedVersionEligible(task, version)) continue;
if (version <= 0) completedNewTasks += 1;
else completedRevisions += 1;
}
}
const invoiceUnits = invoicesData.reduce((sum, invoice) => sum + asArray(invoice.items).filter((item) => item.task_id).length, 0);
const paidUnits = invoicesData
.filter((invoice) => invoice.status === 'paid')
.reduce((sum, invoice) => sum + asArray(invoice.items).filter((item) => item.task_id).length, 0);
const nextStats = {
completedNewTasks,
completedRevisions,
invoicedUnits: invoiceUnits,
paidUnits,
};
setInvoices(invoicesData);
setStats(nextStats);
writePageCache(cacheKey, { invoices: invoicesData, stats: nextStats });
} catch (err) {
console.error('Failed to load subcontractor invoices:', err);
setError(err?.message || 'Failed to load invoices.');
} finally {
setLoading(false);
}
}
load();
}, [cacheKey, currentUser?.id, currentUser?.name]);
const totalInvoiceAmount = useMemo(
() => invoices.reduce((sum, invoice) => sum + invoiceTotal(invoice.items), 0),
[invoices]
);
const paidInvoiceAmount = useMemo(
() => invoices.filter((invoice) => invoice.status === 'paid').reduce((sum, invoice) => sum + invoiceTotal(invoice.items), 0),
[invoices]
);
const sortedInvoices = sort(invoices, (inv, key) => {
if (key === 'total') return invoiceTotal(inv.items);
if (key === 'line_count') return asArray(inv.items).length;
if (key === 'submitted_at') return inv.submitted_at ? new Date(inv.submitted_at).getTime() : 0;
if (key === 'created_at') return inv.created_at ? new Date(inv.created_at).getTime() : 0;
return inv[key] || '';
});
async function openInvoicePopup(invoiceId) {
if (!invoiceId) return;
setDetailLoading(true);
setDetailError('');
try {
const { data, error: err } = await supabase
.from('subcontractor_invoices')
.select('*, items:subcontractor_invoice_items(*)')
.eq('id', invoiceId)
.single();
if (err || !data) throw err || new Error('Invoice not found.');
const taskTypeMap = await fetchTaskTypeMap(asArray(data.items).map((item) => item.task_id));
setDetailTaskTypeMap(taskTypeMap);
setViewingInvoice(data);
} catch (err) {
setDetailError(err?.message || 'Failed to load invoice.');
} finally {
setDetailLoading(false);
}
}
async function handleSubmitInvoice() {
if (!viewingInvoice?.id) return;
setSubmittingInvoice(true);
setDetailError('');
try {
const submittedAt = new Date().toISOString();
const { error: err } = await supabase
.from('subcontractor_invoices')
.update({ status: 'submitted', submitted_at: submittedAt })
.eq('id', viewingInvoice.id);
if (err) throw err;
setViewingInvoice((current) => current ? { ...current, status: 'submitted', submitted_at: submittedAt } : current);
setInvoices((current) => current.map((invoice) => invoice.id === viewingInvoice.id ? { ...invoice, status: 'submitted', submitted_at: submittedAt } : invoice));
} catch (err) {
setDetailError(err?.message || 'Failed to submit invoice.');
} finally {
setSubmittingInvoice(false);
}
}
async function handleDownloadReceipt() {
if (!viewingInvoice) return;
setDownloadingReceipt(true);
setDetailError('');
try {
const total = invoiceTotal(viewingInvoice.items);
await generateSubcontractorPOPDF(
buildPDFArgs(viewingInvoice, { name: currentUser?.name, email: currentUser?.email }, total, 'paid')
);
} catch (err) {
setDetailError(err?.message || 'Failed to download receipt.');
} finally {
setDownloadingReceipt(false);
}
}
async function handleDownloadInvoice() {
if (!viewingInvoice) return;
setDownloadingInvoice(true);
setDetailError('');
try {
const total = invoiceTotal(viewingInvoice.items);
await generateSubcontractorPOPDF(
buildPDFArgs(viewingInvoice, { name: currentUser?.name, email: currentUser?.email }, total)
);
} catch (err) {
setDetailError(err?.message || 'Failed to download invoice.');
} finally {
setDownloadingInvoice(false);
}
}
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">Invoices</div>
<div className="page-subtitle">Submit invoices to Fourge Branding for your completed work.</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4 }}>Payment terms: NET 30 from the date Fourge receives payment from the client.</div>
<Layout loading={loading}>
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{error && <div className="notification notification-info" style={{ marginBottom: 16 }}>{error}</div>}
<div className="dash-stat-grid" style={{ gridTemplateColumns: '1fr 1fr 1fr 1fr', flexShrink: 0 }}>
<TaskStatCard
label="Completed New Tasks"
value={stats.completedNewTasks}
sub={`${stats.completedNewTasks} completed R00 units`}
iconBg="rgba(96,165,250,0.15)"
iconColor="#60a5fa"
iconPath={STAT_ICONS.newTasks}
/>
<TaskStatCard
label="Completed Revisions"
value={stats.completedRevisions}
sub={`${stats.completedRevisions} completed revision units`}
iconBg="rgba(245,165,35,0.15)"
iconColor="#F5A523"
iconPath={STAT_ICONS.revisions}
/>
<TaskStatCard
label="Invoiced"
value={fmt(totalInvoiceAmount)}
sub={`${invoices.length} invoices submitted`}
iconBg="rgba(167,139,250,0.15)"
iconColor="#a78bfa"
iconPath={STAT_ICONS.invoiced}
/>
<TaskStatCard
label="Paid"
value={fmt(paidInvoiceAmount)}
sub={`${invoices.filter((invoice) => invoice.status === 'paid').length} paid invoices`}
iconBg="rgba(74,222,128,0.15)"
iconColor="#4ade80"
iconPath={STAT_ICONS.paid}
/>
</div>
<button className="btn btn-primary" onClick={() => navigate('/my-invoices-sub/new')} disabled={loading}>
+ New Invoice
</button>
{invoices.length === 0 ? (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10, flexShrink: 0, minHeight: 'var(--btn-height)' }}>
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
<button className="btn btn-outline" onClick={() => setShowInvoiceForm(true)} disabled={loading}>
+ Invoice
</button>
</div>
</div>
<div className="card card-empty-center" style={{ flex: 1, minHeight: 0 }}>No invoices</div>
</>
) : (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10, flexShrink: 0, minHeight: 'var(--btn-height)' }}>
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
<button className="btn btn-outline" onClick={() => setShowInvoiceForm(true)} disabled={loading}>
+ Invoice
</button>
</div>
</div>
<div
style={{
background: 'var(--card-bg)',
border: '1px solid var(--border)',
borderRadius: 8,
padding: '18px 21px',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
display: 'flex',
flexDirection: 'column',
flex: 1,
minHeight: 0,
}}
>
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
<colgroup>
<col style={{ width: '28%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '12%' }} />
<col style={{ width: '15%' }} />
</colgroup>
<thead>
<tr>
<SortTh col="invoice_number" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Invoice #</SortTh>
<SortTh col="created_at" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Created</SortTh>
<SortTh col="submitted_at" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Submitted</SortTh>
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Status</SortTh>
<SortTh col="line_count" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'center' }}>Items</SortTh>
<SortTh col="total" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Total</SortTh>
</tr>
</thead>
<tbody>
{sortedInvoices.map((inv) => {
const total = invoiceTotal(inv.items);
return (
<tr key={inv.id} style={{ cursor: 'pointer' }} onClick={() => openInvoicePopup(inv.id)}>
<td style={{ fontWeight: 400 }}>{inv.invoice_number || 'Subcontractor Invoice'}</td>
<td style={{ color: 'var(--text-muted)' }}>{inv.created_at ? new Date(inv.created_at).toLocaleDateString() : '—'}</td>
<td style={{ color: 'var(--text-muted)' }}>{inv.submitted_at ? new Date(inv.submitted_at).toLocaleDateString() : '—'}</td>
<td><StatusBadge status={STATUS_BADGE[inv.status]} label={STATUS_LABEL[inv.status]} /></td>
<td style={{ textAlign: 'center', color: 'var(--text-primary)' }}>{asArray(inv.items).length}</td>
<td style={{ textAlign: 'right', fontWeight: 400, color: 'var(--accent)' }}>{fmt(total)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</>
)}
</div>
{error && <div className="notification notification-info" style={{ marginBottom: 16 }}>{error}</div>}
{!loading && invoices.length > 0 && (
<>
<div className="stat-bar" style={{ marginBottom: 18 }}>
<div className="stat-bar-item"><div className="stat-bar-header"><div className="stat-bar-label">Total Paid</div><div className="stat-bar-dot" style={{ background: '#4ade80' }} /></div><div className="stat-bar-value">{fmt(totalPaid)}</div></div>
<div className="stat-bar-item"><div className="stat-bar-header"><div className="stat-bar-label">Pending Payment</div><div className="stat-bar-dot" style={{ background: '#F5A523' }} /></div><div className="stat-bar-value">{fmt(totalPending)}</div></div>
<div className="stat-bar-item"><div className="stat-bar-header"><div className="stat-bar-label">Total Invoices</div><div className="stat-bar-dot" style={{ background: '#60a5fa' }} /></div><div className="stat-bar-value">{invoices.length}</div></div>
{showInvoiceForm && (
<div style={popupOverlayStyle} onClick={() => setShowInvoiceForm(false)}>
<div
style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }}
onClick={(e) => e.stopPropagation()}
>
<SubcontractorInvoiceForm
embedded
onCancel={() => setShowInvoiceForm(false)}
onSubmitted={(inv) => {
setShowInvoiceForm(false);
openInvoicePopup(inv.id);
}}
/>
</div>
{hasChartData && (
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '16px 16px 8px', marginBottom: 18 }}>
<ResponsiveContainer width="100%" height={180}>
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="gradSubAmt" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#F5A523" stopOpacity={0.25}/><stop offset="95%" stopColor="#F5A523" stopOpacity={0}/></linearGradient>
<linearGradient id="gradSubPaid" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#4ade80" stopOpacity={0.25}/><stop offset="95%" stopColor="#4ade80" stopOpacity={0}/></linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
<XAxis dataKey="month" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} tickFormatter={v => `$${v >= 1000 ? (v/1000).toFixed(0)+'k' : v}`} width={45} />
<Tooltip formatter={(v) => [`$${Number(v).toLocaleString('en-US', { minimumFractionDigits: 2 })}`, undefined]} contentStyle={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, fontSize: 12 }} />
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} />
<Area type="monotone" dataKey="Submitted" stroke="#F5A523" strokeWidth={2} fill="url(#gradSubAmt)" dot={false} activeDot={{ r: 4 }} />
<Area type="monotone" dataKey="Paid" stroke="#4ade80" strokeWidth={2} fill="url(#gradSubPaid)" dot={false} activeDot={{ r: 4 }} />
</AreaChart>
</ResponsiveContainer>
</div>
)}
</>
</div>
)}
{loading ? (
<div className="empty-state">Loading invoices...</div>
) : invoices.length === 0 ? (
<div className="empty-state">
<h3>No invoices yet</h3>
<p>Create your first invoice to get paid for your completed work.</p>
</div>
) : (
<div className="card">
<div className="table-wrapper">
<table>
<thead>
<tr>
<SortTh col="invoice_number" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Invoice #</SortTh>
<SortTh col="submitted_at" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Submitted</SortTh>
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Status</SortTh>
<SortTh col="total" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Total</SortTh>
</tr>
</thead>
<tbody>
{sort(invoices, (inv, key) => {
if (key === 'total') return invoiceTotal(inv.items);
if (key === 'submitted_at') return inv.submitted_at ? new Date(inv.submitted_at).getTime() : 0;
return inv[key] || '';
}).map(inv => {
const total = invoiceTotal(inv.items);
return (
<tr key={inv.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/my-invoices-sub/${inv.id}`)}>
<td style={{ fontWeight: 400 }}>{inv.invoice_number}</td>
<td style={{ color: 'var(--text-muted)' }}>{inv.submitted_at ? new Date(inv.submitted_at).toLocaleDateString() : '—'}</td>
<td><StatusBadge status={STATUS_BADGE[inv.status]} label={STATUS_LABEL[inv.status]} /></td>
<td style={{ textAlign: 'right', fontWeight: 400, color: 'var(--accent)' }}>{fmt(total)}</td>
</tr>
);
})}
</tbody>
</table>
{(viewingInvoice || detailLoading || detailError) && (
<div style={popupOverlayStyle} onClick={() => { if (!submittingInvoice && !downloadingInvoice && !downloadingReceipt) { setViewingInvoice(null); setDetailError(''); } }}>
<div
style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }}
onClick={(e) => e.stopPropagation()}
>
{detailLoading && !viewingInvoice ? (
<div className="card-empty-center" style={{ flex: 1, minHeight: 0 }}>Loading invoice</div>
) : viewingInvoice ? (() => {
const total = invoiceTotal(viewingInvoice.items);
const sortedItems = [...(viewingInvoice.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
const invoiceStatus = viewingInvoice.status ? viewingInvoice.status.charAt(0).toUpperCase() + viewingInvoice.status.slice(1) : '—';
return (
<>
{detailError ? <div className="notification notification-info" style={{ marginBottom: 16, flexShrink: 0 }}>{detailError}</div> : null}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, paddingBottom: 18, borderBottom: '1px solid var(--border)' }}>
<div>
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--text-primary)', letterSpacing: 0.2, lineHeight: 1.1 }}>{viewingInvoice.invoice_number || 'Subcontractor Invoice'}</div>
<div style={{ marginTop: 6, fontSize: 13, color: 'var(--text-secondary)' }}>
{currentUser?.name || 'Subcontractor'}{currentUser?.email ? ` · ${currentUser.email}` : ''}
</div>
</div>
<StatusBadge status={STATUS_BADGE[viewingInvoice.status] || 'not_started'} label={invoiceStatus} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 18, padding: '18px 0', borderBottom: '1px solid var(--border)' }}>
<div>
<div style={FIELD_LABEL_STYLE}>Created</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{viewingInvoice.created_at ? new Date(viewingInvoice.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div>
</div>
<div>
<div style={FIELD_LABEL_STYLE}>Submitted</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{viewingInvoice.submitted_at ? new Date(viewingInvoice.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div>
</div>
<div>
<div style={FIELD_LABEL_STYLE}>Line Items</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{sortedItems.length}</div>
</div>
<div>
<div style={FIELD_LABEL_STYLE}>Total</div>
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--accent)', lineHeight: 1.1 }}>${total.toFixed(2)}</div>
</div>
</div>
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto', paddingTop: 18 }}>
{sortedItems.length === 0 ? (
<div className="card-empty-center">No line items</div>
) : (
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '8%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '32%' }} />
<col style={{ width: '16%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '12%' }} />
<col style={{ width: '12%' }} />
</colgroup>
<thead>
<tr>
<th style={{ textAlign: 'center' }}>Work</th>
<th style={{ textAlign: 'center' }}>R#</th>
<th style={{ textAlign: 'left' }}>Description</th>
<th style={{ textAlign: 'center' }}>Type</th>
<th style={{ textAlign: 'center' }}>Qty</th>
<th style={{ textAlign: 'center' }}>Unit Price</th>
<th style={{ textAlign: 'center' }}>Amount</th>
</tr>
</thead>
<tbody>
{sortedItems.map(item => (
<tr key={item.id}>
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)', textAlign: 'center' }}>{itemWorkLabel(item)}</td>
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)', textAlign: 'center' }}>{itemVersionLabel(item)}</td>
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)' }}>{cleanItemDescription(item)}</td>
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)', textAlign: 'center' }}>{detailTaskTypeMap[item.task_id] || 'Other'}</td>
<td style={{ padding: '5px 0', textAlign: 'center', fontSize: 13, color: 'var(--text-primary)' }}>{item.quantity || 1}</td>
<td style={{ padding: '5px 0', textAlign: 'center', fontSize: 13, color: 'var(--text-primary)' }}>${Number(item.unit_price || 0).toFixed(2)}</td>
<td style={{ padding: '5px 0', textAlign: 'center', fontSize: 13, color: 'var(--text-primary)' }}>${(Number(item.unit_price || 0) * Number(item.quantity || 1)).toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
)}
{viewingInvoice.notes && (
<div style={{ marginTop: 18, padding: '12px 14px', background: 'var(--card-bg-2)', borderRadius: 6, border: '1px solid var(--border)' }}>
<div style={FIELD_LABEL_STYLE}>Notes</div>
<div style={{ fontSize: 13, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap' }}>{viewingInvoice.notes}</div>
</div>
)}
</div>
<div className="modal-action-row" style={{ paddingTop: 18, borderTop: '1px solid var(--border)' }}>
<LoadingButton className="btn btn-outline" loading={downloadingInvoice} loadingText="Generating…" disabled={downloadingInvoice || submittingInvoice || downloadingReceipt} onClick={handleDownloadInvoice}>
Download Invoice
</LoadingButton>
{viewingInvoice.status === 'draft' && (
<LoadingButton className="btn btn-outline" loading={submittingInvoice} loadingText="Submitting…" disabled={submittingInvoice || downloadingInvoice || downloadingReceipt} onClick={handleSubmitInvoice}>
Submit to Team
</LoadingButton>
)}
{viewingInvoice.status === 'paid' && (
<LoadingButton className="btn btn-outline" loading={downloadingReceipt} loadingText="Generating…" disabled={downloadingReceipt || downloadingInvoice || submittingInvoice} onClick={handleDownloadReceipt}>
Download Receipt
</LoadingButton>
)}
<button type="button" className="btn btn-outline" onClick={() => { setViewingInvoice(null); setDetailError(''); }} disabled={submittingInvoice || downloadingInvoice || downloadingReceipt}>
Cancel
</button>
</div>
</>
);
})() : (
<div className="card-empty-center" style={{ flex: 1, minHeight: 0 }}>{detailError || 'Invoice not found.'}</div>
)}
</div>
</div>
)}
+3 -5
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import Layout from '../../components/Layout';
import PageLoader from '../../components/PageLoader';
import StatusBadge from '../../components/StatusBadge';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
@@ -95,14 +96,11 @@ export default function MyPurchaseOrders() {
</div>
{loading ? (
<p style={{ color: 'var(--text-muted)' }}>Loading...</p>
<PageLoader />
) : error ? (
<div className="card" style={{ color: 'var(--danger)' }}>{error}</div>
) : purchaseOrders.length === 0 ? (
<div className="empty-state">
<h3>No purchase orders</h3>
<p>New POs will appear here when the Fourge team sends them.</p>
</div>
<div className="card card-empty-center">No purchase orders</div>
) : (
<div style={{ display: 'grid', gap: 12 }}>
{purchaseOrders.map(po => (
+23 -24
View File
@@ -7,6 +7,8 @@ import { useAuth } from '../../context/AuthContext';
import { generateInvoicePDF } from '../../lib/invoice';
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
import { withTimeout } from '../../lib/withTimeout';
import { isReviewShadowDescription } from '../../lib/taskVersions';
import { getRevisionChargeQuantity, isCompletedVersionEligible, isInitialVersionEligible } from '../../lib/invoiceVersionRules';
// Computed at module load time — stable for the lifetime of the invoice creation session
const INVOICE_TODAY = new Date().toISOString().split('T')[0];
@@ -29,12 +31,6 @@ const buildRevisionItemDescription = (revision) => {
return `${projectName}${taskTitle} • Revision ${versionLabel}`;
};
const getRevisionChargeQuantity = (revision) => {
const version = Number(revision?.version_number || 0);
// Incremental billing: R01 is free, each uninvoiced revision from R02+ is one charge unit.
return version >= 2 ? 1 : 0;
};
export default function CreateInvoice() {
const navigate = useNavigate();
const { currentUser } = useAuth();
@@ -96,30 +92,30 @@ export default function CreateInvoice() {
setInvoiceEmail(recipients[0]?.email || companies.find(c => c.id === selectedCompanyId)?.contact_email || '');
if (projects && projects.length > 0) {
const projectIds = projects.map(p => p.id);
const { data: tasks } = await supabase
const { data: companyTasks } = await supabase
.from('tasks')
.select('*, project:projects(name), submissions(service_type, type, version_number)')
.in('project_id', projectIds)
.eq('invoiced', false)
.eq('status', 'client_approved');
const approvedTaskIds = (tasks || []).map((t) => t.id).filter(Boolean);
const { data: revisions } = approvedTaskIds.length > 0
.in('status', ['not_started', 'in_progress', 'on_hold', 'client_review', 'client_approved', 'invoiced', 'paid']);
const companyTaskIds = (companyTasks || []).map((t) => t.id).filter(Boolean);
const { data: revisions } = companyTaskIds.length > 0
? await supabase
.from('submissions')
.select('*, task:tasks(id, title, project:projects(name), submissions(service_type, type))')
.select('*, task:tasks(id, title, status, current_version, project:projects(name), submissions(service_type, type))')
.eq('type', 'revision')
.or('revision_type.eq.client_revision,revision_type.is.null')
.eq('invoiced', false)
.in('task_id', approvedTaskIds)
.in('task_id', companyTaskIds)
.order('submitted_at', { ascending: false })
: { data: [] };
const tasksWithService = (tasks || []).map(t => {
const tasksWithService = (companyTasks || []).filter(isInitialVersionEligible).map(t => {
const initial = (t.submissions || []).find(s => s.type === 'initial') || (t.submissions || [])[0];
return { ...t, service_type: initial?.service_type || t.title };
});
setUninvoicedTasks(tasksWithService);
// Deduplicate by (task_id, version_number) — multiple submission rows per version (e.g. "Add Files") must not produce multiple invoice charges
const revMap = new Map();
for (const rev of (revisions || [])) {
for (const rev of (revisions || []).filter(rev => !isReviewShadowDescription(rev.description))) {
if (!isCompletedVersionEligible(rev.task, rev.version_number)) continue;
const key = `${rev.task_id}:${rev.version_number}`;
if (!revMap.has(key)) revMap.set(key, rev);
}
@@ -152,7 +148,7 @@ export default function CreateInvoice() {
const serviceLabel = getRevisionServiceType(revision);
const description = buildRevisionItemDescription(revision);
const price = priceList.find(p => p.service_type === serviceLabel && p.price_type === 'revision');
const revisionChargeQty = getRevisionChargeQuantity(revision);
const revisionChargeQty = getRevisionChargeQuantity(revision?.version_number);
const quantity = revisionChargeQty > 0 ? revisionChargeQty : 1;
const unitPrice = revisionChargeQty > 0 ? (price?.price || '') : 0;
setItems(prev => {
@@ -324,7 +320,7 @@ export default function CreateInvoice() {
return (
<Layout>
<button className="back-link" onClick={() => navigate('/invoices')}> Back to Invoices</button>
<button className="back-link" onClick={() => navigate('/finances')}> Back to Invoices</button>
<div className="page-header">
<div>
@@ -403,9 +399,10 @@ export default function CreateInvoice() {
<div key={task.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 400 }}>{buildNewItemDescription(task)}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
{task.service_type || 'Other'} {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', fontSize: 11, color: 'var(--text-muted)' }}>
<span className="badge badge-initial">New</span>
<span>{task.service_type || 'Other'} {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}</span>
</div>
</div>
<button
className={`btn btn-sm ${alreadyAdded ? 'btn-outline' : 'btn-primary'}`}
@@ -441,13 +438,15 @@ export default function CreateInvoice() {
{uninvoicedRevisions.map(rev => {
const revServiceType = getRevisionServiceType(rev);
const price = priceList.find(p => p.service_type === revServiceType && p.price_type === 'revision');
const revisionChargeQty = getRevisionChargeQuantity(rev?.version_number);
const alreadyAdded = items.some(i => i.submission_id === rev.id);
return (
<div key={rev.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 400 }}>{buildRevisionItemDescription(rev)}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
{revServiceType || 'Other'} {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', fontSize: 11, color: 'var(--text-muted)' }}>
<span className="badge badge-client_revision">Revision</span>
<span>{revServiceType || 'Other'} {revisionChargeQty > 0 ? (price ? `$${Number(price.price).toFixed(2)}` : 'No price set') : 'Free'}</span>
</div>
</div>
<button
@@ -537,7 +536,7 @@ export default function CreateInvoice() {
<LoadingButton className="btn btn-primary" onClick={() => handleSave('sent')} loading={saving} loadingText="Finalizing & Sending...">
Finalise & Send
</LoadingButton>
<button className="btn btn-outline" onClick={() => navigate('/invoices')}>Cancel</button>
<button className="btn btn-outline" onClick={() => navigate('/finances')}>Cancel</button>
</div>
</Layout>
);
+3 -27
View File
@@ -3,7 +3,6 @@ import { useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
import { sendEmail } from '../../lib/email';
const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', marginBottom: 4 };
const FIELD_INPUT_STYLE = { minHeight: 42, margin: 0 };
@@ -180,35 +179,12 @@ export default function CreateSubcontractorPO() {
return;
}
if (status === 'sent') {
const subcontractor = externalProfiles.find(profile => profile.id === form.profile_id);
const project = projects.find(row => row.id === form.project_id);
if (subcontractor?.email) {
try {
await sendEmail('subcontractor_po_sent', subcontractor.email, {
poNumber: data.po_number || 'Purchase Order',
subcontractorName: subcontractor.name || 'there',
projectName: project?.name || 'Subcontractor Work',
companyName: project?.company?.name || 'Fourge Branding',
amount: `$${total.toFixed(2)}`,
dueDate: form.due_date ? new Date(form.due_date).toLocaleDateString() : 'Not set',
terms: form.terms.trim() || 'Net 15',
scope: lineItems.map(item => `${item.description} - $${item.amount.toFixed(2)}`).join('\n'),
portalUrl: 'https://portal.fourgebranding.com/my-purchase-orders',
});
} catch (emailError) {
alert(`PO was finalized, but the email failed: ${emailError.message || 'unknown error'}`);
}
} else {
alert('PO was finalized, but this subcontractor has no email on file.');
}
}
navigate('/invoices', { state: { tab: 'subcontractor-po' } });
navigate('/finances', { state: { tab: 'subcontractor-po' } });
};
return (
<Layout>
<button className="back-link" onClick={() => navigate('/invoices', { state: { tab: 'subcontractor-po' } })}> Back to Subcontractor PO</button>
<Layout loading={loading}>
<button className="back-link" onClick={() => navigate('/finances', { state: { tab: 'subcontractor-po' } })}> Back to Subcontractor PO</button>
<div className="page-header">
<div>
+228 -249
View File
@@ -1,15 +1,18 @@
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { useState, useEffect, useMemo, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import PageLoader from '../../components/PageLoader';
import SortTh from '../../components/SortTh';
import ProfileAvatar from '../../components/ProfileAvatar';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
import { readPageCache, writePageCache } from '../../lib/pageCache';
import { withTimeout } from '../../lib/withTimeout';
import { getDeadlineSourceSubmission } from '../../lib/taskDeadlines';
import { useSortable } from '../../hooks/useSortable';
import { useRefetchOnFocus } from '../../hooks/useRefetchOnFocus';
import { useRealtimeSubscription } from '../../hooks/useRealtimeSubscription';
import { useLiveRefresh } from '../../hooks/useLiveRefresh';
import { resolveScopedWorkIds } from '../../lib/workScope';
import { getSubmissionDisplayName, mergeSubmissionDisplayNames } from '../../lib/submissionDisplay';
// ─── Helpers ──────────────────────────────────────────────────────────────
@@ -32,22 +35,6 @@ function fmtMoney(n) {
return `$${Number(n).toFixed(2)}`;
}
function buildClientHighlights(companies, projects, tasks, clientProfiles, companyMemberships = [], invoices = []) {
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
return (companies || []).map(company => {
const companyProjects = (projects || []).filter(p => p.company_id === company.id);
const primaryContact = (clientProfiles || []).find(p =>
p.company_id === company.id ||
(companyMemberships || []).some(m => m.company_id === company.id && m.profile_id === p.id)
);
const openTasks = (tasks || []).filter(t => companyProjects.some(p => p.id === t.project_id) && !doneStatuses.includes(t.status));
const companyInvoices = (invoices || []).filter(i => i.company_id === company.id);
const outstandingTotal = companyInvoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total || 0), 0);
const paidTotal = companyInvoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
return { company, primaryContact, projectCount: companyProjects.length, openTaskCount: openTasks.length, outstandingTotal, paidTotal };
});
}
const ACTION_ICON = {
task_started: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> },
task_resumed: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> },
@@ -75,25 +62,6 @@ function ActionIcon({ actionKey, size = 27 }) {
);
}
function Avatar({ name, size = 27 }) {
return (
<div style={{ width: size, height: size, borderRadius: '50%', background: 'rgba(245,165,35,0.15)', flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="#F5A523">
<circle cx="12" cy="8" r="4" />
<path d="M4 20c0-4.4 3.6-7 8-7s8 2.6 8 7" />
</svg>
</div>
);
}
function InitialPortrait({ name }) {
return (
<div style={{ width: 27, height: 27, borderRadius: '50%', background: 'var(--accent)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: '#000', lineHeight: 1 }}>{(name || '?')[0].toUpperCase()}</span>
</div>
);
}
function smoothCurve(pts) {
if (pts.length < 2) return '';
let d = `M${pts[0][0].toFixed(1)},${pts[0][1].toFixed(1)}`;
@@ -373,170 +341,159 @@ function TasksInProgressCard({ tasks = [] }) {
);
}
function HotItemsCard({ submissions, tasks, isClient = false }) {
function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false, currentUserId = null, cardHeight = null }) {
const navigate = useNavigate();
const { sortKey, sortDir, toggle, sort } = useSortable('deadline');
const taskMap = new Map((tasks || []).map(t => [t.id, t]));
const seen = new Set();
const hotItems = (submissions || [])
.filter(s => {
const task = taskMap.get(s.task_id);
if (isClient) return task?.status === 'client_review' && !seen.has(s.task_id) && seen.add(s.task_id);
const activeStatuses = ['not_started', 'in_progress', 'client_review', 'on_hold'];
return s.is_hot && activeStatuses.includes(task?.status) && !seen.has(s.task_id) && seen.add(s.task_id);
})
.map(s => ({ ...s, task: taskMap.get(s.task_id) }))
.filter(s => s.task)
.sort((a, b) => {
if (!a.deadline && !b.deadline) return 0;
if (!a.deadline) return 1;
if (!b.deadline) return -1;
return new Date(a.deadline) - new Date(b.deadline);
})
.slice(0, 7);
const activeStatuses = ['not_started', 'in_progress', 'client_review', 'on_hold'];
const hotItems = isExternal
? (tasks || [])
.filter(task => task?.assigned_to === currentUserId && activeStatuses.includes(task?.status))
.sort((a, b) => {
if (!a.deadline && !b.deadline) return 0;
if (!a.deadline) return 1;
if (!b.deadline) return -1;
return new Date(a.deadline) - new Date(b.deadline);
})
.slice(0, 8)
: (submissions || [])
.filter(s => {
const task = taskMap.get(s.task_id);
if (isClient) return task?.status === 'client_review' && !seen.has(s.task_id) && seen.add(s.task_id);
return s.is_hot && activeStatuses.includes(task?.status) && !seen.has(s.task_id) && seen.add(s.task_id);
})
.map(s => ({ ...s, task: taskMap.get(s.task_id) }))
.filter(s => s.task)
.sort((a, b) => {
if (!a.deadline && !b.deadline) return 0;
if (!a.deadline) return 1;
if (!b.deadline) return -1;
return new Date(a.deadline) - new Date(b.deadline);
})
.slice(0, 7);
const sortedHotItems = sort(hotItems, (s, key) => {
if (isExternal) {
if (key === 'task') return s.title || '';
if (key === 'project') return s.project_name || '';
if (key === 'status') return s.status || '';
if (key === 'deadline') return s.deadline || '';
return '';
}
if (key === 'task') return s.task?.title || '';
if (key === 'requested_by') return s.submitted_by_name || '';
if (key === 'requested_by') return getSubmissionDisplayName(s) || '';
if (key === 'deadline') return s.deadline || '';
return '';
});
return (
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column' }}>
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column', height: cardHeight || 'auto', minHeight: cardHeight ? 0 : 280 }}>
<div style={{ marginBottom: hotItems.length > 0 ? 14 : 0, flexShrink: 0 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>
{isClient ? 'Tasks Ready For Review' : 'Hot Tasks'}
{isClient ? 'Tasks Ready For Review' : isExternal ? 'My Tasks' : 'Hot Tasks'}
</span>
</div>
{hotItems.length === 0 ? (
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>
{isClient ? 'No tasks ready for review' : 'No hot tasks'}
<div style={{ flex: 1, minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>
{isClient ? 'No tasks ready for review' : isExternal ? 'No active tasks assigned to you' : 'No hot tasks'}
</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '10%' }} />
<col style={{ width: '40%' }} />
<col style={{ width: '35%' }} />
<col style={{ width: '15%' }} />
</colgroup>
<thead style={{ background: 'transparent' }}>
<tr style={{ background: 'transparent' }}>
<th style={{ padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top', textAlign: 'center' }} />
<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="requested_by" 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' }}>Requested 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>
</thead>
<tbody>
{sortedHotItems.map(s => (
<tr key={s.task_id} style={{ verticalAlign: 'middle', background: 'transparent' }}>
<td style={{ padding: '3px 5px 7px', border: 'none', background: 'transparent', textAlign: 'center', verticalAlign: 'middle' }}>
{s.task.status === 'client_approved' ? (
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="rgba(74,222,128,0.8)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'block', margin: '0 auto' }}>
<rect x="1.5" y="1.5" width="13" height="13" rx="2"/><polyline points="4,8 6.5,10.5 12,5.5"/>
</svg>
) : (
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" style={{ display: 'block', margin: '0 auto' }}>
<rect x="1.5" y="1.5" width="13" height="13" rx="2"/>
</svg>
)}
</td>
<td style={{ fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'left' }}>
<button type="button" className="dashboard-inline-link" onClick={() => navigate('/tasks/' + s.task_id)}>{s.task.title}</button>
</td>
<td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', padding: '5px', border: 'none', background: 'transparent', textAlign: 'center' }}>
{s.submitted_by ? (
<button type="button" className="dashboard-inline-link" onClick={() => navigate(profilePath(s.submitted_by, s.submitter_role))}>{s.submitted_by_name || 'Profile'}</button>
) : (s.submitted_by_name || '—')}
</td>
<td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', textAlign: 'center', padding: '5px', border: 'none', background: 'transparent' }}>{s.deadline ? new Date(s.deadline).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—'}</td>
</tr>
))}
</tbody>
</table>
<div className="table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
{isExternal ? (
<>
<colgroup>
<col style={{ width: '42%' }} />
<col style={{ width: '24%' }} />
<col style={{ width: '19%' }} />
<col style={{ width: '15%' }} />
</colgroup>
<thead 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="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="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>
</tr>
</thead>
<tbody>
{sortedHotItems.map((task) => (
<tr key={task.id} style={{ verticalAlign: 'middle', background: 'transparent' }}>
<td style={{ fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'left' }}>
<button type="button" className="dashboard-inline-link" onClick={() => navigate('/tasks/' + task.id)}>{task.title}</button>
</td>
<td style={{ fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'left' }}>{task.project?.name || '—'}</td>
<td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', textAlign: 'center', padding: '5px', border: 'none', background: 'transparent', textTransform: 'capitalize' }}>{String(task.status || '').replace(/_/g, ' ') || '—'}</td>
<td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', textAlign: 'center', padding: '5px', border: 'none', background: 'transparent' }}>{task.deadline ? new Date(task.deadline).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—'}</td>
</tr>
))}
</tbody>
</>
) : (
<>
<colgroup>
<col style={{ width: '10%' }} />
<col style={{ width: '40%' }} />
<col style={{ width: '35%' }} />
<col style={{ width: '15%' }} />
</colgroup>
<thead style={{ background: 'transparent' }}>
<tr style={{ background: 'transparent' }}>
<th style={{ padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top', textAlign: 'center' }} />
<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="requested_by" 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' }}>Requested 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>
</thead>
<tbody>
{sortedHotItems.map(s => (
<tr key={s.task_id} style={{ verticalAlign: 'middle', background: 'transparent' }}>
<td style={{ padding: '3px 5px 7px', border: 'none', background: 'transparent', textAlign: 'center', verticalAlign: 'middle' }}>
{s.task.status === 'client_approved' ? (
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="rgba(74,222,128,0.8)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'block', margin: '0 auto' }}>
<rect x="1.5" y="1.5" width="13" height="13" rx="2"/><polyline points="4,8 6.5,10.5 12,5.5"/>
</svg>
) : (
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" style={{ display: 'block', margin: '0 auto' }}>
<rect x="1.5" y="1.5" width="13" height="13" rx="2"/>
</svg>
)}
</td>
<td style={{ fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'left' }}>
<button type="button" className="dashboard-inline-link" onClick={() => navigate('/tasks/' + s.task_id)}>{s.task.title}</button>
</td>
<td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', padding: '5px', border: 'none', background: 'transparent', textAlign: 'center' }}>
{s.submitted_by ? (
<button type="button" className="dashboard-inline-link" onClick={() => navigate(profilePath(s.submitted_by, s.submitter_role))}>{getSubmissionDisplayName(s) || 'Profile'}</button>
) : (getSubmissionDisplayName(s) || '—')}
</td>
<td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', textAlign: 'center', padding: '5px', border: 'none', background: 'transparent' }}>{s.deadline ? new Date(s.deadline).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—'}</td>
</tr>
))}
</tbody>
</>
)}
</table>
</div>
)}
</div>
);
}
function ClientHighlightCard({ highlights }) {
const navigate = useNavigate();
const { sortKey, sortDir, toggle, sort } = useSortable('company');
const fmt = (n) => `$${Number(n || 0).toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`;
const thStyle = { 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' };
const td = () => ({ padding: '5px', border: 'none', background: 'transparent', textAlign: 'center', verticalAlign: 'middle' });
const sortedHighlights = sort(highlights || [], (row, key) => {
if (key === 'company') return row.company?.name || '';
if (key === 'contact') return row.primaryContact?.name || '';
if (key === 'projects') return row.projectCount || 0;
if (key === 'open_tasks') return row.openTaskCount || 0;
if (key === 'outstanding') return Number(row.outstandingTotal || 0);
if (key === 'paid') return Number(row.paidTotal || 0);
return '';
});
return (
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column' }}>
<div style={{ marginBottom: highlights && highlights.length > 0 ? 14 : 0, flexShrink: 0 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Client Highlight</span>
</div>
{!highlights || highlights.length === 0 ? (
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No data</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '5%' }} />
<col style={{ width: '22%' }} />
<col style={{ width: '23%' }} />
<col style={{ width: '13%' }} />
<col style={{ width: '13%' }} />
<col style={{ width: '12%' }} />
<col style={{ width: '12%' }} />
</colgroup>
<thead style={{ background: 'transparent' }}>
<tr style={{ background: 'transparent' }}>
<th style={{ ...thStyle, padding: '0 0 12px 5px' }} />
<SortTh col="company" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...thStyle, textAlign: 'left', paddingLeft: 5 }}>Company</SortTh>
<SortTh col="contact" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={thStyle}>Contact</SortTh>
<SortTh col="projects" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={thStyle}>Projects</SortTh>
<SortTh col="open_tasks" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={thStyle}>Open Tasks</SortTh>
<SortTh col="outstanding" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={thStyle}>Outstanding</SortTh>
<SortTh col="paid" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={thStyle}>Paid</SortTh>
</tr>
</thead>
<tbody>
{sortedHighlights.map(({ company, primaryContact, projectCount, openTaskCount, outstandingTotal = 0, paidTotal = 0 }) => (
<tr key={company.id} style={{ background: 'transparent' }}>
<td style={{ ...td(), padding: '3px 5px 7px' }}>
<InitialPortrait name={company.name} />
</td>
<td style={{ ...td(), fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'left' }}>
<button type="button" className="dashboard-inline-link" onClick={() => navigate(`/company/${company.id}`)}>{company.name}</button>
</td>
<td style={{ ...td(), fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{primaryContact?.id ? (
<button type="button" className="dashboard-inline-link" onClick={() => navigate(profilePath(primaryContact.id, primaryContact.role))}>{primaryContact.name || 'Profile'}</button>
) : (primaryContact?.name || '—')}
</td>
<td style={{ ...td(), fontSize: 12, color: 'var(--text-primary)' }}>{projectCount}</td>
<td style={{ ...td(), fontSize: 12, color: 'var(--text-primary)' }}>{openTaskCount || 0}</td>
<td style={{ ...td(), fontSize: 12, color: '#F5A523' }}>{fmt(outstandingTotal)}</td>
<td style={{ ...td(), fontSize: 12, color: '#4ade80' }}>{fmt(paidTotal)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
function TeamPerformanceCard({ tasks, profiles, deliveries }) {
function TeamPerformanceCard({ tasks, profiles, deliveries, cardHeight = null }) {
const navigate = useNavigate();
const profileMap = useMemo(() => {
const m = new Map();
(profiles || []).forEach(p => m.set(p.id, p));
return m;
}, [profiles]);
const profileNameMap = useMemo(() => {
const m = new Map();
(profiles || []).forEach((profile) => {
const key = String(profile?.name || '').trim().toLowerCase();
if (key && !m.has(key)) m.set(key, profile);
});
return m;
}, [profiles]);
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
const toEST = (dateStr) => new Date(new Date(dateStr).toLocaleString('en-US', { timeZone: 'America/New_York' }));
const monthsWithData = useMemo(() => new Set(
@@ -556,9 +513,12 @@ function TeamPerformanceCard({ tasks, profiles, deliveries }) {
}, []);
const monthOpts = allMonthOpts.filter(o => monthsWithData.has(o.value));
const [monthKey, setMonthKey] = useState(() => monthOpts[0]?.value || allMonthOpts[0].value);
const effectiveMonthKey = monthOpts.some(option => option.value === monthKey)
? monthKey
: (monthOpts[0]?.value || allMonthOpts[0]?.value || '');
const { people, totalDone } = useMemo(() => {
const [year, month] = monthKey.split('-').map(Number);
const [year, month] = effectiveMonthKey.split('-').map(Number);
const map = new Map();
(deliveries || []).forEach(d => {
const task = d.submission?.task;
@@ -567,35 +527,50 @@ function TeamPerformanceCard({ tasks, profiles, deliveries }) {
if (dt.getFullYear() !== year || dt.getMonth() + 1 !== month) return;
const name = d.sent_by || task.assigned_name;
if (!name) return;
const entry = map.get(name) || { name, id: task.assigned_to || null, newCount: 0, revCount: 0 };
const matchedProfile = (task.assigned_to && profileMap.get(task.assigned_to)) || profileNameMap.get(String(name).trim().toLowerCase()) || null;
const entry = map.get(name) || {
name,
id: matchedProfile?.id || task.assigned_to || null,
avatar_url: matchedProfile?.avatar_url || '',
newCount: 0,
revCount: 0,
};
if ((d.version_number || 0) === 0) entry.newCount += 1;
else entry.revCount += 1;
map.set(name, entry);
});
const people = [...map.values()].map(p => ({ ...p, done: p.newCount + p.revCount })).sort((a, b) => b.done - a.done);
return { people, totalDone: people.reduce((s, p) => s + p.done, 0) };
}, [deliveries, monthKey]); // eslint-disable-line react-hooks/exhaustive-deps
}, [deliveries, effectiveMonthKey, profileMap, profileNameMap]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column' }}>
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column', height: cardHeight || 'auto', minHeight: cardHeight ? 0 : 280 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, flexShrink: 0 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Team Performance</span>
<div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center' }}>
<select value={monthKey} onChange={e => setMonthKey(e.target.value)} style={{ fontSize: 11, color: 'var(--text-muted)', background: 'transparent', border: 'none', boxShadow: 'none', cursor: 'pointer', outline: 'none', fontFamily: 'inherit', appearance: 'none', WebkitAppearance: 'none', MozAppearance: 'none', paddingRight: 14 }}>
<select value={effectiveMonthKey} onChange={e => setMonthKey(e.target.value)} style={{ fontSize: 11, color: 'var(--text-muted)', background: 'transparent', border: 'none', boxShadow: 'none', cursor: 'pointer', outline: 'none', fontFamily: 'inherit', appearance: 'none', WebkitAppearance: 'none', MozAppearance: 'none', paddingRight: 14 }}>
{monthOpts.map(o => <option key={o.value} value={o.value} style={{ background: '#1a1a1a' }}>{o.label}</option>)}
</select>
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" style={{ position: 'absolute', right: 0, pointerEvents: 'none' }} stroke="rgba(255,255,255,0.5)" strokeWidth="1.5" strokeLinecap="round"><polyline points="1,2 4,6 7,2"/></svg>
</div>
</div>
{people.length === 0 ? (
<div className="card-empty-center">No completed tasks this month</div>
<div className="card-empty-center" style={{ flex: 1 }}>No completed tasks this month</div>
) : (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<div className="table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
{people.slice(0, 5).map((p, i) => {
const pct = totalDone > 0 ? Math.round((p.done / totalDone) * 100) : 0;
return (
<div key={p.name} style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: i > 0 ? 10 : 0 }}>
{(() => { const prof = p.id ? profileMap.get(p.id) : null; return prof?.avatar_url ? <div style={{ width: 27, height: 27, borderRadius: '50%', flexShrink: 0, overflow: 'hidden' }}><img src={prof.avatar_url} alt={p.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div> : <Avatar name={p.name} />; })()}
<ProfileAvatar
profileId={p.id}
name={p.name}
avatarUrl={p.avatar_url}
profilesById={profileMap}
profilesByName={profileNameMap}
size={27}
fontSize={12}
/>
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
{p.id ? <button type="button" className="dashboard-inline-link" style={{ fontSize: 13, fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} onClick={() => navigate(`/profile/${p.id}`)}>{p.name}</button> : <span style={{ fontSize: 13, color: 'var(--text-primary)', fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>}
@@ -628,26 +603,40 @@ export default function TeamDashboard() {
const isClient = currentUser?.role === 'client';
const isExternal = currentUser?.role === 'external';
const [refreshKey, setRefreshKey] = useState(0);
const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
useRefetchOnFocus(refresh);
useRealtimeSubscription(['tasks', 'projects', 'submissions', 'activity_log', 'profiles'], refresh);
const { refreshKey } = useLiveRefresh(['tasks', 'projects', 'submissions', 'activity_log', 'profiles']);
const cached = isTeam ? readPageCache(CACHE_KEY, 5 * 60_000) : null;
const [tasks, setTasks] = useState(() => cached?.tasks || []);
const [projects, setProjects] = useState(() => cached?.projects || []);
const [submissions, setSubmissions] = useState(() => cached?.submissions || []);
const [allCompanies, setAllCompanies] = useState(() => cached?.companies || []);
const [clientProfiles, setClientProfiles] = useState(() => cached?.clientProfiles || []);
const [companyMemberships, setCompanyMemberships] = useState(() => cached?.companyMemberships || []);
const [activityLog, setActivityLog] = useState(() => cached?.activityLog || []);
const [allProfiles, setAllProfiles] = useState(() => cached?.allProfiles || []);
const [teamInvoices, setTeamInvoices] = useState(() => cached?.teamInvoices || []);
const [teamExpenses, setTeamExpenses] = useState([]);
const [teamSubcontractorPOs, setTeamSubcontractorPOs] = useState([]);
const [teamSubInvoices, setTeamSubInvoices] = useState([]);
const [myInvoices, setMyInvoices] = useState([]);
const [perfDeliveries, setPerfDeliveries] = useState([]);
const [loading, setLoading] = useState(!cached);
const bottomCardsRowRef = useRef(null);
const [bottomCardsHeight, setBottomCardsHeight] = useState(null);
useEffect(() => {
function updateBottomCardsHeight() {
const row = bottomCardsRowRef.current;
if (!row) return;
const rect = row.getBoundingClientRect();
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
const available = Math.floor(viewportHeight - rect.top - 24);
const nextHeight = available > 0 ? `${available}px` : null;
setBottomCardsHeight(prev => (prev === nextHeight ? prev : nextHeight));
}
updateBottomCardsHeight();
window.addEventListener('resize', updateBottomCardsHeight);
return () => window.removeEventListener('resize', updateBottomCardsHeight);
}, [loading, isTeam, isClient, isExternal]);
useEffect(() => {
let cancelled = false;
@@ -658,37 +647,16 @@ export default function TeamDashboard() {
cutoff.setMonth(cutoff.getMonth() - CUTOFF_MONTHS);
const cutoffStr = cutoff.toISOString();
let scopedTaskIds = null;
let scopedProjectIds = null;
let scopedCompanyIds = null;
if (!isTeam && isClient) {
const companyIds = [...new Set([...(currentUser?.companies?.map(c => c.company?.id || c.id) || []), ...(currentUser?.company_id ? [currentUser.company_id] : [])])].filter(Boolean);
scopedCompanyIds = companyIds;
if (companyIds.length > 0) {
const { data: projRows } = await supabase.from('projects').select('id, tasks(id)').in('company_id', companyIds);
scopedProjectIds = (projRows || []).map(p => p.id).filter(Boolean);
scopedTaskIds = (projRows || []).flatMap(p => (p.tasks || []).map(t => t.id)).filter(Boolean);
} else {
scopedProjectIds = [];
scopedTaskIds = [];
}
}
if (!isTeam && isExternal) {
const uid = currentUser?.id;
const { data: memberData } = await supabase.from('project_members').select('project_id').eq('profile_id', uid);
scopedProjectIds = (memberData || []).map(m => m.project_id).filter(Boolean);
if ((scopedProjectIds || []).length > 0) {
const { data: projectTasks } = await supabase.from('tasks').select('id').in('project_id', scopedProjectIds);
scopedTaskIds = (projectTasks || []).map(row => row.id).filter(Boolean);
} else {
scopedTaskIds = [];
}
}
const {
scopedTaskIds,
scopedProjectIds,
scopedCompanyIds,
} = isTeam
? { scopedTaskIds: null, scopedProjectIds: null, scopedCompanyIds: null }
: await resolveScopedWorkIds(currentUser, { isClient, isExternal });
const tasksQuery = supabase.from('tasks')
.select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at, assignee:profiles!assigned_to(avatar_url)')
.select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at, project:projects(name), assignee:profiles!assigned_to(avatar_url)')
.gte('submitted_at', cutoffStr)
.order('submitted_at', { ascending: false });
if (isClient) {
@@ -724,10 +692,10 @@ export default function TeamDashboard() {
}
const invoicesPromise = isTeam
? supabase.from('invoices').select('total, status, company_id, created_at').in('status', ['sent', 'paid'])
? supabase.from('invoices').select('total, stripe_fee, status, company_id, created_at').in('status', ['sent', 'paid'])
: isClient
? ((scopedCompanyIds || []).length > 0
? supabase.from('invoices').select('total, status, company_id, created_at').in('company_id', scopedCompanyIds).in('status', ['sent', 'paid'])
? supabase.from('invoices').select('total, stripe_fee, status, company_id, created_at').in('company_id', scopedCompanyIds).in('status', ['sent', 'paid'])
: Promise.resolve({ data: [] }))
: supabase.from('subcontractor_invoices').select('total, status').eq('profile_id', currentUser?.id).in('status', ['submitted', 'approved', 'paid']);
@@ -735,17 +703,25 @@ export default function TeamDashboard() {
? supabase.from('expenses').select('amount')
: Promise.resolve({ data: [] });
const [{ data: t }, { data: p }, { data: subs }, { data: profiles }, { data: activity }, { data: cos }, { data: memRows }, { data: invs }, { data: exps }, { data: delivs }] = await withTimeout(Promise.all([
const subcontractorPOsPromise = isTeam
? supabase.from('subcontractor_payments').select('amount, status, paid_at, date')
: Promise.resolve({ data: [] });
const subcontractorInvoicesPromise = isTeam
? supabase.from('subcontractor_invoices').select('status, paid_at, items:subcontractor_invoice_items(unit_price, quantity)')
: Promise.resolve({ data: [] });
const [{ data: t }, { data: p }, { data: subs }, { data: profiles }, { data: activity }, { data: invs }, { data: exps }, { data: subcontractorPOs }, { data: subcontractorInvoices }, { data: delivs }] = await withTimeout(Promise.all([
tasksQuery,
projectsQuery,
submissionsQuery,
supabase.from('profiles').select('id, role, name, email, company_id, brand_book_rate, avatar_url'),
supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action, task_id, task_title, project_name, project_id').order('created_at', { ascending: false }).limit(isTeam ? 20 : 50),
supabase.from('companies').select('id, name').order('name'),
supabase.from('company_members').select('company_id, profile_id'),
invoicesPromise,
expensesPromise,
supabase.from('deliveries').select('sent_by, sent_at, version_number, submission:submissions!inner(task:tasks!inner(assigned_to, assigned_name))').gte('sent_at', cutoffStr),
subcontractorPOsPromise,
subcontractorInvoicesPromise,
supabase.from('deliveries').select('sent_by, sent_at, version_number, submission:submissions!inner(task_id, task:tasks!inner(assigned_to, assigned_name))').gte('sent_at', cutoffStr),
]), 30000, 'Dashboard load');
if (cancelled) return;
@@ -754,14 +730,14 @@ export default function TeamDashboard() {
const roleById = new Map((profiles || []).map(pr => [pr.id, pr.role]));
const roleByName = new Map((profiles || []).map(pr => [pr.name, pr.role]));
const tasksWithDeadlines = (t || []).map(task => ({ ...task, deadline: getDeadlineSourceSubmission(task, subs)?.deadline || null, assignee_role: roleById.get(task.assigned_to) || null }));
const subsWithRole = (subs || []).map(sub => ({ ...sub, submitter_role: roleById.get(sub.submitted_by) || null, delivery_sender_role: roleByName.get(sub.delivery?.sent_by) || null }));
const subsWithRole = mergeSubmissionDisplayNames(
(subs || []).map(sub => ({ ...sub, submitter_role: roleById.get(sub.submitted_by) || null, delivery_sender_role: roleByName.get(sub.delivery?.sent_by) || null })),
profiles || []
);
setTasks(tasksWithDeadlines);
setProjects(p || []);
setSubmissions(subsWithRole);
setClientProfiles((profiles || []).filter(pr => pr.role === 'client'));
setAllProfiles(profiles || []);
setAllCompanies(cos || []);
setCompanyMemberships(memRows || []);
const scopedActivity = isTeam
? (activity || [])
: (activity || []).filter((entry) => {
@@ -773,16 +749,19 @@ export default function TeamDashboard() {
setTeamInvoices(isTeam ? (invs || []) : []);
setMyInvoices(!isTeam ? (invs || []) : []);
setTeamExpenses(exps || []);
setPerfDeliveries(delivs || []);
setTeamSubcontractorPOs(subcontractorPOs || []);
setTeamSubInvoices(subcontractorInvoices || []);
const scopedDeliveryTaskIds = new Set(scopedTaskIds || []);
const scopedDeliveries = isTeam
? (delivs || [])
: (delivs || []).filter(delivery => scopedDeliveryTaskIds.has(delivery.submission?.task_id));
setPerfDeliveries(scopedDeliveries);
if (isTeam) {
writePageCache(CACHE_KEY, {
tasks: tasksWithDeadlines,
projects: p || [],
submissions: subsWithRole,
clientProfiles: (profiles || []).filter(pr => pr.role === 'client'),
companies: cos || [],
companyMemberships: memRows || [],
activityLog: activity || [],
teamInvoices: invs || [],
allProfiles: profiles || [],
@@ -797,12 +776,6 @@ export default function TeamDashboard() {
return () => { cancelled = true; };
}, [isTeam, isClient, isExternal, currentUser?.id, currentUser?.company_id, currentUser?.companies, refreshKey]); // eslint-disable-line react-hooks/exhaustive-deps
const teamHighlights = useMemo(() =>
isTeam ? buildClientHighlights(allCompanies, projects, tasks, clientProfiles, companyMemberships, teamInvoices)
.sort((a, b) => b.openTaskCount - a.openTaskCount || b.projectCount - a.projectCount || a.company.name.localeCompare(b.company.name))
.slice(0, 5) : [],
[isTeam, allCompanies, projects, tasks, clientProfiles, companyMemberships, teamInvoices]);
const activityEvents = useMemo(() =>
(activityLog || []).map(e => ({
time: new Date(e.created_at),
@@ -817,7 +790,7 @@ export default function TeamDashboard() {
})).filter(e => !isNaN(e.time)).slice(0, 10),
[activityLog]);
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
if (loading) return <Layout><PageLoader /></Layout>;
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
const inProgressTasks = tasks.filter(t => t.status === 'in_progress');
@@ -839,7 +812,18 @@ export default function TeamDashboard() {
});
const dashRevenue = teamInvoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
const dashOutstanding = teamInvoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total || 0), 0);
const dashExpensesTotal = teamExpenses.reduce((s, e) => s + Number(e.amount || 0), 0);
const dashOpsExpensesTotal = teamExpenses.reduce((s, e) => s + Number(e.amount || 0), 0);
const dashNetReceived = teamInvoices
.filter(i => i.status === 'paid')
.reduce((s, i) => s + Number(i.total || 0) - Number(i.stripe_fee || 0), 0);
const dashPaidSubcontractorPOsTotal = teamSubcontractorPOs
.filter(po => po.status === 'paid')
.reduce((s, po) => s + Number(po.amount || 0), 0);
const dashPaidSubInvoicesTotal = teamSubInvoices
.filter(inv => inv.status === 'paid')
.reduce((s, inv) => s + (inv.items || []).reduce((a, item) => a + Number(item.unit_price || 0) * Number(item.quantity || 1), 0), 0);
const dashSubcontractorExpensesTotal = dashPaidSubcontractorPOsTotal + dashPaidSubInvoicesTotal;
const dashExpensesTotal = dashOpsExpensesTotal + dashSubcontractorExpensesTotal;
const revenueByMonth = Array.from({ length: 4 }, (_, i) => {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth() - (3 - i), 1);
@@ -851,7 +835,7 @@ export default function TeamDashboard() {
const myOutstanding = myInvoices.filter(i => i.status !== 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
const card3 = isTeam
? { label: 'Net Profit', value: fmtMoney(dashRevenue - dashExpensesTotal), sub: `${fmtMoney(dashExpensesTotal)} expenses`, iconBg: 'rgba(74,222,128,0.15)', iconColor: '#4ade80', iconPath: DASH_ICONS.profit }
? { label: 'Net Profit', value: fmtMoney(dashNetReceived - dashExpensesTotal), sub: `${fmtMoney(dashExpensesTotal)} expenses + sub costs`, iconBg: 'rgba(74,222,128,0.15)', iconColor: '#4ade80', iconPath: DASH_ICONS.profit }
: isClient
? { label: 'Outstanding', value: fmtMoney(myOutstanding), sub: 'invoices due', iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: DASH_ICONS.revenue }
: { label: 'Pending', value: fmtMoney(myOutstanding), sub: 'awaiting payment', iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: DASH_ICONS.revenue };
@@ -875,15 +859,10 @@ export default function TeamDashboard() {
<TasksInProgressCard tasks={inProgressTasks} />
<MiniCalendar items={calendarItems} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}>
<HotItemsCard submissions={submissions} tasks={tasks} isClient={isClient} />
<TeamPerformanceCard tasks={tasks} profiles={allProfiles} deliveries={perfDeliveries} />
<div ref={bottomCardsRowRef} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}>
<HotItemsCard submissions={submissions} tasks={tasks} isClient={isClient} isExternal={isExternal} currentUserId={currentUser?.id || null} cardHeight={bottomCardsHeight} />
<TeamPerformanceCard tasks={tasks} profiles={allProfiles} deliveries={perfDeliveries} cardHeight={bottomCardsHeight} />
</div>
{isTeam && (
<div style={{ marginTop: 24 }}>
<ClientHighlightCard highlights={teamHighlights} />
</div>
)}
</Layout>
);
}
+4 -17
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import Layout from '../../components/Layout';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
import { formatShortDateTime } from '../../lib/dates';
function emptyForm() {
return {
@@ -17,17 +18,6 @@ function sortEntries(entries) {
return [...entries].sort((a, b) => a.service_name.localeCompare(b.service_name, undefined, { sensitivity: 'base' }));
}
function formatDate(value) {
if (!value) return 'Unknown';
return new Date(value).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
function normalizeUrl(value) {
const raw = String(value || '').trim();
if (!raw) return '';
@@ -253,7 +243,7 @@ export default function FourgePasswords() {
}
return (
<Layout>
<Layout loading={loading}>
<div className="page-header">
<div>
<div className="page-title">Fourge Passwords</div>
@@ -294,10 +284,7 @@ export default function FourgePasswords() {
{loading ? (
<div style={{ color: 'var(--text-muted)' }}>Loading passwords...</div>
) : entries.length === 0 ? (
<div className="empty-state" style={{ padding: '28px 20px' }}>
<h3 style={{ marginBottom: 8 }}>No passwords yet</h3>
<p>Add encrypted password entries for internal services and team access.</p>
</div>
<div className="card-empty-center" style={{ minHeight: 120 }}>No passwords</div>
) : (
<div style={{ display: 'grid', gap: 10 }}>
{entries.map(entry => (
@@ -316,7 +303,7 @@ export default function FourgePasswords() {
<div>
<div style={{ fontSize: 15, fontWeight: 400, color: 'var(--text-primary)' }}>{entry.service_name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4 }}>
Updated {formatDate(entry.updated_at || entry.created_at)}
Updated {formatShortDateTime(entry.updated_at || entry.created_at, 'Unknown')}
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
+114 -37
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate, useLocation, Link } from 'react-router-dom';
import Layout from '../../components/Layout';
import PageLoader from '../../components/PageLoader';
import LoadingButton from '../../components/LoadingButton';
import SortTh from '../../components/SortTh';
import StatusBadge from '../../components/StatusBadge';
@@ -9,15 +10,20 @@ import { generateInvoicePDF, generateReceiptPDF } from '../../lib/invoice';
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
import { withTimeout } from '../../lib/withTimeout';
import { useSortable } from '../../hooks/useSortable';
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
export default function InvoiceDetail() {
const { id } = useParams();
export function TeamInvoiceDetailPanel({
invoiceId,
initialInvoice = null,
embedded = false,
onClose,
onInvoiceUpdated,
onInvoiceDeleted,
}) {
const navigate = useNavigate();
const { state } = useLocation();
const [invoice, setInvoice] = useState(state?.invoice || null);
const [invoice, setInvoice] = useState(initialInvoice || null);
const [company, setCompany] = useState(null);
const [companies, setCompanies] = useState([]);
const [items, setItems] = useState([]);
@@ -32,14 +38,14 @@ export default function InvoiceDetail() {
useEffect(() => {
async function load() {
try {
const { data: inv } = await supabase.from('invoices').select('*').eq('id', id).single();
const { data: inv } = await supabase.from('invoices').select('*').eq('id', invoiceId).single();
if (!inv) return;
setInvoice(inv);
const [{ data: co }, { data: companyList }, { data: its }] = await Promise.all([
supabase.from('companies').select('*').eq('id', inv.company_id).single(),
supabase.from('companies').select('*').order('name'),
supabase.from('invoice_items').select('*').eq('invoice_id', id).order('created_at'),
supabase.from('invoice_items').select('*').eq('invoice_id', invoiceId).order('created_at'),
]);
const defaultEmail = inv.invoice_email || await getDefaultInvoiceEmail(inv.company_id, co);
setCompany(co);
@@ -53,23 +59,47 @@ export default function InvoiceDetail() {
}
}
load();
}, [id]);
}, [invoiceId]);
const syncInvoiceState = (updater) => {
setInvoice((current) => {
const next = typeof updater === 'function' ? updater(current) : updater;
onInvoiceUpdated?.(next);
return next;
});
};
const updateStatus = async (status) => {
setSaving(true);
const updates = { status };
if (status === 'paid' && !invoice.paid_at) updates.paid_at = new Date().toISOString();
if (status !== 'paid') updates.paid_at = null;
const { error } = await supabase.from('invoices').update(updates).eq('id', id);
const { error } = await supabase.from('invoices').update(updates).eq('id', invoiceId);
if (!error) {
setInvoice(i => ({ ...i, ...updates }));
syncInvoiceState(i => ({ ...i, ...updates }));
// Sync task statuses along invoice lifecycle
const { data: freshItems } = await supabase.from('invoice_items').select('task_id, submission_id').eq('invoice_id', id);
const { data: freshItems } = await supabase.from('invoice_items').select('task_id, submission_id').eq('invoice_id', invoiceId);
const taskIds = (freshItems || []).filter(i => i.task_id && !i.submission_id).map(i => i.task_id);
if (taskIds.length > 0) {
const newTaskStatus = status === 'paid' ? 'paid' : status === 'sent' ? 'invoiced' : 'client_approved';
await supabase.from('tasks').update({ status: newTaskStatus }).in('id', taskIds);
}
if (status === 'paid') {
try {
const contactEmail = invoice.invoice_email || await getDefaultInvoiceEmail(invoice.company_id, company);
if (contactEmail) {
const paidDate = new Date(updates.paid_at || new Date().toISOString()).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
await sendEmail('receipt_sent', [contactEmail], {
invoiceNumber: invoice.invoice_number,
billTo: invoice.bill_to || company?.name,
total: `$${Number(invoice.total).toFixed(2)}`,
paidDate,
});
}
} catch (emailErr) {
console.error('Failed to send paid invoice email:', emailErr);
}
}
} else {
alert('Failed to update status.');
}
@@ -104,12 +134,12 @@ export default function InvoiceDetail() {
const contactEmail = getEmailRecipient();
if (!contactEmail) throw new Error('Enter an email recipient before sending.');
const { error } = await withTimeout(
supabase.from('invoices').update({ invoice_email: contactEmail }).eq('id', id),
supabase.from('invoices').update({ invoice_email: contactEmail }).eq('id', invoiceId),
12000,
'Saving invoice email'
);
if (error) throw error;
setInvoice(inv => ({ ...inv, invoice_email: contactEmail }));
syncInvoiceState(inv => ({ ...inv, invoice_email: contactEmail }));
return contactEmail;
};
@@ -167,12 +197,12 @@ export default function InvoiceDetail() {
const updates = { status: 'sent' };
const { error } = await withTimeout(
supabase.from('invoices').update(updates).eq('id', id),
supabase.from('invoices').update(updates).eq('id', invoiceId),
12000,
'Updating invoice status'
);
if (error) throw error;
setInvoice(i => ({ ...i, ...updates }));
syncInvoiceState(i => ({ ...i, ...updates }));
alert(`Invoice email sent successfully.${attachmentWarning || ''}`);
} catch (err) {
console.error('Failed to finalize and send invoice:', err);
@@ -202,16 +232,22 @@ export default function InvoiceDetail() {
};
const handleDelete = async () => {
if (invoice?.status === 'paid') {
alert('Paid invoices cannot be deleted.');
return;
}
setSaving(true);
try {
const { data: freshItems } = await supabase.from('invoice_items').select('task_id, submission_id').eq('invoice_id', id);
const { data: freshItems } = await supabase.from('invoice_items').select('task_id, submission_id').eq('invoice_id', invoiceId);
const taskIds = (freshItems || []).filter(i => i.task_id && !i.submission_id).map(i => i.task_id);
if (taskIds.length > 0) await supabase.from('tasks').update({ invoiced: false, status: 'client_approved' }).in('id', taskIds);
const submissionIds = (freshItems || []).filter(i => i.submission_id).map(i => i.submission_id);
if (submissionIds.length > 0) await supabase.from('submissions').update({ invoiced: false }).in('id', submissionIds);
const { error } = await supabase.from('invoices').delete().eq('id', id);
const { error } = await supabase.from('invoices').delete().eq('id', invoiceId);
if (error) throw error;
navigate('/invoices');
onInvoiceDeleted?.(invoiceId);
if (embedded) onClose?.();
else navigate('/finances');
} catch {
alert('Failed to delete invoice. Please try again.');
setSaving(false);
@@ -279,8 +315,8 @@ export default function InvoiceDetail() {
await supabase.from('invoices').update({
invoice_date: dateForm.invoice_date,
due_date: dateForm.due_date,
}).eq('id', id);
setInvoice(i => ({ ...i, invoice_date: dateForm.invoice_date, due_date: dateForm.due_date }));
}).eq('id', invoiceId);
syncInvoiceState(i => ({ ...i, invoice_date: dateForm.invoice_date, due_date: dateForm.due_date }));
setEditingDates(false);
setSaving(false);
};
@@ -288,8 +324,8 @@ export default function InvoiceDetail() {
const handleEmailBlur = async () => {
const nextEmail = getEmailRecipient();
if ((invoice.invoice_email || '') === nextEmail) return;
const { error } = await supabase.from('invoices').update({ invoice_email: nextEmail || null }).eq('id', id);
if (!error) setInvoice(inv => ({ ...inv, invoice_email: nextEmail || null }));
const { error } = await supabase.from('invoices').update({ invoice_email: nextEmail || null }).eq('id', invoiceId);
if (!error) syncInvoiceState(inv => ({ ...inv, invoice_email: nextEmail || null }));
};
const handleCompanyChange = async (companyId) => {
@@ -302,19 +338,37 @@ export default function InvoiceDetail() {
company_id: companyId,
bill_to: nextCompany.name,
invoice_email: defaultEmail,
}).eq('id', id);
}).eq('id', invoiceId);
setSaving(false);
if (error) {
alert('Failed to update invoice company. Please try again.');
return;
}
setInvoice(inv => ({ ...inv, company_id: companyId, bill_to: nextCompany.name, invoice_email: defaultEmail }));
syncInvoiceState(inv => ({ ...inv, company_id: companyId, bill_to: nextCompany.name, invoice_email: defaultEmail }));
setCompany(nextCompany);
setEmailRecipient(defaultEmail);
};
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
if (!invoice) return <Layout><p>Invoice not found.</p></Layout>;
if (loading) {
if (embedded) {
return (
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<PageLoader />
</div>
);
}
return <Layout><PageLoader /></Layout>;
}
if (!invoice) {
if (embedded) {
return (
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-muted)' }}>
Invoice not found.
</div>
);
}
return <Layout><p>Invoice not found.</p></Layout>;
}
const sortedItems = sort(items, (item, key) => {
if (key === 'type') return item.submission_id ? 'Revision' : 'New';
if (key === 'description') return item.description || '';
@@ -325,19 +379,18 @@ export default function InvoiceDetail() {
});
const isOverdue = invoice.status !== 'paid' && new Date(invoice.due_date) < new Date();
const detailContent = (
<>
{!embedded && <button className="back-link" onClick={() => navigate('/finances')}> Back to Invoices</button>}
return (
<Layout>
<button className="back-link" onClick={() => navigate('/invoices')}> Back to Invoices</button>
<div className="page-header">
<div className={embedded ? '' : 'page-header'} style={embedded ? { display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, paddingBottom: 18, borderBottom: '1px solid var(--border)' } : undefined}>
<div>
<div className="page-title">{invoice.invoice_number}</div>
<div className="page-subtitle">
<div className={embedded ? '' : 'page-title'} style={embedded ? { fontSize: 18, fontWeight: 500, color: 'var(--text-primary)', letterSpacing: 0.2, lineHeight: 1.1 } : undefined}>{invoice.invoice_number}</div>
<div className={embedded ? '' : 'page-subtitle'} style={embedded ? { marginTop: 6, fontSize: 13, color: 'var(--text-secondary)' } : undefined}>
<Link to={`/company/${company?.id}`} style={{ color: 'var(--accent)' }}>{company?.name}</Link>
</div>
</div>
<div className="action-buttons">
<div className="action-buttons" style={embedded ? { justifyContent: 'flex-end' } : undefined}>
<StatusBadge
status={statusColor[invoice.status] || 'not_started'}
label={`${invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'}${isOverdue ? ' · Overdue' : ''}`}
@@ -355,7 +408,7 @@ export default function InvoiceDetail() {
</div>
</div>
<div className="grid-2" style={{ marginBottom: 24 }}>
<div className="grid-2" style={{ marginTop: embedded ? 18 : 0, marginBottom: 24 }}>
<div className="card">
<div className="card-title">Bill To</div>
<div style={{ fontSize: 15, fontWeight: 400 }}>{invoice.bill_to || company?.name}</div>
@@ -510,9 +563,33 @@ export default function InvoiceDetail() {
</LoadingButton>
</>
)}
<button className="btn-icon btn-icon-danger" title="Delete Invoice" onClick={handleDelete} disabled={saving}></button>
{invoice.status !== 'paid' && (
<button className="btn-icon btn-icon-danger" title="Delete Invoice" onClick={handleDelete} disabled={saving}></button>
)}
</div>
</div>
</Layout>
</>
);
if (embedded) {
return (
<div style={popupOverlayStyle} onClick={() => { if (!saving && !generating) onClose?.(); }}>
<div
style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0, overflowY: 'auto' }}
onClick={(e) => e.stopPropagation()}
>
{detailContent}
</div>
</div>
);
}
return <Layout>{detailContent}</Layout>;
}
export default function InvoiceDetail() {
const { id } = useParams();
const { state } = useLocation();
return <TeamInvoiceDetailPanel invoiceId={id} initialInvoice={state?.invoice || null} />;
}
+462 -85
View File
@@ -7,6 +7,7 @@ import SortTh from '../../components/SortTh';
import StatusBadge from '../../components/StatusBadge';
import { useSortable } from '../../hooks/useSortable';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
import { readPageCache, writePageCache } from '../../lib/pageCache';
import { exportCPAPackage, generateSubcontractorPOPDF, generateInvoicePDF } from '../../lib/invoice';
import { withTimeout } from '../../lib/withTimeout';
@@ -14,6 +15,9 @@ import LoadingButton from '../../components/LoadingButton';
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
import FileAttachment from '../../components/FileAttachment';
import { isReviewShadowDescription } from '../../lib/taskVersions';
import { getRevisionChargeQuantity, isCompletedVersionEligible, isInitialVersionEligible } from '../../lib/invoiceVersionRules';
import { TeamInvoiceDetailPanel } from './TeamInvoiceDetail';
const CATEGORIES = ['Software', 'Contractor', 'Advertising', 'Office', 'Travel', 'Meals', 'Equipment', 'Other'];
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
@@ -40,12 +44,156 @@ const invoiceStatusBadgeLabel = (status) => {
const RECEIPT_BUCKET = 'expense-receipts';
const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 4 };
const FIELD_INPUT_STYLE = { minHeight: 42, margin: 0 };
const FINANCE_MODAL_INPUT_STYLE = {
...FIELD_INPUT_STYLE,
width: '100%',
padding: '0 10px',
fontSize: 13,
color: 'var(--text-primary)',
background: 'var(--card-bg-2)',
border: '1px solid var(--border)',
borderRadius: 6,
fontFamily: 'inherit',
boxSizing: 'border-box',
};
const FINANCE_MODAL_TEXTAREA_STYLE = {
...FINANCE_MODAL_INPUT_STYLE,
minHeight: 72,
padding: '10px',
resize: 'vertical',
};
const FINANCE_MODAL_LINE_ITEM_INPUT_STYLE = {
...FINANCE_MODAL_INPUT_STYLE,
minHeight: 32,
padding: '0 8px',
};
const FINANCE_MODAL_NUMBER_INPUT_STYLE = {
...FINANCE_MODAL_LINE_ITEM_INPUT_STYLE,
fontVariantNumeric: 'tabular-nums',
};
const FINANCE_MODAL_AMOUNT_FIELD_STYLE = {
display: 'flex',
alignItems: 'center',
gap: 8,
minHeight: 42,
width: '100%',
padding: '0 12px',
background: 'var(--card-bg-2)',
border: '1px solid var(--border)',
borderRadius: 6,
boxSizing: 'border-box',
};
const FINANCE_MODAL_AMOUNT_PREFIX_STYLE = {
flexShrink: 0,
fontSize: 16,
fontWeight: 500,
lineHeight: 1,
color: 'var(--text-secondary)',
};
const FINANCE_MODAL_AMOUNT_INPUT_STYLE = {
flex: 1,
minWidth: 0,
margin: 0,
padding: 0,
border: 'none',
outline: 'none',
background: 'transparent',
boxShadow: 'none',
fontFamily: 'inherit',
fontSize: 22,
fontWeight: 500,
lineHeight: 1.1,
textAlign: 'right',
color: 'var(--text-primary)',
fontVariantNumeric: 'tabular-nums',
appearance: 'textfield',
};
const FINANCE_MODAL_TOTAL_VALUE_STYLE = {
fontSize: 24,
fontWeight: 500,
lineHeight: 1.1,
color: 'var(--accent)',
fontVariantNumeric: 'tabular-nums',
};
function parseSubInvoiceItemVersionNumber(item) {
if (Number.isFinite(Number(item?.version_number))) return Number(item.version_number);
const match = String(item?.description || '').match(/\bR(\d{2})\b/i);
return match ? Number(match[1]) : 0;
}
function subInvoiceItemVersionLabel(item) {
return `R${String(parseSubInvoiceItemVersionNumber(item)).padStart(2, '0')}`;
}
function subInvoiceItemWorkLabel(item) {
return parseSubInvoiceItemVersionNumber(item) > 0 ? 'Revision' : 'New';
}
function cleanSubInvoiceItemDescription(item) {
return String(item?.description || '—').replace(/\s*[-]\s*R\d{2}\b/i, '').trim() || '—';
}
function asArray(value) {
if (Array.isArray(value)) return value;
if (value == null) return [];
return typeof value === 'object' ? [value] : [];
}
async function fetchSubInvoiceTaskTypeMap(taskIds) {
const uniqueTaskIds = [...new Set((taskIds || []).filter(Boolean))];
if (uniqueTaskIds.length === 0) return {};
const { data, error } = await supabase
.from('tasks')
.select('id, title, submissions(service_type, type)')
.in('id', uniqueTaskIds);
if (error) throw error;
const next = {};
for (const task of (data || [])) {
const submissions = asArray(task.submissions);
const initial = submissions.find((submission) => submission?.type === 'initial' && submission?.service_type);
const fallback = submissions.find((submission) => submission?.service_type);
next[task.id] = initial?.service_type || fallback?.service_type || task.title || 'Other';
}
return next;
}
function CurrencyInput({ value, onChange, placeholder = '0.00', required = false, min = '0', step = '0.01' }) {
return (
<div style={FINANCE_MODAL_AMOUNT_FIELD_STYLE}>
<span style={FINANCE_MODAL_AMOUNT_PREFIX_STYLE}>$</span>
<input
type="number"
min={min}
step={step}
required={required}
placeholder={placeholder}
value={value}
onChange={onChange}
style={FINANCE_MODAL_AMOUNT_INPUT_STYLE}
/>
</div>
);
}
const INV_TODAY = new Date().toISOString().split('T')[0];
const INV_NET30 = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const invNewItem = (description = '', unit_price = '', quantity = 1, task_id = null, submission_id = null) =>
({ id: crypto.randomUUID(), description, unit_price, quantity, task_id, submission_id });
const invBuildNewItemDescription = (task) => {
const projectName = task.project?.name || 'No Project';
const taskTitle = task.title || task.service_type || 'Untitled';
return `${projectName}${taskTitle}`;
};
const invBuildRevisionItemDescription = (revision) => {
const projectName = revision.task?.project?.name || 'No Project';
const taskTitle = revision.task?.title || revision.service_type || 'Revision';
const versionLabel = 'R' + String(revision.version_number || 0).padStart(2, '0');
return `${projectName}${taskTitle} • Revision ${versionLabel}`;
};
const blankExpense = () => ({
date: new Date().toISOString().slice(0, 10),
description: '',
@@ -78,6 +226,7 @@ function FinancesChartTooltip({ active, payload, label, year }) {
}
export default function Invoices() {
const { currentUser } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const cached = readPageCache('team_invoices');
@@ -126,6 +275,9 @@ export default function Invoices() {
const [subInvoicesLoading, setSubInvoicesLoading] = useState(true);
const [subInvoicesError, setSubInvoicesError] = useState('');
const [markingPaid, setMarkingPaid] = useState('');
const [viewingSubInvoice, setViewingSubInvoice] = useState(null);
const [viewingSubInvoiceTaskTypeMap, setViewingSubInvoiceTaskTypeMap] = useState({});
const [viewingInvoice, setViewingInvoice] = useState(null);
const [expandedSubInvoiceId, setExpandedSubInvoiceId] = useState(null);
// New Invoice form
@@ -143,11 +295,30 @@ export default function Invoices() {
const [invSaving, setInvSaving] = useState(false);
const [invLoadingTasks, setInvLoadingTasks] = useState(false);
const invDragItem = useRef(null);
const pageLoading = loading || expensesLoading || subcontractorLoading || subInvoicesLoading;
useEffect(() => {
supabase.from('companies').select('id, name, contact_email').order('name').then(({ data }) => setInvCompanies(data || []));
}, []);
useEffect(() => {
let cancelled = false;
async function loadSubInvoiceTaskTypes() {
if (!viewingSubInvoice?.items?.length) {
setViewingSubInvoiceTaskTypeMap({});
return;
}
try {
const map = await fetchSubInvoiceTaskTypeMap(asArray(viewingSubInvoice.items).map((item) => item.task_id));
if (!cancelled) setViewingSubInvoiceTaskTypeMap(map);
} catch {
if (!cancelled) setViewingSubInvoiceTaskTypeMap({});
}
}
loadSubInvoiceTaskTypes();
return () => { cancelled = true; };
}, [viewingSubInvoice]);
useEffect(() => {
if (!invCompanyId) { setInvUnbilledTasks([]); setInvUnbilledRevisions([]); setInvPriceList([]); setInvItems([invNewItem()]); setInvBillTo(''); setInvEmail(''); setInvRecipients([]); return; }
const co = invCompanies.find(c => c.id === invCompanyId);
@@ -163,12 +334,32 @@ export default function Invoices() {
setInvEmail((users || [])[0]?.email || co?.contact_email || '');
if (projects?.length > 0) {
const pids = projects.map(p => p.id);
const { data: tasks } = await supabase.from('tasks').select('*, project:projects(name), submissions(service_type, type, version_number)').in('project_id', pids).eq('invoiced', false).eq('status', 'client_approved');
const tids = (tasks || []).map(t => t.id);
const { data: revisions } = tids.length > 0 ? await supabase.from('submissions').select('*, task:tasks(id, title, project:projects(name), submissions(service_type, type))').eq('type', 'revision').or('revision_type.eq.client_revision,revision_type.is.null').eq('invoiced', false).in('task_id', tids) : { data: [] };
setInvUnbilledTasks((tasks || []).map(t => { const ini = (t.submissions || []).find(s => s.type === 'initial') || t.submissions?.[0]; return { ...t, service_type: ini?.service_type || t.title }; }));
const { data: companyTasks } = await supabase
.from('tasks')
.select('*, project:projects(name), submissions(service_type, type, version_number)')
.in('project_id', pids)
.in('status', ['not_started', 'in_progress', 'on_hold', 'client_review', 'client_approved', 'invoiced', 'paid']);
const revisionTaskIds = (companyTasks || []).map(t => t.id).filter(Boolean);
const { data: revisions } = revisionTaskIds.length > 0
? await supabase
.from('submissions')
.select('*, task:tasks(id, title, status, current_version, project:projects(name), submissions(service_type, type))')
.eq('type', 'revision')
.eq('invoiced', false)
.in('task_id', revisionTaskIds)
.order('submitted_at', { ascending: false })
: { data: [] };
const uninvoicedTasks = (companyTasks || []).filter(isInitialVersionEligible);
setInvUnbilledTasks(uninvoicedTasks.map(t => {
const ini = (t.submissions || []).find(s => s.type === 'initial') || t.submissions?.[0];
return { ...t, service_type: ini?.service_type || t.title };
}));
const revMap = new Map();
for (const r of (revisions || [])) { const k = `${r.task_id}:${r.version_number}`; if (!revMap.has(k)) revMap.set(k, r); }
for (const r of (revisions || []).filter(r => !isReviewShadowDescription(r.description))) {
if (!isCompletedVersionEligible(r.task, r.version_number)) continue;
const k = `${r.task_id}:${r.version_number}`;
if (!revMap.has(k)) revMap.set(k, r);
}
setInvUnbilledRevisions([...revMap.values()]);
} else { setInvUnbilledTasks([]); setInvUnbilledRevisions([]); }
setInvLoadingTasks(false);
@@ -177,18 +368,17 @@ export default function Invoices() {
const invAddTask = (task) => {
const price = invPriceList.find(p => p.service_type === task.service_type && p.price_type === 'new');
const desc = `${task.project?.name || 'No Project'}${task.title || task.service_type || 'Untitled'}`;
const desc = invBuildNewItemDescription(task);
setInvItems(prev => prev.length === 1 && !prev[0].description && !prev[0].unit_price ? [invNewItem(desc, price?.price || '', 1, task.id)] : [...prev, invNewItem(desc, price?.price || '', 1, task.id)]);
};
const invAddRevision = (rev) => {
const ini = (rev.task?.submissions || []).find(s => s.type === 'initial') || rev.task?.submissions?.[0];
const svcType = ini?.service_type || rev.service_type || rev.task?.title || 'Revision';
const price = invPriceList.find(p => p.service_type === svcType && p.price_type === 'revision');
const version = Number(rev.version_number || 0);
const qty = version >= 2 ? 1 : 1;
const unitPrice = version >= 2 ? (price?.price || '') : 0;
const vLabel = 'R' + String(version).padStart(2, '0');
const desc = `${rev.task?.project?.name || 'No Project'}${rev.task?.title || 'Revision'} • Revision ${vLabel}`;
const revisionChargeQty = getRevisionChargeQuantity(rev?.version_number);
const qty = revisionChargeQty > 0 ? revisionChargeQty : 1;
const unitPrice = revisionChargeQty > 0 ? (price?.price || '') : 0;
const desc = invBuildRevisionItemDescription(rev);
setInvItems(prev => prev.length === 1 && !prev[0].description && !prev[0].unit_price ? [invNewItem(desc, unitPrice, qty, rev.task_id, rev.id)] : [...prev, invNewItem(desc, unitPrice, qty, rev.task_id, rev.id)]);
};
const invUpdateItem = (id, field, val) => setInvItems(prev => prev.map(it => it.id === id ? { ...it, [field]: val } : it));
@@ -226,14 +416,18 @@ export default function Invoices() {
const payUrl = `https://portal.fourgebranding.com/pay/${encodeURIComponent(invoiceNumber)}`;
const pdfItems = validItems.map(it => ({ description: it.description, quantity: Number(it.quantity) || 1, unit_price: Number(it.unit_price) || 0 }));
let attachments = [];
try { const pdf = await withTimeout(generateInvoicePDF({ ...invoice, status: 'sent' }, invSelectedCompany, pdfItems, { save: false }), 8000, 'PDF'); attachments = [await withTimeout(blobToEmailAttachment(pdf, `${invoiceNumber}.pdf`), 5000, 'Attachment')]; } catch {}
try { const pdf = await withTimeout(generateInvoicePDF({ ...invoice, status: 'sent' }, invSelectedCompany, pdfItems, { save: false }), 8000, 'PDF'); attachments = [await withTimeout(blobToEmailAttachment(pdf, `${invoiceNumber}.pdf`), 5000, 'Attachment')]; } catch { /* PDF attach failed — send email without attachment */ }
await withTimeout(sendEmail('invoice_sent', invEmail.trim(), { invoiceNumber, billTo: invBillTo || invSelectedCompany?.name, total: `$${invTotal.toFixed(2)}`, dueDate, payUrl, notes: invNotes || '' }, attachments), 12000, 'Email');
await supabase.from('invoices').update({ status: 'sent' }).eq('id', invoice.id);
} catch (e) { alert(`Invoice saved as draft — email failed: ${e.message}`); }
}
setInvoices(prev => [{ ...invoice, company: { name: invSelectedCompany?.name || '' } }, ...prev]);
const createdInvoice = {
...invoice,
company: invSelectedCompany ? { id: invSelectedCompany.id, name: invSelectedCompany.name } : null,
};
setInvoices(prev => [createdInvoice, ...prev]);
invClose();
navigate(`/invoices/${invoice.id}`);
setViewingInvoice(createdInvoice);
} catch (e) { alert(`Failed to save invoice: ${e.message || 'Unknown error'}`); }
setInvSaving(false);
};
@@ -409,26 +603,7 @@ export default function Invoices() {
};
const handleSendSubcontractorPO = async (po) => {
const sentPO = await updateSubcontractorPO(po, { status: 'sent', sent_at: new Date().toISOString() }, 'Failed to send PO');
if (!sentPO?.profile?.email) return;
try {
await sendEmail('subcontractor_po_sent', sentPO.profile.email, {
poNumber: sentPO.po_number || 'Purchase Order',
subcontractorName: sentPO.profile?.name || 'there',
projectName: sentPO.project?.name || 'Subcontractor Work',
companyName: sentPO.project?.company?.name || 'Fourge Branding',
amount: `$${Number(sentPO.amount).toFixed(2)}`,
dueDate: sentPO.due_date ? new Date(sentPO.due_date).toLocaleDateString() : 'Not set',
terms: sentPO.terms || 'Net 15',
scope: sentPO.items?.length
? sentPO.items.map(item => `${item.description}$${Number(item.amount).toFixed(2)}`).join('\n')
: sentPO.description,
portalUrl: 'https://portal.fourgebranding.com/my-purchase-orders',
});
} catch (emailError) {
console.error('Failed to email subcontractor PO:', emailError);
alert(`PO was marked sent, but the email failed: ${emailError.message || 'unknown error'}`);
}
await updateSubcontractorPO(po, { status: 'sent', sent_at: new Date().toISOString() }, 'Failed to send PO');
};
const handleReadyToPaySubcontractorPO = (po) => {
@@ -490,6 +665,56 @@ export default function Invoices() {
setMarkingPaid('');
};
const handleDownloadSubInvoiceReceipt = async (invoice) => {
const items = invoice.items || [];
const total = items.reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
try {
await generateSubcontractorPOPDF({
po_number: invoice.invoice_number,
status: 'paid',
profile: invoice.profile,
project: { name: 'Subcontractor Invoice', company: { name: 'Fourge Branding' } },
date: invoice.created_at?.split('T')[0],
due_date: (invoice.paid_at || new Date().toISOString()).split('T')[0],
amount: total,
description: 'Payment for completed subcontractor work.',
notes: invoice.notes,
items: items.map((item, idx) => ({
description: item.description,
amount: Number(item.unit_price) * Number(item.quantity || 1),
sort_order: item.sort_order ?? idx,
})),
});
} catch (err) {
alert(`Failed to download receipt: ${err.message || 'unknown error'}`);
}
};
const handleDownloadSubInvoice = async (invoice) => {
const items = invoice.items || [];
const total = items.reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
try {
await generateSubcontractorPOPDF({
po_number: invoice.invoice_number,
status: invoice.status || 'draft',
profile: invoice.profile,
project: { name: 'Subcontractor Invoice', company: { name: 'Fourge Branding' } },
date: invoice.created_at?.split('T')[0],
due_date: (invoice.paid_at || invoice.submitted_at || invoice.created_at || new Date().toISOString()).split('T')[0],
amount: total,
description: invoice.status === 'paid' ? 'Payment for completed subcontractor work.' : 'Subcontractor invoice for completed work.',
notes: invoice.notes,
items: items.map((item, idx) => ({
description: item.description,
amount: Number(item.unit_price || 0) * Number(item.quantity || 1),
sort_order: item.sort_order ?? idx,
})),
});
} catch (err) {
alert(`Failed to download invoice: ${err.message || 'unknown error'}`);
}
};
const handleReopenSubcontractorPO = async (po) => {
updateSubcontractorPO(po, { status: 'draft', paid_at: null, cancelled_at: null }, 'Failed to reopen PO');
};
@@ -622,7 +847,7 @@ export default function Invoices() {
return (
<Layout>
<Layout loading={pageLoading}>
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* Top row: 60% chart + 40% stat cards */}
{(() => {
@@ -653,7 +878,7 @@ export default function Invoices() {
<div style={{ ...CARD }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Revenue Overview</span>
<select className="filter-select" value={chartYear} onChange={e => setChartYear(Number(e.target.value))} style={{ width: 90, borderRadius: 8, fontSize: 11, fontWeight: 500 }}>
<select className="filter-select" value={chartYear} onChange={e => setExportYear(Number(e.target.value))} style={{ width: 90, borderRadius: 8, fontSize: 11, fontWeight: 500 }}>
{(paidYears.length > 0 ? paidYears : [new Date().getFullYear()]).map(y => <option key={y} value={y}>{y}</option>)}
</select>
</div>
@@ -694,7 +919,6 @@ export default function Invoices() {
{ id: 'expenses', label: 'Expenses' },
{ id: 'subcontractors', label: 'Subcontractors' },
{ id: 'invoices', label: 'Invoices' },
{ id: 'legacy', label: 'Legacy' },
].map(t => (
<button
key={t.id}
@@ -1075,7 +1299,7 @@ export default function Invoices() {
return (
<tr key={inv.id}>
<td style={{ ...TD }}>
<button type="button" className="table-link" onClick={() => navigate(`/sub-invoices/${inv.id}`)}>
<button type="button" className="table-link" onClick={() => setViewingSubInvoice(inv)}>
{inv.invoice_number || '—'}
</button>
</td>
@@ -1195,7 +1419,7 @@ export default function Invoices() {
<tbody>{sorted.map(inv => (
<tr key={inv.id}>
<td style={{ ...TD }}>
<button type="button" className="dashboard-inline-link" onClick={() => navigate(`/invoices/${inv.id}`)}>{inv.invoice_number}</button>
<button type="button" className="dashboard-inline-link" onClick={() => setViewingInvoice(inv)}>{inv.invoice_number}</button>
</td>
<td style={{ ...TD }}>
<button type="button" className="dashboard-inline-link" onClick={() => inv.company?.id && navigate(`/company/${inv.company.id}`)}>{inv.company?.name || inv.bill_to || '—'}</button>
@@ -1252,24 +1476,22 @@ export default function Invoices() {
})()}
{financeTab === 'legacy' && <div>
<div style={{ display: 'flex', alignItems: 'center', gap: 0, marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10, minHeight: 'var(--btn-height)' }}>
{[
{ id: 'invoices', label: 'INVOICES' },
{ id: 'expenses', label: 'EXPENSES' },
{ id: 'sub-invoices', label: 'SUBCONTRACTOR INVOICES' },
].map((t, i, arr) => (
<span key={t.id} style={{ display: 'flex', alignItems: 'center' }}>
<button
type="button"
onClick={() => setActiveTab(t.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontSize: 13, letterSpacing: 0.5, fontWeight: activeTab === t.id ? 600 : 400, color: activeTab === t.id ? 'var(--text-primary)' : 'var(--text-muted)', fontFamily: 'inherit' }}
>{t.label}</button>
{i < arr.length - 1 && <span style={{ margin: '0 10px', color: 'var(--border)', userSelect: 'none' }}>|</span>}
</span>
<button
key={t.id}
type="button"
onClick={() => setActiveTab(t.id)}
className={`section-tab-btn${activeTab === t.id ? ' is-active' : ''}`}
>{t.label}</button>
))}
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 10, minHeight: 'var(--btn-height)', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{companyNames.length > 0 && activeTab === 'invoices' && (
<select className="filter-select" style={{ width: 120 }} value={filterCompany} onChange={e => setFilterCompany(e.target.value)}>
@@ -1310,10 +1532,10 @@ export default function Invoices() {
{loading ? (
<p style={{ color: 'var(--text-muted)' }}>Loading...</p>
) : filtered.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No invoices.</p>
<div className="card card-empty-center">No invoices</div>
) : (
<div style={{ background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<table>
<div className="table-wrapper">
<table className="table-sticky-head">
<thead>
<tr>
<SortTh col="invoice_number" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle}>Invoice #</SortTh>
@@ -1330,7 +1552,7 @@ export default function Invoices() {
if (key === 'invoice_date' || key === 'due_date') return new Date(inv[key]).getTime();
return inv[key] || inv.company?.name || '';
}).map(inv => (
<tr key={inv.id} onClick={() => navigate(`/invoices/${inv.id}`)} style={{ cursor: 'pointer' }}>
<tr key={inv.id} onClick={() => setViewingInvoice(inv)} style={{ cursor: 'pointer' }}>
<td style={{ fontWeight: 400 }}>{inv.invoice_number}</td>
<td style={{ fontWeight: 400 }}>{inv.bill_to || inv.company?.name}</td>
<td style={{ color: 'var(--text-muted)' }}>{new Date(inv.invoice_date).toLocaleDateString()}</td>
@@ -1359,17 +1581,17 @@ export default function Invoices() {
{activeTab === 'expenses' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<div>
<div style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 10 }}>
{expensesLoading ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading...</p>
) : expensesError ? (
<p style={{ color: 'var(--danger)', fontSize: 13 }}>{expensesError}</p>
) : filteredExpenses.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No expenses yet.</p>
<div className="card card-empty-center">No expenses</div>
) : (
<div style={{ background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<table>
<div className="table-wrapper">
<table className="table-sticky-head">
<thead>
<tr>
<SortTh col="date" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle}>Date</SortTh>
@@ -1431,16 +1653,16 @@ export default function Invoices() {
{activeTab === 'sub-invoices' && (
<div>
<div style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 10 }}>
{subInvoicesLoading ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading...</p>
) : subInvoicesError ? (
<p style={{ color: 'var(--danger)', fontSize: 13 }}>{subInvoicesError}</p>
) : subInvoices.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No sub invoices yet.</p>
<div className="card card-empty-center">No sub invoices</div>
) : (
<div style={{ background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<table>
<div className="table-wrapper">
<table className="table-sticky-head">
<thead>
<tr>
<SortTh col="invoice_number" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Invoice #</SortTh>
@@ -1461,7 +1683,7 @@ export default function Invoices() {
const total = (inv.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
const subInvoiceStatusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
return (
<tr key={inv.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/sub-invoices/${inv.id}`)}>
<tr key={inv.id} style={{ cursor: 'pointer' }} onClick={() => setViewingSubInvoice(inv)}>
<td style={{ fontWeight: 400 }}>{inv.invoice_number}</td>
<td>
<div style={{ fontWeight: 400 }}>{inv.profile?.name || 'External'}</div>
@@ -1488,9 +1710,139 @@ export default function Invoices() {
)}
</div>}{/* end legacy wrapper */}
{viewingSubInvoice && (() => {
const invoice = viewingSubInvoice;
const sortedItems = [...(invoice.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
const total = sortedItems.reduce((s, item) => s + Number(item.unit_price || 0) * Number(item.quantity || 1), 0);
const invoiceStatus = invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—';
const subInvoiceStatusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
return (
<div style={popupOverlayStyle} onClick={() => { if (!markingPaid) setViewingSubInvoice(null); }}>
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, paddingBottom: 18, borderBottom: '1px solid var(--border)' }}>
<div>
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--text-primary)', letterSpacing: 0.2, lineHeight: 1.1 }}>{invoice.invoice_number || 'Subcontractor Invoice'}</div>
<div style={{ marginTop: 6, fontSize: 13, color: 'var(--text-secondary)' }}>
{invoice.profile?.name || 'External'}{invoice.profile?.email ? ` · ${invoice.profile.email}` : ''}
</div>
</div>
<StatusBadge status={subInvoiceStatusColor[invoice.status] || 'not_started'} label={invoiceStatus} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 18, padding: '18px 0', borderBottom: '1px solid var(--border)' }}>
<div>
<div style={FIELD_LABEL_STYLE}>Submitted</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.submitted_at ? new Date(invoice.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div>
</div>
<div>
<div style={FIELD_LABEL_STYLE}>Paid</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.paid_at ? new Date(invoice.paid_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div>
</div>
<div>
<div style={FIELD_LABEL_STYLE}>Line Items</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{sortedItems.length}</div>
</div>
<div>
<div style={FIELD_LABEL_STYLE}>Total</div>
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--accent)', lineHeight: 1.1 }}>${total.toFixed(2)}</div>
</div>
</div>
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto', paddingTop: 18 }}>
{sortedItems.length === 0 ? (
<div className="card-empty-center">No line items</div>
) : (
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '8%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '32%' }} />
<col style={{ width: '16%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '12%' }} />
<col style={{ width: '12%' }} />
</colgroup>
<thead>
<tr>
<th style={{ textAlign: 'center' }}>Work</th>
<th style={{ textAlign: 'center' }}>R#</th>
<th style={{ textAlign: 'left' }}>Description</th>
<th style={{ textAlign: 'center' }}>Type</th>
<th style={{ textAlign: 'center' }}>Qty</th>
<th style={{ textAlign: 'center' }}>Unit Price</th>
<th style={{ textAlign: 'center' }}>Amount</th>
</tr>
</thead>
<tbody>
{sortedItems.map(item => (
<tr key={item.id}>
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)', textAlign: 'center' }}>{subInvoiceItemWorkLabel(item)}</td>
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)', textAlign: 'center' }}>{subInvoiceItemVersionLabel(item)}</td>
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)' }}>{cleanSubInvoiceItemDescription(item)}</td>
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)', textAlign: 'center' }}>{viewingSubInvoiceTaskTypeMap[item.task_id] || 'Other'}</td>
<td style={{ padding: '5px 0', textAlign: 'center', fontSize: 13, color: 'var(--text-primary)' }}>{item.quantity || 1}</td>
<td style={{ padding: '5px 0', textAlign: 'center', fontSize: 13, color: 'var(--text-primary)' }}>${Number(item.unit_price || 0).toFixed(2)}</td>
<td style={{ padding: '5px 0', textAlign: 'center', fontSize: 13, color: 'var(--text-primary)' }}>${(Number(item.unit_price || 0) * Number(item.quantity || 1)).toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
)}
{invoice.notes && (
<div style={{ marginTop: 18, padding: '12px 14px', background: 'var(--card-bg-2)', borderRadius: 6, border: '1px solid var(--border)' }}>
<div style={FIELD_LABEL_STYLE}>Notes</div>
<div style={{ fontSize: 13, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap' }}>{invoice.notes}</div>
</div>
)}
</div>
<div className="modal-action-row" style={{ paddingTop: 18, borderTop: '1px solid var(--border)' }}>
<button type="button" className="btn btn-outline" onClick={() => handleDownloadSubInvoice(invoice)}>
Download Invoice
</button>
{invoice.status === 'submitted' && (
<LoadingButton className="btn btn-outline" onClick={async () => {
await handleMarkSubInvoicePaid(invoice);
setViewingSubInvoice(current => current?.id === invoice.id ? { ...current, status: 'paid', paid_at: new Date().toISOString() } : current);
}} loading={markingPaid === invoice.id} loadingText="Processing…">
Mark as Paid
</LoadingButton>
)}
{invoice.status === 'paid' && (
<button type="button" className="btn btn-outline" onClick={() => handleDownloadSubInvoiceReceipt(invoice)}>
Download Receipt
</button>
)}
<button type="button" className="btn btn-outline" onClick={() => setViewingSubInvoice(null)}>
Cancel
</button>
</div>
</div>
</div>
);
})()}
{viewingInvoice && (
<TeamInvoiceDetailPanel
invoiceId={viewingInvoice.id}
initialInvoice={viewingInvoice}
embedded
onClose={() => setViewingInvoice(null)}
onInvoiceUpdated={(updatedInvoice) => {
setViewingInvoice(updatedInvoice);
setInvoices((current) => current.map((invoice) => (invoice.id === updatedInvoice.id ? { ...invoice, ...updatedInvoice } : invoice)));
}}
onInvoiceDeleted={(deletedId) => {
setInvoices((current) => current.filter((invoice) => invoice.id !== deletedId));
setViewingInvoice((current) => (current?.id === deletedId ? null : current));
}}
/>
)}
{viewingExpense && (() => {
const LABEL = { fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 3 };
const INPUT = { fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' };
const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 };
const INPUT = FINANCE_MODAL_INPUT_STYLE;
const isPdf = viewingExpense.receipt_name?.toLowerCase().endsWith('.pdf');
const isDirty = expenseDetailEditing && (
newExpense.amount !== String(viewingExpense.amount ?? '') ||
@@ -1510,9 +1862,13 @@ export default function Invoices() {
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: '0 0 260px', overflowY: 'auto' }}>
<div style={LABEL}>Expense Detail</div>
{expenseDetailEditing ? (
<input type="number" step="0.01" min="0" required style={{ ...INPUT, fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#ef4444', fontVariantNumeric: 'tabular-nums', padding: '0 6px' }} value={newExpense.amount} onChange={e => setNewExpense(p => ({ ...p, amount: e.target.value }))} />
<CurrencyInput
value={newExpense.amount}
required
onChange={e => setNewExpense(p => ({ ...p, amount: e.target.value }))}
/>
) : (
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#ef4444', fontVariantNumeric: 'tabular-nums' }}>
<div style={{ ...FINANCE_MODAL_TOTAL_VALUE_STYLE, color: 'var(--danger)' }}>
${Number(viewingExpense.amount).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
)}
@@ -1568,7 +1924,7 @@ export default function Invoices() {
)}
</div>
{/* Footer: buttons bottom-right */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 14, marginTop: 14, flexShrink: 0 }}>
<div className="modal-action-row" style={{ paddingTop: 14, marginTop: 14, flexShrink: 0 }}>
{expenseDetailEditing ? (
<>
<button className="btn btn-outline" disabled={!isDirty || addingExpense} onClick={async () => { await saveExpense(); setExpenseDetailEditing(false); setViewingExpense(null); }}>{addingExpense ? 'Saving…' : 'Save'}</button>
@@ -1590,8 +1946,8 @@ export default function Invoices() {
})()}
{showExpenseForm && (() => {
const LABEL = { fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 3 };
const INPUT = { fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' };
const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 };
const INPUT = FINANCE_MODAL_INPUT_STYLE;
return (
<div style={popupOverlayStyle} onClick={cancelExpenseEdit}>
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
@@ -1601,7 +1957,11 @@ export default function Invoices() {
{/* Left: fields */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: '0 0 260px', overflowY: 'auto' }}>
<div style={LABEL}>New Expense</div>
<input type="number" step="0.01" min="0" required placeholder="0.00" style={{ ...INPUT, fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#ef4444', fontVariantNumeric: 'tabular-nums', padding: '0 6px' }} value={newExpense.amount} onChange={e => setNewExpense(p => ({ ...p, amount: e.target.value }))} />
<CurrencyInput
value={newExpense.amount}
required
onChange={e => setNewExpense(p => ({ ...p, amount: e.target.value }))}
/>
<div>
<div style={LABEL}>Description</div>
<input type="text" required placeholder="What was this for?" style={INPUT} value={newExpense.description} onChange={e => setNewExpense(p => ({ ...p, description: e.target.value }))} />
@@ -1629,7 +1989,7 @@ export default function Invoices() {
</div>
</div>
{/* Footer */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 14, marginTop: 14, flexShrink: 0 }}>
<div className="modal-action-row" style={{ paddingTop: 14, marginTop: 14, flexShrink: 0 }}>
<button type="submit" className="btn btn-outline" disabled={addingExpense}>{addingExpense ? 'Saving…' : 'Save'}</button>
<button type="button" className="btn btn-outline" onClick={cancelExpenseEdit}>Cancel</button>
</div>
@@ -1642,8 +2002,8 @@ export default function Invoices() {
</div>{/* end flex column wrapper */}
{showInvoiceForm && (() => {
const LABEL = { fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 3 };
const INPUT = { fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' };
const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 };
const INPUT = FINANCE_MODAL_INPUT_STYLE;
return (
<div style={popupOverlayStyle} onClick={() => { if (!invSaving) invClose(); }}>
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
@@ -1672,11 +2032,11 @@ export default function Invoices() {
</>}
<div>
<div style={LABEL}>Notes</div>
<textarea style={{ ...INPUT, minHeight: 72, resize: 'vertical' }} value={invNotes} onChange={e => setInvNotes(e.target.value)} placeholder="Payment terms, notes…" />
<textarea style={FINANCE_MODAL_TEXTAREA_STYLE} value={invNotes} onChange={e => setInvNotes(e.target.value)} placeholder="Payment terms, notes…" />
</div>
<div style={{ marginTop: 'auto', paddingTop: 8 }}>
<div style={LABEL}>Total</div>
<div style={{ fontSize: 28, fontWeight: 400, color: 'var(--accent)', fontVariantNumeric: 'tabular-nums' }}>${invTotal.toFixed(2)}</div>
<div style={FINANCE_MODAL_TOTAL_VALUE_STYLE}>${invTotal.toFixed(2)}</div>
</div>
</div>
@@ -1697,7 +2057,10 @@ export default function Invoices() {
return (
<div key={t.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--card-bg-2)', borderRadius: 4, border: '1px solid var(--border)', marginBottom: 4 }}>
<div>
<div style={{ fontSize: 12 }}>{t.project?.name} {t.title || t.service_type}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span className="badge badge-initial">New</span>
<div style={{ fontSize: 12 }}>{t.project?.name} {t.title || t.service_type}</div>
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{price ? `$${Number(price.price).toFixed(2)}` : 'No price'}</div>
</div>
<button type="button" className="btn btn-outline" style={{ fontSize: 11 }} disabled={added} onClick={() => invAddTask(t)}>{added ? 'Added' : '+ Add'}</button>
@@ -1715,9 +2078,20 @@ export default function Invoices() {
</div>
{invUnbilledRevisions.map(r => {
const added = invItems.some(i => i.submission_id === r.id);
const revisionQty = getRevisionChargeQuantity(r?.version_number);
const initial = (r.task?.submissions || []).find(s => s.type === 'initial') || r.task?.submissions?.[0];
const revisionServiceType = initial?.service_type || r.service_type || r.task?.title || 'Revision';
const revisionPrice = invPriceList.find(p => p.service_type === revisionServiceType && p.price_type === 'revision');
const revisionLabel = revisionQty > 0 ? (revisionPrice ? `$${Number(revisionPrice.price).toFixed(2)}` : 'No price') : 'Free';
return (
<div key={r.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--card-bg-2)', borderRadius: 4, border: '1px solid var(--border)', marginBottom: 4 }}>
<div style={{ fontSize: 12 }}>{r.task?.project?.name} {r.task?.title} R{String(r.version_number || 0).padStart(2, '0')}</div>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span className="badge badge-client_revision">Revision</span>
<div style={{ fontSize: 12 }}>{r.task?.project?.name} {r.task?.title} R{String(r.version_number || 0).padStart(2, '0')}</div>
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{revisionLabel}</div>
</div>
<button type="button" className="btn btn-outline" style={{ fontSize: 11 }} disabled={added} onClick={() => invAddRevision(r)}>{added ? 'Added' : '+ Add'}</button>
</div>
);
@@ -1727,16 +2101,19 @@ export default function Invoices() {
{/* Line items */}
<div style={{ flex: 1 }}>
<div style={LABEL}>Line Items</div>
<div style={{ display: 'grid', gridTemplateColumns: '20px 1fr 60px 100px 90px 28px', gap: 6, marginBottom: 6 }}>
{['', 'Description', 'Qty', 'Unit Price', 'Total', ''].map((h, i) => <div key={i} style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.5, color: 'var(--text-muted)', textAlign: i > 2 ? 'right' : 'left' }}>{h}</div>)}
<div style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 60px 100px 90px 28px', gap: 6, marginBottom: 6 }}>
{['', 'Type', 'Description', 'Qty', 'Unit Price', 'Total', ''].map((h, i) => <div key={i} style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.5, color: 'var(--text-muted)', textAlign: i > 3 ? 'right' : 'left' }}>{h}</div>)}
</div>
{invItems.map((item, idx) => (
<div key={item.id} onDragOver={e => e.preventDefault()} onDrop={() => invHandleDrop(idx)} style={{ display: 'grid', gridTemplateColumns: '20px 1fr 60px 100px 90px 28px', gap: 6, alignItems: 'center', marginBottom: 4 }}>
<div key={item.id} onDragOver={e => e.preventDefault()} onDrop={() => invHandleDrop(idx)} style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 60px 100px 90px 28px', gap: 6, alignItems: 'center', marginBottom: 4 }}>
<div draggable onDragStart={() => { invDragItem.current = idx; }} style={{ cursor: 'grab', color: 'var(--text-muted)', fontSize: 12, textAlign: 'center', userSelect: 'none' }}></div>
<input style={{ ...INPUT, padding: '4px 8px' }} type="text" placeholder="Description…" value={item.description} onChange={e => invUpdateItem(item.id, 'description', e.target.value)} />
<input style={{ ...INPUT, padding: '4px 6px', textAlign: 'center' }} type="number" min="1" value={item.quantity} onChange={e => invUpdateItem(item.id, 'quantity', e.target.value)} />
<input style={{ ...INPUT, padding: '4px 8px', textAlign: 'right' }} type="number" min="0" step="0.01" placeholder="0.00" value={item.unit_price} onChange={e => invUpdateItem(item.id, 'unit_price', e.target.value)} />
<div style={{ textAlign: 'right', fontSize: 13, color: 'var(--text-primary)', paddingRight: 2 }}>${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)}</div>
<span className={`badge ${item.submission_id ? 'badge-client_revision' : 'badge-initial'}`}>
{item.submission_id ? 'Revision' : 'New'}
</span>
<input style={FINANCE_MODAL_LINE_ITEM_INPUT_STYLE} type="text" placeholder="Description…" value={item.description} onChange={e => invUpdateItem(item.id, 'description', e.target.value)} />
<input style={{ ...FINANCE_MODAL_NUMBER_INPUT_STYLE, textAlign: 'center' }} type="number" min="1" value={item.quantity} onChange={e => invUpdateItem(item.id, 'quantity', e.target.value)} />
<input style={{ ...FINANCE_MODAL_NUMBER_INPUT_STYLE, textAlign: 'right' }} type="number" min="0" step="0.01" placeholder="0.00" value={item.unit_price} onChange={e => invUpdateItem(item.id, 'unit_price', e.target.value)} />
<div style={{ minHeight: 32, display: 'flex', alignItems: 'center', justifyContent: 'flex-end', textAlign: 'right', fontSize: 13, color: 'var(--text-primary)', paddingRight: 2, fontVariantNumeric: 'tabular-nums' }}>${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)}</div>
<button type="button" onClick={() => invRemoveItem(item.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: 14, padding: 2 }}></button>
</div>
))}
@@ -1746,7 +2123,7 @@ export default function Invoices() {
</div>
{/* Footer */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 14, marginTop: 14, flexShrink: 0 }}>
<div className="modal-action-row" style={{ paddingTop: 14, marginTop: 14, flexShrink: 0 }}>
<button type="button" className="btn btn-outline" disabled={invSaving} onClick={() => invHandleSave('draft')}>{invSaving ? 'Saving…' : 'Save Draft'}</button>
<LoadingButton className="btn btn-outline" style={{ color: 'var(--accent)', borderColor: 'var(--accent)' }} onClick={() => invHandleSave('sent')} loading={invSaving} loadingText="Sending…">Finalise & Send</LoadingButton>
<button type="button" className="btn btn-outline" disabled={invSaving} onClick={invClose}>Cancel</button>
+373
View File
@@ -0,0 +1,373 @@
import { useEffect, useMemo, useState } from 'react';
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
import Layout from '../../components/Layout';
import SortTh from '../../components/SortTh';
import StatusBadge from '../../components/StatusBadge';
import LoadingButton from '../../components/LoadingButton';
import { useSortable } from '../../hooks/useSortable';
import { supabase } from '../../lib/supabase';
import { isReviewShadowDescription } from '../../lib/taskVersions';
function fmtVersion(versionNumber) {
return `R${String(Number(versionNumber || 0)).padStart(2, '0')}`;
}
function billingStatusFromInvoices(items = []) {
if (!items.length) return 'not_started';
if (items.some((item) => item.invoice?.status === 'paid')) return 'paid';
return 'invoiced';
}
function billingLabel(status) {
if (status === 'paid') return 'Paid';
if (status === 'invoiced') return 'Invoiced';
return 'Not Invoiced';
}
function versionTypeLabel(versionNumber) {
return Number(versionNumber || 0) === 0 ? 'New' : 'Revision';
}
function versionStateFor(task, versionNumber) {
const currentVersion = Number(task?.current_version || 0);
const version = Number(versionNumber || 0);
if (version < currentVersion) return 'completed';
return task?.status || 'not_started';
}
function rowSort(a, b) {
const companyCmp = (a.company_name || '').localeCompare(b.company_name || '');
if (companyCmp !== 0) return companyCmp;
const projectCmp = (a.project_name || '').localeCompare(b.project_name || '');
if (projectCmp !== 0) return projectCmp;
const taskCmp = (a.task_title || '').localeCompare(b.task_title || '');
if (taskCmp !== 0) return taskCmp;
return Number(a.version_number || 0) - Number(b.version_number || 0);
}
export default function TeamReports() {
const [loading, setLoading] = useState(true);
const [exporting, setExporting] = useState(false);
const [error, setError] = useState('');
const [companies, setCompanies] = useState([]);
const [projects, setProjects] = useState([]);
const [reportRows, setReportRows] = useState([]);
const [companyFilter, setCompanyFilter] = useState('all');
const [projectFilter, setProjectFilter] = useState('all');
const { sortKey, sortDir, toggle, sort } = useSortable('company_name');
useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
setError('');
try {
const [
{ data: taskRows, error: tasksError },
{ data: submissionRows, error: submissionsError },
{ data: invoiceItemRows, error: invoiceItemsError },
] = await Promise.all([
supabase
.from('tasks')
.select('id, title, status, current_version, project:projects(id, name, company:companies(id, name))')
.order('title'),
supabase
.from('submissions')
.select('id, task_id, type, version_number, submitted_at, invoiced, description')
.in('type', ['initial', 'revision'])
.order('submitted_at', { ascending: true }),
supabase
.from('invoice_items')
.select('id, task_id, submission_id, invoice:invoices(id, status), submission:submissions(id, task_id, version_number)')
]);
if (tasksError) throw tasksError;
if (submissionsError) throw submissionsError;
if (invoiceItemsError) throw invoiceItemsError;
if (cancelled) return;
const taskMap = new Map((taskRows || []).map((task) => [task.id, task]));
const companiesMap = new Map();
const projectsMap = new Map();
(taskRows || []).forEach((task) => {
const company = task.project?.company;
if (company?.id && !companiesMap.has(company.id)) companiesMap.set(company.id, company);
if (task.project?.id && !projectsMap.has(task.project.id)) projectsMap.set(task.project.id, task.project);
});
const versionMap = new Map();
for (const submission of (submissionRows || [])) {
if (isReviewShadowDescription(submission.description)) continue;
const versionNumber = Number(submission.version_number || 0);
const key = `${submission.task_id}:${versionNumber}`;
if (!versionMap.has(key)) {
versionMap.set(key, {
task_id: submission.task_id,
version_number: versionNumber,
submission_ids: [],
first_submitted_at: submission.submitted_at || null,
});
}
const entry = versionMap.get(key);
entry.submission_ids.push(submission.id);
if (!entry.first_submitted_at || (submission.submitted_at && submission.submitted_at < entry.first_submitted_at)) {
entry.first_submitted_at = submission.submitted_at;
}
}
const invoiceItemGroups = new Map();
for (const item of (invoiceItemRows || [])) {
const versionNumber = item.submission?.version_number ?? (item.submission_id ? null : 0);
const taskId = item.submission?.task_id || item.task_id;
if (!taskId || versionNumber === null) continue;
const key = `${taskId}:${Number(versionNumber || 0)}`;
if (!invoiceItemGroups.has(key)) invoiceItemGroups.set(key, []);
invoiceItemGroups.get(key).push(item);
}
const rows = [];
for (const task of (taskRows || [])) {
const company = task.project?.company;
const project = task.project;
const taskVersions = [];
const initialKey = `${task.id}:0`;
if (versionMap.has(initialKey) || task) {
taskVersions.push(versionMap.get(initialKey) || {
task_id: task.id,
version_number: 0,
submission_ids: [],
first_submitted_at: null,
});
}
for (const versionEntry of versionMap.values()) {
if (versionEntry.task_id !== task.id) continue;
if (Number(versionEntry.version_number || 0) === 0) continue;
taskVersions.push(versionEntry);
}
taskVersions.sort((a, b) => Number(a.version_number || 0) - Number(b.version_number || 0));
for (const versionEntry of taskVersions) {
const key = `${task.id}:${Number(versionEntry.version_number || 0)}`;
const invoiceItems = invoiceItemGroups.get(key) || [];
const billingStatus = billingStatusFromInvoices(invoiceItems);
rows.push({
key,
company_id: company?.id || '',
company_name: company?.name || '—',
project_id: project?.id || '',
project_name: project?.name || '—',
task_id: task.id,
task_title: task.title || 'Untitled',
version_number: Number(versionEntry.version_number || 0),
version_label: fmtVersion(versionEntry.version_number),
version_type: versionTypeLabel(versionEntry.version_number),
version_state: versionStateFor(task, versionEntry.version_number),
billing_status: billingStatus,
billing_label: billingLabel(billingStatus),
first_submitted_at: versionEntry.first_submitted_at,
});
}
}
rows.sort(rowSort);
setCompanies([...companiesMap.values()].sort((a, b) => (a.name || '').localeCompare(b.name || '')));
setProjects([...projectsMap.values()].sort((a, b) => (a.name || '').localeCompare(b.name || '')));
setReportRows(rows);
} catch (err) {
if (!cancelled) setError(err.message || 'Failed to load reports.');
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => { cancelled = true; };
}, []);
const filteredProjects = useMemo(() => {
if (companyFilter === 'all') return projects;
return projects.filter((project) => project.company?.id === companyFilter);
}, [projects, companyFilter]);
const visibleRows = useMemo(() => {
return reportRows.filter((row) => {
if (companyFilter !== 'all' && row.company_id !== companyFilter) return false;
if (projectFilter !== 'all' && row.project_id !== projectFilter) return false;
return true;
});
}, [reportRows, companyFilter, projectFilter]);
const sortedRows = useMemo(() => sort(visibleRows, (row, key) => {
if (key === 'company_name') return row.company_name || '';
if (key === 'project_name') return row.project_name || '';
if (key === 'task_title') return row.task_title || '';
if (key === 'version_number') return Number(row.version_number || 0);
if (key === 'version_type') return row.version_type || '';
if (key === 'version_state') return row.version_state || '';
if (key === 'billing_label') return row.billing_label || '';
return '';
}), [visibleRows, sort]);
const stats = useMemo(() => ({
total: visibleRows.length,
notInvoiced: visibleRows.filter((row) => row.billing_status === 'not_started').length,
invoiced: visibleRows.filter((row) => row.billing_status === 'invoiced').length,
paid: visibleRows.filter((row) => row.billing_status === 'paid').length,
}), [visibleRows]);
const handleExportPdf = async () => {
setExporting(true);
try {
const doc = new jsPDF({ orientation: 'landscape', unit: 'pt', format: 'letter', compress: true });
const title = 'Task Billing Report';
const companyName = companyFilter === 'all' ? 'All Companies' : (companies.find((company) => company.id === companyFilter)?.name || 'Selected Company');
const projectName = projectFilter === 'all' ? 'All Projects' : (projects.find((project) => project.id === projectFilter)?.name || 'Selected Project');
const generatedOn = new Date().toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' });
doc.setFontSize(18);
doc.text(title, 40, 40);
doc.setFontSize(10);
doc.text(`Company: ${companyName}`, 40, 60);
doc.text(`Project: ${projectName}`, 240, 60);
doc.text(`Generated: ${generatedOn}`, 440, 60);
autoTable(doc, {
startY: 78,
head: [['Company', 'Project', 'Task', 'Version', 'Type', 'Version Status', 'Billing']],
body: sortedRows.map((row) => [
row.company_name,
row.project_name,
row.task_title,
row.version_label,
row.version_type,
row.version_state === 'client_review' ? 'In Review' : row.version_state === 'client_approved' ? 'Approved' : row.version_state.replace(/_/g, ' '),
row.billing_label,
]),
styles: {
fontSize: 9,
cellPadding: 6,
lineColor: [225, 225, 225],
lineWidth: 0.25,
},
headStyles: {
fillColor: [245, 165, 35],
textColor: [17, 17, 17],
fontStyle: 'bold',
},
alternateRowStyles: {
fillColor: [250, 250, 250],
},
margin: { left: 40, right: 40, bottom: 30 },
didDrawPage: ({ pageNumber }) => {
doc.setFontSize(9);
doc.text(`Page ${pageNumber}`, doc.internal.pageSize.width - 70, doc.internal.pageSize.height - 14);
},
});
doc.save(`task-billing-report-${new Date().toISOString().slice(0, 10)}.pdf`);
} finally {
setExporting(false);
}
};
return (
<Layout loading={loading}>
<div className="page-toolbar-grid" style={{ alignItems: 'center', marginBottom: 24 }}>
<div>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 4 }}>Company</div>
<select value={companyFilter} onChange={(event) => { setCompanyFilter(event.target.value); setProjectFilter('all'); }}>
<option value="all">All Companies</option>
{companies.map((company) => (
<option key={company.id} value={company.id}>{company.name}</option>
))}
</select>
</div>
<div>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 4 }}>Project</div>
<select value={projectFilter} onChange={(event) => setProjectFilter(event.target.value)}>
<option value="all">All Projects</option>
{filteredProjects.map((project) => (
<option key={project.id} value={project.id}>{project.name}</option>
))}
</select>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'end' }}>
<LoadingButton className="btn btn-primary" loading={exporting} loadingText="Generating..." onClick={handleExportPdf} disabled={loading || sortedRows.length === 0}>
Export PDF
</LoadingButton>
</div>
</div>
<div className="dash-stat-grid" style={{ gridTemplateColumns: '1fr 1fr 1fr 1fr', marginBottom: 24 }}>
<div className="stat-card">
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 6 }}>Rows</div>
<div style={{ fontSize: 28, lineHeight: 1.1 }}>{stats.total}</div>
</div>
<div className="stat-card">
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 6 }}>Not Invoiced</div>
<div style={{ fontSize: 28, lineHeight: 1.1 }}>{stats.notInvoiced}</div>
</div>
<div className="stat-card">
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 6 }}>Invoiced</div>
<div style={{ fontSize: 28, lineHeight: 1.1 }}>{stats.invoiced}</div>
</div>
<div className="stat-card">
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 6 }}>Paid</div>
<div style={{ fontSize: 28, lineHeight: 1.1 }}>{stats.paid}</div>
</div>
</div>
<div className="table-wrapper" style={{ minHeight: 0 }}>
{loading ? (
<div style={{ padding: 24, fontSize: 13, color: 'var(--text-muted)', textAlign: 'center' }}>Loading report</div>
) : error ? (
<div style={{ padding: 24, fontSize: 13, color: 'var(--danger)', textAlign: 'center' }}>{error}</div>
) : sortedRows.length === 0 ? (
<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' }}>
<colgroup>
<col style={{ width: '16%' }} />
<col style={{ width: '16%' }} />
<col style={{ width: '24%' }} />
<col style={{ width: '8%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '13%' }} />
<col style={{ width: '13%' }} />
</colgroup>
<thead>
<tr>
<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="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_type" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Type</SortTh>
<SortTh col="version_state" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Version Status</SortTh>
<SortTh col="billing_label" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Billing</SortTh>
</tr>
</thead>
<tbody>
{sortedRows.map((row) => (
<tr key={row.key}>
<td>{row.company_name}</td>
<td>{row.project_name}</td>
<td>{row.task_title}</td>
<td>{row.version_label}</td>
<td><StatusBadge status={row.version_number === 0 ? 'initial' : 'revision'} label={row.version_type} /></td>
<td><StatusBadge status={row.version_state} /></td>
<td><StatusBadge status={row.billing_status} label={row.billing_label} /></td>
</tr>
))}
</tbody>
</table>
)}
</div>
</Layout>
);
}
+77 -114
View File
@@ -1,14 +1,13 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import SortTh from '../../components/SortTh';
import StatusBadge from '../../components/StatusBadge';
import PageLoader from '../../components/PageLoader';
import LoadingButton from '../../components/LoadingButton';
import { supabase } from '../../lib/supabase';
import { generateSubcontractorPOPDF } from '../../lib/invoice';
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
import { useSortable } from '../../hooks/useSortable';
const statusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
import SubcontractorInvoiceDetailView from '../../components/SubcontractorInvoiceDetailView';
export default function SubInvoiceDetail() {
const { id } = useParams();
@@ -17,6 +16,7 @@ export default function SubInvoiceDetail() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [generating, setGenerating] = useState(false);
const [error, setError] = useState('');
const { sortKey, sortDir, toggle, sort } = useSortable('description');
useEffect(() => {
@@ -25,18 +25,24 @@ export default function SubInvoiceDetail() {
.select('*, profile:profiles!subcontractor_invoices_profile_id_fkey(id, name, email), items:subcontractor_invoice_items(*)')
.eq('id', id)
.single()
.then(({ data }) => {
setInvoice(data);
.then(({ data, error: err }) => {
if (err || !data) setError('Invoice not found.');
setInvoice(data || null);
setLoading(false);
});
}, [id]);
const total = (invoice?.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
const total = (invoice?.items || []).reduce((sum, item) => sum + Number(item.unit_price || 0) * Number(item.quantity || 1), 0);
const sortedItems = [...(invoice?.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
const tableItems = sort(sortedItems, (item, key) => {
const typeSort = /\bR(\d{2})\b/i.test(String(item.description || ''))
? Number(String(item.description).match(/\bR(\d{2})\b/i)?.[1] || 0)
: item.task_id ? 0 : 999;
if (key === 'type') return typeSort;
if (key === 'description') return item.description || '';
if (key === 'quantity') return Number(item.quantity || 0);
if (key === 'unit_price') return Number(item.unit_price || 0);
if (key === 'amount') return Number(item.unit_price || 0) * Number(item.quantity || 1);
if (key === 'line_total') return Number(item.unit_price || 0) * Number(item.quantity || 1);
return '';
});
@@ -58,15 +64,18 @@ export default function SubInvoiceDetail() {
});
const handleMarkPaid = async () => {
if (!invoice) return;
setSaving(true);
try {
const paidAt = new Date().toISOString();
const { error } = await supabase.from('subcontractor_invoices').update({ status: 'paid', paid_at: paidAt }).eq('id', id);
if (error) throw error;
const { error: updateError } = await supabase.from('subcontractor_invoices').update({ status: 'paid', paid_at: paidAt }).eq('id', id);
if (updateError) throw updateError;
const updated = { ...invoice, status: 'paid', paid_at: paidAt };
setInvoice(updated);
let pdfBlob = null;
try { pdfBlob = await generateSubcontractorPOPDF(buildPDFArgs(updated), { output: 'blob' }); } catch {}
try {
pdfBlob = await generateSubcontractorPOPDF(buildPDFArgs(updated), { output: 'blob' });
} catch { /* PDF generation failed — continue without attachment */ }
if (invoice.profile?.email) {
try {
const attachments = pdfBlob ? [await blobToEmailAttachment(pdfBlob, `${invoice.invoice_number}-receipt.pdf`)] : [];
@@ -77,136 +86,90 @@ export default function SubInvoiceDetail() {
paidDate: new Date(paidAt).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }),
}, attachments);
} catch (emailErr) {
alert(`Marked paid, but email failed: ${emailErr.message || 'unknown error'}`);
setError(`Marked paid, but email failed: ${emailErr.message || 'unknown error'}`);
}
}
} catch (err) {
alert(`Failed to mark as paid: ${err.message}`);
setError(`Failed to mark as paid: ${err.message}`);
}
setSaving(false);
};
const handleReceipt = async () => {
if (!invoice) return;
setGenerating(true);
try {
await generateSubcontractorPOPDF(buildPDFArgs(invoice));
} catch (err) {
alert(err.message);
setError(err.message);
}
setGenerating(false);
};
const handleDelete = async () => {
if (!invoice) return;
if (!window.confirm(`Delete invoice ${invoice.invoice_number}? This cannot be undone.`)) return;
setSaving(true);
const { error } = await supabase.from('subcontractor_invoices').delete().eq('id', id);
if (error) { alert('Failed to delete: ' + error.message); setSaving(false); return; }
navigate('/invoices', { state: { tab: 'sub-invoices' } });
const { error: deleteError } = await supabase.from('subcontractor_invoices').delete().eq('id', id);
if (deleteError) {
setError(`Failed to delete: ${deleteError.message}`);
setSaving(false);
return;
}
navigate('/finances', { state: { tab: 'sub-invoices' } });
};
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
if (!invoice) return <Layout><p style={{ padding: 24 }}>Invoice not found.</p></Layout>;
if (loading) return <Layout><PageLoader /></Layout>;
if (!invoice) return <Layout><p style={{ padding: 24 }}>{error || 'Invoice not found.'}</p></Layout>;
const sortedItems = [...(invoice.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
const headerActions = (
<>
{invoice.status === 'submitted' ? (
<LoadingButton className="btn btn-outline" loading={saving} loadingText="Processing…" disabled={saving} onClick={handleMarkPaid}>
Mark as Paid
</LoadingButton>
) : null}
{invoice.status === 'paid' ? (
<LoadingButton className="btn btn-outline" loading={generating} loadingText="Generating…" disabled={generating} onClick={handleReceipt}>
Download Receipt
</LoadingButton>
) : null}
<button className="btn btn-outline" style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }} onClick={handleDelete} disabled={saving}>
Delete
</button>
</>
);
return (
<Layout>
<button className="back-link" onClick={() => navigate('/invoices', { state: { tab: 'sub-invoices' } })}>
Back to Subcontractor Invoices
</button>
<div className="page-header">
<div>
<div className="page-title">{invoice.invoice_number}</div>
<div className="page-subtitle">
{invoice.profile?.name || 'External'} · {invoice.profile?.email || '—'}
</div>
</div>
<StatusBadge
status={statusColor[invoice.status] || 'not_started'}
label={invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'}
/>
</div>
<div className="grid-2" style={{ marginBottom: 24 }}>
<div className="card">
<div className="card-title">Invoice Info</div>
<div className="detail-grid" style={{ marginBottom: 0 }}>
<div className="detail-item"><label>Invoice #</label><p style={{ fontWeight: 400 }}>{invoice.invoice_number}</p></div>
<div className="detail-item"><label>Status</label><p style={{ textTransform: 'capitalize' }}>{invoice.status}</p></div>
<div className="detail-item"><label>Submitted</label><p>{invoice.submitted_at ? new Date(invoice.submitted_at).toLocaleDateString() : '—'}</p></div>
{invoice.paid_at && (
<div className="detail-item"><label>Paid On</label><p style={{ color: 'var(--success)', fontWeight: 400 }}>{new Date(invoice.paid_at).toLocaleDateString()}</p></div>
)}
</div>
</div>
<div className="card">
<div className="card-title">Summary</div>
<div className="detail-grid" style={{ marginBottom: 0 }}>
<div className="detail-item"><label>Line Items</label><p>{sortedItems.length}</p></div>
<div className="detail-item"><label>Total</label><p style={{ fontWeight: 400, fontSize: 18 }}>${total.toFixed(2)}</p></div>
</div>
</div>
</div>
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">Line Items</div>
{sortedItems.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No line items.</p>
) : (
<div className="table-wrapper" style={{ border: 'none' }}>
<table>
<thead>
<tr>
<SortTh col="description" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Description</SortTh>
<SortTh col="quantity" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Qty</SortTh>
<SortTh col="unit_price" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Unit Price</SortTh>
<SortTh col="amount" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Amount</SortTh>
</tr>
</thead>
<tbody>
{tableItems.map(item => (
<tr key={item.id}>
<td>{item.description}</td>
<td style={{ textAlign: 'right' }}>{item.quantity}</td>
<td style={{ textAlign: 'right' }}>${Number(item.unit_price).toFixed(2)}</td>
<td style={{ textAlign: 'right', fontWeight: 400 }}>${(Number(item.unit_price) * Number(item.quantity || 1)).toFixed(2)}</td>
</tr>
))}
<tr>
<td colSpan={3} style={{ textAlign: 'right', fontWeight: 400, borderTop: '1px solid var(--border)', paddingTop: 10 }}>Total</td>
<td style={{ textAlign: 'right', fontWeight: 400, fontSize: 16, borderTop: '1px solid var(--border)', paddingTop: 10 }}>${total.toFixed(2)}</td>
</tr>
</tbody>
</table>
<SubcontractorInvoiceDetailView
error={error}
headerActions={headerActions}
leftCardTitle="Submitted By"
leftCardBody={(
<>
<div style={{ fontSize: 15, fontWeight: 400 }}>{invoice.profile?.name || 'External'}</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 4 }}>{invoice.profile?.email || '—'}</div>
</>
)}
rightCardTitle="Invoice Details"
rightCardBody={(
<div className="invoice-detail-meta-grid">
<div className="invoice-detail-meta-item"><label>Invoice #</label><p>{invoice.invoice_number}</p></div>
<div className="invoice-detail-meta-item"><label>Status</label><p>{invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'}</p></div>
<div className="invoice-detail-meta-item"><label>Submitted</label><p>{invoice.submitted_at ? new Date(invoice.submitted_at).toLocaleDateString() : '—'}</p></div>
{invoice.paid_at ? <div className="invoice-detail-meta-item"><label>Paid On</label><p style={{ color: 'var(--success, #16a34a)' }}>{new Date(invoice.paid_at).toLocaleDateString()}</p></div> : null}
<div className="invoice-detail-meta-item"><label>Line Items</label><p>{sortedItems.length}</p></div>
<div className="invoice-detail-meta-item"><label>Total</label><p style={{ fontSize: 18, color: 'var(--accent)' }}>${total.toFixed(2)}</p></div>
</div>
)}
{invoice.notes && (
<div style={{ marginTop: 16, padding: '12px 14px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<div style={{ fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 6 }}>Notes</div>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap', margin: 0 }}>{invoice.notes}</p>
</div>
)}
</div>
<div className="card">
<div className="card-title">Actions</div>
<div className="action-buttons">
{invoice.status === 'submitted' && (
<button className="btn btn-success" onClick={handleMarkPaid} disabled={saving}>
{saving ? 'Processing…' : 'Mark as Paid'}
</button>
)}
{invoice.status === 'paid' && (
<button className="btn btn-outline" onClick={handleReceipt} disabled={generating}>
{generating ? 'Generating…' : 'Download Receipt'}
</button>
)}
<button className="btn-icon btn-icon-danger" title="Delete Invoice" onClick={handleDelete} disabled={saving}>
</button>
</div>
</div>
sortKey={sortKey}
sortDir={sortDir}
onSort={toggle}
items={tableItems}
notes={invoice.notes}
total={total}
/>
</Layout>
);
}
+5 -40
View File
@@ -1,11 +1,11 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import Layout from '../../components/Layout';
import PageLoader from '../../components/PageLoader';
import LoadingButton from '../../components/LoadingButton';
import SortTh from '../../components/SortTh';
import StatusBadge from '../../components/StatusBadge';
import { supabase } from '../../lib/supabase';
import { sendEmail } from '../../lib/email';
import { generateSubcontractorPOPDF } from '../../lib/invoice';
import { useSortable } from '../../hooks/useSortable';
@@ -73,49 +73,14 @@ export default function SubcontractorPODetail() {
return data;
};
const sendPOEmail = async (sentPO) => {
if (!sentPO?.profile?.email) {
alert('This subcontractor has no email on file.');
return;
}
await sendEmail('subcontractor_po_sent', sentPO.profile.email, {
poNumber: sentPO.po_number || 'Purchase Order',
subcontractorName: sentPO.profile?.name || 'there',
projectName: sentPO.project?.name || 'Subcontractor Work',
companyName: sentPO.project?.company?.name || 'Fourge Branding',
amount: `$${Number(sentPO.amount).toFixed(2)}`,
dueDate: sentPO.due_date ? new Date(sentPO.due_date).toLocaleDateString() : 'Not set',
terms: sentPO.terms || 'Net 15',
scope: sentPO.items?.length
? sentPO.items.map(item => `${item.description} - $${Number(item.amount).toFixed(2)}`).join('\n')
: sentPO.description,
portalUrl: 'https://portal.fourgebranding.com/my-purchase-orders',
});
};
const handleFinalizeSend = async () => {
if (!window.confirm(`Finalize and send ${po.po_number || 'this PO'} to ${po.profile?.email || 'the subcontractor'}?`)) return;
const sentPO = await updatePO({ status: 'sent', sent_at: new Date().toISOString() }, 'Failed to finalize PO');
if (!sentPO) return;
try {
await sendPOEmail(sentPO);
alert('PO email sent successfully.');
} catch (error) {
alert(`PO was finalized, but the email failed: ${error.message || 'unknown error'}`);
}
};
const handleResend = async () => {
if (!window.confirm(`Resend ${po.po_number || 'this PO'} to ${po.profile?.email || 'the subcontractor'}?`)) return;
setSaving(true);
try {
await sendPOEmail(po);
alert('PO email sent successfully.');
} catch (error) {
alert(`Failed to send PO: ${error.message || 'unknown error'}`);
} finally {
setSaving(false);
}
alert('PO emails are disabled.');
};
const handleReadyToPay = () => updatePO({ status: 'ready_to_pay' }, 'Failed to mark ready to pay');
@@ -136,7 +101,7 @@ export default function SubcontractorPODetail() {
setSaving(false);
return;
}
navigate('/invoices', { state: { tab: 'subcontractor-po' } });
navigate('/finances', { state: { tab: 'subcontractor-po' } });
};
const handleDownload = async () => {
@@ -149,7 +114,7 @@ export default function SubcontractorPODetail() {
}
};
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
if (loading) return <Layout><PageLoader /></Layout>;
if (!po) return <Layout><p>Purchase order not found.</p></Layout>;
const sortedItems = (po.items || []).slice().sort((a, b) => Number(a.sort_order || 0) - Number(b.sort_order || 0));
@@ -163,7 +128,7 @@ export default function SubcontractorPODetail() {
return (
<Layout>
<button className="back-link" onClick={() => navigate('/invoices', { state: { tab: 'subcontractor-po' } })}> Back to Subcontractor PO</button>
<button className="back-link" onClick={() => navigate('/finances', { state: { tab: 'subcontractor-po' } })}> Back to Subcontractor PO</button>
<div className="page-header">
<div>