Session 2026-05-28: profile page overhaul, nav fixes, dashboard activity links

- Fix nav links not working from profile page (useEffect infinite re-render via unstable profile object ref)
- Fix nav hover/active: gold icon highlight, no background change; active links non-clickable
- Fix hover layout shift: add border: 1px solid transparent to all interactive elements
- Header icon buttons (search, theme toggle) now highlight gold on hover
- Profile page: replace calendar with activity feed (60/40 grid), add stat cards (tasks completed, active projects, revision requests, submissions)
- Profile card: title field, icon rows for location/email/linkedin, member since + role bottom-right, edit button top-right
- Profile portrait: remove wrapper column, fix left-gap alignment
- Add profiles.title migration
- Dashboard recent activity: name → /profile/{id}, task → /requests/{id} (clickable links)
- Icon-only sidebar with gold active/hover state, pointer-events: none on active links
- layout.md updated with profile page geometry rules

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Krao Hasanee
2026-05-28 15:32:46 -04:00
parent 565d2ed4bc
commit 283511bf3a
48 changed files with 4151 additions and 1889 deletions
+27 -27
View File
@@ -825,7 +825,7 @@ export default function BrandBook() {
return (
<tr key={book.id} onClick={() => handleLoad(book)} style={{ cursor: 'pointer' }}>
<td style={{ fontWeight: 600 }}>{book.project_name || 'Brand Book'}</td>
<td style={{ fontWeight: 400 }}>{book.project_name || 'Brand Book'}</td>
<td>{`R${String(book.revision || '01').padStart(2, '0')}`}</td>
<td>{signCount}</td>
<td>{clientName}</td>
@@ -911,7 +911,7 @@ export default function BrandBook() {
<div className="form-group" style={{ maxWidth: 180 }}>
<label>Revision</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontWeight: 600, color: 'var(--text-primary)' }}>R</span>
<span style={{ fontWeight: 400, color: 'var(--text-primary)' }}>R</span>
<input
type="text"
inputMode="numeric"
@@ -936,7 +936,7 @@ export default function BrandBook() {
<label>Project Logo <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(top left, 5"×5" area)</span></label>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{projectLogoPreview && (
<img src={projectLogoPreview} alt="Project logo" style={{ maxHeight: 60, maxWidth: 120, objectFit: 'contain', border: '1px solid var(--border)', borderRadius: 6, padding: 4, background: '#fff' }} />
<img src={projectLogoPreview} alt="Project logo" style={{ maxHeight: 60, maxWidth: 120, objectFit: 'contain', border: '1px solid var(--border)', borderRadius: 4, padding: 4, background: '#fff' }} />
)}
<div>
<button className="btn btn-outline btn-sm" onClick={() => projectLogoRef.current?.click()}>
@@ -1001,7 +1001,7 @@ export default function BrandBook() {
right: 0,
zIndex: 20,
border: '1px solid var(--border)',
borderRadius: 8,
borderRadius: 4,
background: 'var(--card-bg)',
boxShadow: '0 16px 36px rgba(0,0,0,0.28)',
overflow: 'hidden',
@@ -1046,7 +1046,7 @@ export default function BrandBook() {
{/* Client info (saved per company) */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>Client Info</div>
<div style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)' }}>Client Info</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>Logo and contact saved to company reused across all brand books.</div>
</div>
<button className="btn btn-outline btn-sm" onClick={handleSaveClientInfo} disabled={savingClientInfo || !bookInfo.clientId}>
@@ -1058,7 +1058,7 @@ export default function BrandBook() {
<label>Client Logo <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(3.5"×1.5" area, bottom right)</span></label>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{bookInfo.clientLogoUrl && (
<img src={bookInfo.clientLogoUrl} alt="Client logo" style={{ maxHeight: 44, maxWidth: 130, objectFit: 'contain', border: '1px solid var(--border)', borderRadius: 6, padding: 4, background: '#fff' }} />
<img src={bookInfo.clientLogoUrl} alt="Client logo" style={{ maxHeight: 44, maxWidth: 130, objectFit: 'contain', border: '1px solid var(--border)', borderRadius: 4, padding: 4, background: '#fff' }} />
)}
<div>
<button className="btn btn-outline btn-sm" onClick={() => clientLogoRef.current?.click()} disabled={uploadingClientLogo}>
@@ -1088,7 +1088,7 @@ export default function BrandBook() {
<div style={{ borderTop: '1px solid var(--border)', margin: '4px 0 20px' }} />
{/* Approval */}
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 14 }}>Approval</div>
<div style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)', marginBottom: 14 }}>Approval</div>
<div className="form-group" style={{ maxWidth: 280 }}>
<label>Approved Date <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
<input type="date" value={bookInfo.approvedDate} onChange={set('approvedDate')} />
@@ -1212,16 +1212,16 @@ function SignCard({ sign, index, onChange, onPhotoChange, onRemove, canRemove, t
const hasPhoto = sign._photoPreview || sign._existingPhotoPreview || sign._recommendationPhotoPreview || sign._signDetailPhotoPreview;
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
<div style={{ border: '1px solid var(--border)', borderRadius: 4, overflow: 'hidden' }}>
<div style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '10px 14px', background: 'var(--card-bg-2)', borderBottom: '1px solid var(--border)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 10, fontWeight: 700, padding: '2px 8px', background: 'var(--accent)', color: '#1a1a1a', borderRadius: 4 }}>
<span style={{ fontSize: 10, fontWeight: 400, padding: '2px 8px', background: 'var(--accent)', color: '#1a1a1a', borderRadius: 4 }}>
#{sign.signNumber || (index + 1)}
</span>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{summary}</span>
<span style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)' }}>{summary}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{hasPhoto && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>📷</span>}
@@ -1333,7 +1333,7 @@ function PhotoField({ label, preview, fileName, dragging, inputRef, onDragEnter,
onClick={() => inputRef.current?.click()}
style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 8,
borderRadius: 4,
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
padding: 12,
cursor: 'pointer',
@@ -1387,7 +1387,7 @@ function SiteMapDropZone({ preview, onFile, onClear, inputRef }) {
if (preview) {
return (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 16 }}>
<img src={preview} alt="site map" style={{ maxHeight: 160, maxWidth: 280, borderRadius: 6, border: '1px solid var(--border)', objectFit: 'contain' }} />
<img src={preview} alt="site map" style={{ maxHeight: 160, maxWidth: 280, borderRadius: 4, border: '1px solid var(--border)', objectFit: 'contain' }} />
<div>
<button className="btn btn-outline btn-sm" onClick={onClear}>Remove</button>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 8 }}>Click to replace</div>
@@ -1404,7 +1404,7 @@ function SiteMapDropZone({ preview, onFile, onClear, inputRef }) {
onClick={() => inputRef.current?.click()}
style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 8,
borderRadius: 4,
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
padding: '24px 16px', textAlign: 'center', cursor: 'pointer',
color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13, transition: 'all 0.15s',
@@ -1435,7 +1435,7 @@ function SitePhotosDropZone({ photoItems, onFiles, onRemove, inputRef }) {
onClick={() => inputRef.current?.click()}
style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 8,
borderRadius: 4,
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
padding: '20px 16px', textAlign: 'center', cursor: 'pointer',
color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13,
@@ -1456,7 +1456,7 @@ function SitePhotosDropZone({ photoItems, onFiles, onRemove, inputRef }) {
style={{ width: 80, height: 60, objectFit: 'cover', borderRadius: 4, border: '1px solid var(--border)', display: 'block' }}
/>
{item.file && (
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'rgba(245,165,35,0.8)', fontSize: 8, textAlign: 'center', borderRadius: '0 0 4px 4px', padding: '1px 2px', color: '#1a1a1a', fontWeight: 700 }}>NEW</div>
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'rgba(245,165,35,0.8)', fontSize: 8, textAlign: 'center', borderRadius: '0 0 4px 4px', padding: '1px 2px', color: '#1a1a1a', fontWeight: 400 }}>NEW</div>
)}
<button
type="button"
@@ -1507,7 +1507,7 @@ function CombinedMockupPhotoField({
const tileStyle = {
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 8,
borderRadius: 4,
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
padding: 12,
cursor: 'pointer',
@@ -1646,7 +1646,7 @@ function RecommendationPhotoField({ preview, fileName, dragging, inputRef, onDra
onClick={() => setShowEditor(true)}
style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 8,
borderRadius: 4,
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
padding: 12,
cursor: 'pointer',
@@ -1729,7 +1729,7 @@ function SignDetailPhotoField({ preview, fileName, dragging, inputRef, onDragEnt
onClick={() => preview && setShowEditor(true)}
style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 8,
borderRadius: 4,
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
padding: 12,
cursor: preview ? 'pointer' : 'default',
@@ -2357,10 +2357,10 @@ function DimensionEditorModal({ sourceImage, onApply, onCancel }) {
}}
onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
>
<div style={{ background: 'var(--card-bg)', borderRadius: 12, display: 'flex', flexDirection: 'column', maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden', boxShadow: '0 24px 64px rgba(0,0,0,0.55)' }}>
<div style={{ background: 'var(--card-bg)', borderRadius: 4, display: 'flex', flexDirection: 'column', maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden', boxShadow: '0 24px 64px rgba(0,0,0,0.55)' }}>
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
<div>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>Sign Detail Dimensions</div>
<div style={{ fontSize: 14, fontWeight: 400, color: 'var(--text-primary)' }}>Sign Detail Dimensions</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>Add line dimensions or drag a box to auto-place width and height callouts.</div>
</div>
<button onClick={onCancel} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: 18, lineHeight: 1, padding: '0 2px' }}></button>
@@ -3445,14 +3445,14 @@ function PhotoEditorModal({
onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
>
<div style={{
background: 'var(--card-bg)', borderRadius: 12, display: 'flex', flexDirection: 'column',
background: 'var(--card-bg)', borderRadius: 4, display: 'flex', flexDirection: 'column',
maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden',
boxShadow: '0 24px 64px rgba(0,0,0,0.55)',
}}>
{/* Header */}
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
<div>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>{title}</div>
<div style={{ fontSize: 14, fontWeight: 400, color: 'var(--text-primary)' }}>{title}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{subtitle}</div>
</div>
<button onClick={onCancel} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: 18, lineHeight: 1, padding: '0 2px' }}></button>
@@ -3597,10 +3597,10 @@ function PhotoEditorModal({
<div style={{ display: 'grid', gridTemplateColumns: '220px minmax(0, 1fr)', minHeight: 0, flex: 1 }}>
<div style={{ borderRight: '1px solid var(--border)', background: 'var(--card-bg)', padding: 12, overflowY: 'auto' }}>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>Layers</div>
<div style={{ fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>Layers</div>
{dimensions.length > 0 && (
<>
<div style={{ fontSize: 10, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 6 }}>Dimensions</div>
<div style={{ fontSize: 10, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 6 }}>Dimensions</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 14 }}>
{dimensions.map((item, index) => (
<button
@@ -3616,7 +3616,7 @@ function PhotoEditorModal({
border: `1px solid ${item.id === activeDimensionId ? 'var(--accent)' : 'var(--border)'}`,
background: item.id === activeDimensionId ? 'rgba(245,165,35,0.12)' : 'var(--card-bg-2)',
color: 'var(--text-primary)',
borderRadius: 6,
borderRadius: 4,
padding: '8px 10px',
cursor: 'pointer',
textAlign: 'left',
@@ -3632,7 +3632,7 @@ function PhotoEditorModal({
</div>
</>
)}
<div style={{ fontSize: 10, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 6 }}>Artwork</div>
<div style={{ fontSize: 10, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 6 }}>Artwork</div>
{artworks.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5 }}>
Import or drop artwork to create layers.
@@ -3660,7 +3660,7 @@ function PhotoEditorModal({
border: `1px solid ${item.id === selectedArtworkId ? 'var(--accent)' : 'var(--border)'}`,
background: item.id === selectedArtworkId ? 'rgba(245,165,35,0.12)' : 'var(--card-bg-2)',
color: 'var(--text-primary)',
borderRadius: 6,
borderRadius: 4,
padding: '8px 10px',
cursor: 'grab',
textAlign: 'left',
+24 -20
View File
@@ -15,6 +15,8 @@ function TeamCompanies() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const tab = searchParams.get('tab') || 'companies';
const profileId = searchParams.get('profile');
const profileRole = searchParams.get('role');
const cached = readPageCache('team_companies');
const [companies, setCompanies] = useState(() => cached?.companies || []);
const [profiles, setProfiles] = useState(() => cached?.profiles || []);
@@ -30,7 +32,7 @@ function TeamCompanies() {
const [editUserVal, setEditUserVal] = useState('');
const [deletingUserId, setDeletingUserId] = useState(null);
const [filterCompany, setFilterCompany] = useState('');
const [userSubTab, setUserSubTab] = useState('client');
const [userSubTab, setUserSubTab] = useState(profileRole === 'external' ? 'external' : 'client');
const { sortKey: coSortKey, sortDir: coSortDir, toggle: coToggle, sort: coSort } = useSortable('name');
const { sortKey: clSortKey, sortDir: clSortDir, toggle: clToggle, sort: clSort } = useSortable('name');
const { sortKey: subSortKey, sortDir: subSortDir, toggle: subToggle, sort: subSort } = useSortable('name');
@@ -144,7 +146,7 @@ function TeamCompanies() {
{clientProfiles.length} client user{clientProfiles.length !== 1 ? 's' : ''}
<span style={{ marginLeft: 10 }}>· {subcontractors.length} subcontractor{subcontractors.length !== 1 ? 's' : ''}</span>
{unassigned.length > 0 && (
<span style={{ marginLeft: 10, color: 'var(--danger)', fontWeight: 600 }}>
<span style={{ marginLeft: 10, color: 'var(--danger)', fontWeight: 400 }}>
· {unassigned.length} unassigned
</span>
)}
@@ -173,7 +175,7 @@ function TeamCompanies() {
{/* Clients (companies) — only on companies tab */}
{tab === 'companies' && <>
{showNew && (
<div className="card" style={{ marginBottom: 16, border: '1px solid var(--accent)', borderRadius: 12, flexShrink: 0 }}>
<div className="card" style={{ marginBottom: 16, border: '1px solid var(--accent)', borderRadius: 4, flexShrink: 0 }}>
<div className="card-title">New Client</div>
<form onSubmit={handleCreate}>
<div className="form-group">
@@ -212,6 +214,7 @@ function TeamCompanies() {
<tr>
<SortTh col="name" sortKey={coSortKey} sortDir={coSortDir} onSort={coToggle}>Company</SortTh>
<SortTh col="clients" sortKey={coSortKey} sortDir={coSortDir} onSort={coToggle}>Users</SortTh>
<th>Primary Contact</th>
<SortTh col="phone" sortKey={coSortKey} sortDir={coSortDir} onSort={coToggle}>Phone</SortTh>
<SortTh col="address" sortKey={coSortKey} sortDir={coSortDir} onSort={coToggle}>Address</SortTh>
<th style={{ width: 1, whiteSpace: 'nowrap' }}>Actions</th>
@@ -225,8 +228,9 @@ function TeamCompanies() {
const companyProfiles = clientProfiles.filter(p => getProfileCompanyIds(p).includes(company.id));
return (
<tr key={company.id} onClick={() => navigate(`/company/${company.id}`)} style={{ cursor: 'pointer' }}>
<td style={{ fontWeight: 600 }}>{company.name}</td>
<td style={{ fontWeight: 400 }}>{company.name}</td>
<td>{companyProfiles.length}</td>
<td>{companyProfiles[0]?.name || '—'}</td>
<td>{company.phone || '—'}</td>
<td>{company.address || '—'}</td>
<td onClick={e => e.stopPropagation()}>
@@ -252,7 +256,7 @@ function TeamCompanies() {
</select>
</div>
{showNewUser && userForm.role === 'client' && (
<div className="card" style={{ marginBottom: 16, border: '1px solid var(--accent)', borderRadius: 12, flexShrink: 0 }}>
<div className="card" style={{ marginBottom: 16, border: '1px solid var(--accent)', borderRadius: 4, flexShrink: 0 }}>
<div className="card-title">New User</div>
<form onSubmit={handleCreateUser}>
<div className="grid-2">
@@ -290,7 +294,7 @@ function TeamCompanies() {
</div>
)}
{showNewUser && userForm.role === 'external' && (
<div className="card" style={{ marginBottom: 16, border: '1px solid var(--accent)', borderRadius: 12, flexShrink: 0 }}>
<div className="card" style={{ marginBottom: 16, border: '1px solid var(--accent)', borderRadius: 4, flexShrink: 0 }}>
<div className="card-title">New Subcontractor</div>
<form onSubmit={handleCreateUser}>
<div className="grid-2">
@@ -321,11 +325,11 @@ function TeamCompanies() {
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{userSubTab === 'client' && <>
{unassigned.length > 0 && (
<div style={{ marginBottom: 12, padding: 14, background: 'rgba(220,38,38,0.06)', borderRadius: 8, border: '1px solid var(--danger)', flexShrink: 0 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--danger)', marginBottom: 8 }}>Unassigned ({unassigned.length})</div>
<div style={{ marginBottom: 12, padding: 14, background: 'rgba(220,38,38,0.06)', borderRadius: 4, border: '1px solid var(--danger)', flexShrink: 0 }}>
<div style={{ fontSize: 12, fontWeight: 400, color: 'var(--danger)', marginBottom: 8 }}>Unassigned ({unassigned.length})</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{unassigned.map(user => (
<div key={user.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--card-bg)', borderRadius: 6, border: '1px solid var(--border)' }}>
<div key={user.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<div style={{ flex: 1 }}>
{editingUserId === user.id ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
@@ -337,7 +341,7 @@ function TeamCompanies() {
</div>
) : (
<>
<div style={{ fontWeight: 600, fontSize: 13 }}>{user.name}</div>
<div style={{ fontWeight: 400, fontSize: 13 }}>{user.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{user.email}</div>
</>
)}
@@ -375,8 +379,8 @@ function TeamCompanies() {
}).map(user => {
const companyNames = getProfileCompanyIds(user).map(id => companies.find(c => c.id === id)?.name).filter(Boolean);
return (
<tr key={user.id}>
<td style={{ fontWeight: 600 }}>
<tr key={user.id} style={user.id === profileId ? { background: 'rgba(245,165,35,0.08)' } : undefined}>
<td style={{ fontWeight: 400 }}>
{editingUserId === user.id ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<input type="text" value={editUserVal} onChange={e => setEditUserVal(e.target.value)} autoFocus
@@ -423,8 +427,8 @@ function TeamCompanies() {
</thead>
<tbody>
{subSort(subcontractors, (u, key) => u[key] || '').map(user => (
<tr key={user.id}>
<td style={{ fontWeight: 600 }}>
<tr key={user.id} style={user.id === profileId ? { background: 'rgba(245,165,35,0.08)' } : undefined}>
<td style={{ fontWeight: 400 }}>
{editingUserId === user.id ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<input type="text" value={editUserVal} onChange={e => setEditUserVal(e.target.value)} autoFocus
@@ -491,7 +495,7 @@ function ClientCompanyList() {
<tbody>
{sort(companies, (c, key) => c[key] || '').map(company => (
<tr key={company.id} onClick={() => navigate(`/company/${company.id}`)} style={{ cursor: 'pointer' }}>
<td style={{ fontWeight: 600 }}>{company.name}</td>
<td style={{ fontWeight: 400 }}>{company.name}</td>
<td>{company.phone || '—'}</td>
<td>{company.address || '—'}</td>
</tr>
@@ -577,8 +581,8 @@ function _UnusedClientCompanies() {
value={selectedId}
onChange={e => setSelectedId(e.target.value)}
style={{
fontSize: 22, fontWeight: 700, background: 'var(--card-bg)',
border: '1px solid var(--border)', borderRadius: 6,
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',
}}
@@ -652,14 +656,14 @@ function _UnusedClientCompanies() {
borderBottom: i < members.length - 1 ? '1px solid var(--border)' : 'none',
}}>
<div style={{
width: 36, height: 36, borderRadius: 6, background: 'var(--accent)',
width: 36, height: 36, borderRadius: 4, background: 'var(--accent)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 13, fontWeight: 700, color: '#111', flexShrink: 0,
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: 600, fontSize: 14, color: 'var(--text-primary)' }}>
<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>
+14 -10
View File
@@ -7,6 +7,7 @@ import { useAuth } from '../context/AuthContext';
import { serviceTypes } from '../data/mockData';
import { cleanupTaskStorage, deleteCompanyData } from '../lib/deleteHelpers';
import { renameClientFolder, backfillClientFolders } from '../lib/filebrowserFolders';
import { logActivity } from '../lib/activityLog';
export default function CompanyDetail() {
const { id } = useParams();
@@ -174,6 +175,7 @@ export default function CompanyDetail() {
status: 'active',
}).select().single();
if (data) {
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'project_created', projectId: data.id, projectName: data.name });
setProjects(prev => [data, ...prev]);
setNewProjectName('');
setShowNewProject(false);
@@ -241,7 +243,7 @@ export default function CompanyDetail() {
onChange={e => setNameVal(e.target.value)}
autoFocus
required
style={{ fontSize: 22, fontWeight: 700, padding: '4px 10px', margin: 0, width: 260 }}
style={{ fontSize: 22, fontWeight: 400, padding: '4px 10px', margin: 0, width: 260 }}
/>
<button type="submit" className="btn btn-primary btn-sm" disabled={savingName}>{savingName ? '...' : 'Save'}</button>
<button type="button" className="btn btn-outline btn-sm" onClick={() => setEditingName(false)}>Cancel</button>
@@ -253,10 +255,12 @@ export default function CompanyDetail() {
</div>
)}
<div className="page-subtitle">
{users[0]?.name && <>{users[0].name}</>}
{users[0]?.name && (company.phone || company.address) && ' · '}
{company.phone && <>{company.phone}</>}
{company.phone && company.address && ' · '}
{company.address && <>{company.address}</>}
{!company.phone && !company.address && 'No contact info'}
{!users[0]?.name && !company.phone && !company.address && 'No contact info'}
</div>
</div>
{isTeam && <button
@@ -299,7 +303,7 @@ export default function CompanyDetail() {
>
{t}
{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: 10, fontWeight: 700 }}>
<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}
</span>
)}
@@ -326,7 +330,7 @@ export default function CompanyDetail() {
<div style={{
width: 32, height: 32, borderRadius: 4, background: 'var(--accent)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 12, fontWeight: 700, color: '#111', flexShrink: 0,
fontSize: 12, fontWeight: 400, color: '#111', flexShrink: 0,
}}>
{user.name?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
</div>
@@ -346,7 +350,7 @@ export default function CompanyDetail() {
</div>
) : (
<>
<div style={{ fontWeight: 600, fontSize: 14 }}>{user.name}</div>
<div style={{ fontWeight: 400, fontSize: 14 }}>{user.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{user.email || '—'}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', textTransform: 'capitalize', marginTop: 2 }}>{user.role || '—'}</div>
</>
@@ -388,7 +392,7 @@ export default function CompanyDetail() {
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{availableUsers.map(user => (
<div key={user.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
<div key={user.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<div style={{ flex: 1 }}>
{editingUserId === user.id ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
@@ -405,7 +409,7 @@ export default function CompanyDetail() {
</div>
) : (
<>
<div style={{ fontWeight: 600, fontSize: 13 }}>{user.name}</div>
<div style={{ fontWeight: 400, fontSize: 13 }}>{user.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{user.email}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', textTransform: 'capitalize', marginTop: 2 }}>{user.role || '—'}</div>
</>
@@ -476,10 +480,10 @@ export default function CompanyDetail() {
const active = projectTasks.filter(t => t.status !== 'client_approved').length;
const done = projectTasks.filter(t => t.status === 'client_approved').length;
return (
<div key={project.id} className="interactive-surface" style={{ display: 'flex', alignItems: 'center', background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
<div key={project.id} className="interactive-surface" style={{ display: 'flex', alignItems: 'center', background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, overflow: 'hidden' }}>
<Link to={`/projects/${project.id}`} className="interactive-row" style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', textDecoration: 'none', cursor: 'pointer' }}>
<div>
<div style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>{project.name}</div>
<div style={{ fontWeight: 400, fontSize: 14, color: 'var(--text-primary)' }}>{project.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 3 }}>
{projectTasks.length} job{projectTasks.length !== 1 ? 's' : ''}
{active > 0 && <> · <span style={{ color: 'var(--accent)' }}>{active} active</span></>}
@@ -513,7 +517,7 @@ export default function CompanyDetail() {
<div style={{ display: 'grid', gridTemplateColumns: '1fr 130px 130px 60px', gap: 8, marginBottom: 8, alignItems: 'center' }}>
<div />
{['New', 'Revision'].map(label => (
<div key={label} style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: 'right' }}>{label}</div>
<div key={label} style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: 'right' }}>{label}</div>
))}
<div />
</div>
+4 -4
View File
@@ -287,7 +287,7 @@ export default function Converters() {
}}
style={{
border: '2px dashed var(--border)',
borderRadius: 10,
borderRadius: 4,
padding: '28px 18px',
textAlign: 'center',
cursor: 'pointer',
@@ -295,7 +295,7 @@ export default function Converters() {
color: 'var(--text-muted)',
}}
>
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 6 }}>
<div style={{ fontSize: 15, fontWeight: 400, color: 'var(--text-primary)', marginBottom: 6 }}>
Drop images here or click to upload
</div>
<div style={{ fontSize: 13 }}>
@@ -381,7 +381,7 @@ export default function Converters() {
key={id}
style={{
border: '1px solid var(--border)',
borderRadius: 10,
borderRadius: 4,
padding: 14,
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) auto',
@@ -390,7 +390,7 @@ export default function Converters() {
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>{file.name}</div>
<div style={{ fontSize: 14, fontWeight: 400, color: 'var(--text-primary)' }}>{file.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4 }}>
{file.type || 'Unknown type'} · {(file.size / 1024 / 1024).toFixed(file.size >= 1024 * 1024 ? 2 : 3)} MB
</div>
+351 -484
View File
@@ -1,359 +1,322 @@
import { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import Layout from '../components/Layout';
import StatusBadge from '../components/StatusBadge';
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 { formatDateOnly, parseDateOnly } from '../lib/dates';
import SortTh from '../components/SortTh';
import { useSortable } from '../hooks/useSortable';
// ─── Team / External helpers ───────────────────────────────────────────────
const ICON_TONES = [
{ bg: 'rgba(245,165,35,0.15)', color: '#F5A523' },
{ bg: 'rgba(74,222,128,0.15)', color: '#4ade80' },
{ bg: 'rgba(96,165,250,0.15)', color: '#60a5fa' },
{ bg: 'rgba(167,139,250,0.15)', color: '#a78bfa' },
];
function getDeadlineMeta(value) {
const date = parseDateOnly(value);
if (!date) return null;
const today = new Date();
today.setHours(0, 0, 0, 0);
const diffDays = Math.round((date.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays < 0) return { label: `${Math.abs(diffDays)} day${Math.abs(diffDays) === 1 ? '' : 's'} overdue`, color: 'var(--danger)' };
if (diffDays === 0) return { label: 'Due today', color: '#f97316' };
if (diffDays === 1) return { label: 'Due tomorrow', color: '#f5a523' };
return { label: `Due in ${diffDays} days`, color: 'var(--text-muted)' };
function iconTone(key) {
let h = 0;
for (let i = 0; i < (key || '').length; i++) h = (h * 31 + key.charCodeAt(i)) % ICON_TONES.length;
return ICON_TONES[h];
}
function TaskTable({ title, subtitle, tasks, projects, emptyMessage, fill }) {
const navigate = useNavigate();
function InitialPortrait({ name }) {
const tone = iconTone(name);
return (
<div className="card" style={fill ? { display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' } : {}}>
<div className="card-title" style={{ marginBottom: subtitle ? 2 : 12, flexShrink: 0 }}>{title}</div>
{subtitle && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 12, flexShrink: 0 }}>{subtitle}</div>}
{tasks.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>{emptyMessage}</div>
) : (
<div className="table-wrapper" style={{ marginTop: 4, ...(fill ? { flex: 1, minHeight: 0, overflowY: 'auto' } : {}) }}>
<table>
<thead>
<tr>
<th>Task</th>
<th>Project</th>
<th>Deadline</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{tasks.map(task => {
const project = projects.find(p => p.id === task.project_id);
const deadlineMeta = getDeadlineMeta(task.deadline);
return (
<tr key={task.id} onClick={() => navigate(`/requests/${task.id}`)} style={{ cursor: 'pointer' }}>
<td style={{ fontWeight: 600 }}>{task.title}</td>
<td style={{ color: 'var(--text-muted)' }}>{project?.name || '—'}</td>
<td style={{ color: deadlineMeta?.color || 'var(--text-muted)', fontWeight: deadlineMeta ? 600 : 400, whiteSpace: 'nowrap' }}>
{formatDateOnly(task.deadline, '—')}
{deadlineMeta ? <span style={{ fontSize: 11, marginLeft: 6 }}>({deadlineMeta.label})</span> : null}
</td>
<td><StatusBadge status={task.status} /></td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<div style={{ width: 27, height: 27, borderRadius: '50%', background: tone.bg, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<span style={{ fontSize: 12, fontWeight: 500, color: tone.color, lineHeight: 1 }}>{(name || '?')[0].toUpperCase()}</span>
</div>
);
}
function CompanyGroup({ company, tasks, projects }) {
const [open, setOpen] = useState(true);
return (
<div className="interactive-surface" style={{ marginBottom: 10, border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', background: 'var(--card-bg)' }}>
<button
className="interactive-panel-toggle"
onClick={() => setOpen(o => !o)}
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', background: 'var(--card-bg-2)', border: 'none', cursor: 'pointer', borderBottom: open ? '1px solid var(--border)' : 'none', fontFamily: 'inherit' }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>{company.name}</span>
<span style={{ fontSize: 11, fontWeight: 600, padding: '1px 7px', borderRadius: 20, background: 'var(--accent)', color: '#1a1a1a' }}>{tasks.length}</span>
</div>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{open ? '▲' : '▼'}</span>
</button>
{open && (
<div>
{tasks.map(task => {
const project = projects.find(p => p.id === task.project_id);
return (
<Link key={task.id} to={`/requests/${task.id}`} className="interactive-row" style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)', display: 'flex', flexDirection: 'column', gap: 4, textDecoration: 'none', cursor: 'pointer' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
{task.title} <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>{'R' + String(task.current_version || 0).padStart(2, '0')}</span>
</span>
<StatusBadge status={task.status} />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 11, color: 'var(--text-secondary)' }}>{project?.name}</span>
<span style={{ fontSize: 11, color: task.assigned_name ? 'var(--text-secondary)' : 'var(--text-muted)' }}>{task.assigned_name || 'Unassigned'}</span>
</div>
</Link>
);
})}
</div>
)}
</div>
);
}
function ProjectGroup({ project, tasks }) {
const [open, setOpen] = useState(true);
return (
<div className="interactive-surface" style={{ marginBottom: 10, border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', background: 'var(--card-bg)' }}>
<button
className="interactive-panel-toggle"
onClick={() => setOpen(o => !o)}
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', background: 'var(--card-bg-2)', border: 'none', cursor: 'pointer', borderBottom: open ? '1px solid var(--border)' : 'none', fontFamily: 'inherit' }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>{project.name}</span>
<span style={{ fontSize: 11, fontWeight: 600, padding: '1px 7px', borderRadius: 20, background: 'var(--accent)', color: '#1a1a1a' }}>{tasks.length}</span>
</div>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{open ? '▲' : '▼'}</span>
</button>
{open && (
<div>
{tasks.map(task => (
<a key={task.id} href={`/requests/${task.id}`} className="interactive-row" style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', textDecoration: 'none' }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
{task.title} <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>{'R' + String(task.current_version || 0).padStart(2, '0')}</span>
</span>
<StatusBadge status={task.status} />
</a>
))}
</div>
)}
</div>
);
}
function OutputCharts({ title, subtitle, taskPeople, revisionPeople }) {
const taskRows = [...(taskPeople || [])].sort((a, b) => b.total - a.total || a.name.localeCompare(b.name));
const revisionRows = [...(revisionPeople || [])].sort((a, b) => b.revisions - a.revisions || a.name.localeCompare(b.name));
const hasData = taskRows.length > 0 || revisionRows.length > 0;
const chartColors = ['#F5A523', '#60A5FA', '#4ADE80', '#F87171', '#C084FC', '#FBBF24', '#22C55E', '#38BDF8'];
const totalTasks = taskRows.reduce((sum, p) => sum + p.total, 0);
const totalRevisions = revisionRows.reduce((sum, p) => sum + p.revisions, 0);
const taskGradient = taskRows.length
? `conic-gradient(${taskRows.map((p, i) => { const start = (taskRows.slice(0, i).reduce((s, x) => s + x.total, 0) / Math.max(totalTasks, 1)) * 100; const end = (taskRows.slice(0, i + 1).reduce((s, x) => s + x.total, 0) / Math.max(totalTasks, 1)) * 100; return `${chartColors[i % chartColors.length]} ${start}% ${end}%`; }).join(', ')})`
: 'none';
const revisionGradient = totalRevisions > 0
? `conic-gradient(${revisionRows.map((p, i) => { const start = (revisionRows.slice(0, i).reduce((s, x) => s + x.revisions, 0) / totalRevisions) * 100; const end = (revisionRows.slice(0, i + 1).reduce((s, x) => s + x.revisions, 0) / totalRevisions) * 100; return `${chartColors[i % chartColors.length]} ${start}% ${end}%`; }).join(', ')})`
: 'none';
return (
<div className="card">
<div className="card-title" style={{ marginBottom: 6 }}>{title}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14 }}>{subtitle}</div>
{!hasData ? (
<div style={{ color: 'var(--text-muted)', fontSize: 13 }}>No completed assigned tasks yet.</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 18 }}>
{[
{ title: 'New Tasks', total: totalTasks, rows: taskRows, valueKey: 'total', gradient: taskGradient },
{ title: 'Revisions', total: totalRevisions, rows: revisionRows, valueKey: 'revisions', gradient: revisionGradient },
].map(chart => (
<div key={chart.title} style={{ border: '1px solid var(--border)', borderRadius: 8, padding: 16, background: 'var(--card-bg-2)' }}>
<div style={{ display: 'grid', gridTemplateColumns: '140px minmax(0, 1fr)', gap: 18, alignItems: 'center' }}>
<div className="dashboard-pie-wrap">
<div className="dashboard-pie" style={{ background: chart.gradient }}>
<div className="dashboard-pie-center">
<strong>{chart.total}</strong>
<span>{chart.title}</span>
</div>
</div>
</div>
<div className="dashboard-legend">
{chart.rows.map((person, index) => {
const value = person[chart.valueKey];
const percent = chart.total ? Math.round((value / chart.total) * 100) : 0;
return (
<div key={`${chart.title}-${person.name}`} className="dashboard-legend-item">
<span className="dashboard-legend-dot" style={{ background: chartColors[index % chartColors.length] }} />
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{person.name}</span>
<strong>{value} · {percent}%</strong>
</div>
);
})}
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}
function buildTaskPeople(tasks) {
const completed = tasks.filter(t => t.status === 'client_approved' && t.assigned_name);
return [...completed.reduce((map, t) => {
const entry = map.get(t.assigned_name) || { name: t.assigned_name, total: 0 };
entry.total += 1;
map.set(t.assigned_name, entry);
return map;
}, new Map()).values()];
}
function buildRevisionPeople(submissions, tasks, roleFilter) {
return [...(submissions || []).reduce((map, sub) => {
if ((sub.version_number || 0) <= 0) return map;
if (!sub.delivery?.sent_by) return map;
if (roleFilter && sub.delivery_sender_role !== roleFilter) return map;
if (!roleFilter && sub.delivery_sender_role === 'external') return map;
const entry = map.get(sub.delivery.sent_by) || { name: sub.delivery.sent_by, revisions: 0 };
entry.revisions += 1;
map.set(sub.delivery.sent_by, entry);
return map;
}, new Map()).values()];
}
function SubcontractorRates({ externals }) {
const [rates, setRates] = useState(() => Object.fromEntries(externals.map(p => [p.id, String(p.brand_book_rate ?? 60)])));
const [saving, setSaving] = useState('');
const [saved, setSaved] = useState('');
const handleSave = async (profile) => {
const rate = parseFloat(rates[profile.id]);
if (isNaN(rate) || rate < 0) return;
setSaving(profile.id);
await supabase.from('profiles').update({ brand_book_rate: rate }).eq('id', profile.id);
setSaving('');
setSaved(profile.id);
setTimeout(() => setSaved(s => s === profile.id ? '' : s), 2000);
};
if (externals.length === 0) return null;
return (
<div className="card" style={{ marginTop: 24 }}>
<div className="card-title" style={{ marginBottom: 4 }}>Subcontractor Rates</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 16 }}>Brand book rate per completed task, used to calculate invoices.</div>
<div style={{ display: 'grid', gap: 10 }}>
{externals.map(profile => (
<div key={profile.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--card-bg-2)' }}>
<div style={{ flex: 1, fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{profile.name || profile.email}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>$/task</span>
<input type="number" min="0" step="0.01" value={rates[profile.id] ?? '60'} onChange={e => setRates(r => ({ ...r, [profile.id]: e.target.value }))} style={{ width: 80, fontSize: 13, padding: '4px 8px', borderRadius: 6, border: '1px solid var(--border)', background: 'var(--input-bg)', color: 'var(--text-primary)', textAlign: 'right' }} />
<button className="btn btn-outline btn-sm" disabled={saving === profile.id} onClick={() => handleSave(profile)}>
{saving === profile.id ? 'Saving...' : saved === profile.id ? '✓ Saved' : 'Save'}
</button>
</div>
</div>
))}
</div>
</div>
);
}
function ExternalDashboard({ currentUser, projects, tasks, pos }) {
const activeTasks = tasks.filter(t => t.status !== 'client_approved');
const completedTasks = tasks.filter(t => t.status === 'client_approved');
function ExternalDashboard({ currentUser, projects, tasks, pos, submissions, clientProfiles, companyMemberships }) {
const myTasks = tasks.filter(t => t.assigned_to === currentUser?.id);
const myActiveTasks = myTasks.filter(t => !['client_approved', 'on_hold'].includes(t.status));
const myCompleted = myTasks.filter(t => t.status === 'client_approved');
const myCompletedRevisions = myCompleted.reduce((sum, t) => sum + Number(t.current_version || 0), 0);
const myOnHold = myTasks.filter(t => t.status === 'on_hold');
const myAssignedProjectCount = new Set(myTasks.map(t => t.project_id).filter(Boolean)).size;
const unpaidAmount = pos.filter(p => !['paid', 'cancelled'].includes(p.status)).reduce((s, p) => s + Number(p.amount || 0), 0);
const paidAmount = pos.filter(p => p.status === 'paid').reduce((s, p) => s + Number(p.amount || 0), 0);
const myProjectIds = new Set(myTasks.map(t => t.project_id).filter(Boolean));
const myProjects = myProjectIds.size > 0
? projects.filter(p => myProjectIds.has(p.id))
: projects;
const companyById = Object.fromEntries(myProjects.filter(p => p.company).map(p => [p.company_id, p.company]));
const myProjectIdSet = new Set(myProjects.map(p => p.id));
const activityEvents = buildActivityEvents(submissions, myProjectIdSet);
const externalHighlights = buildClientHighlights(Object.values(companyById), myProjects, tasks, clientProfiles, companyMemberships || [])
.sort((a, b) => b.openTaskCount - a.openTaskCount || a.company.name.localeCompare(b.company.name))
.slice(0, 5);
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">Welcome back, {currentUser?.name?.split(' ')[0]}</div>
<div className="page-subtitle">Your assigned projects.</div>
</div>
<div className="dash-stat-grid">
<DashStatCard label="Active Tasks" value={myActiveTasks.length} sub={`${myOnHold.length} on hold`} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={DASH_ICONS.tasks} />
<DashStatCard label="Completed Tasks" value={myCompleted.length} sub={`${myCompletedRevisions} total revisions`} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={DASH_ICONS.trending} />
<DashStatCard label="Active Projects" value={myProjects.length} sub={`${myAssignedProjectCount} assigned to me`} iconBg="rgba(167,139,250,0.15)" iconColor="#a78bfa" iconPath={DASH_ICONS.projects} />
<DashStatCard label="Pending Payment" value={fmtMoney(unpaidAmount)} sub={`${fmtMoney(paidAmount)} paid`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={DASH_ICONS.invoice} />
</div>
<div className="stats-grid" style={{ marginBottom: 24 }}>
<div className="stat-card stat-card-highlight">
<div className="stat-value">{activeTasks.length}</div>
<div className="stat-label">Active Tasks</div>
</div>
<div className="stat-card">
<div className="stat-value">{completedTasks.length}</div>
<div className="stat-label">Completed Tasks</div>
</div>
<div className="stat-card">
<div className="stat-value" style={{ color: unpaidAmount > 0 ? 'var(--accent)' : undefined }}>${unpaidAmount.toFixed(2)}</div>
<div className="stat-label">Unpaid Invoices</div>
</div>
<div className="stat-card">
<div className="stat-value">${paidAmount.toFixed(2)}</div>
<div className="stat-label">Paid Invoices</div>
</div>
<div className="dashboard-bottom-grid">
<ActivityFeed events={activityEvents} />
{myProjects.length > 0 && <ClientHighlightTable highlights={externalHighlights} />}
</div>
{projects.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon">📋</div>
<h3>No projects assigned yet</h3>
<p>Your team lead will assign you to projects.</p>
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24 }}>
<div>
<div className="card-title">Active Jobs</div>
{activeTasks.length === 0 ? (
<div style={{ padding: '24px 16px', textAlign: 'center', color: 'var(--text-muted)', fontSize: 13, background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)' }}>No active jobs</div>
) : projects.map(project => {
const projectTasks = activeTasks.filter(t => t.project_id === project.id);
if (projectTasks.length === 0) return null;
return <ProjectGroup key={project.id} project={project} tasks={projectTasks} />;
})}
</div>
<div>
<div className="card-title">Completed</div>
{completedTasks.length === 0 ? (
<div style={{ padding: '24px 16px', textAlign: 'center', color: 'var(--text-muted)', fontSize: 13, background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)' }}>No completed jobs yet</div>
) : projects.map(project => {
const projectTasks = completedTasks.filter(t => t.project_id === project.id);
if (projectTasks.length === 0) return null;
return <ProjectGroup key={project.id} project={project} tasks={projectTasks} />;
})}
</div>
</div>
)}
</Layout>
);
}
// ─── Client helpers ────────────────────────────────────────────────────────
// ─── Shared dashboard helpers ──────────────────────────────────────────────
function ClientTaskRow({ task, project }) {
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"/> },
task_submitted: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: <><line x1="12" y1="19" x2="12" y2="5" strokeWidth="2" strokeLinecap="round"/><polyline points="5,12 12,5 19,12" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/></> },
task_approved: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: <polyline points="4,13 9,18 20,7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/> },
task_on_hold: { bg: 'rgba(239,68,68,0.15)', color: '#ef4444', path: <><rect x="6" y="4" width="4" height="16" rx="1" fill="currentColor"/><rect x="14" y="4" width="4" height="16" rx="1" fill="currentColor"/></> },
task_created: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: <><line x1="12" y1="5" x2="12" y2="19" strokeWidth="2" strokeLinecap="round"/><line x1="5" y1="12" x2="19" y2="12" strokeWidth="2" strokeLinecap="round"/></> },
project_created: { bg: 'rgba(245,165,35,0.15)', color: '#F5A523', path: <><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" fill="none" strokeWidth="1.5"/></> },
request_submitted: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: <><rect x="5" y="3" width="14" height="18" rx="2" fill="none" strokeWidth="1.5"/><line x1="9" y1="8" x2="15" y2="8" strokeWidth="1.5" strokeLinecap="round"/><line x1="9" y1="12" x2="15" y2="12" strokeWidth="1.5" strokeLinecap="round"/><line x1="9" y1="16" x2="12" y2="16" strokeWidth="1.5" strokeLinecap="round"/></> },
revision_requested: { bg: 'rgba(245,158,11,0.15)', color: '#f59e0b', path: <><path d="M4 12a8 8 0 018-8v0a8 8 0 018 8" fill="none" strokeWidth="1.5" strokeLinecap="round"/><polyline points="18,8 20,12 16,12" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none"/></> },
};
function ActionIcon({ actionKey, size = 27 }) {
const cfg = ACTION_ICON[actionKey] || { bg: 'rgba(255,255,255,0.08)', color: 'rgba(255,255,255,0.4)', path: <circle cx="12" cy="12" r="3" fill="currentColor"/> };
return (
<Link
to={`/requests/${task.id}`}
className="interactive-row"
style={{ display: 'flex', flexDirection: 'column', gap: 4, padding: '10px 14px', borderBottom: '1px solid var(--border)', textDecoration: 'none', cursor: 'pointer' }}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{task.title}</span>
<StatusBadge status={task.status} />
</div>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{project?.name || '—'}</span>
</Link>
<div style={{ width: size, height: size, borderRadius: '50%', background: cfg.bg, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="13" height="13" viewBox="0 0 24 24" stroke={cfg.color} fill="none" style={{ color: cfg.color }}>{cfg.path}</svg>
</div>
);
}
function ClientTaskColumn({ title, tasks, projects, emptyMessage }) {
const ACTION_LABEL = {
task_created: 'created',
task_started: 'started',
task_on_hold: 'put on hold',
task_resumed: 'resumed',
task_submitted: 'submitted',
task_approved: 'approved',
project_created: 'created project',
request_submitted: 'submitted',
revision_requested: 'requested revision on',
};
function buildActivityEvents(activityData, projectIdSet = null) {
return (activityData || [])
.filter(e => !projectIdSet || !e.project_id || projectIdSet.has(e.project_id))
.map(e => ({
time: new Date(e.created_at),
name: e.actor_name || 'Fourge',
actionKey: e.action,
action: ACTION_LABEL[e.action] || e.action,
task: e.task_title || null,
project: e.project_name || null,
})).filter(e => !isNaN(e.time)).slice(0, 10);
}
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 };
});
}
function fmtMoney(n) {
if (Math.abs(n) >= 1000000) return `$${(n / 1000000).toFixed(2)}M`;
if (Math.abs(n) >= 10000) return `$${(n / 1000).toFixed(1)}k`;
return `$${Number(n).toFixed(2)}`;
}
function smoothCurve(pts) {
if (pts.length < 2) return '';
let d = `M${pts[0][0].toFixed(1)},${pts[0][1].toFixed(1)}`;
for (let i = 1; i < pts.length; i++) {
const [x0, y0] = pts[i - 1];
const [x1, y1] = pts[i];
const cpX = (x0 + x1) / 2;
d += ` C${cpX.toFixed(1)},${y0.toFixed(1)} ${cpX.toFixed(1)},${y1.toFixed(1)} ${x1.toFixed(1)},${y1.toFixed(1)}`;
}
return d;
}
function MiniAreaChart({ data }) {
const W = 90, H = 42;
if (!data || data.length < 2) return null;
const max = Math.max(...data, 1);
const pts = data.map((v, i) => [
(i / (data.length - 1)) * W,
4 + (1 - v / max) * (H - 8),
]);
const line = smoothCurve(pts);
const lastPt = pts[pts.length - 1];
const firstPt = pts[0];
const area = `${line} L${lastPt[0].toFixed(1)},${H} L${firstPt[0].toFixed(1)},${H} Z`;
return (
<div className="card" style={{ padding: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '12px 14px', borderBottom: tasks.length > 0 ? '1px solid var(--border)' : 'none', background: 'var(--card-bg-2)', flexShrink: 0 }}>
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>{title}</span>
{tasks.length > 0 && (
<span style={{ marginLeft: 8, fontSize: 11, fontWeight: 600, padding: '1px 7px', borderRadius: 20, background: 'var(--accent)', color: '#1a1a1a' }}>{tasks.length}</span>
)}
<svg width="100%" height={H} viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" style={{ display: 'block', overflow: 'hidden' }}>
<defs>
<linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#F5A523" stopOpacity="0.3" />
<stop offset="95%" stopColor="#F5A523" stopOpacity="0" />
</linearGradient>
</defs>
<path d={area} fill="url(#areaGrad)" />
<path d={line} fill="none" stroke="#F5A523" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function DashStatCard({ label, value, sub, iconBg, iconColor, iconPath, chartData }) {
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', alignItems: 'stretch', gap: 21, minHeight: 120 }}>
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', ...(chartData ? {} : { flex: 1 }) }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'rgba(255,255,255,0.7)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
<div style={{ fontSize: 30, fontWeight: 400, color: '#ffffff', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
</div>
{sub && <div style={{ fontSize: 12, color: 'rgba(255,255,255,0.5)', marginTop: 5 }}>{sub}</div>}
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'space-between', ...(chartData ? { flex: 1, minWidth: 0 } : { flexShrink: 0 }) }}>
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<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>
{chartData && <MiniAreaChart data={chartData} />}
</div>
</div>
);
}
const DASH_ICONS = {
revenue: '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6"/>',
projects: '<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>',
tasks: '<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/>',
invoice: '<rect x="2" y="2" width="20" height="20" rx="2"/><line x1="12" y1="6" x2="12" y2="18"/><path d="M16 8H9.5a2.5 2.5 0 000 5h5a2.5 2.5 0 010 5H8"/>',
trending: '<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/>',
profit: '<line x1="12" y1="20" x2="12" y2="4"/><polyline points="5 11 12 4 19 11"/>',
};
function StatBar({ items }) {
return (
<div className="stat-bar">
{items.map((item, i) => (
<div key={i} className="stat-bar-item">
<div className="stat-bar-header">
<div className="stat-bar-label">{item.label}</div>
<div className="stat-bar-dot" style={{ background: item.color }} />
</div>
<div className="stat-bar-value">{item.value}</div>
</div>
))}
</div>
);
}
function TaskFeed({ title, tasks, projects, emptyMessage }) {
const navigate = useNavigate();
return (
<div className="card" style={{ padding: 0, overflow: 'hidden', borderRadius: 4, flexShrink: 0 }}>
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)', background: 'var(--card-bg-2)', display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)' }}>{title}</span>
{tasks.length > 0 && <span style={{ fontSize: 11, fontWeight: 400, padding: '1px 7px', borderRadius: 20, background: 'var(--accent)', color: '#1a1a1a' }}>{tasks.length}</span>}
</div>
{tasks.length === 0 ? (
<div style={{ padding: '14px', fontSize: 13, color: 'var(--text-muted)' }}>{emptyMessage}</div>
) : (
<div style={{ overflowY: 'auto', maxHeight: 'calc(100vh - 412px)' }}>
{tasks.map(task => (
<ClientTaskRow key={task.id} task={task} project={projects.find(p => p.id === task.project_id)} />
))}
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)', fontSize: 13 }}>{emptyMessage}</div>
) : tasks.map((task, i) => {
const project = projects.find(p => p.id === task.project_id);
return (
<div key={task.id} onClick={() => navigate(`/requests/${task.id}`)} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '10px 16px', borderBottom: i < tasks.length - 1 ? '1px solid var(--border)' : 'none', cursor: 'pointer' }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{task.title}</div>
{project && <div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{project.name}</div>}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
{task.assigned_name ? <>Assigned to <span style={{ color: 'var(--text-primary)' }}>{task.assigned_name}</span></> : 'Unassigned'}
</div>
</div>
);
})}
</div>
);
}
function ActivityFeed({ events }) {
const visible = events.slice(0, 5);
return (
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', flexShrink: 0 }}>
<div style={{ marginBottom: visible.length > 0 ? 14 : 0 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'rgba(255,255,255,0.7)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Recent Activity</span>
</div>
{visible.length === 0 ? (
<div style={{ fontSize: 13, color: 'rgba(255,255,255,0.5)', marginTop: 14 }}>No recent activity</div>
) : visible.map((e, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: i > 0 ? 10 : 0 }}>
<ActionIcon actionKey={e.actionKey} />
<div style={{ flex: 1, minWidth: 0, fontSize: 13, lineHeight: 1.4 }}>
<span style={{ color: '#ffffff', fontWeight: 400 }}>{e.name}</span>
{e.action && <span style={{ color: 'rgba(255,255,255,0.5)' }}> {e.action}</span>}
{e.task && <span style={{ color: '#ffffff' }}> {e.task}</span>}
</div>
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', whiteSpace: 'nowrap', flexShrink: 0 }}>{e.time.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}</span>
</div>
)}
))}
</div>
);
}
function ClientHighlightTable({ highlights }) {
const { sortKey, sortDir, toggle, sort } = useSortable('company');
if (!highlights || highlights.length === 0) return null;
const fmtMoney = (n) => `$${Number(n || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
const sortedHighlights = sort(highlights, (row, key) => {
if (key === 'company') return row.company?.name || '';
if (key === 'primaryContact') return row.primaryContact?.name || '';
if (key === 'projectCount') return row.projectCount || 0;
if (key === 'openTaskCount') return row.openTaskCount || 0;
if (key === 'outstandingTotal') return Number(row.outstandingTotal || 0);
if (key === 'paidTotal') return Number(row.paidTotal || 0);
return '';
});
return (
<div className="card" style={{ padding: 0, overflow: 'hidden', borderRadius: 4, flexShrink: 0 }}>
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)', background: 'var(--card-bg-2)' }}>
<span style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)' }}>Client Highlight</span>
</div>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: 'var(--card-bg-2)' }}>
<SortTh col="company" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ padding: '8px 16px', textAlign: 'left', fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', borderBottom: '1px solid var(--border)' }}>Company</SortTh>
<SortTh col="primaryContact" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ padding: '8px 16px', textAlign: 'center', fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', borderBottom: '1px solid var(--border)' }}>Primary Contact</SortTh>
<SortTh col="projectCount" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ padding: '8px 16px', textAlign: 'center', fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', borderBottom: '1px solid var(--border)' }}>Projects</SortTh>
<SortTh col="openTaskCount" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ padding: '8px 16px', textAlign: 'center', fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', borderBottom: '1px solid var(--border)' }}>Open Tasks</SortTh>
<SortTh col="outstandingTotal" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ padding: '8px 16px', textAlign: 'right', fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', borderBottom: '1px solid var(--border)' }}>Outstanding</SortTh>
<SortTh col="paidTotal" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ padding: '8px 16px', textAlign: 'right', fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', borderBottom: '1px solid var(--border)' }}>Paid</SortTh>
</tr>
</thead>
<tbody>
{sortedHighlights.map(({ company, primaryContact, projectCount, openTaskCount, outstandingTotal = 0, paidTotal = 0 }, i) => (
<tr key={company.id} style={{ borderBottom: i < sortedHighlights.length - 1 ? '1px solid var(--border)' : 'none' }}>
<td style={{ padding: '10px 16px', fontSize: 13, fontWeight: 400, color: 'var(--text-primary)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<InitialPortrait name={company.name} />
{company.name}
</div>
</td>
<td style={{ padding: '10px 16px', fontSize: 13, color: 'var(--text-secondary)', textAlign: 'center' }}>{primaryContact?.name || '—'}</td>
<td style={{ padding: '10px 16px', fontSize: 13, color: 'var(--text-secondary)', textAlign: 'center' }}>{projectCount}</td>
<td style={{ padding: '10px 16px', fontSize: 13, color: 'var(--text-primary)', textAlign: 'center' }}>{openTaskCount}</td>
<td style={{ padding: '10px 16px', fontSize: 13, color: 'var(--accent)', textAlign: 'right' }}>{fmtMoney(outstandingTotal)}</td>
<td style={{ padding: '10px 16px', fontSize: 13, color: 'var(--accent)', textAlign: 'right' }}>{fmtMoney(paidTotal)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
@@ -370,155 +333,116 @@ export default function DashboardPage() {
const companies = isClient
? (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 [allClientTasks, setAllClientTasks] = useState([]);
const [allClientProjects, setAllClientProjects] = useState([]);
const [allClientInvoices, setAllClientInvoices] = useState([]);
const [clientActivity, setClientActivity] = useState([]);
// ── Team/External state ───────────────────────────────────────────────
const cacheKey = isExternal ? 'team_dashboard_external' : 'team_dashboard';
const cached = !isClient ? readPageCache(cacheKey, 5 * 60_000) : null;
// ── External state ────────────────────────────────────────────────────
const cacheKey = 'team_dashboard_external';
const cached = isExternal ? readPageCache(cacheKey, 5 * 60_000) : null;
const [tasks, setTasks] = useState(() => cached?.tasks || []);
const [projects, setProjects] = useState(() => cached?.projects || []);
const [submissions, setSubmissions] = useState(() => cached?.submissions || []);
const [pos, setPos] = useState(() => cached?.pos || []);
const [externalProfiles, setExternalProfiles] = useState(() => cached?.externalProfiles || []);
const [clientProfiles, setClientProfiles] = useState(() => cached?.clientProfiles || []);
const [companyMemberships, setCompanyMemberships] = useState(() => cached?.companyMemberships || []);
const [loading, setLoading] = useState(() => isClient ? hasCompany : !cached);
const [loading, setLoading] = useState(() => isClient ? hasCompany : isExternal && !cached);
useEffect(() => {
let cancelled = false;
if (isClient) {
if (!hasCompany) { setLoading(false); return; }
async function loadClient() {
try {
const [{ data: activeTasks }, { data: invoices }] = await withTimeout(Promise.all([
supabase.from('tasks').select('id, title, status, project_id, project:projects(id, name, company_id)').order('submitted_at', { ascending: false }),
const [{ data: activeTasks }, { data: invoices }, { data: activityData }] = await withTimeout(Promise.all([
supabase.from('tasks').select('id, title, status, project_id, assigned_name, project:projects(id, name, company_id)').order('submitted_at', { ascending: false }),
supabase.from('invoices').select('total, status, company_id').in('status', ['sent', 'paid']),
]), 12000, 'Client dashboard load');
supabase.from('activity_log').select('id, created_at, actor_name, action, task_title, project_name, project_id').order('created_at', { ascending: false }).limit(20),
]), 30000, 'Client dashboard load');
if (cancelled) return;
const clientTasks = activeTasks || [];
setAllClientTasks(clientTasks);
setAllClientInvoices(invoices || []);
setClientActivity(activityData || []);
const projectMap = {};
clientTasks.forEach(t => { if (t.project?.id) projectMap[t.project.id] = t.project; });
setAllClientProjects(Object.values(projectMap));
} catch (error) {
console.error('ClientDashboard load failed:', error);
} finally {
setLoading(false);
if (!cancelled) setLoading(false);
}
}
loadClient();
} else {
async function loadTeam() {
} else if (isExternal) {
async function loadExternal() {
try {
if (isExternal) {
const [{ data: p }, { data: t }, { data: posData }] = await withTimeout(Promise.all([
supabase.from('projects').select('id, name').order('created_at', { ascending: false }),
supabase.from('tasks').select('id, title, status, current_version, project_id').order('submitted_at', { ascending: false }),
supabase.from('subcontractor_payments').select('id, amount, status').eq('profile_id', currentUser.id),
]), 12000, 'Dashboard load');
const [{ data: p }, { data: t }, { data: posData }, { data: memRows }, { data: clientProfiles }, { data: activityData }] = await withTimeout(Promise.all([
supabase.from('projects').select('id, name, company_id, company:companies(id, name)').order('created_at', { ascending: false }),
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_to, assigned_name').order('submitted_at', { ascending: false }),
supabase.from('subcontractor_payments').select('id, amount, status').eq('profile_id', currentUser.id),
supabase.from('company_members').select('company_id, profile_id'),
supabase.from('profiles').select('id, name, email, company_id').eq('role', 'client'),
supabase.from('activity_log').select('id, created_at, actor_name, action, task_title, project_name, project_id').order('created_at', { ascending: false }).limit(20),
]), 30000, 'External dashboard load');
if (!cancelled) {
setProjects(p || []);
setTasks(t || []);
setPos(posData || []);
setSubmissions([]);
writePageCache(cacheKey, { projects: p || [], tasks: t || [], submissions: [], pos: posData || [], externalProfiles: [] });
} else {
const [{ data: t }, { data: p }, { data: subs }, { data: profiles }] = await withTimeout(Promise.all([
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at').order('submitted_at', { ascending: false }),
supabase.from('projects').select('id, name, status, company_id'),
supabase.from('submissions').select('task_id, version_number, deadline, type, submitted_by, submitted_by_name, delivery:deliveries(sent_by, sent_at)').order('version_number', { ascending: false }),
supabase.from('profiles').select('id, role, name, email, brand_book_rate'),
]), 12000, 'Dashboard load');
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 externals = (profiles || []).filter(pr => pr.role === 'external');
setTasks(tasksWithDeadlines);
setProjects(p || []);
setExternalProfiles(externals);
writePageCache(cacheKey, { tasks: tasksWithDeadlines, projects: p || [], submissions: subsWithRole, pos: [], externalProfiles: externals });
setSubmissions(subsWithRole);
setSubmissions(activityData || []);
setClientProfiles(clientProfiles || []);
setCompanyMemberships(memRows || []);
writePageCache(cacheKey, { projects: p || [], tasks: t || [], submissions: activityData || [], pos: posData || [], clientProfiles: clientProfiles || [], companyMemberships: memRows || [] });
}
} catch (error) {
console.error('Dashboard load failed:', error);
console.error('External dashboard load failed:', error);
} finally {
if (!cancelled) setLoading(false);
}
setLoading(false);
}
loadTeam();
loadExternal();
} else {
setLoading(false);
}
}, [isClient, isExternal, hasCompany, cacheKey]); // eslint-disable-line react-hooks/exhaustive-deps
return () => { cancelled = true; };
}, [isClient, isExternal, hasCompany, cacheKey, currentUser?.id]);
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
// ── Client render ──────────────────────────────────────────────────────
if (isClient) {
const filterByCompany = (clientTasks) => {
if (companies.length <= 1 || !activeCompanyId) return clientTasks;
return clientTasks.filter(t => {
const proj = allClientProjects.find(p => p.id === t.project_id);
return proj?.company_id === activeCompanyId;
});
};
const visibleTasks = filterByCompany(allClientTasks);
const visibleProjects = companies.length <= 1 ? allClientProjects : allClientProjects.filter(p => p.company_id === activeCompanyId);
const visibleInvoices = companies.length <= 1 || !activeCompanyId ? allClientInvoices : allClientInvoices.filter(i => i.company_id === activeCompanyId);
const reviewTasks = visibleTasks.filter(t => t.status === 'client_review');
const inProgressTasks = visibleTasks.filter(t => t.status === 'in_progress');
const onHoldTasks = visibleTasks.filter(t => t.status === 'on_hold');
const notStartedTasks = visibleTasks.filter(t => t.status === 'not_started');
const outstandingInvoices = visibleInvoices.filter(i => i.status === 'sent').reduce((sum, inv) => sum + Number(inv.total || 0), 0);
const paidInvoices = visibleInvoices.filter(i => i.status === 'paid').reduce((sum, inv) => sum + Number(inv.total || 0), 0);
const myCompanyIds = new Set(companies.map(c => c.id));
const myTasks = myCompanyIds.size > 0
? allClientTasks.filter(t => t.project?.company_id && myCompanyIds.has(t.project.company_id))
: allClientTasks;
const myProjects = myCompanyIds.size > 0
? allClientProjects.filter(p => myCompanyIds.has(p.company_id))
: allClientProjects;
const myInvoices = myCompanyIds.size > 0
? allClientInvoices.filter(i => myCompanyIds.has(i.company_id))
: allClientInvoices;
const myProjectIds = new Set(myProjects.map(p => p.id));
const reviewTasks = myTasks.filter(t => t.status === 'client_review');
const inProgressTasks = myTasks.filter(t => t.status === 'in_progress');
const onHoldTasks = myTasks.filter(t => t.status === 'on_hold');
const notStartedTasks = myTasks.filter(t => t.status === 'not_started');
const outstandingInvoices = myInvoices.filter(i => i.status === 'sent').reduce((sum, inv) => sum + Number(inv.total || 0), 0);
const paidInvoices = myInvoices.filter(i => i.status === 'paid').reduce((sum, inv) => sum + Number(inv.total || 0), 0);
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">Welcome back, {currentUser?.name?.split(' ')[0]}</div>
<div className="page-subtitle">Track active work and the items that need your attention.</div>
</div>
<Link to="/new-request" className="btn btn-primary">+ New Request</Link>
<div className="dash-stat-grid">
<DashStatCard label="Active Projects" value={myProjects.length} sub={`${myTasks.length} total tasks`} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={DASH_ICONS.projects} />
<DashStatCard label="Outstanding" value={fmtMoney(outstandingInvoices)} sub={`${fmtMoney(paidInvoices)} paid`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={DASH_ICONS.invoice} />
<DashStatCard label="In Progress" value={inProgressTasks.length} sub={`${notStartedTasks.length} not started`} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={DASH_ICONS.tasks} />
<DashStatCard label="Awaiting Review" value={reviewTasks.length} sub={`${onHoldTasks.length} on hold`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={DASH_ICONS.trending} />
</div>
{companies.length > 1 && (
<div style={{ marginBottom: 16 }}>
<select value={activeCompanyId || ''} onChange={e => setActiveCompanyId(e.target.value)} className="filter-select">
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
)}
<div className="stats-grid" style={{ marginBottom: 24 }}>
<div className="stat-card stat-card-highlight">
<div className="stat-value" style={{ color: reviewTasks.length > 0 ? 'var(--accent)' : undefined }}>{reviewTasks.length}</div>
<div className="stat-label">Awaiting Review</div>
</div>
<div className="stat-card">
<div className="stat-value">{inProgressTasks.length + onHoldTasks.length}</div>
<div className="stat-label">In Progress</div>
</div>
<div className="stat-card">
<div className="stat-value">{notStartedTasks.length}</div>
<div className="stat-label">Not Started</div>
</div>
<div className="stat-card">
<div className="stat-value">${outstandingInvoices.toFixed(2)}</div>
<div className="stat-label">Outstanding Invoices</div>
</div>
<div className="stat-card">
<div className="stat-value">${paidInvoices.toFixed(2)}</div>
<div className="stat-label">Paid Invoices</div>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, flex: 1, minHeight: 0 }}>
<TaskTable title="Awaiting Your Review" subtitle="Items waiting for your approval." tasks={reviewTasks} projects={visibleProjects} emptyMessage="No items need your review." fill />
<TaskTable title="In Progress" subtitle="Active work across your projects." tasks={inProgressTasks} projects={visibleProjects} emptyMessage="No items currently in progress." fill />
<ActivityFeed events={buildActivityEvents(clientActivity, myProjectIds)} />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}>
<TaskFeed title="Awaiting Your Review" tasks={reviewTasks} projects={myProjects} emptyMessage="No items need your review." />
<TaskFeed title="In Progress" tasks={inProgressTasks} projects={myProjects} emptyMessage="No items currently in progress." />
</div>
</Layout>
);
@@ -526,65 +450,8 @@ export default function DashboardPage() {
// ── External render ────────────────────────────────────────────────────
if (isExternal) {
return <ExternalDashboard currentUser={currentUser} projects={projects} tasks={tasks} pos={pos} />;
return <ExternalDashboard currentUser={currentUser} projects={projects} tasks={tasks} pos={pos} submissions={submissions} clientProfiles={clientProfiles} companyMemberships={companyMemberships} />;
}
// ── Team render ────────────────────────────────────────────────────────
const activeTasks = tasks.filter(t => t.status !== 'client_approved');
const inProgressTasks = tasks.filter(t => t.status === 'in_progress');
const notStartedTasks = tasks.filter(t => t.status === 'not_started');
const onHoldTasks = tasks.filter(t => t.status === 'on_hold');
const reviewTasks = tasks.filter(t => t.status === 'client_review');
const upcomingDeadlineTasks = [...tasks].filter(t => t.deadline && t.status !== 'client_approved').sort((a, b) => parseDateOnly(a.deadline) - parseDateOnly(b.deadline)).slice(0, 6);
const assignedToMeTasks = [...tasks].filter(t => t.assigned_to === currentUser?.id && t.status !== 'client_approved').sort((a, b) => { const ad = parseDateOnly(a.deadline); const bd = parseDateOnly(b.deadline); if (ad && bd) return ad - bd; if (ad) return -1; if (bd) return 1; return 0; }).slice(0, 6);
const teamOutputTasks = tasks.filter(t => t.assignee_role !== 'external');
const subOutputTasks = tasks.filter(t => t.assignee_role === 'external');
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">Welcome back, {currentUser?.name?.split(' ')[0]}</div>
<div className="page-subtitle">Here's what's happening across your projects.</div>
</div>
</div>
<div className="stats-grid">
<div className="stat-card stat-card-highlight">
<div className="stat-icon"></div>
<div className="stat-value">{activeTasks.length}</div>
<div className="stat-label">Active Jobs</div>
</div>
<div className="stat-card">
<div className="stat-icon"></div>
<div className="stat-value">{notStartedTasks.length}</div>
<div className="stat-label">Not Started</div>
</div>
<div className="stat-card">
<div className="stat-icon"></div>
<div className="stat-value">{inProgressTasks.length}</div>
<div className="stat-label">In Progress</div>
</div>
<div className="stat-card">
<div className="stat-icon"></div>
<div className="stat-value">{onHoldTasks.length}</div>
<div className="stat-label">On Hold</div>
</div>
<div className="stat-card">
<div className="stat-icon">🕓</div>
<div className="stat-value">{reviewTasks.length}</div>
<div className="stat-label">In Review</div>
</div>
</div>
<div style={{ marginTop: 24 }}>
<OutputCharts title="Completed By Team Member" subtitle="Completed-task output by team assignee, with revisions counted by the person who submitted them." taskPeople={buildTaskPeople(teamOutputTasks)} revisionPeople={buildRevisionPeople(submissions, tasks, null)} />
</div>
<div style={{ marginTop: 24 }}>
<OutputCharts title="Completed By Subcontractor" subtitle="Completed-task output by subcontractor assignee, with revisions counted by the person who submitted them." taskPeople={buildTaskPeople(subOutputTasks)} revisionPeople={buildRevisionPeople(submissions, tasks, 'external')} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}>
<TaskTable title="Deadlines" subtitle="Upcoming due dates across active work." tasks={upcomingDeadlineTasks} projects={projects} emptyMessage="No active deadlines right now." />
<TaskTable title="Assigned To You" subtitle="Your active jobs, sorted by deadline." tasks={assignedToMeTasks} projects={projects} emptyMessage="Nothing is assigned to you right now." />
</div>
</Layout>
);
return null;
}
+15 -15
View File
@@ -83,49 +83,49 @@ export default function PayInvoice() {
{loading ? (
<div style={{ textAlign: 'center', color: '#666' }}>Loading...</div>
) : !invoice ? (
<div style={{ background: '#fff', color: '#141414', borderRadius: 12, padding: 32, textAlign: 'center', boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
<div style={{ fontSize: 18, fontWeight: 700, marginBottom: 8 }}>Invoice not found</div>
<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>
</div>
) : success || invoice.status === 'paid' ? (
<div style={{ background: '#fff', color: '#141414', borderRadius: 12, padding: 32, textAlign: 'center', boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
<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: 32, marginBottom: 12 }}></div>
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 8 }}>Payment received</div>
<div style={{ fontSize: 20, fontWeight: 400, marginBottom: 8 }}>Payment received</div>
<div style={{ color: '#666', marginBottom: 4 }}>{invoice.invoice_number}</div>
<div style={{ fontSize: 24, fontWeight: 700, color: '#16a34a', marginTop: 16 }}>{totalLabel}</div>
<div style={{ fontSize: 24, fontWeight: 400, color: '#16a34a', marginTop: 16 }}>{totalLabel}</div>
<div style={{ color: '#666', marginTop: 6, fontSize: 12, letterSpacing: '0.3px' }}>Charged in USD</div>
<div style={{ color: '#666', marginTop: 8, fontSize: 13 }}>Thank you for your payment. We'll be in touch!</div>
</div>
) : (
<div style={{ background: '#fff', color: '#141414', borderRadius: 12, padding: 32, boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
<div style={{ fontSize: 13, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: '#999', marginBottom: 4 }}>Invoice</div>
<div style={{ fontSize: 22, fontWeight: 700, marginBottom: 4 }}>{invoice.invoice_number}</div>
<div style={{ background: '#fff', color: '#141414', borderRadius: 4, padding: 32, boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
<div style={{ fontSize: 13, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: '#999', marginBottom: 4 }}>Invoice</div>
<div style={{ fontSize: 22, fontWeight: 400, marginBottom: 4 }}>{invoice.invoice_number}</div>
<div style={{ color: '#666', marginBottom: 24 }}>{invoice.bill_to || company?.name}</div>
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '12px 0', borderTop: '1px solid #eee', borderBottom: '1px solid #eee', marginBottom: 24 }}>
<div>
<div style={{ fontSize: 12, color: '#999', marginBottom: 2 }}>Invoice Date</div>
<div style={{ fontWeight: 600, color: '#141414' }}>{new Date(invoice.invoice_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</div>
<div style={{ fontWeight: 400, color: '#141414' }}>{new Date(invoice.invoice_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 12, color: '#999', marginBottom: 2 }}>Due Date</div>
<div style={{ fontWeight: 600, color: '#141414' }}>{new Date(invoice.due_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</div>
<div style={{ fontWeight: 400, color: '#141414' }}>{new Date(invoice.due_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</div>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<div style={{ fontSize: 14, color: '#666' }}>Total Due</div>
<div style={{ fontSize: 28, fontWeight: 700, color: '#141414' }}>{totalLabel}</div>
<div style={{ fontSize: 28, fontWeight: 400, color: '#141414' }}>{totalLabel}</div>
</div>
{cancelled && (
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 8, padding: '10px 14px', fontSize: 13, color: '#dc2626', marginBottom: 16 }}>
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 4, padding: '10px 14px', fontSize: 13, color: '#dc2626', marginBottom: 16 }}>
Payment was cancelled. You can try again below.
</div>
)}
{error && (
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 8, padding: '10px 14px', fontSize: 13, color: '#dc2626', marginBottom: 16 }}>
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 4, padding: '10px 14px', fontSize: 13, color: '#dc2626', marginBottom: 16 }}>
{error}
</div>
)}
@@ -134,9 +134,9 @@ export default function PayInvoice() {
onClick={handlePay}
disabled={paying}
style={{
width: '100%', padding: '14px', borderRadius: 8, border: 'none',
width: '100%', padding: '14px', borderRadius: 4, border: 'none',
background: paying ? '#999' : '#141414', color: '#fff',
fontSize: 16, fontWeight: 700, cursor: paying ? 'not-allowed' : 'pointer',
fontSize: 16, fontWeight: 400, cursor: paying ? 'not-allowed' : 'pointer',
}}
>
{paying ? 'Redirecting to payment...' : `Pay ${totalLabel}`}
+32 -17
View File
@@ -3,10 +3,13 @@ import { useParams, Link, useNavigate } from 'react-router-dom';
import Layout from '../components/Layout';
import StatusBadge from '../components/StatusBadge';
import FileBrowser from '../components/FileBrowser';
import SortTh from '../components/SortTh';
import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext';
import { serviceTypes } from '../data/mockData';
import { addDaysToDateOnly, getTodayDateOnlyEST } from '../lib/dates';
import { logActivity } from '../lib/activityLog';
import { useSortable } from '../hooks/useSortable';
const safeFbName = v => String(v || '').trim().replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-').replace(/\s+/g, ' ').replace(/^-+|-+$/g, '');
@@ -44,6 +47,7 @@ export default function ProjectDetailPage() {
const [filter, setFilter] = useState('all');
const { sortKey, sortDir, toggle, sort } = useSortable('title');
const requesterOptions = [
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)`, email: currentUser.email || '' }] : []),
@@ -73,7 +77,7 @@ export default function ProjectDetailPage() {
supabase.from('companies').select('*').eq('id', p.company_id).single(),
supabase.from('tasks').select('*').eq('project_id', id).order('submitted_at', { ascending: false }),
supabase.from('profiles').select('id, name, email').eq('company_id', p.company_id).eq('role', 'client'),
supabase.from('project_members').select('*, profile:profiles(id, name, email)').eq('project_id', id),
supabase.from('project_members').select('*, profile:profiles(id, name, email, role)').eq('project_id', id),
supabase.from('profiles').select('id, name, email').eq('role', 'external').order('name'),
]);
setCompany(co);
@@ -144,6 +148,7 @@ export default function ProjectDetailPage() {
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', is_hot: jobForm.isHot, 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 });
setTasks(prev => [task, ...prev]);
setJobForm(emptyJobForm());
setShowAddJob(false);
@@ -172,6 +177,14 @@ export default function ProjectDetailPage() {
return initial?.submitted_by === currentUser.id;
})
: tasks;
const sortedTasks = sort(filteredTasks, (task, key) => {
if (key === 'title') return task.title || '';
if (key === 'assigned_name') return task.assigned_name || '';
if (key === 'current_version') return Number(task.current_version || 0);
if (key === 'status') return task.status || '';
if (key === 'submitted_at') return task.submitted_at || '';
return '';
});
return (
<Layout>
@@ -183,7 +196,7 @@ export default function ProjectDetailPage() {
<div>
{editingName && (isTeam || isClient) ? (
<form onSubmit={handleSaveName} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<input type="text" value={nameVal} onChange={e => setNameVal(e.target.value)} autoFocus required style={{ fontSize: 22, fontWeight: 700, padding: '4px 10px', margin: 0, width: 280 }} />
<input type="text" value={nameVal} onChange={e => setNameVal(e.target.value)} autoFocus required style={{ fontSize: 22, fontWeight: 400, padding: '4px 10px', margin: 0, width: 280 }} />
<button type="submit" className="btn btn-primary btn-sm" disabled={savingName}>{savingName ? '...' : 'Save'}</button>
<button type="button" className="btn btn-outline btn-sm" onClick={() => setEditingName(false)}>Cancel</button>
</form>
@@ -215,7 +228,9 @@ export default function ProjectDetailPage() {
<StatusBadge status={project.status} />
{isClient && (
<>
<button className="btn btn-outline btn-sm" style={{ color: 'var(--danger, #dc2626)', borderColor: 'var(--danger, #dc2626)' }} onClick={handleDeleteProject}>Delete Project</button>
{tasks.length === 0 && (
<button className="btn btn-outline btn-sm" style={{ color: 'var(--danger, #dc2626)', borderColor: 'var(--danger, #dc2626)' }} onClick={handleDeleteProject}>Delete Project</button>
)}
<Link to={`/new-request?project=${encodeURIComponent(project.name)}`} className="btn btn-primary">+ Add Request</Link>
</>
)}
@@ -230,7 +245,7 @@ export default function ProjectDetailPage() {
{/* Team: Add job form */}
{isTeam && showAddJob && (
<div className="card" style={{ marginBottom: 24, border: '1px solid var(--accent)', borderRadius: 12 }}>
<div className="card" style={{ marginBottom: 24, border: '1px solid var(--accent)', borderRadius: 4 }}>
<div className="card-title">Add Job {project.name}</div>
<form onSubmit={handleAddJob}>
<div className="grid-2">
@@ -345,7 +360,7 @@ export default function ProjectDetailPage() {
</div>
) : isClient ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{filteredTasks.map(task => {
{sortedTasks.map(task => {
const taskSubs = submissions.filter(s => s.task_id === task.id);
const initialSub = taskSubs.find(s => s.type === 'initial') || taskSubs[0];
const latestSub = taskSubs[taskSubs.length - 1];
@@ -358,7 +373,7 @@ export default function ProjectDetailPage() {
<div className="request-card-title">
{task.title}{' '}
<span style={{ fontWeight: 400, color: 'var(--text-muted)', fontSize: 13 }}>{rLabel(task.current_version)}</span>
{isMine && <span style={{ marginLeft: 8, fontSize: 11, background: 'rgba(245,165,35,0.15)', color: 'var(--accent)', padding: '2px 8px', borderRadius: 4, fontWeight: 600 }}>Mine</span>}
{isMine && <span style={{ marginLeft: 8, fontSize: 11, background: 'rgba(245,165,35,0.15)', color: 'var(--accent)', padding: '2px 8px', borderRadius: 4, fontWeight: 400 }}>Mine</span>}
</div>
<div className="request-card-meta" style={{ marginTop: 4 }}>
{initialSub?.submitted_by_name && <>By {initialSub.submitted_by_name}</>}
@@ -376,18 +391,18 @@ export default function ProjectDetailPage() {
<table>
<thead>
<tr>
<th>Job</th>
<th>Assigned To</th>
<th>Revision</th>
<th>Status</th>
<th>Submitted</th>
<SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Job</SortTh>
<SortTh col="assigned_name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Assigned To</SortTh>
<SortTh col="current_version" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Revision</SortTh>
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Status</SortTh>
<SortTh col="submitted_at" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Submitted</SortTh>
<th></th>
</tr>
</thead>
<tbody>
{filteredTasks.map(task => (
{sortedTasks.map(task => (
<tr key={task.id} onClick={() => navigate(`/requests/${task.id}`)} style={{ cursor: 'pointer' }}>
<td style={{ fontWeight: 600 }}>
<td style={{ fontWeight: 400 }}>
{task.title}
<span style={{ marginLeft: 6, fontWeight: 400, color: 'var(--text-muted)', fontSize: 12 }}>{'R' + String(task.current_version || 0).padStart(2, '0')}</span>
</td>
@@ -425,14 +440,14 @@ export default function ProjectDetailPage() {
<button className="btn btn-outline btn-sm" onClick={() => { setAddingMember(false); setSelectedExternal(''); }}>Cancel</button>
</div>
)}
{members.length === 0 ? (
{members.filter(m => m.profile?.role === 'external').length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)', margin: 0 }}>No external members assigned to this project.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{members.map(m => (
<div key={m.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
{members.filter(m => m.profile?.role === 'external').map(m => (
<div key={m.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: 600, color: 'var(--text-primary)' }}>{m.profile?.name}</div>
<div style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)' }}>{m.profile?.name}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{m.profile?.email}</div>
</div>
<button onClick={() => handleRemoveMember(m.profile_id)} style={{ background: 'none', border: 'none', color: 'var(--danger)', cursor: 'pointer', fontSize: 16, padding: '0 4px' }} title="Remove from project"></button>
+332 -161
View File
@@ -5,6 +5,10 @@ import StatusBadge from '../components/StatusBadge';
import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext';
import { withTimeout } from '../lib/withTimeout';
import { DashboardBanner } from '../lib/dashboardBanner';
import SortTh from '../components/SortTh';
import { useSortable } from '../hooks/useSortable';
import FilterDropdown from '../components/FilterDropdown';
// ─── Client helpers ────────────────────────────────────────────────────────
@@ -20,17 +24,17 @@ function ClientProjectGroup({ project, tasks, submissions, currentUserId, filter
: tasks;
if (filter === 'mine' && filteredTasks.length === 0) return null;
return (
<div className="interactive-surface" style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', marginBottom: 8 }}>
<div className="interactive-surface" style={{ border: '1px solid var(--border)', borderRadius: 4, overflow: 'hidden', marginBottom: 8 }}>
<button
className="interactive-panel-toggle"
onClick={() => setOpen(o => !o)}
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', background: 'var(--card-bg-2)', border: 'none', cursor: 'pointer', borderBottom: open ? '1px solid var(--border)' : 'none', fontFamily: 'inherit' }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Link to={`/projects/${project.id}`} onClick={e => e.stopPropagation()} style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)', textDecoration: 'none' }}>
<Link to={`/projects/${project.id}`} onClick={e => e.stopPropagation()} style={{ fontSize: 14, fontWeight: 400, color: 'var(--text-primary)', textDecoration: 'none' }}>
{project.name}
</Link>
<span style={{ fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 4, background: 'rgba(245,165,35,0.15)', color: 'var(--accent)' }}>
<span style={{ fontSize: 11, fontWeight: 400, padding: '2px 8px', borderRadius: 4, background: 'rgba(245,165,35,0.15)', color: 'var(--accent)' }}>
{filteredTasks.length} request{filteredTasks.length !== 1 ? 's' : ''}
</span>
</div>
@@ -58,9 +62,9 @@ function ClientProjectGroup({ project, tasks, submissions, currentUserId, filter
>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-primary)' }}>{task.title}</span>
<span style={{ fontWeight: 400, fontSize: 13, color: 'var(--text-primary)' }}>{task.title}</span>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{rLabel(task.current_version)}</span>
{isMine && <span style={{ fontSize: 11, background: 'rgba(245,165,35,0.15)', color: 'var(--accent)', padding: '1px 7px', borderRadius: 4, fontWeight: 600 }}>Mine</span>}
{isMine && <span style={{ fontSize: 11, background: 'rgba(245,165,35,0.15)', color: 'var(--accent)', padding: '1px 7px', borderRadius: 4, fontWeight: 400 }}>Mine</span>}
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 3 }}>
{initialSub?.submitted_by_name && <>By {initialSub.submitted_by_name}</>}
@@ -77,6 +81,33 @@ function ClientProjectGroup({ project, tasks, submissions, currentUserId, filter
);
}
function ProgressBar({ tasks = [], noPad = false }) {
const total = tasks.length;
if (total === 0) return <span style={{ fontSize: 12, color: 'var(--text-muted)' }}></span>;
const done = tasks.filter(t => ['client_approved', 'invoiced', 'paid'].includes(t.status)).length;
const pct = Math.round((done / total) * 100);
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, paddingRight: noPad ? 0 : '10%' }}>
<div style={{ flex: 1, minWidth: 60, height: 6, borderRadius: 3, background: 'var(--border)', overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', borderRadius: 3, background: pct === 100 ? '#4ade80' : 'var(--accent)', transition: 'width 0.3s' }} />
</div>
<span style={{ fontSize: 11, color: 'var(--text-muted)', whiteSpace: 'nowrap', textAlign: 'right' }}>{done}/{total} <span style={{ opacity: 0.65 }}>({pct}%)</span></span>
</div>
);
}
const ListViewIcon = () => (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<line x1="1" y1="3" x2="13" y2="3"/><line x1="1" y1="7" x2="13" y2="7"/><line x1="1" y1="11" x2="13" y2="11"/>
</svg>
);
const GridViewIcon = () => (
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
<rect x="1" y="1" width="5" height="5" rx="1"/><rect x="8" y="1" width="5" height="5" rx="1"/>
<rect x="1" y="8" width="5" height="5" rx="1"/><rect x="8" y="8" width="5" height="5" rx="1"/>
</svg>
);
// ─── Main export ───────────────────────────────────────────────────────────
export default function Projects() {
@@ -92,6 +123,9 @@ export default function Projects() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [viewMode, setViewMode] = useState(() => localStorage.getItem('projectsViewMode') || 'list');
const toggleView = () => setViewMode(v => { const n = v === 'list' ? 'grid' : 'list'; localStorage.setItem('projectsViewMode', n); return n; });
// Team/External state
const [filterCompany, setFilterCompany] = useState('');
const [filterStatus, setFilterStatus] = useState('all');
@@ -115,7 +149,7 @@ export default function Projects() {
try {
if (isTeam) {
const [{ data }, { data: cos }] = await Promise.all([
supabase.from('projects').select('id, name, status, created_at, company:companies(id, name)').order('created_at', { ascending: false }),
supabase.from('projects').select('id, name, status, created_at, company:companies(id, name), tasks:tasks(id,status)').order('created_at', { ascending: false }),
supabase.from('companies').select('id, name').order('name'),
]);
setProjects(data || []);
@@ -124,7 +158,7 @@ export default function Projects() {
if (!currentUser?.id) { setLoading(false); return; }
const { data, error: err } = await supabase
.from('projects')
.select('id, name, status, company:companies(name)')
.select('id, name, status, created_at, company:companies(name), tasks:tasks(id,status)')
.order('created_at', { ascending: false });
if (err) setError(err.message);
else setProjects(data || []);
@@ -157,6 +191,12 @@ export default function Projects() {
load();
}, [isTeam, isExternal, isClient, currentUser?.id]); // eslint-disable-line react-hooks/exhaustive-deps
const { sortKey: projSortKey, sortDir: projSortDir, toggle: projToggle, sort: projSort } = useSortable('status', 'asc');
const { sortKey: extSortKey, sortDir: extSortDir, toggle: extToggle, sort: extSort } = useSortable('status', 'asc');
const { sortKey: clientSortKey, sortDir: clientSortDir, toggle: clientToggle, sort: clientSort } = useSortable('status', 'asc');
const fmtDate = (d) => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
const teamCompanies = useMemo(() => {
if (!isTeam) return [];
const seen = new Map();
@@ -190,7 +230,10 @@ export default function Projects() {
// ── Team render ────────────────────────────────────────────────────────
if (isTeam) {
const companyFiltered = filterCompany ? projects.filter(p => p.company?.id === filterCompany) : projects;
const statusFiltered = filterStatus === 'all' ? companyFiltered : companyFiltered.filter(p => (p.status || 'active') === filterStatus);
const statusFiltered = projSort(
filterStatus === 'all' ? companyFiltered : companyFiltered.filter(p => (p.status || 'active') === filterStatus),
(p, key) => key === 'client' ? (p.company?.name || '') : key === 'status' ? (p.status || 'active') : p[key] || ''
);
const allCount = companyFiltered.length;
const activeCount = companyFiltered.filter(p => !p.status || p.status === 'active').length;
const completedCount = companyFiltered.filter(p => p.status === 'completed').length;
@@ -198,20 +241,27 @@ export default function Projects() {
return (
<Layout>
<div className="page-header" style={{ flexShrink: 0 }}>
<div>
<div className="page-title">Projects</div>
<div className="page-subtitle">All active client projects.</div>
<div className="page-header-left">
<DashboardBanner />
<div className="page-title dashboard-greeting">Projects</div>
<div className="page-subtitle">{activeCount} active &bull; {completedCount} completed</div>
</div>
<button className="btn btn-primary btn-sm" onClick={() => { setShowAddForm(s => !s); setAddError(''); }}>
{showAddForm ? 'Cancel' : '+ Add Project'}
<button className="btn btn-primary btn-sm" onClick={() => { setShowAddForm(true); setAddError(''); }}>
+ Add Project
</button>
</div>
{showAddForm && (
<div className="card" style={{ marginBottom: 24, border: '1px solid var(--accent)', borderRadius: 12, flexShrink: 0 }}>
<div className="card-title">Add Project</div>
<form onSubmit={handleAddProject}>
<div className="grid-2">
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }} onClick={() => { setShowAddForm(false); setAddForm({ name: '', companyId: '' }); setAddError(''); }}>
<div style={{ background: 'var(--card-bg)', borderRadius: 4, padding: 28, width: '100%', maxWidth: 480, border: '1px solid var(--border)', boxShadow: '0 24px 64px rgba(0,0,0,0.5)' }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 20 }}>
<div>
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>New Project</div>
<div style={{ fontSize: 22, color: 'var(--text-primary)' }}>Add a project</div>
</div>
<button onClick={() => { setShowAddForm(false); setAddForm({ name: '', companyId: '' }); setAddError(''); }} style={{ background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 20, cursor: 'pointer', lineHeight: 1, padding: 4 }}>×</button>
</div>
<form onSubmit={handleAddProject}>
<div className="form-group">
<label>Company *</label>
<select value={addForm.companyId} onChange={e => setAddForm(f => ({ ...f, companyId: e.target.value }))} required>
@@ -221,125 +271,180 @@ export default function Projects() {
</div>
<div className="form-group">
<label>Project Name *</label>
<input type="text" placeholder="e.g. Brand Identity 2026" value={addForm.name} onChange={e => setAddForm(f => ({ ...f, name: e.target.value }))} required />
<input type="text" placeholder="e.g. Brand Identity 2026" value={addForm.name} onChange={e => setAddForm(f => ({ ...f, name: e.target.value }))} required autoFocus />
</div>
</div>
{addError && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}> {addError}</div>}
<div className="action-buttons">
<button type="submit" className="btn btn-primary" disabled={addSaving}>{addSaving ? 'Creating...' : 'Create Project'}</button>
<button type="button" className="btn btn-outline" onClick={() => { setShowAddForm(false); setAddForm({ name: '', companyId: '' }); setAddError(''); }}>Cancel</button>
</div>
</form>
{addError && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}> {addError}</div>}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 20 }}>
<button type="button" className="btn btn-outline" onClick={() => { setShowAddForm(false); setAddForm({ name: '', companyId: '' }); setAddError(''); }}>Cancel</button>
<button type="submit" className="btn btn-primary" disabled={addSaving}>{addSaving ? 'Creating...' : 'Create Project'}</button>
</div>
</form>
</div>
</div>
)}
{teamCompanies.length > 0 && (
<div style={{ display: 'flex', gap: 12, marginBottom: 16, flexShrink: 0 }}>
<select className="filter-select" value={filterCompany} onChange={e => setFilterCompany(e.target.value)}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16, flexShrink: 0, flexWrap: 'wrap' }}>
{teamCompanies.length > 0 && (
<select className="filter-select" style={{ width: 120 }} value={filterCompany} onChange={e => setFilterCompany(e.target.value)}>
<option value="">All Companies</option>
{teamCompanies.map(([id, name]) => <option key={id} value={id}>{name}</option>)}
</select>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginLeft: 'auto' }}>
<select className="filter-select" style={{ width: 120 }} value={filterStatus} onChange={e => setFilterStatus(e.target.value)}>
<option value="active">Active ({activeCount})</option>
<option value="completed">Completed ({completedCount})</option>
<option value="all">All ({allCount})</option>
</select>
<button className="btn btn-outline btn-sm" onClick={toggleView} title={viewMode === 'list' ? 'Grid view' : 'List view'} style={{ padding: '5px 9px', lineHeight: 1, display: 'flex', alignItems: 'center' }}>
{viewMode === 'list' ? <GridViewIcon /> : <ListViewIcon />}
</button>
</div>
</div>
{statusFiltered.length === 0 ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No projects found.</div>
) : viewMode === 'grid' ? (
<div style={{ flex: 1, overflowY: 'auto' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: 12 }}>
{statusFiltered.map(p => (
<div key={p.id} className="grid-card" onClick={() => navigate(`/projects/${p.id}`)} style={{ padding: '14px 16px', border: '1px solid var(--border)', borderRadius: 6 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8, marginBottom: 6 }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 400, fontSize: 13, color: 'var(--text-primary)', marginBottom: filterCompany ? 0 : 2 }}>{p.name}</div>
{!filterCompany && <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{p.company?.name || '—'}</div>}
</div>
<StatusBadge status={p.status || 'active'} />
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 10, textAlign: 'right' }}>Created {fmtDate(p.created_at)}</div>
<ProgressBar tasks={p.tasks || []} noPad />
</div>
))}
</div>
</div>
) : (
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<table style={{ tableLayout: 'fixed', width: '100%' }}>
<colgroup>
<col style={{ width: filterCompany ? '30%' : '25%' }} />
{!filterCompany && <col style={{ width: '20%' }} />}
<col style={{ width: filterCompany ? '35%' : '25%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '20%' }} />
</colgroup>
<thead>
<tr>
<SortTh col="name" sortKey={projSortKey} sortDir={projSortDir} onSort={projToggle}>Project</SortTh>
{!filterCompany && <SortTh col="client" sortKey={projSortKey} sortDir={projSortDir} onSort={projToggle}>Client</SortTh>}
<th>Progress</th>
<SortTh col="status" sortKey={projSortKey} sortDir={projSortDir} onSort={projToggle}>Status</SortTh>
<SortTh col="created_at" sortKey={projSortKey} sortDir={projSortDir} onSort={projToggle}>Date Created</SortTh>
</tr>
</thead>
<tbody>
{statusFiltered.map(p => (
<tr key={p.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/projects/${p.id}`)}>
<td style={{ fontWeight: 400 }}>{p.name}</td>
{!filterCompany && <td style={{ color: 'var(--text-muted)' }}>{p.company?.name || '—'}</td>}
<td><ProgressBar tasks={p.tasks || []} /></td>
<td><StatusBadge status={p.status} /></td>
<td style={{ color: 'var(--text-muted)', fontSize: 12 }}>{fmtDate(p.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 16, flexShrink: 0 }}>
{[
{ id: 'all', label: 'All', count: allCount },
{ id: 'active', label: 'Active', count: activeCount },
{ id: 'completed', label: 'Completed', count: completedCount },
].map(tab => (
<button key={tab.id} className={`tab-btn${filterStatus === tab.id ? ' active' : ''}`} onClick={() => setFilterStatus(tab.id)}>
{tab.label} ({tab.count})
</button>
))}
</div>
{statusFiltered.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>No projects found.</div>
) : (
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table>
<thead>
<tr>
<th>Project</th>
{!filterCompany && <th>Client</th>}
<th>Status</th>
<th>Started</th>
</tr>
</thead>
<tbody>
{statusFiltered.map(p => (
<tr key={p.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/projects/${p.id}`)}>
<td style={{ fontWeight: 600 }}>{p.name}</td>
{!filterCompany && <td style={{ color: 'var(--text-muted)' }}>{p.company?.name || '—'}</td>}
<td><StatusBadge status={p.status} /></td>
<td style={{ color: 'var(--text-muted)' }}>{new Date(p.created_at).toLocaleDateString()}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</Layout>
);
}
// ── External render ────────────────────────────────────────────────────
if (isExternal) {
const extFiltered = filterStatus === 'all' ? projects : projects.filter(p => (p.status || 'active') === filterStatus);
const extBase = filterStatus === 'all' ? projects : projects.filter(p => (p.status || 'active') === filterStatus);
const extFiltered = extSort(extBase, (p, key) => key === 'client' ? (p.company?.name || '') : p[key] || '');
const extActiveCount = projects.filter(p => !p.status || p.status === 'active').length;
const extCompletedCount = projects.filter(p => p.status === 'completed').length;
return (
<Layout>
<div className="page-header" style={{ flexShrink: 0 }}>
<div>
<div className="page-title">Projects</div>
<div className="page-subtitle">All projects you are assigned to.</div>
<div className="page-header-left">
<DashboardBanner />
<div className="page-title dashboard-greeting">Projects</div>
<div className="page-subtitle">{extActiveCount} active &bull; {extCompletedCount} completed</div>
</div>
</div>
{error && <div className="card" style={{ color: 'var(--danger)', marginBottom: 16, flexShrink: 0 }}>{error}</div>}
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 16, flexShrink: 0 }}>
{[
{ id: 'all', label: 'All', count: projects.length },
{ id: 'active', label: 'Active', count: extActiveCount },
{ id: 'completed', label: 'Completed', count: extCompletedCount },
].map(tab => (
<button key={tab.id} className={`tab-btn${filterStatus === tab.id ? ' active' : ''}`} onClick={() => setFilterStatus(tab.id)}>
{tab.label} ({tab.count})
</button>
))}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16, flexShrink: 0, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginLeft: 'auto' }}>
<select className="filter-select" style={{ width: 120 }} value={filterStatus} onChange={e => setFilterStatus(e.target.value)}>
<option value="active">Active ({extActiveCount})</option>
<option value="completed">Completed ({extCompletedCount})</option>
<option value="all">All ({projects.length})</option>
</select>
<button className="btn btn-outline btn-sm" onClick={toggleView} title={viewMode === 'list' ? 'Grid view' : 'List view'} style={{ padding: '5px 9px', lineHeight: 1, display: 'flex', alignItems: 'center' }}>
{viewMode === 'list' ? <GridViewIcon /> : <ListViewIcon />}
</button>
</div>
{projects.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>No projects yet. Team will assign you to one.</div>
) : extFiltered.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>No {filterStatus} projects.</div>
) : (
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table>
<thead>
<tr>
<th>Project</th>
<th>Client</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{extFiltered.map(p => (
<tr key={p.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/projects/${p.id}`)}>
<td style={{ fontWeight: 600 }}>{p.name}</td>
<td style={{ color: 'var(--text-muted)' }}>{p.company?.name || '—'}</td>
<td style={{ textTransform: 'capitalize', color: 'var(--text-muted)' }}>{p.status || 'Active'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{projects.length === 0 ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No projects yet. Team will assign you to one.</div>
) : extFiltered.length === 0 ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No {filterStatus} projects.</div>
) : viewMode === 'grid' ? (
<div style={{ flex: 1, overflowY: 'auto' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: 12 }}>
{extFiltered.map(p => (
<div key={p.id} className="grid-card" onClick={() => navigate(`/projects/${p.id}`)} style={{ padding: '14px 16px', border: '1px solid var(--border)', borderRadius: 6 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8, marginBottom: 6 }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 400, fontSize: 13, color: 'var(--text-primary)', marginBottom: 2 }}>{p.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{p.company?.name || '—'}</div>
</div>
<StatusBadge status={p.status || 'active'} />
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 10, textAlign: 'right' }}>Created {fmtDate(p.created_at)}</div>
<ProgressBar tasks={p.tasks || []} noPad />
</div>
))}
</div>
</div>
) : (
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<table style={{ tableLayout: 'fixed', width: '100%' }}>
<colgroup>
<col style={{ width: '25%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '30%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '15%' }} />
</colgroup>
<thead>
<tr>
<SortTh col="name" sortKey={extSortKey} sortDir={extSortDir} onSort={extToggle}>Project</SortTh>
<SortTh col="client" sortKey={extSortKey} sortDir={extSortDir} onSort={extToggle}>Client</SortTh>
<th>Progress</th>
<SortTh col="status" sortKey={extSortKey} sortDir={extSortDir} onSort={extToggle}>Status</SortTh>
<SortTh col="created_at" sortKey={extSortKey} sortDir={extSortDir} onSort={extToggle}>Date Created</SortTh>
</tr>
</thead>
<tbody>
{extFiltered.map(p => (
<tr key={p.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/projects/${p.id}`)}>
<td style={{ fontWeight: 400 }}>{p.name}</td>
<td style={{ color: 'var(--text-muted)' }}>{p.company?.name || '—'}</td>
<td><ProgressBar tasks={p.tasks || []} /></td>
<td><StatusBadge status={p.status || 'active'} /></td>
<td style={{ color: 'var(--text-muted)', fontSize: 12 }}>{fmtDate(p.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Layout>
);
}
@@ -348,72 +453,138 @@ export default function Projects() {
const clientBase = companies.length > 1 && activeCompanyId
? projects.filter(p => p.company_id === activeCompanyId)
: projects;
const clientFiltered = filterStatus === 'all' ? clientBase : clientBase.filter(p => (p.status || 'active') === filterStatus);
const clientFiltered = clientSort(
filterStatus === 'all' ? clientBase : clientBase.filter(p => (p.status || 'active') === filterStatus),
(p, key) => p[key] || ''
);
const clientActiveCount = clientBase.filter(p => !p.status || p.status === 'active').length;
const clientCompletedCount = clientBase.filter(p => p.status === 'completed').length;
return (
<Layout>
<div className="page-header" style={{ flexShrink: 0 }}>
<div>
<div className="page-title">Projects</div>
<div className="page-subtitle">All work for your company.</div>
<div className="page-header-left">
<DashboardBanner />
<div className="page-title dashboard-greeting">Projects</div>
<div className="page-subtitle">{clientActiveCount} active &bull; {clientCompletedCount} completed</div>
</div>
<button className="btn btn-primary btn-sm" onClick={() => { setShowAddForm(true); setAddError(''); if (companies.length === 1) setAddForm(f => ({ ...f, companyId: companies[0].id })); }}>
+ Add Project
</button>
</div>
{companies.length > 1 && (
<div style={{ display: 'flex', gap: 12, marginBottom: 16, flexShrink: 0 }}>
<select className="filter-select" value={activeCompanyId || ''} onChange={e => setActiveCompanyId(e.target.value)}>
<option value="">All Companies</option>
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
{showAddForm && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }} onClick={() => { setShowAddForm(false); setAddForm({ name: '', companyId: '' }); setAddError(''); }}>
<div style={{ background: 'var(--card-bg)', borderRadius: 4, padding: 28, width: '100%', maxWidth: 480, border: '1px solid var(--border)', boxShadow: '0 24px 64px rgba(0,0,0,0.5)' }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 20 }}>
<div>
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>New Project</div>
<div style={{ fontSize: 22, color: 'var(--text-primary)' }}>Add a project</div>
</div>
<button onClick={() => { setShowAddForm(false); setAddForm({ name: '', companyId: '' }); setAddError(''); }} style={{ background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 20, cursor: 'pointer', lineHeight: 1, padding: 4 }}>×</button>
</div>
<form onSubmit={handleAddProject}>
{companies.length > 1 && (
<div className="form-group">
<label>Company *</label>
<select value={addForm.companyId} onChange={e => setAddForm(f => ({ ...f, companyId: e.target.value }))} required>
<option value="">Select company...</option>
{companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
</select>
</div>
)}
<div className="form-group">
<label>Project Name *</label>
<input type="text" placeholder="e.g. Brand Identity 2026" value={addForm.name} onChange={e => setAddForm(f => ({ ...f, name: e.target.value }))} required autoFocus />
</div>
{addError && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}> {addError}</div>}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 20 }}>
<button type="button" className="btn btn-outline" onClick={() => { setShowAddForm(false); setAddForm({ name: '', companyId: '' }); setAddError(''); }}>Cancel</button>
<button type="submit" className="btn btn-primary" disabled={addSaving}>{addSaving ? 'Creating...' : 'Create Project'}</button>
</div>
</form>
</div>
</div>
)}
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 16, flexShrink: 0 }}>
{[
{ id: 'all', label: 'All', count: clientBase.length },
{ id: 'active', label: 'Active', count: clientActiveCount },
{ id: 'completed', label: 'Completed', count: clientCompletedCount },
].map(tab => (
<button key={tab.id} className={`tab-btn${filterStatus === tab.id ? ' active' : ''}`} onClick={() => setFilterStatus(tab.id)}>
{tab.label} ({tab.count})
</button>
))}
</div>
{clientBase.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>No projects yet.</div>
) : clientFiltered.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>No {filterStatus} projects.</div>
) : (
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table>
<thead>
<tr>
<th>Project</th>
<th>Tasks</th>
<th>Status</th>
<th>Started</th>
</tr>
</thead>
<tbody>
{clientFiltered.map(p => {
const projectTasks = tasks.filter(t => t.project_id === p.id);
return (
<tr key={p.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/projects/${p.id}`)}>
<td style={{ fontWeight: 600 }}>{p.name}</td>
<td style={{ color: 'var(--text-muted)' }}>{projectTasks.length}</td>
<td><StatusBadge status={p.status || 'active'} /></td>
<td style={{ color: 'var(--text-muted)' }}>{new Date(p.created_at).toLocaleDateString()}</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16, flexShrink: 0, flexWrap: 'wrap' }}>
{companies.length > 1 && (
<select className="filter-select" style={{ width: 120 }} value={activeCompanyId || ''} onChange={e => setActiveCompanyId(e.target.value)}>
<option value="">All Companies</option>
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginLeft: 'auto' }}>
<select className="filter-select" style={{ width: 120 }} value={filterStatus} onChange={e => setFilterStatus(e.target.value)}>
<option value="active">Active ({clientActiveCount})</option>
<option value="completed">Completed ({clientCompletedCount})</option>
<option value="all">All ({clientBase.length})</option>
</select>
<button className="btn btn-outline btn-sm" onClick={toggleView} title={viewMode === 'list' ? 'Grid view' : 'List view'} style={{ padding: '5px 9px', lineHeight: 1, display: 'flex', alignItems: 'center' }}>
{viewMode === 'list' ? <GridViewIcon /> : <ListViewIcon />}
</button>
</div>
</div>
{clientBase.length === 0 ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No projects yet.</div>
) : clientFiltered.length === 0 ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No {filterStatus} projects.</div>
) : viewMode === 'grid' ? (
<div style={{ flex: 1, overflowY: 'auto' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: 12 }}>
{clientFiltered.map(p => {
const projectTasks = tasks.filter(t => t.project_id === p.id);
const co = companies.find(c => c.id === p.company_id);
return (
<div key={p.id} className="grid-card" onClick={() => navigate(`/projects/${p.id}`)} style={{ padding: '14px 16px', border: '1px solid var(--border)', borderRadius: 6 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8, marginBottom: 6 }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 400, fontSize: 13, color: 'var(--text-primary)', marginBottom: 2 }}>{p.name}</div>
{co && <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{co.name}</div>}
</div>
<StatusBadge status={p.status || 'active'} />
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 10, textAlign: 'right' }}>Created {fmtDate(p.created_at)}</div>
<ProgressBar tasks={projectTasks} noPad />
</div>
);
})}
</div>
</div>
) : (
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<table style={{ tableLayout: 'fixed', width: '100%' }}>
<colgroup>
<col style={{ width: '30%' }} />
<col style={{ width: '35%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '20%' }} />
</colgroup>
<thead>
<tr>
<SortTh col="name" sortKey={clientSortKey} sortDir={clientSortDir} onSort={clientToggle}>Project</SortTh>
<th>Progress</th>
<SortTh col="status" sortKey={clientSortKey} sortDir={clientSortDir} onSort={clientToggle}>Status</SortTh>
<SortTh col="created_at" sortKey={clientSortKey} sortDir={clientSortDir} onSort={clientToggle}>Date Created</SortTh>
</tr>
</thead>
<tbody>
{clientFiltered.map(p => {
const projectTasks = tasks.filter(t => t.project_id === p.id);
return (
<tr key={p.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/projects/${p.id}`)}>
<td style={{ fontWeight: 400 }}>{p.name}</td>
<td><ProgressBar tasks={projectTasks} /></td>
<td><StatusBadge status={p.status || 'active'} /></td>
<td style={{ color: 'var(--text-muted)', fontSize: 12 }}>{fmtDate(p.created_at)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</Layout>
);
}
+134 -95
View File
@@ -11,6 +11,7 @@ import { useAuth } from '../context/AuthContext';
import { serviceTypes } from '../data/mockData';
import { addDaysToDateOnly, formatDateEST, getTodayDateOnlyEST } from '../lib/dates';
import { uploadFilesToRequestInfo } from '../lib/filebrowserFolders';
import { logActivity } from '../lib/activityLog';
const rLabel = (v) => 'R' + String(v || 0).padStart(2, '0');
const MAX_FILES = 20;
@@ -70,6 +71,7 @@ export default function RequestDetail() {
const [savingRevision, setSavingRevision] = useState(false);
const [editingRequest, setEditingRequest] = useState(false);
const [requestForm, setRequestForm] = useState({ serviceType: '', deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3), description: '', requestedBy: '', isHot: false });
const [requestFiles, setRequestFiles] = useState([]);
const [savingRequest, setSavingRequest] = useState(false);
const [notification, setNotification] = useState(null);
@@ -169,6 +171,10 @@ export default function RequestDetail() {
const update = completedAt ? { status: newStatus, completed_at: completedAt } : { status: newStatus };
await supabase.from('tasks').update(update).eq('id', id);
setTask(t => ({ ...t, status: newStatus, ...(completedAt ? { completed_at: completedAt } : {}) }));
const actionMap = { on_hold: 'task_on_hold', in_progress: 'task_resumed', client_approved: 'task_approved' };
if (actionMap[newStatus]) {
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: actionMap[newStatus], taskId: id, taskTitle: task?.title, projectId: project?.id, projectName: project?.name });
}
setNotification(message);
setSaving(false);
};
@@ -176,6 +182,7 @@ export default function RequestDetail() {
const handleStart = async () => {
await supabase.from('tasks').update({ status: 'in_progress', assigned_to: currentUser.id, assigned_name: currentUser.name }).eq('id', id);
setTask(t => ({ ...t, status: 'in_progress', assigned_to: currentUser.id, assigned_name: currentUser.name }));
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'task_started', taskId: id, taskTitle: task?.title, projectId: project?.id, projectName: project?.name });
setNotification('✓ Job started and assigned to you.');
};
const handleOnHold = () => updateStatus('on_hold', '✓ Job placed on hold.');
@@ -196,6 +203,7 @@ export default function RequestDetail() {
setNotification(`✗ Error approving: ${error.message}`);
} else {
setTask(t => ({ ...t, status: 'client_approved', completed_at: new Date().toISOString() }));
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'task_approved', taskId: id, taskTitle: task?.title, projectId: project?.id, projectName: project?.name });
setNotification('✓ Job approved.');
}
setSaving(false);
@@ -276,9 +284,28 @@ export default function RequestDetail() {
submitted_by_name: selectedRequester.name.replace(' (You)', ''),
}).eq('id', currentPrimary.id);
if (!error) {
setSubmissions(prev => prev.map(sub => sub.id === currentPrimary.id ? { ...sub, service_type: requestForm.serviceType, deadline: requestForm.deadline || null, is_hot: requestForm.isHot, description: requestForm.description, submitted_by: requestForm.requestedBy, submitted_by_name: selectedRequester.name.replace(' (You)', '') } : sub));
let uploadedFiles = [];
if (requestFiles.length > 0) {
try {
for (const file of requestFiles) {
const path = `${id}/${Date.now()}_${file.name}`;
const { data: uploaded, error: upErr } = await supabase.storage.from('submissions').upload(path, file);
if (upErr) throw new Error(`Upload failed: ${upErr.message}`);
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: currentPrimary.id })));
}
} catch (upErr) {
setNotification(`✗ Upload error: ${upErr.message}`);
setSavingRequest(false);
return;
}
}
await refreshSubmissions();
setEditingRequest(false);
setNotification('✓ Request details updated.');
setRequestFiles([]);
setNotification(uploadedFiles.length > 0 ? `✓ Request updated — ${uploadedFiles.length} file${uploadedFiles.length !== 1 ? 's' : ''} added.` : '✓ Request details updated.');
} else {
setNotification(`✗ Error: ${error.message}`);
}
@@ -316,17 +343,12 @@ export default function RequestDetail() {
e.preventDefault();
setSaving(true);
try {
const recipients = clientEmails.length > 0 ? clientEmails : (extraClientEmail ? [extraClientEmail] : []);
if (recipients.length === 0) throw new Error('No client email — add one before sending.');
const recipients = [...clientEmails, ...(extraClientEmail ? [extraClientEmail] : [])];
const primarySub = getCurrentPrimarySubmission(submissions, getRevisionBaseline(task, submissions));
if (!primarySub) { setSaving(false); return; }
const existingFiles = primarySub.delivery?.files || [];
if (sendForm.files.length === 0 && existingFiles.length === 0) throw new Error('Attach at least one file before sending.');
const uploadedFiles = await uploadDeliveryFiles(id, sendForm.files);
if (extraClientEmail && clientEmails.length === 0) {
await supabase.from('companies').update({ contact_email: extraClientEmail }).eq('id', company.id);
setCompany(c => ({ ...c, contact_email: extraClientEmail }));
}
const { data: delivery, error: deliveryError } = await supabase.from('deliveries').upsert({ submission_id: primarySub.id, sent_by: currentUser?.name, message: sendForm.message, sent_at: new Date().toISOString() }, { onConflict: 'submission_id' }).select().single();
if (deliveryError) throw new Error(`Delivery failed: ${deliveryError.message}`);
if (delivery && uploadedFiles.length > 0) {
@@ -335,6 +357,7 @@ export default function RequestDetail() {
}
await supabase.from('tasks').update({ status: 'client_review' }).eq('id', id);
setTask(t => ({ ...t, status: 'client_review' }));
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'task_submitted', taskId: id, taskTitle: task?.title, projectId: project?.id, projectName: project?.name });
await refreshSubmissions();
setShowSendForm(false);
setSendForm({ files: [], message: '' });
@@ -342,7 +365,7 @@ export default function RequestDetail() {
const totalDeliveredFiles = existingFiles.length + uploadedFiles.length;
const updateLabel = existingFiles.length > 0 ? `${uploadedFiles.length} new file${uploadedFiles.length !== 1 ? 's' : ''} added, ${totalDeliveredFiles} total` : `${uploadedFiles.length} file${uploadedFiles.length !== 1 ? 's' : ''} delivered`;
setNotification(`✓ Sent to client — ${updateLabel}.`);
void sendEmail('sent_to_client', recipients, { clientFirstName: company?.name, serviceType: task.title, projectName: project?.name, message: sendForm.message, taskId: id, senderEmail: currentUser?.email }).catch(err => console.error('Send-to-client email failed:', err));
if (recipients.length > 0) void sendEmail('sent_to_client', recipients, { clientFirstName: company?.name, serviceType: task.title, projectName: project?.name, message: sendForm.message, taskId: id, senderEmail: currentUser?.email }).catch(err => console.error('Send-to-client email failed:', err));
} catch (err) {
setNotification(`✗ Error: ${err.message}`);
} finally {
@@ -369,6 +392,7 @@ export default function RequestDetail() {
}
await supabase.from('tasks').update({ status: 'client_review' }).eq('id', id);
setTask(t => ({ ...t, status: 'client_review' }));
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'task_submitted', taskId: id, taskTitle: task?.title, projectId: project?.id, projectName: project?.name });
await refreshSubmissions();
setWorkForm({ files: [], description: '' });
setShowWorkUpload(false);
@@ -423,8 +447,14 @@ export default function RequestDetail() {
setSaving(true);
await supabase.from('tasks').update({ status: 'client_approved', completed_at: new Date().toISOString() }).eq('id', id);
setTask(t => ({ ...t, status: 'client_approved' }));
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'task_approved', taskId: id, taskTitle: task?.title, projectId: project?.id, projectName: project?.name });
setClientAction('approved');
sendEmail('client_approved', 'hello@fourgebranding.com', { clientName: currentUser.name, serviceType: task.title, projectName: project?.name, taskId: id }).catch(err => console.error('Client approved email failed:', err));
const approvalRecipients = ['hello@fourgebranding.com'];
if (task.assigned_to) {
const { data: ap } = await supabase.from('profiles').select('email').eq('id', task.assigned_to).single();
if (ap?.email && ap.email !== 'hello@fourgebranding.com') approvalRecipients.push(ap.email);
}
sendEmail('client_approved', approvalRecipients, { clientName: currentUser.name, serviceType: task.title, projectName: project?.name, taskId: id }).catch(err => console.error('Client approved email failed:', err));
setSaving(false);
};
@@ -466,7 +496,13 @@ export default function RequestDetail() {
if (project?.name && task?.title) uploadFilesToRequestInfo(revisionFiles, company?.name, project.name, task.title, newVersion).catch(() => {});
}
setTask(t => ({ ...t, status: 'not_started', current_version: newVersion, assigned_to: null, assigned_name: null }));
sendEmail('revision_submitted', 'hello@fourgebranding.com', { clientName: currentUser.name, serviceType: task.title, projectName: project?.name, version: rLabel(newVersion), deadline: revisionForm.deadline, description: revisionForm.description, taskId: id }).catch(err => console.error('Revision submitted email failed:', err));
const revisionRecipients = ['hello@fourgebranding.com'];
if (task.assigned_to) {
const { data: rp } = await supabase.from('profiles').select('email').eq('id', task.assigned_to).single();
if (rp?.email && rp.email !== 'hello@fourgebranding.com') revisionRecipients.push(rp.email);
}
sendEmail('revision_submitted', revisionRecipients, { clientName: currentUser.name, serviceType: task.title, projectName: project?.name, version: rLabel(newVersion), deadline: revisionForm.deadline, description: revisionForm.description, taskId: id }).catch(err => console.error('Revision submitted email failed:', err));
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'revision_requested', taskId: id, taskTitle: task?.title, projectId: project?.id, projectName: project?.name }).catch(() => {});
}
await refreshSubmissions();
setSubmitted(true);
@@ -482,13 +518,22 @@ export default function RequestDetail() {
// ── File helpers ───────────────────────────────────────────────────────────
const triggerBlobDownload = (blob, filename) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
};
const getFileUrl = async (file) => {
const key = `delivery:${file.storage_path}`;
if (downloading) return;
setDownloading(key);
try {
const { data } = await supabase.storage.from('deliveries').createSignedUrl(file.storage_path, 3600, { download: file.name });
if (data?.signedUrl) window.open(data.signedUrl, '_blank');
if (data?.signedUrl) { const res = await fetch(data.signedUrl); const blob = await res.blob(); triggerBlobDownload(blob, file.name); }
} finally { setDownloading(''); }
};
@@ -498,7 +543,7 @@ export default function RequestDetail() {
setDownloading(key);
try {
const { data } = await supabase.storage.from('submissions').createSignedUrl(file.storage_path, 3600, { download: file.name });
if (data?.signedUrl) window.open(data.signedUrl, '_blank');
if (data?.signedUrl) { const res = await fetch(data.signedUrl); const blob = await res.blob(); triggerBlobDownload(blob, file.name); }
} finally { setDownloading(''); }
};
@@ -510,11 +555,16 @@ export default function RequestDetail() {
try {
const zip = new JSZip();
for (const file of files) {
const { data } = await supabase.storage.from('submissions').createSignedUrl(file.storage_path, 3600, { download: file.name });
if (data?.signedUrl) {
const response = await fetch(data.signedUrl);
const blob = await response.blob();
zip.file(file.name, blob);
try {
const { data } = await supabase.storage.from('submissions').createSignedUrl(file.storage_path, 3600, { download: file.name });
if (data?.signedUrl) {
const response = await fetch(data.signedUrl);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const blob = await response.blob();
zip.file(file.name, blob);
}
} catch (fileErr) {
console.error(`Skipping ${file.name}:`, fileErr);
}
}
const content = await zip.generateAsync({ type: 'blob' });
@@ -555,7 +605,7 @@ export default function RequestDetail() {
<div>
{editingTitle && !isClient && !isExternal ? (
<form onSubmit={handleSaveTitle} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<input type="text" value={titleVal} onChange={e => setTitleVal(e.target.value)} autoFocus required style={{ fontSize: 22, fontWeight: 700, padding: '4px 10px', margin: 0, width: 280 }} />
<input type="text" value={titleVal} onChange={e => setTitleVal(e.target.value)} autoFocus required style={{ fontSize: 22, fontWeight: 400, padding: '4px 10px', margin: 0, width: 280 }} />
<button type="submit" className="btn btn-primary btn-sm" disabled={savingTitle}>{savingTitle ? '...' : 'Save'}</button>
<button type="button" className="btn btn-outline btn-sm" onClick={() => setEditingTitle(false)}>Cancel</button>
</form>
@@ -581,30 +631,12 @@ export default function RequestDetail() {
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<StatusBadge status={task.status} />
{isClient && clientAction !== 'confirm-delete' && (
<button className="btn-icon btn-icon-danger" onClick={() => setClientAction('confirm-delete')} title="Delete"></button>
)}
{!isClient && !isExternal && (
{isTeam && (
<button className="btn-icon btn-icon-danger" onClick={handleDeleteTask} title="Delete Job"></button>
)}
</div>
</div>
{/* Client delete confirm */}
{isClient && clientAction === 'confirm-delete' && (
<div className="card" style={{ background: 'var(--bg)', borderColor: 'var(--danger)', marginBottom: 24 }}>
<div style={{ fontWeight: 600, marginBottom: 8 }}> Delete this request?</div>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
This will permanently delete <strong>{titleWithVersion}</strong> and all its history. This cannot be undone.
</p>
<div className="action-buttons">
<button className="btn" style={{ background: '#ef4444', color: 'white', border: 'none' }} onClick={handleDeleteTask} disabled={saving}>
{saving ? 'Deleting...' : 'Yes, Delete'}
</button>
<button className="btn btn-outline" onClick={() => setClientAction(null)}>Cancel</button>
</div>
</div>
)}
{/* Notifications */}
{notification && <div className="notification notification-success">{notification}</div>}
@@ -622,9 +654,9 @@ export default function RequestDetail() {
{/* Client action cards */}
{isClient && canReview && !submitted && clientAction !== 'confirm-delete' && clientAction !== 'revision' && (
<div className="card" style={{ borderColor: 'var(--accent)', marginBottom: 24 }}>
<div style={{ fontWeight: 600, marginBottom: 8 }}>🎨 Your work is ready for review!</div>
<div style={{ fontWeight: 400, marginBottom: 8 }}>🎨 Your work is ready for review!</div>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
Please review the delivered work for <strong>{titleWithVersion}</strong> and let us know if you are happy or need changes.
Please review the delivered work for <span style={{ color: "var(--text-primary)" }}>{titleWithVersion}</span> and let us know if you are happy or need changes.
</p>
<div className="action-buttons">
<button className="btn btn-success" onClick={handleClientApprove} disabled={saving}> Approve I'm Happy!</button>
@@ -634,7 +666,7 @@ export default function RequestDetail() {
)}
{isClient && canEdit && !submitted && clientAction !== 'confirm-delete' && clientAction !== 'edit' && (
<div className="card" style={{ marginBottom: 24 }}>
<div style={{ fontWeight: 600, marginBottom: 8 }}>✏️ Need to make changes?</div>
<div style={{ fontWeight: 400, marginBottom: 8 }}>✏️ Need to make changes?</div>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
Your request is still being worked on. You can update the details or requirements.
</p>
@@ -649,7 +681,7 @@ export default function RequestDetail() {
)}
{isClient && canReopen && !submitted && clientAction !== 'confirm-delete' && clientAction !== 'reopen' && (
<div className="card" style={{ marginBottom: 24 }}>
<div style={{ fontWeight: 600, marginBottom: 8 }}>🔄 Need more changes?</div>
<div style={{ fontWeight: 400, marginBottom: 8 }}>🔄 Need more changes?</div>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
This job was approved but you can still request a new revision if needed.
</p>
@@ -687,14 +719,14 @@ export default function RequestDetail() {
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer', fontWeight: 400 }}>
<input type="radio" name="revisionType" value="client_revision" checked={revisionForm.revisionType === 'client_revision'} onChange={e => setRevisionForm(f => ({ ...f, revisionType: e.target.value }))} style={{ marginTop: 2, flexShrink: 0 }} />
<div>
<div style={{ fontWeight: 600, fontSize: 13 }}>Client Revision</div>
<div style={{ fontWeight: 400, fontSize: 13 }}>Client Revision</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>I want changes made to the current work</div>
</div>
</label>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer', fontWeight: 400 }}>
<input type="radio" name="revisionType" value="fourge_error" checked={revisionForm.revisionType === 'fourge_error'} onChange={e => setRevisionForm(f => ({ ...f, revisionType: e.target.value }))} style={{ marginTop: 2, flexShrink: 0 }} />
<div>
<div style={{ fontWeight: 600, fontSize: 13 }}>Fourge Error</div>
<div style={{ fontWeight: 400, fontSize: 13 }}>Fourge Error</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>Something was incorrect or not delivered as agreed</div>
</div>
</label>
@@ -716,7 +748,7 @@ export default function RequestDetail() {
{/* Team/External: Send form */}
{!isClient && showSendForm && (
<div className="card" style={{ marginBottom: 24, border: '1px solid var(--accent)', borderRadius: 12 }}>
<div className="card" style={{ marginBottom: 24, border: '1px solid var(--accent)', borderRadius: 4 }}>
<div className="card-title">{currentDelivery ? 'Update Client Delivery' : 'Send to Client'} — {company?.name}</div>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
{currentDelivery ? 'Add files or resend the email with the current delivery package.' : 'Upload the completed file and add an optional message for the client.'}
@@ -732,12 +764,12 @@ export default function RequestDetail() {
onDragLeave={e => { e.preventDefault(); dragCounter.current--; if (dragCounter.current === 0) setDragging(false); }}
onDragOver={e => e.preventDefault()}
onDrop={e => { e.preventDefault(); dragCounter.current = 0; setDragging(false); const dropped = Array.from(e.dataTransfer.files); if (dropped.length > 0) processFiles(dropped); }}
style={{ border: `2px dashed ${dragging ? 'var(--accent)' : sendForm.files.length > 0 ? 'var(--accent)' : 'var(--border)'}`, borderRadius: 8, padding: '20px 16px', textAlign: 'center', background: dragging ? 'color-mix(in srgb, var(--accent) 8%, var(--bg))' : 'var(--bg)', transition: 'all 0.15s' }}
style={{ border: `2px dashed ${dragging ? 'var(--accent)' : sendForm.files.length > 0 ? 'var(--accent)' : 'var(--border)'}`, borderRadius: 4, padding: '20px 16px', textAlign: 'center', background: dragging ? 'color-mix(in srgb, var(--accent) 8%, var(--bg))' : 'var(--bg)', transition: 'all 0.15s' }}
>
<input type="file" multiple onChange={e => { processFiles(Array.from(e.target.files)); e.target.value = ''; }} style={{ display: 'none' }} id="file-upload" />
<label htmlFor="file-upload" style={{ cursor: 'pointer' }}>
<div style={{ fontSize: 24, marginBottom: 6 }}>{dragging ? '📂' : '📎'}</div>
<div style={{ fontWeight: 600, fontSize: 13 }}>{dragging ? 'Drop files here' : sendForm.files.length > 0 ? `${sendForm.files.length} file${sendForm.files.length !== 1 ? 's' : ''} added — click or drag to add more` : 'Click or drag files here'}</div>
<div style={{ fontWeight: 400, fontSize: 13 }}>{dragging ? 'Drop files here' : sendForm.files.length > 0 ? `${sendForm.files.length} file${sendForm.files.length !== 1 ? 's' : ''} added — click or drag to add more` : 'Click or drag files here'}</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 4 }}>Any file type accepted</div>
</label>
</div>
@@ -745,8 +777,8 @@ export default function RequestDetail() {
{sendForm.files.length > 0 && (
<div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 6 }}>
{sendForm.files.map((file, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}><span>📄</span><div><div style={{ fontSize: 13, fontWeight: 600 }}>{file.name}</div><div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{formatSize(file.size)}</div></div></div>
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}><span>📄</span><div><div style={{ fontSize: 13, fontWeight: 400 }}>{file.name}</div><div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{formatSize(file.size)}</div></div></div>
<button type="button" onClick={() => setSendForm(f => ({ ...f, files: f.files.filter((_, fi) => fi !== i) }))} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: 16 }}>✕</button>
</div>
))}
@@ -754,11 +786,11 @@ export default function RequestDetail() {
)}
{currentDeliveryFiles.length > 0 && (
<div style={{ marginTop: 12 }}>
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 6 }}>Already Delivered</div>
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 6 }}>Already Delivered</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{currentDeliveryFiles.map((file, i) => (
<div key={file.id || i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}><span>📄</span><div><div style={{ fontSize: 13, fontWeight: 600 }}>{file.name}</div>{file.size > 0 && <div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{formatSize(file.size)}</div>}</div></div>
<div key={file.id || i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}><span>📄</span><div><div style={{ fontSize: 13, fontWeight: 400 }}>{file.name}</div>{file.size > 0 && <div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{formatSize(file.size)}</div>}</div></div>
<LoadingButton className="btn btn-outline btn-sm" loading={downloading === `delivery:${file.storage_path}`} disabled={Boolean(downloading)} loadingText="Downloading..." onClick={() => getFileUrl(file)}>📥 Download</LoadingButton>
</div>
))}
@@ -770,15 +802,20 @@ export default function RequestDetail() {
<label>Message to Client <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
<textarea placeholder={`Hi ${company?.name}, your ${task.title} is ready for review!`} value={sendForm.message} onChange={e => setSendForm(f => ({ ...f, message: e.target.value }))} style={{ minHeight: 80 }} />
</div>
{clientEmails.length === 0 && (
<div className="form-group" style={{ padding: '12px 14px', background: 'color-mix(in srgb, var(--danger) 8%, var(--bg))', borderRadius: 8, border: '1px solid var(--danger)' }}>
<label style={{ color: 'var(--danger)', marginBottom: 6, display: 'block' }}>⚠ No client email on file for {company?.name}</label>
<input type="email" required placeholder="client@example.com" value={extraClientEmail} onChange={e => setExtraClientEmail(e.target.value)} />
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 6, marginBottom: 0 }}>This will be saved as the company contact email.</p>
{clientEmails.length > 0 && (
<div className="form-group">
<label>Sending To</label>
<div style={{ fontSize: 13, color: 'var(--text-secondary)', padding: '8px 12px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
{clientEmails.join(', ')}
</div>
</div>
)}
<div className="form-group">
<label>Additional Recipient <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
<input type="email" placeholder="cc@example.com" value={extraClientEmail} onChange={e => setExtraClientEmail(e.target.value)} />
</div>
<div className="action-buttons">
<button type="submit" className="btn btn-success" disabled={(sendForm.files.length === 0 && currentDeliveryFiles.length === 0) || saving || (clientEmails.length === 0 && !extraClientEmail)}>
<button type="submit" className="btn btn-success" disabled={(sendForm.files.length === 0 && currentDeliveryFiles.length === 0) || saving}>
{saving ? 'Uploading...' : `✉️ ${currentDelivery ? 'Resend to Client' : 'Send to Client'}${sendForm.files.length > 0 ? ` (${sendForm.files.length} new file${sendForm.files.length !== 1 ? 's' : ''})` : ''}`}
</button>
<button type="button" className="btn btn-outline" onClick={() => { setShowSendForm(false); setSendForm({ files: [], message: '' }); setExtraClientEmail(''); }}>Cancel</button>
@@ -789,7 +826,7 @@ export default function RequestDetail() {
{/* Team/External: External work upload */}
{isExternal && showWorkUpload && (
<div className="card" style={{ marginBottom: 24, border: '1px solid var(--accent)', borderRadius: 12 }}>
<div className="card" style={{ marginBottom: 24, border: '1px solid var(--accent)', borderRadius: 4 }}>
<div className="card-title">Submit Finished Work</div>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>Upload your completed files. This will mark the task as ready for team review.</p>
<form onSubmit={handleWorkUpload}>
@@ -800,12 +837,12 @@ export default function RequestDetail() {
onDragLeave={e => { e.preventDefault(); workDragCounter.current--; if (workDragCounter.current === 0) setWorkDragging(false); }}
onDragOver={e => e.preventDefault()}
onDrop={e => { e.preventDefault(); workDragCounter.current = 0; setWorkDragging(false); const dropped = Array.from(e.dataTransfer.files); if (dropped.length > 0) { const combined = [...workForm.files, ...dropped]; if (combined.length > MAX_FILES) { setWorkFileErrors([`Maximum ${MAX_FILES} files allowed.`]); return; } setWorkFileErrors([]); setWorkForm(f => ({ ...f, files: combined })); }; }}
style={{ border: `2px dashed ${workDragging ? 'var(--accent)' : workForm.files.length > 0 ? 'var(--accent)' : 'var(--border)'}`, borderRadius: 8, padding: '20px 16px', textAlign: 'center', background: workDragging ? 'color-mix(in srgb, var(--accent) 8%, var(--bg))' : 'var(--bg)', transition: 'all 0.15s' }}
style={{ border: `2px dashed ${workDragging ? 'var(--accent)' : workForm.files.length > 0 ? 'var(--accent)' : 'var(--border)'}`, borderRadius: 4, padding: '20px 16px', textAlign: 'center', background: workDragging ? 'color-mix(in srgb, var(--accent) 8%, var(--bg))' : 'var(--bg)', transition: 'all 0.15s' }}
>
<input type="file" multiple onChange={e => { const combined = [...workForm.files, ...Array.from(e.target.files)]; if (combined.length > MAX_FILES) { setWorkFileErrors([`Maximum ${MAX_FILES} files allowed.`]); return; } setWorkFileErrors([]); setWorkForm(f => ({ ...f, files: combined })); e.target.value = ''; }} style={{ display: 'none' }} id="work-file-upload" />
<label htmlFor="work-file-upload" style={{ cursor: 'pointer' }}>
<div style={{ fontSize: 24, marginBottom: 6 }}>{workDragging ? '📂' : '📎'}</div>
<div style={{ fontWeight: 600, fontSize: 13 }}>{workDragging ? 'Drop files here' : workForm.files.length > 0 ? `${workForm.files.length} file${workForm.files.length !== 1 ? 's' : ''} added` : 'Click or drag files here'}</div>
<div style={{ fontWeight: 400, fontSize: 13 }}>{workDragging ? 'Drop files here' : workForm.files.length > 0 ? `${workForm.files.length} file${workForm.files.length !== 1 ? 's' : ''} added` : 'Click or drag files here'}</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 4 }}>Any file type accepted</div>
</label>
</div>
@@ -813,8 +850,8 @@ export default function RequestDetail() {
{workForm.files.length > 0 && (
<div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 6 }}>
{workForm.files.map((file, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}><span>📄</span><div><div style={{ fontSize: 13, fontWeight: 600 }}>{file.name}</div><div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{formatSize(file.size)}</div></div></div>
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}><span>📄</span><div><div style={{ fontSize: 13, fontWeight: 400 }}>{file.name}</div><div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{formatSize(file.size)}</div></div></div>
<button type="button" onClick={() => setWorkForm(f => ({ ...f, files: f.files.filter((_, fi) => fi !== i) }))} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: 16 }}>✕</button>
</div>
))}
@@ -844,13 +881,13 @@ export default function RequestDetail() {
<label>Revision</label>
{editingRevision && isTeam ? (
<form onSubmit={handleSaveRevision} style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4 }}>
<span style={{ fontWeight: 700, fontSize: 13 }}>R</span>
<span style={{ fontWeight: 400, fontSize: 13 }}>R</span>
<input type="text" inputMode="numeric" pattern="[0-9]*" maxLength={2} value={revisionVal} onChange={e => setRevisionVal(e.target.value.replace(/\D/g, '').slice(0, 2))} autoFocus required style={{ width: 54, padding: '4px 8px', fontSize: 13 }} />
<button type="submit" className="btn btn-primary btn-sm" disabled={savingRevision}>{savingRevision ? '...' : 'Save'}</button>
<button type="button" className="btn btn-outline btn-sm" onClick={() => setEditingRevision(false)}>Cancel</button>
</form>
) : (
<p style={{ display: 'flex', alignItems: 'center', gap: 8, fontWeight: 700, fontSize: 16 }}>
<p style={{ display: 'flex', alignItems: 'center', gap: 8, fontWeight: 400, fontSize: 16 }}>
{rLabel(revisionBaseline)}
{isTeam && (
<button className="btn-icon" title="Edit" onClick={() => { setRevisionVal(String(revisionBaseline).padStart(2, '0')); setEditingRevision(true); }}>
@@ -901,14 +938,14 @@ export default function RequestDetail() {
{!isExternal && !showSendForm && <button className="btn btn-success btn-sm" onClick={handleOpenSendForm}>✉️ Add Files / Resend</button>}
{isExternal && !showSendForm && <button className="btn btn-outline btn-sm" onClick={handleOpenSendForm}>📎 Add Files</button>}
{!isClient && (
<div style={{ padding: '10px 14px', background: 'var(--bg)', borderRadius: 8, fontSize: 13, color: '#7c3aed', fontWeight: 500 }}>
<div style={{ padding: '10px 14px', background: 'var(--bg)', borderRadius: 4, fontSize: 13, color: '#7c3aed', fontWeight: 500 }}>
⏳ Awaiting client review.
</div>
)}
</>
)}
{task.status === 'client_approved' && (
<div style={{ padding: '10px 14px', background: 'var(--bg)', borderRadius: 8, fontSize: 13, color: '#16a34a', fontWeight: 500 }}>
<div style={{ padding: '10px 14px', background: 'var(--bg)', borderRadius: 4, fontSize: 13, color: '#16a34a', fontWeight: 500 }}>
✓ Client approved this job.
</div>
)}
@@ -951,12 +988,13 @@ export default function RequestDetail() {
if (!primary) return null;
const startEditRequest = () => {
setRequestForm({ serviceType: primary.service_type || '', deadline: primary.deadline || '', description: primary.description || '', requestedBy: primary.submitted_by || currentUser?.id || '', isHot: Boolean(primary.is_hot) });
setRequestFiles([]);
setEditingRequest(true);
};
return (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
<span style={{ fontWeight: 700, fontSize: 13 }}>{rLabel(primary.version_number)}</span>
<span style={{ fontWeight: 400, fontSize: 13 }}>{rLabel(primary.version_number)}</span>
<StatusBadge status={primary.type} />
<span style={{ fontSize: 12, color: 'var(--text-muted)', marginLeft: 'auto' }}>{primary.submitted_by_name} · {formatDateEST(primary.submitted_at)}</span>
{isTeam && !editingRequest && (
@@ -997,6 +1035,7 @@ export default function RequestDetail() {
<label>Description</label>
<textarea value={requestForm.description} onChange={e => setRequestForm(f => ({ ...f, description: e.target.value }))} style={{ minHeight: 100 }} />
</div>
<FileAttachment files={requestFiles} onChange={setRequestFiles} />
<div className="action-buttons" style={{ marginBottom: 14 }}>
<button type="submit" className="btn btn-primary btn-sm" disabled={savingRequest}>{savingRequest ? 'Saving...' : 'Save'}</button>
<button type="button" className="btn btn-outline btn-sm" onClick={() => setEditingRequest(false)}>Cancel</button>
@@ -1014,20 +1053,20 @@ export default function RequestDetail() {
<p>{primary.submitted_by_name || ''}</p>
</div>
<div>
<label style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Description</label>
<p style={{ marginTop: 8, fontSize: 14, lineHeight: 1.7, color: 'var(--text-primary)', background: 'var(--bg)', padding: '12px 14px', borderRadius: 8, border: '1px solid var(--border)', whiteSpace: 'pre-wrap' }}>{primary.description}</p>
<label style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Description</label>
<p style={{ marginTop: 8, fontSize: 14, lineHeight: 1.7, color: 'var(--text-primary)', background: 'var(--bg)', padding: '12px 14px', borderRadius: 4, border: '1px solid var(--border)', whiteSpace: 'pre-wrap' }}>{primary.description}</p>
</div>
</>
)}
{amendments.map(amendment => (
<div key={amendment.id} style={{ marginTop: 12, padding: '12px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', color: 'var(--accent)', marginBottom: 6, letterSpacing: 0.5 }}>Amended Request</div>
<div key={amendment.id} style={{ marginTop: 12, padding: '12px 14px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', color: 'var(--accent)', marginBottom: 6, letterSpacing: 0.5 }}>Amended Request</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 6 }}>{amendment.submitted_by_name} · {formatDateEST(amendment.submitted_at)}</div>
<p style={{ fontSize: 13, lineHeight: 1.6, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap', margin: 0 }}>{amendment.description}</p>
{amendment.files?.length > 0 && (
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
{amendment.files.map((file, fi) => (
<div key={fi} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '5px 10px', background: 'var(--card-bg)', borderRadius: 6, border: '1px solid var(--border)' }}>
<div key={fi} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '5px 10px', background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}><span>📎</span><span style={{ fontSize: 12, fontWeight: 500 }}>{file.name}</span></div>
<LoadingButton className="btn-icon" loading={downloading === `submission:${file.storage_path}`} disabled={Boolean(downloading)} loadingText="↓" onClick={() => getSubmissionFileUrl(file)}>📥 Download</LoadingButton>
</div>
@@ -1039,12 +1078,12 @@ export default function RequestDetail() {
{primary.files?.length > 0 && (
<div style={{ marginTop: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<label style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Attached Files</label>
<label style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Attached Files</label>
<LoadingButton className="btn-icon" loading={downloading === `zip:${rLabel(primary.version_number)}`} disabled={Boolean(downloading)} loadingText="↓" onClick={() => downloadAllSubmissionFiles(primary.files, rLabel(primary.version_number))}>⬇ Download All</LoadingButton>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{primary.files.map((file, fi) => (
<div key={fi} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--bg)', borderRadius: 6, border: '1px solid var(--border)' }}>
<div key={fi} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}><span>📎</span><span style={{ fontSize: 13, fontWeight: 500 }}>{file.name}</span></div>
<LoadingButton className="btn btn-outline btn-sm" loading={downloading === `submission:${file.storage_path}`} disabled={Boolean(downloading)} loadingText="Downloading..." onClick={() => getSubmissionFileUrl(file)}>📥 Download</LoadingButton>
</div>
@@ -1100,12 +1139,12 @@ export default function RequestDetail() {
{primary.files?.length > 0 && (
<div style={{ marginTop: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
<label style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Attached Files</label>
<label style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Attached Files</label>
<LoadingButton className="btn-icon" loading={downloading === `zip:${rLabel(primary.version_number)}`} disabled={Boolean(downloading)} loadingText="↓" onClick={() => downloadAllSubmissionFiles(primary.files, rLabel(primary.version_number))}>⬇ Download All</LoadingButton>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{primary.files.map((file, fi) => (
<div key={fi} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--bg)', borderRadius: 6, border: '1px solid var(--border)' }}>
<div key={fi} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}><span>📎</span><span style={{ fontSize: 13, fontWeight: 500 }}>{file.name}</span></div>
<LoadingButton className="btn-icon" loading={downloading === `submission:${file.storage_path}`} disabled={Boolean(downloading)} loadingText="↓" onClick={() => getSubmissionFileUrl(file)}>📥 Download</LoadingButton>
</div>
@@ -1114,14 +1153,14 @@ export default function RequestDetail() {
</div>
)}
{amendments.map(amendment => (
<div key={amendment.id} style={{ marginTop: 12, padding: '12px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', color: 'var(--accent)', marginBottom: 8, letterSpacing: 0.5 }}>Amended Request</div>
<div key={amendment.id} style={{ marginTop: 12, padding: '12px 14px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', color: 'var(--accent)', marginBottom: 8, letterSpacing: 0.5 }}>Amended Request</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 6 }}>{amendment.submitted_by_name} · {formatDateEST(amendment.submitted_at)}</div>
<p style={{ fontSize: 13, lineHeight: 1.6, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap', margin: 0 }}>{amendment.description}</p>
{amendment.files?.length > 0 && (
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
{amendment.files.map((file, fi) => (
<div key={fi} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '5px 10px', background: 'var(--card-bg)', borderRadius: 6, border: '1px solid var(--border)' }}>
<div key={fi} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '5px 10px', background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}><span>📎</span><span style={{ fontSize: 12, fontWeight: 500 }}>{file.name}</span></div>
<LoadingButton className="btn-icon" loading={downloading === `submission:${file.storage_path}`} disabled={Boolean(downloading)} loadingText="↓" onClick={() => getSubmissionFileUrl(file)}>📥 Download</LoadingButton>
</div>
@@ -1131,10 +1170,10 @@ export default function RequestDetail() {
</div>
))}
{delivery && delivery.files && delivery.files.length > 0 && (
<div style={{ marginTop: 12, padding: '10px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', color: '#16a34a', marginBottom: 8 }}>✓ Delivered {formatDateEST(delivery.sent_at)}</div>
<div style={{ marginTop: 12, padding: '10px 14px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', color: '#16a34a', marginBottom: 8 }}>✓ Delivered {formatDateEST(delivery.sent_at)}</div>
{delivery.files.map((file, fi) => (
<div key={fi} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--card-bg)', borderRadius: 6, border: '1px solid var(--border)', marginBottom: 4 }}>
<div key={fi} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)', marginBottom: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}><span>📄</span><span style={{ fontSize: 13, fontWeight: 500 }}>{file.name}</span></div>
<LoadingButton className="btn-icon" loading={downloading === `delivery:${file.storage_path}`} disabled={Boolean(downloading)} loadingText="↓" onClick={() => getFileUrl(file)}>📥 Download</LoadingButton>
</div>
@@ -1147,29 +1186,29 @@ export default function RequestDetail() {
// Team/External revision history style
return (
<div key={primary.id} style={{ borderRadius: 8, border: `1px solid ${isCurrent ? 'var(--accent)' : 'var(--border)'}`, background: 'var(--bg)', overflow: 'hidden', opacity: isCurrent ? 1 : 0.85, marginBottom: 12 }}>
<div key={primary.id} style={{ borderRadius: 4, border: `1px solid ${isCurrent ? 'var(--accent)' : 'var(--border)'}`, background: 'var(--bg)', overflow: 'hidden', opacity: isCurrent ? 1 : 0.85, marginBottom: 12 }}>
<div style={{ padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 10, borderBottom: '1px solid var(--border)', background: 'var(--card-bg-2)' }}>
<span style={{ fontWeight: 700, fontSize: 13 }}>{rLabel(primary.version_number)}</span>
<span style={{ fontWeight: 400, fontSize: 13 }}>{rLabel(primary.version_number)}</span>
<StatusBadge status={primary.type} />
{primary.revision_type && <StatusBadge status={primary.revision_type} />}
{isCurrent && <span style={{ fontSize: 11, color: 'var(--accent)', fontWeight: 600 }}>Current</span>}
{isCurrent && <span style={{ fontSize: 11, color: 'var(--accent)', fontWeight: 400 }}>Current</span>}
<span style={{ fontSize: 12, color: 'var(--text-muted)', marginLeft: 'auto' }}>{primary.submitted_by_name} · {formatDateEST(primary.submitted_at)}</span>
</div>
<div style={{ padding: '12px 16px' }}>
<div style={{ display: 'flex', gap: 24, marginBottom: 8 }}>
<div><span style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Service</span><div style={{ fontSize: 13, marginTop: 2 }}>{primary.service_type}</div></div>
<div><span style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Deadline</span><div style={{ fontSize: 13, marginTop: 2 }}>{primary.deadline || ''}</div></div>
<div><span style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Service</span><div style={{ fontSize: 13, marginTop: 2 }}>{primary.service_type}</div></div>
<div><span style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Deadline</span><div style={{ fontSize: 13, marginTop: 2 }}>{primary.deadline || ''}</div></div>
</div>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.6, whiteSpace: 'pre-wrap', margin: 0 }}>{primary.description}</p>
{primary.files?.length > 0 && (
<div style={{ marginTop: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Attached Files</span>
<span style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Attached Files</span>
<LoadingButton className="btn-icon" loading={downloading === `zip:${rLabel(revisionBaseline)}`} disabled={Boolean(downloading)} loadingText="↓" onClick={() => downloadAllSubmissionFiles(primary.files)}>⬇ Download All</LoadingButton>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{primary.files.map((file, fi) => (
<div key={fi} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--card-bg-2)', borderRadius: 6, border: '1px solid var(--border)' }}>
<div key={fi} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--card-bg-2)', borderRadius: 4, border: '1px solid var(--border)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}><span>📎</span><span style={{ fontSize: 13, fontWeight: 500 }}>{file.name}</span></div>
<LoadingButton className="btn-icon" loading={downloading === `submission:${file.storage_path}`} disabled={Boolean(downloading)} loadingText="↓" onClick={() => getSubmissionFileUrl(file)}>📥 Download</LoadingButton>
</div>
@@ -1178,14 +1217,14 @@ export default function RequestDetail() {
</div>
)}
{amendments.map(amendment => (
<div key={amendment.id} style={{ marginTop: 12, padding: '12px 14px', background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', color: 'var(--accent)', marginBottom: 6, letterSpacing: 0.5 }}>Amended Request</div>
<div key={amendment.id} style={{ marginTop: 12, padding: '12px 14px', background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', color: 'var(--accent)', marginBottom: 6, letterSpacing: 0.5 }}>Amended Request</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 6 }}>{amendment.submitted_by_name} · {formatDateEST(amendment.submitted_at)}</div>
<p style={{ fontSize: 13, lineHeight: 1.6, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap', margin: 0 }}>{amendment.description}</p>
{amendment.files?.length > 0 && (
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
{amendment.files.map((file, fi) => (
<div key={fi} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '5px 10px', background: 'var(--bg)', borderRadius: 6, border: '1px solid var(--border)' }}>
<div key={fi} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '5px 10px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}><span>📎</span><span style={{ fontSize: 12, fontWeight: 500 }}>{file.name}</span></div>
<LoadingButton className="btn-icon" loading={downloading === `submission:${file.storage_path}`} disabled={Boolean(downloading)} loadingText="↓" onClick={() => getSubmissionFileUrl(file)}>📥 Download</LoadingButton>
</div>
@@ -1197,10 +1236,10 @@ export default function RequestDetail() {
</div>
{delivery ? (
<div style={{ padding: '10px 16px', borderTop: '1px solid var(--border)' }}>
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: '#16a34a', marginBottom: 8 }}>✓ Delivered by {delivery.sent_by} on {formatDateEST(delivery.sent_at)}</div>
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: '#16a34a', marginBottom: 8 }}>✓ Delivered by {delivery.sent_by} on {formatDateEST(delivery.sent_at)}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{(delivery.files || []).map((file, fi) => (
<div key={fi} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--card-bg-2)', borderRadius: 6, border: '1px solid var(--border)' }}>
<div key={fi} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--card-bg-2)', borderRadius: 4, border: '1px solid var(--border)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}><span>📄</span><span style={{ fontSize: 13, fontWeight: 500 }}>{file.name}</span>{file.size > 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{formatSize(file.size)}</span>}</div>
<LoadingButton className="btn-icon" loading={downloading === `delivery:${file.storage_path}`} disabled={Boolean(downloading)} loadingText="↓" onClick={() => getFileUrl(file)}>📥 Download</LoadingButton>
</div>
+376 -225
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import Layout from '../components/Layout';
import { DashboardBanner } from '../lib/dashboardBanner';
import StatusBadge from '../components/StatusBadge';
import RequestForm from '../components/RequestForm';
import { supabase } from '../lib/supabase';
@@ -8,10 +9,16 @@ import { useAuth } from '../context/AuthContext';
import { readPageCache, writePageCache } from '../lib/pageCache';
import { withTimeout } from '../lib/withTimeout';
import { getCurrentVersionForTask, getDeadlineSourceSubmission } from '../lib/taskDeadlines';
import { formatDateOnly } from '../lib/dates';
import { formatDateOnly, fmtShortDate } from '../lib/dates';
import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../lib/requestSubmission';
import { sendEmail } from '../lib/email';
import { uploadFilesToRequestInfo } from '../lib/filebrowserFolders';
import SortTh from '../components/SortTh';
import { useSortable } from '../hooks/useSortable';
import FilterDropdown from '../components/FilterDropdown';
const ListViewIcon = () => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><line x1="3" y1="4" x2="13" y2="4"/><line x1="3" y1="8" x2="13" y2="8"/><line x1="3" y1="12" x2="13" y2="12"/></svg>;
const GridViewIcon = () => <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>;
export default function RequestsPage() {
const { currentUser } = useAuth();
@@ -30,7 +37,10 @@ export default function RequestsPage() {
return true;
});
const [error, setError] = useState('');
const [loadError, setLoadError] = useState(false);
const [activeTab, setActiveTab] = useState('all');
const [viewMode, setViewMode] = useState(() => localStorage.getItem('requestsViewMode') || 'list');
const toggleView = () => setViewMode(v => { const n = v === 'list' ? 'grid' : 'list'; localStorage.setItem('requestsViewMode', n); return n; });
// ── Team-only state ────────────────────────────────────────────────────
const teamCached = isTeam ? readPageCache('team_requests') : null;
@@ -39,6 +49,9 @@ export default function RequestsPage() {
const [invoiceItems, setInvoiceItems] = useState(() => teamCached?.invoiceItems || []);
const [filterCompany, setFilterCompany] = useState('');
const [filterUser, setFilterUser] = useState('');
const { sortKey: reqSortKey, sortDir: reqSortDir, toggle: reqToggle, sort: reqSort } = useSortable('submitted_at', 'desc');
const { sortKey: extSortKey, sortDir: extSortDir, toggle: extToggle, sort: extSort } = useSortable('submitted_at', 'desc');
const { sortKey: clientSortKey, sortDir: clientSortDir, toggle: clientToggle, sort: clientSort } = useSortable('submitted_at', 'desc');
const [showAddForm, setShowAddForm] = useState(false);
const [addFormKey, setAddFormKey] = useState(0);
const [addSaving, setAddSaving] = useState(false);
@@ -77,7 +90,7 @@ export default function RequestsPage() {
writePageCache('team_requests', { submissions: subs || [], tasks: t || [], projects: p || [], companies: co || [], invoices: inv || [], invoiceItems: itemRows || [] });
} catch (err) {
console.error('Requests load failed:', err);
setSubmissions([]); setTasks([]); setProjects([]); setCompanies([]); setInvoices([]); setInvoiceItems([]);
setLoadError(true);
} finally {
setLoading(false);
}
@@ -86,17 +99,19 @@ export default function RequestsPage() {
setSubmissions(teamCached.submissions || []);
setTasks(teamCached.tasks || []);
setProjects(teamCached.projects || []);
setCompanies(teamCached.companies || []);
setInvoices(teamCached.invoices || []);
setInvoiceItems(teamCached.invoiceItems || []);
setLoading(false);
} else {
loadTeam();
}
loadTeam();
} else if (isExternal) {
async function loadExternal() {
if (!currentUser?.id) { setLoading(false); return; }
try {
const [{ data: projectData }, { data: taskData }, { data: subData }, { data: paidItems }] = await withTimeout(
Promise.all([
supabase.from('projects').select('id, name, company_id').order('created_at', { ascending: false }),
supabase.from('projects').select('id, name, company_id, company:companies(id, name)').order('created_at', { ascending: false }),
supabase.from('tasks').select('id, title, status, current_version, project_id, invoiced, completed_at').order('submitted_at', { ascending: false }),
supabase.from('submissions').select('task_id, submitted_by_name, version_number, deadline, is_hot, service_type, submitted_at').order('submitted_at', { ascending: false }),
supabase.from('subcontractor_invoice_items').select('task_id, invoice:subcontractor_invoices!inner(status)').eq('subcontractor_invoices.status', 'paid'),
@@ -136,8 +151,8 @@ export default function RequestsPage() {
const myTaskIds = mySubs.map(s => s.task_id);
const [{ data: t }, { data: allSubs }, { data: inv }, { data: itemRows }] = await withTimeout(
Promise.all([
supabase.from('tasks').select('*, project:projects(id, name, created_at, status)').in('id', myTaskIds),
supabase.from('submissions').select('task_id, submitted_by_name, version_number, type').in('task_id', myTaskIds).order('version_number'),
supabase.from('tasks').select('*, project:projects(id, name, created_at, status, company_id)').in('id', myTaskIds),
supabase.from('submissions').select('task_id, submitted_by_name, version_number, type, service_type, deadline').in('task_id', myTaskIds).order('version_number'),
supabase.from('invoices').select('id, status'),
supabase.from('invoice_items').select('task_id, invoice_id').in('task_id', myTaskIds),
]),
@@ -153,6 +168,7 @@ export default function RequestsPage() {
setProjects(Object.values(projectMap));
} catch (err) {
console.error('MyRequests load failed:', err);
setLoadError(true);
} finally {
setLoading(false);
}
@@ -236,6 +252,7 @@ export default function RequestsPage() {
};
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></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>;
// ── Team render ────────────────────────────────────────────────────────
if (isTeam) {
@@ -248,10 +265,10 @@ export default function RequestsPage() {
const latestGroup = taskSubs.filter(s => s.version_number === currentVersion);
return { task, primary: deadlineSource, group: latestGroup };
}).filter(Boolean);
const filteredGroups = latestTaskGroups.filter(({ task, group }) => {
const filteredGroups = latestTaskGroups.filter(({ task }) => {
const project = projects.find(p => p.id === task?.project_id);
if (filterCompany && project?.company_id !== filterCompany) return false;
if (filterUser && !group.some(s => s.submitted_by_name === filterUser)) return false;
if (filterProject && task?.project_id !== filterProject) return false;
return true;
}).sort((a, b) => Math.max(...b.group.map(s => new Date(s.submitted_at).getTime())) - Math.max(...a.group.map(s => new Date(s.submitted_at).getTime())));
const byStatus = (s) => filteredGroups.filter(({ task }) => task?.status === s);
@@ -264,18 +281,18 @@ export default function RequestsPage() {
|| '—';
return (
<tr key={task.id} onClick={() => task && navigate(`/requests/${task.id}`)} style={{ cursor: task ? 'pointer' : 'default' }}>
<td style={{ color: 'var(--text-muted)' }}>{project?.name || 'No project'}</td>
<td style={{ fontWeight: 600 }}>
<td style={{ fontWeight: 400 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>{task?.title || '—'}</span>
<span style={{ color: 'var(--text-muted)' }}>{`R${String(primary.version_number ?? 0).padStart(2, '0')}`}</span>
{primary.is_hot ? <span className="badge badge-needs_revision">HOT</span> : null}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>{project?.name || '—'}</div>
</td>
<td style={{ color: 'var(--text-muted)' }}>{serviceType}</td>
<td>{`R${String(primary.version_number ?? 0).padStart(2, '0')}`}</td>
<td>{company ? <Link to={`/company/${company.id}`} className="table-link" onClick={e => e.stopPropagation()}>{company.name}</Link> : 'No client'}</td>
<td>{formatDateOnly(primary.deadline, 'Not specified')}</td>
<td style={{ color: 'var(--text-muted)' }}>{task?.completed_at ? new Date(task.completed_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</td>
<td style={{ color: 'var(--accent)' }}>{company ? <Link to={`/company/${company.id}`} className="table-link" style={{ color: 'var(--accent)' }} onClick={e => e.stopPropagation()}>{company.name}</Link> : 'No client'}</td>
<td>{serviceType}</td>
<td>{fmtShortDate(primary.deadline, 'Not specified')}</td>
<td style={{ color: 'var(--text-muted)' }}>{fmtShortDate(task?.completed_at)}</td>
<td><StatusBadge status={task?.status || 'not_started'} /></td>
</tr>
);
@@ -290,89 +307,144 @@ export default function RequestsPage() {
{ id: 'invoiced', label: 'Invoiced', groups: byStatus('invoiced') },
{ id: 'paid', label: 'Paid', groups: byStatus('paid') },
];
const currentGroups = teamTabs.find(t => t.id === activeTab)?.groups || [];
const rawGroups = teamTabs.find(t => t.id === activeTab)?.groups || [];
const currentGroups = reqSort(rawGroups, ({ task, primary }, key) => {
const project = projects.find(p => p.id === task?.project_id);
const company = companies.find(co => co.id === project?.company_id);
if (key === 'project') return project?.name || '';
if (key === 'title') return task?.title || '';
if (key === 'serviceType') return primary?.service_type || '';
if (key === 'revision') return primary?.version_number ?? 0;
if (key === 'client') return company?.name || '';
if (key === 'deadline') return primary?.deadline || '';
if (key === 'completed_at') return task?.completed_at || '';
if (key === 'status') return task?.status || '';
if (key === 'submitted_at') return primary?.submitted_at || '';
return '';
});
const doneStatuses = new Set(['client_approved', 'invoiced', 'paid']);
const teamActiveCount = latestTaskGroups.filter(({ task }) => !doneStatuses.has(task?.status)).length;
const teamCompletedCount = latestTaskGroups.filter(({ task }) => doneStatuses.has(task?.status)).length;
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">Requests</div>
<div className="page-subtitle">Track incoming requests, revisions, and completed jobs in one place.</div>
<div className="page-header-left">
<DashboardBanner />
<div className="page-title dashboard-greeting">Requests</div>
<div className="page-subtitle">{teamActiveCount} active &bull; {teamCompletedCount} completed</div>
</div>
<button className="btn btn-primary btn-sm" onClick={() => { setShowAddForm(s => !s); setAddError(''); }}>
{showAddForm ? 'Cancel' : '+ Add Request'}
+ Add Request
</button>
</div>
{showAddForm && (
<div className="card" style={{ marginBottom: 24, border: '1px solid var(--accent)', borderRadius: 12, flexShrink: 0 }}>
<div className="card-title">Add Request</div>
<RequestForm
key={addFormKey}
companies={companies}
currentUser={currentUser}
showRequester={true}
onSubmit={handleAddRequest}
onCancel={() => { setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }}
saving={addSaving}
error={addError}
submitLabel="Add Request"
/>
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }} onClick={() => { setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }}>
<div style={{ background: 'var(--card-bg)', borderRadius: 4, padding: 28, width: '100%', maxWidth: 560, border: '1px solid var(--border)', boxShadow: '0 24px 64px rgba(0,0,0,0.5)', maxHeight: '90vh', overflowY: 'auto' }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 20 }}>
<div>
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>New Request</div>
<div style={{ fontSize: 22, color: 'var(--text-primary)' }}>Add a request</div>
</div>
<button onClick={() => { setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }} style={{ background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 20, cursor: 'pointer', lineHeight: 1, padding: 4 }}>×</button>
</div>
<RequestForm
key={addFormKey}
companies={companies}
currentUser={currentUser}
showRequester={true}
onSubmit={handleAddRequest}
onCancel={() => { setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }}
saving={addSaving}
error={addError}
submitLabel="Add Request"
/>
</div>
</div>
)}
{!showAddForm && (
<>
{(companies.length > 0 || requesterNames.length > 0) && (
<div style={{ display: 'flex', gap: 12, marginBottom: 16, flexWrap: 'wrap', flexShrink: 0 }}>
{companies.length > 0 && (
<select className="filter-select" value={filterCompany} onChange={e => setFilterCompany(e.target.value)}>
<option value="">All Companies</option>
{companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
</select>
)}
{requesterNames.length > 0 && (
<select className="filter-select" value={filterUser} onChange={e => setFilterUser(e.target.value)}>
<option value="">All Requesters</option>
{requesterNames.map(name => <option key={name} value={name}>{name}</option>)}
</select>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16, flexShrink: 0, flexWrap: 'wrap' }}>
{companies.length > 0 && (
<select className="filter-select" style={{ width: 120 }} value={filterCompany} onChange={e => { setFilterCompany(e.target.value); setFilterProject(''); }}>
<option value="">All Companies</option>
{companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
</select>
)}
{filterCompany && (() => { const co_projects = projects.filter(p => p.company_id === filterCompany); return co_projects.length > 0 ? (
<select className="filter-select" style={{ width: 120 }} value={filterProject} onChange={e => setFilterProject(e.target.value)}>
<option value="">All Projects</option>
{co_projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
) : null; })()}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginLeft: 'auto' }}>
<select className="filter-select" style={{ width: 120 }} value={activeTab} onChange={e => setActiveTab(e.target.value)}>
{teamTabs.map(t => <option key={t.id} value={t.id}>{t.label} ({t.groups.length})</option>)}
</select>
<button className="btn btn-outline btn-sm" onClick={toggleView} title={viewMode === 'list' ? 'Grid view' : 'List view'} style={{ padding: '5px 9px', lineHeight: 1, display: 'flex', alignItems: 'center' }}>
{viewMode === 'list' ? <GridViewIcon /> : <ListViewIcon />}
</button>
</div>
)}
</div>
{submissions.length === 0 ? (
<div className="empty-state"><h3>No requests yet</h3><p>Client requests will appear here.</p></div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No requests yet.</div>
) : filteredGroups.length === 0 ? (
<div className="empty-state"><h3>No matching requests</h3><p>Try clearing the current company or requester filters.</p></div>
) : (
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 16, flexShrink: 0 }}>
{teamTabs.map(tab => (
<button key={tab.id} type="button" className={`tab-btn${activeTab === tab.id ? ' active' : ''}`} onClick={() => setActiveTab(tab.id)}>
{tab.label} ({tab.groups.length})
</button>
))}
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No matching requests.</div>
) : currentGroups.length === 0 ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No {teamTabs.find(t => t.id === activeTab)?.label.toLowerCase()} requests.</div>
) : viewMode === 'grid' ? (
<div style={{ flex: 1, overflowY: 'auto' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: 12 }}>
{currentGroups.map(({ task, primary }) => {
const project = projects.find(p => p.id === task?.project_id);
const company = companies.find(co => co.id === project?.company_id);
const serviceType = submissions.find(s => s.task_id === task?.id && s.type === 'initial')?.service_type || primary?.service_type || '—';
return (
<div key={task.id} className="grid-card" onClick={() => navigate(`/requests/${task.id}`)} style={{ padding: '14px 16px', border: '1px solid var(--border)', borderRadius: 6 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8, marginBottom: 4 }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 400, fontSize: 13, color: 'var(--text-primary)', marginBottom: 2 }}>{task?.title || '—'}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{project?.name || '—'}</div>
</div>
<StatusBadge status={task?.status || 'not_started'} />
</div>
{company && <div style={{ fontSize: 12, color: 'var(--accent)', marginBottom: 6 }}>{company.name}</div>}
<div style={{ fontSize: 11, color: 'var(--text-muted)', display: 'flex', justifyContent: 'space-between' }}>
<span>{serviceType}</span>
<span>{fmtShortDate(primary?.deadline)}</span>
</div>
</div>
);
})}
</div>
{currentGroups.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>
No {teamTabs.find(t => t.id === activeTab)?.label.toLowerCase()} requests.
</div>
) : (
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table>
<thead>
<tr>
<th>Project</th><th>Name</th><th>Request Type</th><th>Revision</th><th>Client</th><th>Deadline</th><th>Approved</th><th>Status</th>
</tr>
</thead>
<tbody>{currentGroups.map(group => renderRow(group))}</tbody>
</table>
</div>
)}
</div>
) : (
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<table style={{ tableLayout: 'fixed', width: '100%' }}>
<colgroup>
<col style={{ width: '28%' }} />
<col style={{ width: '25%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '12%' }} />
</colgroup>
<thead>
<tr>
<SortTh col="title" sortKey={reqSortKey} sortDir={reqSortDir} onSort={reqToggle}>Name</SortTh>
<SortTh col="client" sortKey={reqSortKey} sortDir={reqSortDir} onSort={reqToggle}>Client</SortTh>
<SortTh col="serviceType" sortKey={reqSortKey} sortDir={reqSortDir} onSort={reqToggle}>Request Type</SortTh>
<SortTh col="deadline" sortKey={reqSortKey} sortDir={reqSortDir} onSort={reqToggle}>Deadline</SortTh>
<SortTh col="completed_at" sortKey={reqSortKey} sortDir={reqSortDir} onSort={reqToggle}>Approved</SortTh>
<SortTh col="status" sortKey={reqSortKey} sortDir={reqSortDir} onSort={reqToggle}>Status</SortTh>
</tr>
</thead>
<tbody>{currentGroups.map(group => renderRow(group))}</tbody>
</table>
</div>
)}
</>
)}
</Layout>
);
}
@@ -405,96 +477,129 @@ export default function RequestsPage() {
{ id: 'invoiced', label: 'Invoiced', groups: byStatusExt('invoiced') },
{ id: 'paid', label: 'Paid', groups: byStatusExt('paid') },
];
const currentExtGroups = extTabs.find(t => t.id === activeTab)?.groups || [];
const rawExtGroups = extTabs.find(t => t.id === activeTab)?.groups || [];
const currentExtGroups = extSort(rawExtGroups, ({ task, primary }, key) => {
const project = projects.find(p => p.id === task?.project_id);
if (key === 'title') return task?.title || '';
if (key === 'serviceType') return submissions.find(s => s.task_id === task?.id && s.service_type)?.service_type || primary?.service_type || '';
if (key === 'revision') return primary?.version_number ?? 0;
if (key === 'client') return project?.company?.name || '';
if (key === 'deadline') return primary?.deadline || '';
if (key === 'completed_at') return task?.completed_at || '';
if (key === 'status') return task?.status || '';
if (key === 'submitted_at') return primary?.submitted_at || '';
return '';
});
const renderExtRow = ({ task, primary }) => {
const project = projects.find(p => p.id === task?.project_id);
const extServiceType = submissions.find(s => s.task_id === task?.id && s.service_type)?.service_type || primary.service_type || '—';
return (
<tr key={task.id} onClick={() => navigate(`/requests/${task.id}`)} style={{ cursor: 'pointer' }}>
<td style={{ color: 'var(--text-muted)' }}>{project?.name || 'No project'}</td>
<td style={{ fontWeight: 600 }}>
<td style={{ fontWeight: 400 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>{task?.title || '—'}</span>
<span style={{ color: 'var(--text-muted)' }}>{`R${String(primary.version_number ?? 0).padStart(2, '0')}`}</span>
{primary.is_hot ? <span className="badge badge-needs_revision">HOT</span> : null}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>{project?.name || '—'}</div>
</td>
<td style={{ color: 'var(--text-muted)' }}>{extServiceType}</td>
<td>{`R${String(primary.version_number ?? 0).padStart(2, '0')}`}</td>
<td>{formatDateOnly(primary.deadline, 'Not specified')}</td>
<td style={{ color: 'var(--text-muted)' }}>{task?.completed_at ? new Date(task.completed_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</td>
<td style={{ color: 'var(--accent)' }}>{project?.company?.name || '—'}</td>
<td>{extServiceType}</td>
<td>{fmtShortDate(primary.deadline, 'Not specified')}</td>
<td style={{ color: 'var(--text-muted)' }}>{fmtShortDate(task?.completed_at)}</td>
<td><StatusBadge status={task?.status || 'not_started'} /></td>
</tr>
);
};
const doneStatusesExt = new Set(['client_approved', 'invoiced', 'paid']);
const extActiveCount = latestTaskGroupsExt.filter(({ task }) => !doneStatusesExt.has(task?.status)).length;
const extCompletedCount = latestTaskGroupsExt.filter(({ task }) => doneStatusesExt.has(task?.status)).length;
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">Requests</div>
<div className="page-subtitle">All tasks in your assigned projects.</div>
<div className="page-header-left">
<DashboardBanner />
<div className="page-title dashboard-greeting">Requests</div>
<div className="page-subtitle">{extActiveCount} active &bull; {extCompletedCount} completed</div>
</div>
</div>
{(projectNames.length > 0 || requesterNamesExt.length > 0) && (
<div className="card request-toolbar-card" style={{ marginBottom: 16 }}>
<div className="request-toolbar-grid">
{projectNames.length > 0 && (
<div className="request-toolbar-section">
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Project</div>
<div className="request-filter-row">
<button className={`btn btn-sm ${!filterProject ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterProject('')}>All</button>
{projectNames.map(p => (
<button key={p.id} className={`btn btn-sm ${filterProject === p.id ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterProject(f => f === p.id ? '' : p.id)}>{p.name}</button>
))}
</div>
</div>
)}
{requesterNamesExt.length > 0 && (
<div className="request-toolbar-section">
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Requester</div>
<div className="request-filter-row">
<button className={`btn btn-sm ${!filterRequester ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterRequester('')}>All</button>
{requesterNamesExt.map(name => (
<button key={name} className={`btn btn-sm ${filterRequester === name ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterRequester(f => f === name ? '' : name)}>{name}</button>
))}
</div>
</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16, flexShrink: 0, flexWrap: 'wrap' }}>
{projectNames.length > 0 && (
<select className="filter-select" style={{ width: 120 }} value={filterProject} onChange={e => setFilterProject(e.target.value)}>
<option value="">All Projects</option>
{projectNames.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginLeft: 'auto' }}>
<select className="filter-select" style={{ width: 120 }} value={activeTab} onChange={e => setActiveTab(e.target.value)}>
{extTabs.map(t => <option key={t.id} value={t.id}>{t.label} ({t.groups.length})</option>)}
</select>
<button className="btn btn-outline btn-sm" onClick={toggleView} title={viewMode === 'list' ? 'Grid view' : 'List view'} style={{ padding: '5px 9px', lineHeight: 1, display: 'flex', alignItems: 'center' }}>
{viewMode === 'list' ? <GridViewIcon /> : <ListViewIcon />}
</button>
</div>
)}
</div>
{submissions.length === 0 ? (
<div className="empty-state"><h3>No requests yet</h3><p>Tasks will appear here once Fourge assigns you to a project.</p></div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No requests yet.</div>
) : filteredGroupsExt.length === 0 ? (
<div className="empty-state"><h3>No matching requests</h3><p>Try clearing the current filters.</p></div>
) : (
<div>
<div style={{ marginBottom: 20, display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{extTabs.map(tab => (
<button key={tab.id} type="button" className={`tab-btn${activeTab === tab.id ? ' active' : ''}`} onClick={() => setActiveTab(tab.id)}>
{tab.label} ({tab.groups.length})
</button>
))}
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No matching requests.</div>
) : currentExtGroups.length === 0 ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No {extTabs.find(t => t.id === activeTab)?.label.toLowerCase()} requests.</div>
) : viewMode === 'grid' ? (
<div style={{ flex: 1, overflowY: 'auto' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: 12 }}>
{currentExtGroups.map(({ task, primary }) => {
const project = projects.find(p => p.id === task?.project_id);
const extServiceType = submissions.find(s => s.task_id === task?.id && s.service_type)?.service_type || primary?.service_type || '—';
return (
<div key={task.id} className="grid-card" onClick={() => navigate(`/requests/${task.id}`)} style={{ padding: '14px 16px', border: '1px solid var(--border)', borderRadius: 6 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8, marginBottom: 4 }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 400, fontSize: 13, color: 'var(--text-primary)', marginBottom: 2 }}>{task?.title || '—'}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{project?.name || '—'}</div>
</div>
<StatusBadge status={task?.status || 'not_started'} />
</div>
{project?.company?.name && <div style={{ fontSize: 12, color: 'var(--accent)', marginBottom: 6 }}>{project.company.name}</div>}
<div style={{ fontSize: 11, color: 'var(--text-muted)', display: 'flex', justifyContent: 'space-between' }}>
<span>{extServiceType}</span>
<span>{fmtShortDate(primary?.deadline)}</span>
</div>
</div>
);
})}
</div>
{currentExtGroups.length === 0 ? (
<div className="empty-state" style={{ padding: '28px 20px' }}>
<h3>No {extTabs.find(t => t.id === activeTab)?.label.toLowerCase()} requests</h3>
</div>
) : (
<div className="table-wrapper">
<table>
<thead>
<tr><th>Project</th><th>Name</th><th>Request Type</th><th>Revision</th><th>Deadline</th><th>Approved</th><th>Status</th></tr>
</thead>
<tbody>{currentExtGroups.map(group => renderExtRow(group))}</tbody>
</table>
</div>
)}
</div>
) : (
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<table style={{ tableLayout: 'fixed', width: '100%' }}>
<colgroup>
<col style={{ width: '28%' }} />
<col style={{ width: '25%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '12%' }} />
</colgroup>
<thead>
<tr>
<SortTh col="title" sortKey={extSortKey} sortDir={extSortDir} onSort={extToggle}>Name</SortTh>
<SortTh col="client" sortKey={extSortKey} sortDir={extSortDir} onSort={extToggle}>Client</SortTh>
<SortTh col="serviceType" sortKey={extSortKey} sortDir={extSortDir} onSort={extToggle}>Request Type</SortTh>
<SortTh col="deadline" sortKey={extSortKey} sortDir={extSortDir} onSort={extToggle}>Deadline</SortTh>
<SortTh col="completed_at" sortKey={extSortKey} sortDir={extSortDir} onSort={extToggle}>Approved</SortTh>
<SortTh col="status" sortKey={extSortKey} sortDir={extSortDir} onSort={extToggle}>Status</SortTh>
</tr>
</thead>
<tbody>{currentExtGroups.map(group => renderExtRow(group))}</tbody>
</table>
</div>
)}
{error && <div className="card" style={{ color: 'var(--danger)', marginTop: 16 }}>{error}</div>}
{error && <div style={{ color: 'var(--danger)', marginTop: 16, fontSize: 13 }}>{error}</div>}
</Layout>
);
}
@@ -506,10 +611,7 @@ export default function RequestsPage() {
const clientRequesterNames = [...new Set(submissions.filter(s => s.type === 'initial').map(s => s.submitted_by_name).filter(Boolean))].sort();
const clientFilteredTasks = tasks.filter(task => {
if (filterCompany && task.project?.company_id !== filterCompany) return false;
if (filterRequester) {
const initialSub = submissions.find(s => s.task_id === task.id && s.type === 'initial');
if (initialSub?.submitted_by_name !== filterRequester) return false;
}
if (filterProject && task.project?.id !== filterProject) return false;
return true;
});
const byStatusClientFiltered = (s) => clientFilteredTasks.filter(t => t.status === s);
@@ -523,106 +625,155 @@ export default function RequestsPage() {
{ id: 'invoiced', label: 'Invoiced', tasks: byStatusClientFiltered('invoiced') },
{ id: 'paid', label: 'Paid', tasks: byStatusClientFiltered('paid') },
];
const currentClientTasks = clientTabs.find(t => t.id === activeTab)?.tasks || [];
const rawClientTasks = clientTabs.find(t => t.id === activeTab)?.tasks || [];
const currentClientTasks = clientSort(rawClientTasks, (task, key) => {
if (key === 'title') return task?.title || '';
if (key === 'serviceType') return submissions.find(s => s.task_id === task?.id && s.type === 'initial')?.service_type || '';
if (key === 'revision') return task?.current_version ?? 0;
if (key === 'client') return (clientCompanies.find(c => c.id === task.project?.company_id))?.name || '';
if (key === 'deadline') return submissions.find(s => s.task_id === task?.id && s.type === 'initial')?.deadline || '';
if (key === 'completed_at') return task?.completed_at || '';
if (key === 'status') return task?.status || '';
if (key === 'submitted_at') return task?.submitted_at || '';
return '';
});
return (
<Layout>
<div className="page-header" style={{ flexShrink: 0 }}>
<div>
<div className="page-title">Requests</div>
<div className="page-subtitle">Track your active requests and their status.</div>
<div className="page-header-left">
<DashboardBanner />
<div className="page-title dashboard-greeting">Requests</div>
<div className="page-subtitle">{clientFilteredTasks.filter(t => !['client_approved','invoiced','paid'].includes(t.status)).length} active &bull; {clientFilteredTasks.filter(t => ['client_approved','invoiced','paid'].includes(t.status)).length} completed</div>
</div>
<button className="btn btn-primary btn-sm" onClick={() => { setShowAddForm(s => !s); setAddError(''); }}>
{showAddForm ? 'Cancel' : '+ New Request'}
+ New Request
</button>
</div>
{showAddForm && (
<div className="card" style={{ marginBottom: 24, border: '1px solid var(--accent)', borderRadius: 12, flexShrink: 0 }}>
<div className="card-title">New Request</div>
<RequestForm
key={addFormKey}
companies={clientCompanies}
initialCompanyId={clientCompanies[0]?.id || ''}
showRequester={false}
onSubmit={handleClientRequest}
onCancel={() => { setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }}
saving={addSaving}
error={addError}
submitLabel="Submit Request"
/>
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }} onClick={() => { setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }}>
<div style={{ background: 'var(--card-bg)', borderRadius: 4, padding: 28, width: '100%', maxWidth: 560, border: '1px solid var(--border)', boxShadow: '0 24px 64px rgba(0,0,0,0.5)', maxHeight: '90vh', overflowY: 'auto' }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 20 }}>
<div>
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>New Request</div>
<div style={{ fontSize: 22, color: 'var(--text-primary)' }}>Submit a request</div>
</div>
<button onClick={() => { setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }} style={{ background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 20, cursor: 'pointer', lineHeight: 1, padding: 4 }}>×</button>
</div>
<RequestForm
key={addFormKey}
companies={clientCompanies}
initialCompanyId={clientCompanies[0]?.id || ''}
showRequester={false}
onSubmit={handleClientRequest}
onCancel={() => { setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }}
saving={addSaving}
error={addError}
submitLabel="Submit Request"
/>
</div>
</div>
)}
{!showAddForm && (
<>
{(clientCompanies.length > 1 || clientRequesterNames.length > 0) && (
<div style={{ display: 'flex', gap: 12, marginBottom: 16, flexWrap: 'wrap', flexShrink: 0 }}>
{clientCompanies.length > 1 && (
<select className="filter-select" value={filterCompany} onChange={e => setFilterCompany(e.target.value)}>
<option value="">All Companies</option>
{clientCompanies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
)}
{clientRequesterNames.length > 0 && (
<select className="filter-select" value={filterRequester} onChange={e => setFilterRequester(e.target.value)}>
<option value="">All Requesters</option>
{clientRequesterNames.map(name => <option key={name} value={name}>{name}</option>)}
</select>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16, flexShrink: 0, flexWrap: 'wrap' }}>
{clientCompanies.length > 1 && (
<select className="filter-select" style={{ width: 120 }} value={filterCompany} onChange={e => { setFilterCompany(e.target.value); setFilterProject(''); }}>
<option value="">All Companies</option>
{clientCompanies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
)}
{filterCompany && (() => { const co_projects = projects.filter(p => p.company_id === filterCompany); return co_projects.length > 0 ? (
<select className="filter-select" style={{ width: 120 }} value={filterProject} onChange={e => setFilterProject(e.target.value)}>
<option value="">All Projects</option>
{co_projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
) : null; })()}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginLeft: 'auto' }}>
<select className="filter-select" style={{ width: 120 }} value={activeTab} onChange={e => setActiveTab(e.target.value)}>
{clientTabs.map(t => <option key={t.id} value={t.id}>{t.label} ({t.tasks.length})</option>)}
</select>
<button className="btn btn-outline btn-sm" onClick={toggleView} title={viewMode === 'list' ? 'Grid view' : 'List view'} style={{ padding: '5px 9px', lineHeight: 1, display: 'flex', alignItems: 'center' }}>
{viewMode === 'list' ? <GridViewIcon /> : <ListViewIcon />}
</button>
</div>
)}
</div>
{tasks.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon">📋</div>
<h3>No requests yet</h3>
<p>Submit a new request to get started.</p>
<button className="btn btn-primary" style={{ marginTop: 16 }} onClick={() => setShowAddForm(true)}>Submit Request</button>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No requests yet.</div>
) : currentClientTasks.length === 0 ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No {clientTabs.find(t => t.id === activeTab)?.label.toLowerCase()} requests.</div>
) : viewMode === 'grid' ? (
<div style={{ flex: 1, overflowY: 'auto' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: 12 }}>
{currentClientTasks.map(task => {
const clientSub = submissions.find(s => s.task_id === task.id && s.type === 'initial');
const clientCo = clientCompanies.find(c => c.id === task.project?.company_id);
return (
<div key={task.id} className="grid-card" onClick={() => navigate(`/requests/${task.id}`)} style={{ padding: '14px 16px', border: '1px solid var(--border)', borderRadius: 6 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8, marginBottom: 4 }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 400, fontSize: 13, color: 'var(--text-primary)', marginBottom: 2 }}>{task.title}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{task.project?.name || '—'}</div>
</div>
<StatusBadge status={task.status} />
</div>
{clientCo && <div style={{ fontSize: 12, color: 'var(--accent)', marginBottom: 6 }}>{clientCo.name}</div>}
<div style={{ fontSize: 11, color: 'var(--text-muted)', display: 'flex', justifyContent: 'space-between' }}>
<span>{clientSub?.service_type || '—'}</span>
<span>{fmtShortDate(clientSub?.deadline)}</span>
</div>
</div>
);
})}
</div>
</div>
) : (
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 16, flexShrink: 0 }}>
{clientTabs.map(tab => (
<button key={tab.id} type="button" className={`tab-btn${activeTab === tab.id ? ' active' : ''}`} onClick={() => setActiveTab(tab.id)}>
{tab.label} ({tab.tasks.length})
</button>
))}
</div>
{currentClientTasks.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>
No {clientTabs.find(t => t.id === activeTab)?.label.toLowerCase()} requests.
</div>
) : (
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table>
<thead>
<tr>
<th>Project</th>
<th>Name</th>
<th>Revision</th>
<th>Approved</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{currentClientTasks.map(task => (
<tr key={task.id} onClick={() => navigate(`/requests/${task.id}`)} style={{ cursor: 'pointer' }}>
<td style={{ color: 'var(--text-muted)' }}>{task.project?.name || '—'}</td>
<td style={{ fontWeight: 600 }}>{task.title}</td>
<td>{`R${String(task.current_version || 0).padStart(2, '0')}`}</td>
<td style={{ color: 'var(--text-muted)' }}>{task.completed_at ? formatDateOnly(task.completed_at) : '—'}</td>
<td><StatusBadge status={task.status} /></td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<table style={{ tableLayout: 'fixed', width: '100%' }}>
<colgroup>
<col style={{ width: '28%' }} />
<col style={{ width: '25%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '12%' }} />
</colgroup>
<thead>
<tr>
<SortTh col="title" sortKey={clientSortKey} sortDir={clientSortDir} onSort={clientToggle}>Name</SortTh>
<SortTh col="client" sortKey={clientSortKey} sortDir={clientSortDir} onSort={clientToggle}>Client</SortTh>
<SortTh col="serviceType" sortKey={clientSortKey} sortDir={clientSortDir} onSort={clientToggle}>Request Type</SortTh>
<SortTh col="deadline" sortKey={clientSortKey} sortDir={clientSortDir} onSort={clientToggle}>Deadline</SortTh>
<SortTh col="completed_at" sortKey={clientSortKey} sortDir={clientSortDir} onSort={clientToggle}>Approved</SortTh>
<SortTh col="status" sortKey={clientSortKey} sortDir={clientSortDir} onSort={clientToggle}>Status</SortTh>
</tr>
</thead>
<tbody>
{currentClientTasks.map(task => {
const clientSub = submissions.find(s => s.task_id === task.id && s.type === 'initial');
const clientCo = clientCompanies.find(c => c.id === task.project?.company_id);
return (
<tr key={task.id} onClick={() => navigate(`/requests/${task.id}`)} style={{ cursor: 'pointer' }}>
<td style={{ fontWeight: 400 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>{task.title}</span>
<span style={{ color: 'var(--text-muted)' }}>{`R${String(task.current_version || 0).padStart(2, '0')}`}</span>
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>{task.project?.name || '—'}</div>
</td>
<td style={{ color: 'var(--accent)' }}>{clientCo?.name || '—'}</td>
<td>{clientSub?.service_type || '—'}</td>
<td>{fmtShortDate(clientSub?.deadline)}</td>
<td style={{ color: 'var(--text-muted)' }}>{fmtShortDate(task.completed_at)}</td>
<td><StatusBadge status={task.status} /></td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</>
)}
</Layout>
);
}
+427 -64
View File
@@ -1,90 +1,453 @@
import { useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import Layout from '../components/Layout';
import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext';
export default function Settings() {
export default function ProfilePage() {
const { currentUser } = useAuth();
const [passwords, setPasswords] = useState({ next: '', confirm: '' });
const [passwordSaved, setPasswordSaved] = useState(false);
const [passwordError, setPasswordError] = useState('');
const [savingPw, setSavingPw] = useState(false);
const navigate = useNavigate();
const { id: profileId } = useParams();
const [loadingProfile, setLoadingProfile] = useState(false);
const [profileError, setProfileError] = useState('');
const [viewedProfile, setViewedProfile] = useState(null);
const [viewedCompanies, setViewedCompanies] = useState([]);
const [primaryCompanyAddress, setPrimaryCompanyAddress] = useState('');
const [editOpen, setEditOpen] = useState(false);
const [savingProfile, setSavingProfile] = useState(false);
const [editError, setEditError] = useState('');
const [editForm, setEditForm] = useState({
name: '',
title: '',
email: '',
website: '',
linkedin: '',
instagram: '',
twitter: '',
});
const [activityItems, setActivityItems] = useState([]);
const [profileStats, setProfileStats] = useState(null);
const setPw = (field) => (e) => setPasswords(p => ({ ...p, [field]: e.target.value }));
const isSelfView = !profileId || profileId === currentUser?.id;
const handlePasswordSave = async (e) => {
e.preventDefault();
setPasswordError('');
setPasswordSaved(false);
if (passwords.next !== passwords.confirm) { setPasswordError('New passwords do not match.'); return; }
if (passwords.next.length < 6) { setPasswordError('Password must be at least 6 characters.'); return; }
setSavingPw(true);
try {
const { error } = await supabase.auth.updateUser({ password: passwords.next });
if (error) { setPasswordError(error.message); return; }
setPasswords({ next: '', confirm: '' });
setPasswordSaved(true);
setTimeout(() => setPasswordSaved(false), 3000);
} finally {
setSavingPw(false);
useEffect(() => {
let cancelled = false;
async function loadViewedProfile() {
if (!profileId || profileId === currentUser?.id) {
setViewedProfile(null);
setViewedCompanies([]);
setPrimaryCompanyAddress('');
setProfileError('');
return;
}
setLoadingProfile(true);
setProfileError('');
try {
const [{ data: profile, error }, { data: assignedTasks }] = await Promise.all([
supabase
.from('profiles')
.select('*')
.eq('id', profileId)
.single(),
supabase
.from('tasks')
.select('id, title, deadline, status')
.eq('assigned_to', profileId)
.not('deadline', 'is', null),
]);
if (error || !profile) {
setProfileError('Unable to load profile.');
return;
}
const [{ data: primaryCompany }, { data: memberships }] = await Promise.all([
profile.company_id
? supabase.from('companies').select('id, name, address').eq('id', profile.company_id).maybeSingle()
: Promise.resolve({ data: null }),
supabase
.from('company_members')
.select('company_id, company:companies(id, name, address)')
.eq('profile_id', profileId),
]);
if (cancelled) return;
const names = new Set();
if (primaryCompany?.name) names.add(primaryCompany.name);
const primaryAddress = primaryCompany?.address || '';
(memberships || []).forEach((row) => {
const n = row?.company?.name;
if (n) names.add(n);
});
setViewedProfile(profile);
setViewedCompanies([...names]);
setPrimaryCompanyAddress(primaryAddress);
setCalendarItems(
(assignedTasks || []).map((t) => ({
id: t.id,
title: t.title,
deadline: t.deadline,
isDone: ['client_approved', 'invoiced', 'paid'].includes(t.status),
isOverdue: !!t.deadline && !['client_approved', 'invoiced', 'paid'].includes(t.status) && new Date(t.deadline) < new Date(),
isHot: t.status === 'revision_requested',
}))
);
} catch (err) {
if (!cancelled) setProfileError('Unable to load profile.');
} finally {
if (!cancelled) setLoadingProfile(false);
}
}
loadViewedProfile();
return () => {
cancelled = true;
};
}, [profileId, currentUser?.id]);
useEffect(() => {
let cancelled = false;
const uid = isSelfView ? currentUser?.id : profileId;
if (!uid) return;
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
Promise.all([
supabase.from('tasks').select('id', { count: 'exact', head: true }).eq('assigned_to', uid).in('status', doneStatuses),
supabase.from('project_members').select('project_id').eq('profile_id', uid),
supabase.from('tasks').select('id', { count: 'exact', head: true }).eq('assigned_to', uid).eq('status', 'revision_requested'),
supabase.from('submissions').select('id', { count: 'exact', head: true }).eq('submitted_by', uid),
]).then(([completed, members, revisions, submissions]) => {
if (cancelled) return;
const projectIds = (members.data || []).map(m => m.project_id);
const fetchActiveProjects = projectIds.length > 0
? supabase.from('projects').select('id', { count: 'exact', head: true }).in('id', projectIds).not('status', 'in', '("completed","cancelled")')
: Promise.resolve({ count: 0 });
fetchActiveProjects.then(active => {
if (cancelled) return;
setProfileStats({
tasksCompleted: completed.count ?? 0,
activeProjects: active.count ?? 0,
revisionRequests: revisions.count ?? 0,
submissions: submissions.count ?? 0,
});
});
});
return () => { cancelled = true; };
}, [isSelfView, currentUser?.id, profileId]);
useEffect(() => {
let cancelled = false;
const uid = isSelfView ? currentUser?.id : profileId;
if (!uid) return;
supabase
.from('activity_log')
.select('id, created_at, action, task_id, task_title, project_name, project_id')
.eq('actor_id', uid)
.order('created_at', { ascending: false })
.limit(10)
.then(({ data }) => {
if (cancelled) return;
setActivityItems(data || []);
});
return () => { cancelled = true; };
}, [isSelfView, currentUser?.id, profileId]);
const profile = useMemo(
() => isSelfView ? { ...(currentUser || {}), ...(viewedProfile || {}) } : viewedProfile,
// eslint-disable-next-line react-hooks/exhaustive-deps
[isSelfView, currentUser?.id, currentUser?.name, currentUser?.title, currentUser?.email, currentUser?.role, currentUser?.website, currentUser?.linkedin, currentUser?.instagram, currentUser?.twitter, viewedProfile]
);
const companyNames = useMemo(() => {
if (isSelfView) {
const names = new Set((currentUser?.companies || []).map((c) => c?.company?.name).filter(Boolean));
if (currentUser?.company?.name) names.add(currentUser.company.name);
return [...names];
}
return viewedCompanies;
}, [isSelfView, currentUser, viewedCompanies]);
const companyAddress = useMemo(() => {
if (isSelfView) return currentUser?.company?.address || currentUser?.companies?.[0]?.company?.address || '';
return primaryCompanyAddress || '';
}, [isSelfView, currentUser, primaryCompanyAddress]);
const memberSince = useMemo(() => {
const d = profile?.created_at ? new Date(profile.created_at) : null;
return d && !Number.isNaN(d.getTime())
? d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: null;
}, [profile?.created_at]);
const socialLinks = useMemo(() => {
if (!profile) return [];
const candidates = [
{ key: 'website', label: 'Website' },
{ key: 'linkedin', label: 'LinkedIn' },
{ key: 'instagram', label: 'Instagram' },
{ key: 'twitter', label: 'X / Twitter' },
];
return candidates
.map(({ key, label }) => ({ label, value: profile[key] }))
.filter((item) => typeof item.value === 'string' && item.value.trim().length > 0);
}, [profile]);
const dashCardStyle = {
background: 'var(--card-bg)',
border: '1px solid var(--border)',
borderRadius: 8,
padding: '18px 21px',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
};
const initials = (currentUser?.name || '')
const initials = (profile?.name || '')
.split(' ')
.map(n => n[0])
.join('')
.toUpperCase()
.slice(0, 2);
useEffect(() => {
if (!profile) return;
setEditForm({
name: profile.name || '',
title: profile.title || '',
email: profile.email || '',
website: profile.website || '',
linkedin: profile.linkedin || '',
instagram: profile.instagram || '',
twitter: profile.twitter || '',
});
setEditError('');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [profile?.name, profile?.title, profile?.email, profile?.website, profile?.linkedin, profile?.instagram, profile?.twitter]);
const setEditField = (field) => (e) => setEditForm((prev) => ({ ...prev, [field]: e.target.value }));
const handleProfileSave = async (e) => {
e.preventDefault();
if (!currentUser?.id) return;
setSavingProfile(true);
setEditError('');
try {
const payload = {
name: editForm.name.trim(),
title: editForm.title.trim(),
email: editForm.email.trim(),
website: editForm.website.trim(),
linkedin: editForm.linkedin.trim(),
instagram: editForm.instagram.trim(),
twitter: editForm.twitter.trim(),
};
const { data, error } = await supabase
.from('profiles')
.update(payload)
.eq('id', currentUser.id)
.select('*')
.single();
if (error) {
setEditError(error.message || 'Unable to update profile.');
return;
}
setViewedProfile(data || null);
setEditOpen(false);
} finally {
setSavingProfile(false);
}
};
const ACTION_LABEL = {
task_created: 'created', task_started: 'started', task_on_hold: 'put on hold',
task_resumed: 'resumed', task_submitted: 'submitted', task_approved: 'approved',
project_created: 'created project', request_submitted: 'submitted', revision_requested: 'requested revision on',
};
const ProfileActivityFeed = ({ items }) => (
<div style={{ ...dashCardStyle }}>
<div style={{ marginBottom: items.length > 0 ? 14 : 0 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Recent Activity</span>
</div>
{items.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 14 }}>No recent activity</div>
) : items.map((e, i) => (
<div key={e.id} style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: i > 0 ? 10 : 0 }}>
<div style={{ flex: 1, minWidth: 0, fontSize: 13, lineHeight: 1.4 }}>
<span style={{ color: 'var(--text-muted)' }}>{ACTION_LABEL[e.action] || e.action}</span>
{e.task_title && e.task_id && (
<><span style={{ color: 'var(--text-muted)' }}> </span>
<button type="button" className="dashboard-inline-link" onClick={() => navigate(`/requests/${e.task_id}`)}>{e.task_title}</button></>
)}
{e.task_title && !e.task_id && <span style={{ color: 'var(--text-primary)' }}> {e.task_title}</span>}
</div>
<span style={{ fontSize: 11, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
{new Date(e.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</span>
</div>
))}
</div>
);
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">Settings</div>
<div className="page-subtitle">Your account info and password.</div>
</div>
</div>
<div style={{ maxWidth: 520, display: 'flex', flexDirection: 'column', gap: 24 }}>
<div className="card" style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<div className="sidebar-avatar" style={{ width: 56, height: 56, fontSize: 20, flexShrink: 0 }}>
{initials || '?'}
</div>
<div>
<div style={{ fontWeight: 700, fontSize: 15 }}>{currentUser?.name || '—'}</div>
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{currentUser?.email}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2, textTransform: 'capitalize' }}>
{currentUser?.role}{currentUser?.company?.name ? ` · ${currentUser.company.name}` : ''}
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
{loadingProfile && <div style={dashCardStyle}>Loading profile...</div>}
{!loadingProfile && profileError && <div style={{ ...dashCardStyle, color: 'var(--danger)' }}>{profileError}</div>}
{!loadingProfile && !profileError && (
<div className="profile-top-grid">
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<div style={{ ...dashCardStyle, display: 'flex', alignItems: 'flex-start', gap: 20, position: 'relative' }}>
{isSelfView && (
<div style={{ position: 'absolute', top: 18, right: 21, bottom: 18, display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'space-between' }}>
<button
type="button"
className="btn btn-outline"
onClick={() => setEditOpen(true)}
style={{ borderRadius: 8, height: 30, padding: '0 12px', fontSize: 12 }}
>
Edit Profile
</button>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 10 }}>
{memberSince && (
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 10, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6 }}>Member Since</div>
<div style={{ fontSize: 12, color: 'var(--text-primary)', marginTop: 2 }}>{memberSince}</div>
</div>
)}
{profile?.role && (
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 10, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6 }}>Role</div>
<div style={{ fontSize: 12, color: 'var(--text-primary)', marginTop: 2, textTransform: 'capitalize' }}>{profile.role}</div>
</div>
)}
</div>
</div>
)}
<div style={{ width: 120, height: 120, flexShrink: 0, borderRadius: '50%', background: 'var(--card-bg-2)', border: '2px solid #111', outline: '2px solid var(--accent)', outlineOffset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 36, color: 'var(--text-primary)', fontWeight: 500, lineHeight: 1 }}>
{initials || '?'}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 500, fontSize: 18, color: 'var(--text-primary)' }}>{profile?.name || '—'}</div>
{profile?.title && <div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 3 }}>{profile.title}</div>}
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: 3 }}>
{companyNames.length > 0 ? companyNames.join(', ') : '—'}
</div>
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
{companyAddress && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><path d="M12 22s-8-4.5-8-11.8A8 8 0 0112 2a8 8 0 018 8.2c0 7.3-8 11.8-8 11.8z"/><circle cx="12" cy="10" r="3"/></svg>
<span style={{ color: 'var(--text-primary)' }}>{companyAddress}</span>
</div>
)}
{profile?.email && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="2,4 12,13 22,4"/></svg>
<span style={{ color: 'var(--text-primary)' }}>{profile.email}</span>
</div>
)}
{profile?.linkedin && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="var(--text-muted)" style={{ flexShrink: 0 }}><path d="M16 8a6 6 0 016 6v7h-4v-7a2 2 0 00-2-2 2 2 0 00-2 2v7h-4v-7a6 6 0 016-6z"/><rect x="2" y="9" width="4" height="12"/><circle cx="4" cy="4" r="2"/></svg>
<a href={profile.linkedin.startsWith('http') ? profile.linkedin : `https://${profile.linkedin}`} target="_blank" rel="noreferrer" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: 13 }}>{profile.linkedin.replace(/^https?:\/\/(www\.)?/, '')}</a>
</div>
)}
</div>
</div>
</div>
</div>
<div className="card">
<div className="card-title">Change Password</div>
<form onSubmit={handlePasswordSave}>
<div className="form-group">
<label>New Password *</label>
<input type="password" placeholder="Min. 6 characters" value={passwords.next} onChange={setPw('next')} required />
{profileStats && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 24 }}>
{[
{ label: 'Tasks Completed', value: profileStats.tasksCompleted, iconBg: 'rgba(74,222,128,0.15)', iconColor: '#4ade80', iconPath: '<polyline points="4,13 9,18 20,7"/>' },
{ label: 'Active Projects', value: profileStats.activeProjects, iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: '<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" fill="none"/>' },
{ label: 'Revision Requests',value: profileStats.revisionRequests, iconBg: 'rgba(239,68,68,0.15)', iconColor: '#f87171', iconPath: '<path d="M4 12a8 8 0 018-8v0a8 8 0 018 8" fill="none" stroke-linecap="round"/><polyline points="18,8 20,12 16,12" fill="none"/>' },
{ label: 'Submissions', value: profileStats.submissions, iconBg: 'rgba(96,165,250,0.15)', iconColor: '#60a5fa', iconPath: '<line x1="12" y1="19" x2="12" y2="5" stroke-linecap="round"/><polyline points="5,12 12,5 19,12" fill="none"/>' },
].map(({ label, value, iconBg, iconColor, iconPath }) => (
<div key={label} style={{ ...dashCardStyle, display: 'flex', alignItems: 'stretch', gap: 21, minHeight: 120 }}>
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', flex: 1 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'space-between', flexShrink: 0 }}>
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<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>
))}
</div>
<div className="form-group">
<label>Confirm New Password *</label>
<input type="password" placeholder="Repeat new password" value={passwords.confirm} onChange={setPw('confirm')} required />
</div>
{passwordError && (
<div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}> {passwordError}</div>
)}
{passwordSaved && (
<div className="notification notification-success" style={{ marginBottom: 12 }}> Password updated.</div>
)}
<button type="submit" className="btn btn-primary" disabled={savingPw}>
{savingPw ? 'Updating...' : 'Update Password'}
</button>
</form>
)}
</div>
<ProfileActivityFeed items={activityItems} />
</div>
)}
</div>
{isSelfView && editOpen && (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 1200,
background: 'rgba(0,0,0,0.58)',
backdropFilter: 'blur(6px)',
WebkitBackdropFilter: 'blur(6px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 24,
}}
onClick={() => { if (!savingProfile) setEditOpen(false); }}
>
<div
style={{
...dashCardStyle,
width: 'min(620px, 100%)',
maxHeight: 'calc(100vh - 48px)',
overflowY: 'auto',
}}
onClick={(e) => e.stopPropagation()}
>
<div style={{ fontSize: 18, fontWeight: 500, marginBottom: 14, color: 'var(--text-primary)' }}>Edit Profile</div>
<form onSubmit={handleProfileSave}>
<div className="form-group">
<label>Name</label>
<input value={editForm.name} onChange={setEditField('name')} />
</div>
<div className="form-group">
<label>Title</label>
<input value={editForm.title} onChange={setEditField('title')} placeholder="e.g. Creative Director" />
</div>
<div className="form-group">
<label>Email</label>
<input type="email" value={editForm.email} onChange={setEditField('email')} />
</div>
<div className="form-group">
<label>Website</label>
<input value={editForm.website} onChange={setEditField('website')} placeholder="example.com" />
</div>
<div className="form-group">
<label>LinkedIn</label>
<input value={editForm.linkedin} onChange={setEditField('linkedin')} placeholder="linkedin.com/in/username" />
</div>
<div className="form-group">
<label>Instagram</label>
<input value={editForm.instagram} onChange={setEditField('instagram')} placeholder="instagram.com/username" />
</div>
<div className="form-group">
<label>X / Twitter</label>
<input value={editForm.twitter} onChange={setEditField('twitter')} placeholder="x.com/username" />
</div>
{editError && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>{editError}</div>}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 6 }}>
<button type="button" className="btn btn-outline" onClick={() => setEditOpen(false)} disabled={savingProfile}>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={savingProfile}>
{savingProfile ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
</div>
</div>
)}
</Layout>
);
}
+4 -4
View File
@@ -154,9 +154,9 @@ function SignCard({ sign, index, onChange, onPhoto, onRemove, canRemove }) {
const contextInputRef3 = useRef(null);
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 10, padding: 16, display: 'grid', gap: 14 }}>
<div style={{ border: '1px solid var(--border)', borderRadius: 4, padding: 16, display: 'grid', gap: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>
<div style={{ fontSize: 14, fontWeight: 400, color: 'var(--text-primary)' }}>
{sign.signName || `Sign ${index + 1}`}
</div>
{canRemove && (
@@ -276,7 +276,7 @@ function PhotoPicker({ inputRef, preview, label, onPick, small = false }) {
onDrop={handleDrop}
style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 8,
borderRadius: 4,
minHeight: small ? 110 : 120,
cursor: 'pointer',
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--card-bg-2)',
@@ -289,7 +289,7 @@ function PhotoPicker({ inputRef, preview, label, onPick, small = false }) {
}}
>
{preview ? (
<img src={preview} alt={label} style={{ maxHeight: small ? 100 : 150, maxWidth: '100%', objectFit: 'contain', borderRadius: 6 }} />
<img src={preview} alt={label} style={{ maxHeight: small ? 100 : 150, maxWidth: '100%', objectFit: 'contain', borderRadius: 4 }} />
) : (
<div style={{ fontSize: 13, color: 'var(--text-muted)', textAlign: 'center' }}>
{dragging ? 'Drop photo here' : label}
+32 -16
View File
@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import Layout from '../../components/Layout';
import LoadingButton from '../../components/LoadingButton';
import SortTh from '../../components/SortTh';
@@ -49,6 +50,15 @@ export default function MyInvoices() {
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;
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 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);
return { month, Paid: +paidAmt.toFixed(2), Outstanding: +outAmt.toFixed(2) };
}), [visible, chartYear]);
const hasChartData = chartData.some(d => d.Paid > 0 || d.Outstanding > 0);
const sorted = sort(visible, (inv, key) => {
if (key === 'invoice_date' || key === 'due_date') return new Date(inv[key] || 0).getTime();
if (key === 'total') return Number(inv.total || 0);
@@ -66,20 +76,26 @@ export default function MyInvoices() {
</div>
</div>
<div className="stats-grid" style={{ marginBottom: 24 }}>
<div className="stat-card stat-card-highlight">
<div className="stat-value">${outstanding.toFixed(2)}</div>
<div className="stat-label">Outstanding</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>
<div className="stat-card">
<div className="stat-value">${paid.toFixed(2)}</div>
<div className="stat-label">Paid</div>
</div>
<div className="stat-card">
<div className="stat-value" style={{ color: overdueCount > 0 ? 'var(--danger)' : undefined }}>{overdueCount}</div>
<div className="stat-label">Overdue</div>
</div>
</div>
)}
{companies.length > 1 && (
<div style={{ marginBottom: 16 }}>
@@ -115,14 +131,14 @@ export default function MyInvoices() {
const isOverdue = inv.status !== 'paid' && new Date(inv.due_date) < new Date();
return (
<tr key={inv.id}>
<td style={{ fontWeight: 600 }}>{inv.invoice_number}</td>
<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><span className={`badge badge-${statusColor[inv.status]}`} style={{ textTransform: 'capitalize' }}>{inv.status}</span></td>
<td style={{ fontWeight: 700, color: 'var(--accent)' }}>${Number(inv.total).toFixed(2)}</td>
<td style={{ fontWeight: 400, color: 'var(--accent)' }}>${Number(inv.total).toFixed(2)}</td>
<td>
<LoadingButton
className="btn btn-outline btn-sm"
+1 -1
View File
@@ -20,7 +20,7 @@ export default function NewProject() {
return (
<Layout>
<div style={{ maxWidth: 480, margin: '0 auto', textAlign: 'center', paddingTop: 48 }}>
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 8 }}>Account Not Yet Active</h2>
<h2 style={{ fontSize: 20, fontWeight: 400, marginBottom: 8 }}>Account Not Yet Active</h2>
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
Your account hasn't been linked to a company yet. Please contact the Fourge team to get set up.
</p>
+7 -4
View File
@@ -7,6 +7,7 @@ import { sendEmail } from '../../lib/email';
import { useAuth } from '../../context/AuthContext';
import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../../lib/requestSubmission';
import { uploadFilesToRequestInfo } from '../../lib/filebrowserFolders';
import { logActivity } from '../../lib/activityLog';
export default function NewRequest() {
const { currentUser } = useAuth();
@@ -29,7 +30,7 @@ export default function NewRequest() {
<Layout>
<div style={{ maxWidth: 480, margin: '0 auto', textAlign: 'center', paddingTop: 48 }}>
<div style={{ fontSize: 48, marginBottom: 16 }}></div>
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 8 }}>Account Not Yet Active</h2>
<h2 style={{ fontSize: 20, fontWeight: 400, marginBottom: 8 }}>Account Not Yet Active</h2>
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
Your account hasn't been linked to a company yet. Please contact the Fourge team to get set up.
</p>
@@ -43,10 +44,10 @@ export default function NewRequest() {
<Layout>
<div style={{ maxWidth: 480, margin: '0 auto', textAlign: 'center', paddingTop: 48 }}>
<div style={{ fontSize: 48, marginBottom: 16 }}>✅</div>
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 8 }}>Request Submitted!</h2>
<h2 style={{ fontSize: 20, fontWeight: 400, marginBottom: 8 }}>Request Submitted!</h2>
<p style={{ color: 'var(--text-secondary)', marginBottom: 24, fontSize: 14 }}>
Thanks, {currentUser?.name?.split(' ')[0]}! We've received your request for <strong>{lastServiceType}</strong>
{lastProject && <> under <strong>{lastProject}</strong></>}.
Thanks, {currentUser?.name?.split(' ')[0]}! We've received your request for <span style={{ color: "var(--text-primary)" }}>{lastServiceType}</span>
{lastProject && <> under <span style={{ color: "var(--text-primary)" }}>{lastProject}</span></>}.
Our team will review it and update you shortly.
</p>
<div className="action-buttons" style={{ justifyContent: 'center' }}>
@@ -75,6 +76,8 @@ export default function NewRequest() {
});
if (!task) { setSaving(false); return; }
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'request_submitted', taskId: task.id, taskTitle: formData.title.trim(), projectId: resolvedProject.id, projectName: resolvedProject.name }).catch(() => {});
const { submission } = await createInitialSubmissionForRequest({
taskId: task.id,
requestKey,
+29 -16
View File
@@ -4,6 +4,7 @@ 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];
@@ -62,14 +63,15 @@ export default function MyInvoiceCreate() {
const addTask = (task) => {
if (addedTaskIds.has(task.id)) return;
const isRevision = (task.current_version || 0) > 0;
const desc = task.project?.name ? `${task.project.name}${task.title}` : task.title;
const price = isRevision ? 30 : rate;
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 [newItem(desc, price, 1, task.id, isRevision)];
}
return [...prev, newItem(desc, price, 1, task.id, isRevision)];
if (prev.length === 1 && !prev[0].description && !prev[0].unit_price) return toAdd;
return [...prev, ...toAdd];
});
};
@@ -123,6 +125,14 @@ export default function MyInvoiceCreate() {
);
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);
@@ -164,17 +174,20 @@ export default function MyInvoiceCreate() {
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{completedTasks.map(task => {
const isRevision = (task.current_version || 0) > 0;
const price = isRevision ? 30 : rate;
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: 6, border: '1px solid var(--border)' }}>
<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: 600 }}>
<div style={{ fontSize: 13, fontWeight: 400 }}>
{task.project?.name ? `${task.project.name}` : ''}{task.title}
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
{isRevision ? 'Revision' : 'New'} · ${price.toFixed(2)}{isRevision ? '/hr' : ''}
{(() => {
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
@@ -198,7 +211,7 @@ export default function MyInvoiceCreate() {
<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: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: i > 3 ? 'right' : 'left' }}>{h}</div>
<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>
@@ -242,7 +255,7 @@ export default function MyInvoiceCreate() {
onChange={e => updateItem(item.id, 'unit_price', e.target.value)}
style={{ margin: 0, textAlign: 'right' }}
/>
<div style={{ textAlign: 'right', fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', paddingRight: 4 }}>
<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>
@@ -256,8 +269,8 @@ export default function MyInvoiceCreate() {
<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: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
<div style={{ fontSize: 26, fontWeight: 700, color: 'var(--accent)' }}>${total.toFixed(2)}</div>
<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>
+24 -13
View File
@@ -2,9 +2,11 @@ import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import LoadingButton from '../../components/LoadingButton';
import SortTh from '../../components/SortTh';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
import { generateSubcontractorPOPDF } from '../../lib/invoice';
import { useSortable } from '../../hooks/useSortable';
const STATUS_COLOR = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
const STATUS_LABEL = { draft: 'Draft', submitted: 'Submitted', paid: 'Paid' };
@@ -28,6 +30,7 @@ export default function MyInvoiceDetail() {
const [deleting, setDeleting] = useState(false);
const [downloading, setDownloading] = useState(false);
const [error, setError] = useState('');
const { sortKey, sortDir, toggle, sort } = useSortable('description');
useEffect(() => {
async function load() {
@@ -93,6 +96,14 @@ export default function MyInvoiceDetail() {
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';
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 === 'line_total') return Number(item.unit_price || 0) * Number(item.quantity || 1);
return '';
});
return (
<Layout>
@@ -126,7 +137,7 @@ export default function MyInvoiceDetail() {
<div className="grid-2" style={{ marginBottom: 24 }}>
<div className="card">
<div className="card-title">From</div>
<div style={{ fontSize: 15, fontWeight: 700 }}>{currentUser?.name || 'Subcontractor'}</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>
@@ -135,7 +146,7 @@ export default function MyInvoiceDetail() {
<div className="detail-grid" style={{ marginBottom: 0 }}>
<div className="detail-item">
<label>Invoice #</label>
<p style={{ fontWeight: 700 }}>{invoice.invoice_number}</p>
<p style={{ fontWeight: 400 }}>{invoice.invoice_number}</p>
</div>
<div className="detail-item">
<label>Created</label>
@@ -161,12 +172,12 @@ export default function MyInvoiceDetail() {
)}
<div className="detail-item">
<label>Total</label>
<p style={{ fontSize: 18, fontWeight: 700, color: 'var(--accent)' }}>{fmt(total)}</p>
<p style={{ fontSize: 18, fontWeight: 400, color: 'var(--accent)' }}>{fmt(total)}</p>
</div>
{invoice.paid_at && (
<div className="detail-item">
<label>Paid On</label>
<p style={{ color: 'var(--success, #16a34a)', fontWeight: 600 }}>
<p style={{ color: 'var(--success, #16a34a)', fontWeight: 400 }}>
{new Date(invoice.paid_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
</p>
</div>
@@ -181,15 +192,15 @@ export default function MyInvoiceDetail() {
<table>
<thead>
<tr>
<th style={{ width: 100 }}>Type</th>
<th>Description</th>
<th style={{ textAlign: 'center' }}>Qty / Hrs</th>
<th style={{ textAlign: 'right' }}>Rate</th>
<th style={{ textAlign: 'right' }}>Total</th>
<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>
{sortedItems.map(item => (
{tableItems.map(item => (
<tr key={item.id}>
<td>
<span className={`badge ${item.task_id ? 'badge-in_progress' : 'badge-initial'}`}>
@@ -199,7 +210,7 @@ export default function MyInvoiceDetail() {
<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: 700 }}>
<td style={{ textAlign: 'right', fontWeight: 400 }}>
{fmt(Number(item.unit_price) * Number(item.quantity || 1))}
</td>
</tr>
@@ -209,8 +220,8 @@ export default function MyInvoiceDetail() {
</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: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--accent)' }}>{fmt(total)}</div>
<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>
+44 -3
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
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';
@@ -42,6 +43,17 @@ export default function MyInvoices() {
});
}, [currentUser?.id]); // eslint-disable-line react-hooks/exhaustive-deps
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);
return (
<Layout>
<div className="page-header">
@@ -57,6 +69,35 @@ export default function MyInvoices() {
{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>
</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>
)}
</>
)}
{loading ? (
<div className="empty-state">Loading invoices...</div>
) : invoices.length === 0 ? (
@@ -85,10 +126,10 @@ export default function MyInvoices() {
const total = invoiceTotal(inv.items);
return (
<tr key={inv.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/my-invoices-sub/${inv.id}`)}>
<td style={{ fontWeight: 600 }}>{inv.invoice_number}</td>
<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: 700, color: 'var(--accent)' }}>{fmt(total)}</td>
<td style={{ textAlign: 'right', fontWeight: 400, color: 'var(--accent)' }}>{fmt(total)}</td>
</tr>
);
})}
+10 -10
View File
@@ -108,7 +108,7 @@ export default function MyPurchaseOrders() {
<div key={po.id} className="card">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, marginBottom: 12 }}>
<div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', fontWeight: 700, textTransform: 'uppercase' }}>
<div style={{ fontSize: 12, color: 'var(--text-muted)', fontWeight: 400, textTransform: 'uppercase' }}>
{po.po_number || 'Purchase Order'}
</div>
<div className="card-title" style={{ marginBottom: 4 }}>{po.project?.name || 'Subcontractor Work'}</div>
@@ -124,25 +124,25 @@ export default function MyPurchaseOrders() {
<div style={{ display: 'grid', gap: 10 }}>
<div>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', marginBottom: 4 }}>Scope</div>
<div style={{ fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', marginBottom: 4 }}>Scope</div>
<div style={{ whiteSpace: 'pre-wrap' }}>{po.description}</div>
</div>
{po.items?.length > 0 && (
<div>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', marginBottom: 4 }}>Line Items</div>
<div style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
<div style={{ fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', marginBottom: 4 }}>Line Items</div>
<div style={{ border: '1px solid var(--border)', borderRadius: 4, overflow: 'hidden' }}>
{po.items
.slice()
.sort((a, b) => Number(a.sort_order || 0) - Number(b.sort_order || 0))
.map(item => (
<div key={item.id} style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12, padding: '10px 12px', borderBottom: '1px solid var(--border)' }}>
<div>
<div style={{ fontWeight: 700 }}>{item.description || item.task?.title}</div>
<div style={{ fontWeight: 400 }}>{item.description || item.task?.title}</div>
{item.task?.title && item.description !== item.task.title && (
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>{item.task.title}</div>
)}
</div>
<div style={{ fontWeight: 800 }}>${Number(item.amount).toFixed(2)}</div>
<div style={{ fontWeight: 400 }}>${Number(item.amount).toFixed(2)}</div>
</div>
))}
</div>
@@ -150,15 +150,15 @@ export default function MyPurchaseOrders() {
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 10 }}>
<div>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase' }}>Amount</div>
<div style={{ fontWeight: 800 }}>${Number(po.amount).toFixed(2)}</div>
<div style={{ fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase' }}>Amount</div>
<div style={{ fontWeight: 400 }}>${Number(po.amount).toFixed(2)}</div>
</div>
<div>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase' }}>Terms</div>
<div style={{ fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase' }}>Terms</div>
<div>{po.terms || 'Net 15'}</div>
</div>
<div>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase' }}>Paid</div>
<div style={{ fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase' }}>Paid</div>
<div>{po.paid_at ? new Date(po.paid_at).toLocaleDateString() : 'Not paid'}</div>
</div>
</div>
+11 -10
View File
@@ -95,7 +95,8 @@ export default function CreateInvoice() {
.from('tasks')
.select('*, project:projects(name), submissions(service_type, type, version_number)')
.in('project_id', projectIds)
.eq('invoiced', false),
.eq('invoiced', false)
.eq('status', 'client_approved'),
supabase
.from('submissions')
.select('*, task:tasks(id, title, project:projects(name), submissions(service_type, type))')
@@ -385,9 +386,9 @@ export default function CreateInvoice() {
const price = priceList.find(p => p.service_type === task.service_type && p.price_type === 'new');
const alreadyAdded = items.some(i => i.task_id === task.id && !i.submission_id);
return (
<div key={task.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 6, border: '1px solid var(--border)' }}>
<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: 600 }}>{buildNewItemDescription(task)}</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>
@@ -428,9 +429,9 @@ export default function CreateInvoice() {
const price = priceList.find(p => p.service_type === revServiceType && p.price_type === 'revision');
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: 6, border: '1px solid var(--border)' }}>
<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: 600 }}>{buildRevisionItemDescription(rev)}</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>
@@ -457,7 +458,7 @@ export default function CreateInvoice() {
<select
onChange={e => { if (e.target.value) { sortItems(e.target.value); e.target.value = ''; } }}
defaultValue=""
style={{ fontSize: 12, padding: '4px 8px', borderRadius: 6, border: '1px solid var(--border)', background: 'var(--card-bg)', color: 'var(--text-primary)', cursor: 'pointer' }}
style={{ fontSize: 12, padding: '4px 8px', borderRadius: 4, border: '1px solid var(--border)', background: 'var(--card-bg)', color: 'var(--text-primary)', cursor: 'pointer' }}
>
<option value="" disabled>Sort by</option>
<option value="new-first">New first</option>
@@ -469,7 +470,7 @@ export default function CreateInvoice() {
<div style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 80px 120px 120px 40px', gap: 8, marginBottom: 8 }}>
{['', 'Type', 'Description', 'Qty', 'Unit Price', 'Total', ''].map((h, i) => (
<div key={i} style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: i > 3 ? 'right' : 'left' }}>{h}</div>
<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>
@@ -492,7 +493,7 @@ export default function CreateInvoice() {
<input type="text" placeholder="Description..." value={item.description} onChange={e => updateItem(item.id, 'description', e.target.value)} style={{ margin: 0 }} />
<input type="number" min="1" 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: 600, color: 'var(--text-primary)', paddingRight: 4 }}>
<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>
@@ -506,8 +507,8 @@ export default function CreateInvoice() {
<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: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
<div style={{ fontSize: 26, fontWeight: 700, color: 'var(--accent)' }}>${total.toFixed(2)}</div>
<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>
+6 -6
View File
@@ -5,7 +5,7 @@ import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
import { sendEmail } from '../../lib/email';
const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', marginBottom: 4 };
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 };
const blankSubcontractorPO = () => ({
@@ -264,9 +264,9 @@ export default function CreateSubcontractorPO() {
const usedItem = getUsedTaskItem(task.id);
const usedBy = usedItem?.po?.profile?.name || usedItem?.po?.profile?.email || 'another PO';
return (
<div key={task.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 6, border: '1px solid var(--border)' }}>
<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: 700 }}>{task.title}</div>
<div style={{ fontSize: 13, fontWeight: 400 }}>{task.title}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
{usedItem
? `Already on ${usedItem.po?.po_number || 'a PO'} for ${usedBy}`
@@ -292,7 +292,7 @@ export default function CreateSubcontractorPO() {
<div className="card-title">Line Items</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 120px 40px', gap: 8, marginBottom: 8 }}>
{['Description', 'Pay Amount', ''].map((header, index) => (
<div key={header} style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: index > 0 ? 'right' : 'left' }}>{header}</div>
<div key={header} style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: index > 0 ? 'right' : 'left' }}>{header}</div>
))}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
@@ -339,8 +339,8 @@ export default function CreateSubcontractorPO() {
<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: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
<div style={{ fontSize: 26, fontWeight: 700, color: 'var(--accent)' }}>${total.toFixed(2)}</div>
<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>
+2 -2
View File
@@ -307,14 +307,14 @@ export default function FourgePasswords() {
style={{
padding: '16px',
border: '1px solid var(--border)',
borderRadius: 10,
borderRadius: 4,
display: 'grid',
gap: 10,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'flex-start', flexWrap: 'wrap' }}>
<div>
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>{entry.service_name}</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)}
</div>
+25 -14
View File
@@ -2,10 +2,12 @@ import { useState, useEffect } from 'react';
import { useParams, useNavigate, useLocation, Link } from 'react-router-dom';
import Layout from '../../components/Layout';
import LoadingButton from '../../components/LoadingButton';
import SortTh from '../../components/SortTh';
import { supabase } from '../../lib/supabase';
import { generateInvoicePDF, generateReceiptPDF } from '../../lib/invoice';
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
import { withTimeout } from '../../lib/withTimeout';
import { useSortable } from '../../hooks/useSortable';
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
@@ -24,6 +26,7 @@ export default function InvoiceDetail() {
const [editingDates, setEditingDates] = useState(false);
const [dateForm, setDateForm] = useState({ invoice_date: '', due_date: '' });
const [emailRecipient, setEmailRecipient] = useState('');
const { sortKey, sortDir, toggle, sort } = useSortable('description');
useEffect(() => {
async function load() {
@@ -311,6 +314,14 @@ export default function InvoiceDetail() {
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>;
const sortedItems = sort(items, (item, key) => {
if (key === 'type') return item.submission_id ? 'Revision' : 'New';
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 === 'line_total') return Number(item.quantity || 0) * Number(item.unit_price || 0);
return '';
});
const isOverdue = invoice.status !== 'paid' && new Date(invoice.due_date) < new Date();
@@ -345,7 +356,7 @@ export default function InvoiceDetail() {
<div className="grid-2" style={{ marginBottom: 24 }}>
<div className="card">
<div className="card-title">Bill To</div>
<div style={{ fontSize: 15, fontWeight: 700 }}>{invoice.bill_to || company?.name}</div>
<div style={{ fontSize: 15, fontWeight: 400 }}>{invoice.bill_to || company?.name}</div>
<div style={{ marginTop: 12 }}>
<Link to={`/company/${company?.id}`} className="btn btn-outline btn-sm">View Company</Link>
</div>
@@ -400,14 +411,14 @@ export default function InvoiceDetail() {
disabled={saving}
/>
</div>
<div className="detail-item"><label>Total</label><p style={{ fontSize: 18, fontWeight: 700, color: 'var(--accent)' }}>${Number(invoice.total).toFixed(2)}</p></div>
<div className="detail-item"><label>Total</label><p style={{ fontSize: 18, fontWeight: 400, color: 'var(--accent)' }}>${Number(invoice.total).toFixed(2)}</p></div>
{invoice.paid_at && (
<div className="detail-item"><label>Paid On</label><p style={{ color: 'var(--success, #16a34a)', fontWeight: 600 }}>{new Date(invoice.paid_at).toLocaleDateString()}</p></div>
<div className="detail-item"><label>Paid On</label><p style={{ color: 'var(--success, #16a34a)', fontWeight: 400 }}>{new Date(invoice.paid_at).toLocaleDateString()}</p></div>
)}
{invoice.status === 'paid' && invoice.stripe_fee != null && (
<>
<div className="detail-item"><label>Stripe Fee</label><p style={{ color: 'var(--text-secondary)' }}>${Number(invoice.stripe_fee).toFixed(2)}</p></div>
<div className="detail-item"><label>Net Received</label><p style={{ fontWeight: 700 }}>${(Number(invoice.total) - Number(invoice.stripe_fee)).toFixed(2)}</p></div>
<div className="detail-item"><label>Net Received</label><p style={{ fontWeight: 400 }}>${(Number(invoice.total) - Number(invoice.stripe_fee)).toFixed(2)}</p></div>
</>
)}
</div>
@@ -420,15 +431,15 @@ export default function InvoiceDetail() {
<table>
<thead>
<tr>
<th style={{ width: 100 }}>Type</th>
<th>Description</th>
<th style={{ textAlign: 'center' }}>Qty</th>
<th style={{ textAlign: 'right' }}>Unit Price</th>
<th style={{ textAlign: 'right' }}>Total</th>
<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</SortTh>
<SortTh col="unit_price" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Unit Price</SortTh>
<SortTh col="line_total" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Total</SortTh>
</tr>
</thead>
<tbody>
{items.map(item => (
{sortedItems.map(item => (
<tr key={item.id}>
<td>
<span className={`badge ${item.submission_id ? 'badge-client_revision' : 'badge-initial'}`}>
@@ -438,7 +449,7 @@ export default function InvoiceDetail() {
<td>{item.description}</td>
<td style={{ textAlign: 'center' }}>{item.quantity}</td>
<td style={{ textAlign: 'right' }}>${Number(item.unit_price).toFixed(2)}</td>
<td style={{ textAlign: 'right', fontWeight: 700 }}>${(Number(item.quantity) * Number(item.unit_price)).toFixed(2)}</td>
<td style={{ textAlign: 'right', fontWeight: 400 }}>${(Number(item.quantity) * Number(item.unit_price)).toFixed(2)}</td>
</tr>
))}
</tbody>
@@ -446,15 +457,15 @@ export default function InvoiceDetail() {
</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: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--accent)' }}>${Number(invoice.total).toFixed(2)}</div>
<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).toFixed(2)}</div>
{invoice.status === 'paid' && invoice.stripe_fee != null && (
<div style={{ marginTop: 10, paddingTop: 10, borderTop: '1px solid var(--border)' }}>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
Stripe fee: <span style={{ color: 'var(--text-secondary)' }}>${Number(invoice.stripe_fee).toFixed(2)}</span>
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
Net received: <span style={{ fontWeight: 700, color: 'var(--text-primary)' }}>${(Number(invoice.total) - Number(invoice.stripe_fee)).toFixed(2)}</span>
Net received: <span style={{ fontWeight: 400, color: 'var(--text-primary)' }}>${(Number(invoice.total) - Number(invoice.stripe_fee)).toFixed(2)}</span>
</div>
</div>
)}
+151 -132
View File
@@ -1,4 +1,6 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { DashboardBanner } from '../../lib/dashboardBanner';
import { useLocation, useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import SortTh from '../../components/SortTh';
@@ -27,7 +29,7 @@ const poStatusLabel = {
cancelled: 'Cancelled',
};
const RECEIPT_BUCKET = 'expense-receipts';
const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', marginBottom: 4 };
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 };
const blankExpense = () => ({
@@ -47,6 +49,20 @@ function getFileExt(name = '') {
return clean && clean !== name ? clean.replace(/[^a-z0-9]/g, '') : 'bin';
}
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
function FinancesChartTooltip({ 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>${p.value.toLocaleString('en-US', { minimumFractionDigits: 2 })}</strong></div>
))}
</div>
);
}
export default function Invoices() {
const navigate = useNavigate();
const location = useLocation();
@@ -68,6 +84,7 @@ export default function Invoices() {
const [newExpense, setNewExpense] = useState(blankExpense());
const [addingExpense, setAddingExpense] = useState(false);
const [editingExpenseId, setEditingExpenseId] = useState('');
const [showExpenseForm, setShowExpenseForm] = useState(false);
const [expenseFilter, setExpenseFilter] = useState('all');
const [subcontractorPOs, setSubcontractorPOs] = useState([]);
const [subcontractorLoading, setSubcontractorLoading] = useState(true);
@@ -150,12 +167,14 @@ export default function Invoices() {
removeReceipt: false,
});
setExpensesError('');
setShowExpenseForm(true);
};
const cancelExpenseEdit = () => {
setEditingExpenseId('');
setNewExpense(blankExpense());
setExpensesError('');
setShowExpenseForm(false);
};
const handleAddExpense = async (e) => {
@@ -214,6 +233,7 @@ export default function Invoices() {
);
setNewExpense(blankExpense());
setEditingExpenseId('');
setShowExpenseForm(false);
}
} catch (error) {
console.error(`Failed to ${editingExpenseId ? 'update' : 'add'} expense:`, error);
@@ -456,74 +476,115 @@ export default function Invoices() {
const revenue = totals.paid;
const profit = totals.netReceived - expenses.reduce((s, e) => s + Number(e.amount), 0) - totalPaidSubcontractors;
const chartYear = exportYear;
const chartData = useMemo(() => MONTHS.map((month, mi) => {
const paid = invoices
.filter(inv => inv.status === 'paid' && new Date(inv.invoice_date).getFullYear() === chartYear && new Date(inv.invoice_date).getMonth() === mi)
.reduce((s, inv) => s + Number(inv.total || 0), 0);
const outstanding = invoices
.filter(inv => inv.status === 'sent' && new Date(inv.invoice_date).getFullYear() === chartYear && new Date(inv.invoice_date).getMonth() === mi)
.reduce((s, inv) => s + Number(inv.total || 0), 0);
const exp = expenses
.filter(e => new Date(e.date).getFullYear() === chartYear && new Date(e.date).getMonth() === mi)
.reduce((s, e) => s + Number(e.amount || 0), 0);
const subExp = subInvoices
.filter(i => i.status === 'paid' && new Date(i.created_at).getFullYear() === chartYear && new Date(i.created_at).getMonth() === mi)
.reduce((s, i) => s + (i.items || []).reduce((a, x) => a + Number(x.unit_price || 0) * Number(x.quantity || 1), 0), 0);
const totalExp = exp + subExp;
return { month, Revenue: +paid.toFixed(2), Outstanding: +outstanding.toFixed(2), Expenses: +totalExp.toFixed(2), Profit: +(paid - totalExp).toFixed(2) };
}), [invoices, expenses, subInvoices, chartYear]);
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">Invoices & Expenses</div>
<div className="page-header" style={{ flexShrink: 0 }}>
<div className="page-header-left">
<DashboardBanner />
<div className="page-title dashboard-greeting">Finances</div>
<div className="page-subtitle">{invoices.length} invoice{invoices.length !== 1 ? 's' : ''} · {expenses.length} expense{expenses.length !== 1 ? 's' : ''} · {subcontractorPOs.length} subcontractor PO{subcontractorPOs.length !== 1 ? 's' : ''}</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{paidYears.length > 0 && (
<select
value={exportYear}
onChange={e => setExportYear(Number(e.target.value))}
className="btn btn-outline btn-sm"
style={{ cursor: 'pointer' }}
>
{paidYears.map(y => (
<option key={y} value={y}>{y}</option>
))}
<select className="filter-select" value={exportYear} onChange={e => setExportYear(Number(e.target.value))} style={{ width: 120 }}>
{paidYears.map(y => <option key={y} value={y}>{y}</option>)}
</select>
)}
<button className="btn btn-outline btn-sm" onClick={handleCPAExport} disabled={exporting}>
<button className="btn btn-primary btn-sm" onClick={handleCPAExport} disabled={exporting}>
{exporting ? 'Exporting…' : 'Export for CPA'}
</button>
</div>
</div>
<div className="stats-grid" style={{ marginBottom: 18 }}>
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '16px 16px 8px', marginBottom: 18 }}>
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="gradRevenue" 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="gradProfit" 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="gradExpenses" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#ef4444" stopOpacity={0.2}/><stop offset="95%" stopColor="#ef4444" stopOpacity={0}/></linearGradient>
<linearGradient id="gradOutstanding" 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={<FinancesChartTooltip year={chartYear} />} />
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} />
<Area type="monotone" dataKey="Revenue" stroke="#F5A523" strokeWidth={2} fill="url(#gradRevenue)" dot={false} activeDot={{ r: 4 }} />
<Area type="monotone" dataKey="Profit" stroke="#4ade80" strokeWidth={2} fill="url(#gradProfit)" dot={false} activeDot={{ r: 4 }} />
<Area type="monotone" dataKey="Expenses" stroke="#ef4444" strokeWidth={2} fill="url(#gradExpenses)" dot={false} activeDot={{ r: 4 }} />
<Area type="monotone" dataKey="Outstanding" stroke="#60a5fa" strokeWidth={2} fill="url(#gradOutstanding)" dot={false} activeDot={{ r: 4 }} />
</AreaChart>
</ResponsiveContainer>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 0, marginBottom: 16 }}>
{[
{ label: 'Revenue', value: revenue, detail: 'paid invoices' },
{ label: 'Profit', value: profit, detail: 'net minus expenses' },
{ label: 'Outstanding', value: totals.sent, count: invoices.filter(i => i.status === 'sent').length },
{ label: 'Paid', value: totals.paid, count: invoices.filter(i => i.status === 'paid').length },
{ label: 'Net Received', value: totals.netReceived, detail: 'after Stripe fees' },
].map(({ label, value, count, detail }) => (
<div key={label} className={`stat-card${label === 'Revenue' ? ' stat-card-highlight' : ''}`}>
<div className="stat-value" style={{ fontSize: 22, color: label === 'Profit' && value < 0 ? 'var(--danger)' : undefined }}>${value.toFixed(2)}</div>
<div className="stat-label">{count !== undefined ? `${label} · ${count} invoice${count !== 1 ? 's' : ''}` : `${label} · ${detail}`}</div>
</div>
{ 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>
))}
</div>
<div className="stats-grid" style={{ marginBottom: 24 }}>
<div className="stat-card">
<div className="stat-value" style={{ fontSize: 22 }}>${currentYearTotalExpenses.toFixed(2)}</div>
<div className="stat-label">Expenses This Year · includes paid subcontractors</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 16, 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)}>
<option value="">All Companies</option>
{companyNames.map(name => <option key={name} value={name}>{name}</option>)}
</select>
)}
</div>
<div className="stat-card">
<div className="stat-value" style={{ fontSize: 22 }}>${totalExpenses.toFixed(2)}</div>
<div className="stat-label">Filtered Expenses · {filteredExpenses.length} item{filteredExpenses.length !== 1 ? 's' : ''}</div>
</div>
<div className="stat-card">
<div className="stat-value" style={{ fontSize: 22 }}>${totalPayableSubcontractors.toFixed(2)}</div>
<div className="stat-label">Subcontractors Payable · {payableSubcontractorCount} pending</div>
</div>
</div>
<div style={{ display: 'flex', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
{companyNames.length > 0 && (
<select className="filter-select" value={filterCompany} onChange={e => setFilterCompany(e.target.value)}>
<option value="">All Companies</option>
{companyNames.map(name => <option key={name} value={name}>{name}</option>)}
</select>
{activeTab === 'invoices' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<select className="filter-select" style={{ width: 120 }} value={filter} onChange={e => setFilter(e.target.value)}>
<option value="all">All</option>
<option value="draft">Draft</option>
<option value="sent">Sent</option>
<option value="paid">Paid</option>
</select>
<button className="btn btn-primary btn-sm" onClick={() => navigate('/invoices/new')}>+ New Invoice</button>
</div>
)}
{activeTab === 'expenses' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<select className="filter-select" style={{ width: 120 }} value={expenseFilter} onChange={e => setExpenseFilter(e.target.value)}>
<option value="all">All</option>
{CATEGORIES.filter(c => expenses.some(ex => ex.category === c)).map(c => (
<option key={c} value={c}>{c}</option>
))}
</select>
<button className="btn btn-primary btn-sm" onClick={() => { setEditingExpenseId(''); setNewExpense(blankExpense()); setExpensesError(''); setShowExpenseForm(true); }}>+ Add Expense</button>
</div>
)}
<select className="filter-select" value={activeTab} onChange={e => setActiveTab(e.target.value)}>
<option value="invoices">Invoices</option>
<option value="sub-invoices">Subcontractor Invoices</option>
<option value="expenses">Expenses</option>
</select>
</div>
{activeTab === 'invoices' && (
@@ -533,29 +594,10 @@ export default function Invoices() {
<div>
{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">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{[
{ id: 'all', label: 'All' },
{ id: 'draft', label: 'Draft' },
{ id: 'sent', label: 'Sent' },
{ id: 'paid', label: 'Paid' },
].map(s => (
<button key={s.id} type="button" className={`tab-btn${filter === s.id ? ' active' : ''}`} onClick={() => setFilter(s.id)}>{s.label}</button>
))}
</div>
<button className="btn btn-primary btn-sm" onClick={() => navigate('/invoices/new')}>+ New Invoice</button>
</div>
{filtered.length === 0 ? (
<div className="empty-state">
<h3>No invoices</h3>
<p>Create your first invoice to get started.</p>
<button className="btn btn-primary" style={{ marginTop: 16 }} onClick={() => navigate('/invoices/new')}>+ New Invoice</button>
</div>
) : (
<div className="table-wrapper">
<div style={{ background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<table>
<thead>
<tr>
@@ -574,8 +616,8 @@ export default function Invoices() {
return inv[key] || inv.company?.name || '';
}).map(inv => (
<tr key={inv.id} onClick={() => navigate(`/invoices/${inv.id}`)} style={{ cursor: 'pointer' }}>
<td style={{ fontWeight: 600 }}>{inv.invoice_number}</td>
<td style={{ fontWeight: 600 }}>{inv.bill_to || inv.company?.name}</td>
<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>
<td>
<span style={{ color: inv.status !== 'paid' && new Date(inv.due_date) < new Date() ? 'var(--danger)' : 'var(--text-muted)' }}>
@@ -583,13 +625,11 @@ export default function Invoices() {
</span>
</td>
<td><span className={`badge badge-${statusColor[inv.status]}`} style={{ textTransform: 'capitalize' }}>{inv.status}</span></td>
<td style={{ fontWeight: 700, color: 'var(--accent)' }}>${Number(inv.total).toFixed(2)}</td>
<td style={{ fontWeight: 400, color: 'var(--accent)' }}>${Number(inv.total).toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
@@ -599,18 +639,7 @@ export default function Invoices() {
{activeTab === 'expenses' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<div>
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>
<div className="card-title" style={{ marginBottom: 0 }}>Saved Expenses</div>
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--accent)' }}>${totalExpenses.toFixed(2)}</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 12 }}>
<button onClick={() => setExpenseFilter('all')} className={`tab-btn${expenseFilter === 'all' ? ' active' : ''}`}>All</button>
{CATEGORIES.filter(c => expenses.some(e => e.category === c)).map(c => (
<button key={c} onClick={() => setExpenseFilter(f => f === c ? 'all' : c)} className={`tab-btn${expenseFilter === c ? ' active' : ''}`}>{c}</button>
))}
</div>
<div style={{ marginBottom: 16 }}>
{expensesLoading ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading...</p>
@@ -619,7 +648,7 @@ export default function Invoices() {
) : filteredExpenses.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No expenses yet.</p>
) : (
<div className="table-wrapper" style={{ marginTop: 0 }}>
<div style={{ background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<table>
<thead>
<tr>
@@ -637,9 +666,9 @@ export default function Invoices() {
if (key === 'date') return exp.date || '';
return exp[key] || '';
}).map(exp => (
<tr key={exp.id}>
<tr key={exp.id} style={{ cursor: 'pointer' }} onClick={() => startEditExpense(exp)}>
<td>{new Date(exp.date).toLocaleDateString()}</td>
<td style={{ fontWeight: 600 }}>{exp.description}</td>
<td style={{ fontWeight: 400 }}>{exp.description}</td>
<td>{exp.category}</td>
<td style={{ color: 'var(--text-muted)' }}>
{exp.notes || exp.receipt_path ? (
@@ -649,7 +678,7 @@ export default function Invoices() {
<button
className="btn btn-outline btn-sm"
style={{ width: 'fit-content' }}
onClick={() => handleViewReceipt(exp)}
onClick={e => { e.stopPropagation(); handleViewReceipt(exp); }}
>
View Receipt
</button>
@@ -657,24 +686,15 @@ export default function Invoices() {
</div>
) : '—'}
</td>
<td style={{ textAlign: 'right', fontWeight: 700 }}>${Number(exp.amount).toFixed(2)}</td>
<td style={{ textAlign: 'right', fontWeight: 400 }}>${Number(exp.amount).toFixed(2)}</td>
<td style={{ textAlign: 'right' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 6 }}>
<button
className="btn-icon"
title="Edit"
onClick={() => startEditExpense(exp)}
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<button
className="btn-icon btn-icon-danger"
title="Delete"
onClick={() => handleDeleteExpense(exp.id)}
>
</button>
</div>
<button
className="btn-icon btn-icon-danger"
title="Delete"
onClick={e => { e.stopPropagation(); handleDeleteExpense(exp.id); }}
>
</button>
</td>
</tr>
))}
@@ -686,11 +706,17 @@ export default function Invoices() {
</div>
{/* ── Expenses ── */}
<div>
<div className="card" style={{ marginBottom: 16 }}>
<div className="card-title">{editingExpenseId ? 'Edit Expense' : 'Add Expense'}</div>
<form onSubmit={handleAddExpense} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{showExpenseForm && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }} onClick={cancelExpenseEdit}>
<div style={{ background: 'var(--card-bg)', borderRadius: 4, padding: 28, width: '100%', maxWidth: 520, border: '1px solid var(--border)', boxShadow: '0 24px 64px rgba(0,0,0,0.5)', maxHeight: '90vh', overflowY: 'auto' }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 20 }}>
<div>
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>{editingExpenseId ? 'Edit Expense' : 'New Expense'}</div>
<div style={{ fontSize: 22, color: 'var(--text-primary)' }}>{editingExpenseId ? 'Edit expense' : 'Add an expense'}</div>
</div>
<button onClick={cancelExpenseEdit} style={{ background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 20, cursor: 'pointer', lineHeight: 1, padding: 4 }}>×</button>
</div>
<form onSubmit={handleAddExpense} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<div>
<label style={FIELD_LABEL_STYLE}>Date</label>
@@ -713,7 +739,7 @@ export default function Invoices() {
placeholder="0.00"
value={newExpense.amount}
onChange={e => setNewExpense(p => ({ ...p, amount: e.target.value }))}
style={{ ...FIELD_INPUT_STYLE, minHeight: 38, borderRadius: 10 }}
style={{ ...FIELD_INPUT_STYLE, minHeight: 38, borderRadius: 4, paddingLeft: 10 }}
required
/>
</div>
@@ -787,7 +813,7 @@ export default function Invoices() {
</div>
<div className="action-buttons" style={{ marginTop: 4 }}>
<button className="btn btn-primary btn-sm" type="submit" disabled={addingExpense}>
{addingExpense ? (editingExpenseId ? 'Saving…' : 'Adding…') : (editingExpenseId ? 'Save Changes' : '+ Add Expense')}
{addingExpense ? (editingExpenseId ? 'Saving…' : 'Adding…') : (editingExpenseId ? 'Save Changes' : 'Add Expense')}
</button>
{editingExpenseId && (
<button className="btn btn-outline btn-sm" type="button" onClick={cancelExpenseEdit}>
@@ -801,22 +827,15 @@ export default function Invoices() {
</div>
)}
</form>
</div>
</div>
</div>
)}
</div>
)}
{activeTab === 'sub-invoices' && (
<div>
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>
<div className="card-title" style={{ marginBottom: 0 }}>Sub Invoices</div>
<div style={{ display: 'flex', gap: 12, fontSize: 13, fontWeight: 700 }}>
<span style={{ color: 'var(--accent)' }}>${subInvoices.filter(i => i.status === 'submitted').reduce((s, i) => s + (i.items || []).reduce((a, x) => a + Number(x.unit_price || 0) * Number(x.quantity || 1), 0), 0).toFixed(2)} pending</span>
<span>${subInvoices.filter(i => i.status === 'paid').reduce((s, i) => s + (i.items || []).reduce((a, x) => a + Number(x.unit_price || 0) * Number(x.quantity || 1), 0), 0).toFixed(2)} paid</span>
</div>
</div>
<div style={{ marginBottom: 16 }}>
{subInvoicesLoading ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading...</p>
) : subInvoicesError ? (
@@ -824,7 +843,7 @@ export default function Invoices() {
) : subInvoices.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No sub invoices yet.</p>
) : (
<div className="table-wrapper" style={{ marginTop: 0 }}>
<div style={{ background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<table>
<thead>
<tr>
@@ -847,14 +866,14 @@ export default function Invoices() {
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}`)}>
<td style={{ fontWeight: 600 }}>{inv.invoice_number}</td>
<td style={{ fontWeight: 400 }}>{inv.invoice_number}</td>
<td>
<div style={{ fontWeight: 600 }}>{inv.profile?.name || 'External'}</div>
<div style={{ fontWeight: 400 }}>{inv.profile?.name || 'External'}</div>
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>{inv.profile?.email || '—'}</div>
</td>
<td style={{ color: 'var(--text-muted)' }}>{inv.submitted_at ? new Date(inv.submitted_at).toLocaleDateString() : '—'}</td>
<td><span className={`badge badge-${subInvoiceStatusColor[inv.status] || 'not_started'}`} style={{ textTransform: 'capitalize' }}>{inv.status}</span></td>
<td style={{ textAlign: 'right', fontWeight: 700, color: 'var(--accent)' }}>${total.toFixed(2)}</td>
<td style={{ textAlign: 'right', fontWeight: 400, color: 'var(--accent)' }}>${total.toFixed(2)}</td>
<td />
</tr>
);
-194
View File
@@ -1,194 +0,0 @@
import { useEffect, useState } from 'react';
import Layout from '../../components/Layout';
import LoadingButton from '../../components/LoadingButton';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
function emptyForm() {
return {
title: '',
attendees: '',
meeting_at: new Date().toISOString().slice(0, 16),
notes: '',
};
}
function formatMeetingDate(value) {
if (!value) return 'Unknown date';
return new Date(value).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
export default function MeetingNotes() {
const { currentUser } = useAuth();
const [notes, setNotes] = useState([]);
const [form, setForm] = useState(emptyForm());
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [deletingId, setDeletingId] = useState('');
const [status, setStatus] = useState('');
useEffect(() => {
let isMounted = true;
async function loadInitialNotes() {
const { data, error } = await supabase
.from('meeting_notes')
.select('*')
.order('meeting_at', { ascending: false })
.order('created_at', { ascending: false });
if (!isMounted) return;
if (error) {
setStatus(`Failed to load meeting notes: ${error.message}`);
setNotes([]);
} else {
setNotes(data || []);
setStatus('');
}
setLoading(false);
}
loadInitialNotes();
return () => {
isMounted = false;
};
}, []);
const handleSubmit = async (e) => {
e.preventDefault();
if (!form.title.trim() || !form.notes.trim()) {
setStatus('Meeting title and notes are required.');
return;
}
setSaving(true);
const payload = {
title: form.title.trim(),
attendees: form.attendees.trim(),
meeting_at: form.meeting_at ? new Date(form.meeting_at).toISOString() : new Date().toISOString(),
notes: form.notes.trim(),
created_by: currentUser?.id || null,
updated_at: new Date().toISOString(),
};
const { data, error } = await supabase
.from('meeting_notes')
.insert(payload)
.select('*')
.single();
setSaving(false);
if (error) {
setStatus(`Failed to save note: ${error.message}`);
return;
}
setNotes(prev => [data, ...prev]);
setForm(emptyForm());
setStatus('Meeting note added.');
};
const handleDelete = async (entry) => {
if (!window.confirm(`Delete meeting note "${entry.title}"?`)) return;
setDeletingId(entry.id);
const { error } = await supabase.from('meeting_notes').delete().eq('id', entry.id);
setDeletingId('');
if (error) {
setStatus(`Failed to delete note: ${error.message}`);
return;
}
setNotes(prev => prev.filter(note => note.id !== entry.id));
setStatus('Meeting note deleted.');
};
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">Meeting Notes</div>
<div className="page-subtitle">Internal team timeline for meeting recaps, decisions, and follow-ups.</div>
</div>
</div>
<div style={{ display: 'grid', gap: 18 }}>
<section className="card">
<div className="card-title">Add Note</div>
<form onSubmit={handleSubmit}>
<div className="grid-2">
<div className="form-group">
<label>Meeting Title</label>
<input type="text" value={form.title} onChange={(e) => setForm(prev => ({ ...prev, title: e.target.value }))} placeholder="Weekly team sync" />
</div>
<div className="form-group">
<label>Meeting Date</label>
<input type="datetime-local" value={form.meeting_at} onChange={(e) => setForm(prev => ({ ...prev, meeting_at: e.target.value }))} />
</div>
</div>
<div className="form-group">
<label>Attendees</label>
<input type="text" value={form.attendees} onChange={(e) => setForm(prev => ({ ...prev, attendees: e.target.value }))} placeholder="Team, client, subcontractor" />
</div>
<div className="form-group" style={{ marginBottom: 12 }}>
<label>Notes</label>
<textarea value={form.notes} onChange={(e) => setForm(prev => ({ ...prev, notes: e.target.value }))} placeholder="Key decisions, next steps, blockers, and follow-up items..." style={{ minHeight: 180 }} />
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>{status || 'Newest notes appear first in the timeline.'}</div>
<LoadingButton className="btn btn-primary" loading={saving} loadingText="Saving...">Save Note</LoadingButton>
</div>
</form>
</section>
<section className="card">
<div className="card-title">Timeline</div>
{loading ? (
<div style={{ color: 'var(--text-muted)' }}>Loading meeting notes...</div>
) : notes.length === 0 ? (
<div className="empty-state" style={{ padding: '36px 12px' }}>
<h3>No meeting notes yet</h3>
<p>Add the first entry to start the internal timeline.</p>
</div>
) : (
<div className="meeting-timeline">
{notes.map((entry) => (
<article key={entry.id} className="meeting-note-card">
<div className="meeting-note-marker" aria-hidden="true" />
<div className="meeting-note-content">
<div className="meeting-note-header">
<div>
<div className="meeting-note-title">{entry.title}</div>
<div className="meeting-note-meta">
<span>{formatMeetingDate(entry.meeting_at)}</span>
{entry.attendees ? <span>Attendees: {entry.attendees}</span> : null}
</div>
</div>
<LoadingButton
className="btn-icon btn-icon-danger"
loading={deletingId === entry.id}
disabled={Boolean(deletingId)}
loadingText="..."
title="Delete"
onClick={() => handleDelete(entry)}
>
</LoadingButton>
</div>
<div className="meeting-note-body">{entry.notes}</div>
</div>
</article>
))}
</div>
)}
</section>
</div>
</Layout>
);
}
+23 -13
View File
@@ -1,9 +1,11 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import SortTh from '../../components/SortTh';
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' };
@@ -14,6 +16,7 @@ export default function SubInvoiceDetail() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [generating, setGenerating] = useState(false);
const { sortKey, sortDir, toggle, sort } = useSortable('description');
useEffect(() => {
supabase
@@ -28,6 +31,13 @@ export default function SubInvoiceDetail() {
}, [id]);
const total = (invoice?.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
const tableItems = sort(sortedItems, (item, key) => {
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);
return '';
});
const buildPDFArgs = (inv) => ({
po_number: inv.invoice_number,
@@ -120,11 +130,11 @@ export default function SubInvoiceDetail() {
<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: 700 }}>{invoice.invoice_number}</p></div>
<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: 600 }}>{new Date(invoice.paid_at).toLocaleDateString()}</p></div>
<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>
@@ -132,7 +142,7 @@ export default function SubInvoiceDetail() {
<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: 700, fontSize: 18 }}>${total.toFixed(2)}</p></div>
<div className="detail-item"><label>Total</label><p style={{ fontWeight: 400, fontSize: 18 }}>${total.toFixed(2)}</p></div>
</div>
</div>
</div>
@@ -146,32 +156,32 @@ export default function SubInvoiceDetail() {
<table>
<thead>
<tr>
<th>Description</th>
<th style={{ textAlign: 'right' }}>Qty</th>
<th style={{ textAlign: 'right' }}>Unit Price</th>
<th style={{ textAlign: 'right' }}>Amount</th>
<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>
{sortedItems.map(item => (
{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: 700 }}>${(Number(item.unit_price) * Number(item.quantity || 1)).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: 700, borderTop: '1px solid var(--border)', paddingTop: 10 }}>Total</td>
<td style={{ textAlign: 'right', fontWeight: 700, fontSize: 16, borderTop: '1px solid var(--border)', paddingTop: 10 }}>${total.toFixed(2)}</td>
<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>
</div>
)}
{invoice.notes && (
<div style={{ marginTop: 16, padding: '12px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 6 }}>Notes</div>
<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>
)}
+20 -10
View File
@@ -2,9 +2,11 @@ import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import Layout from '../../components/Layout';
import LoadingButton from '../../components/LoadingButton';
import SortTh from '../../components/SortTh';
import { supabase } from '../../lib/supabase';
import { sendEmail } from '../../lib/email';
import { generateSubcontractorPOPDF } from '../../lib/invoice';
import { useSortable } from '../../hooks/useSortable';
const poStatusColor = {
draft: 'not_started',
@@ -33,6 +35,7 @@ export default function SubcontractorPODetail() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [generating, setGenerating] = useState(false);
const { sortKey, sortDir, toggle, sort } = useSortable('task');
useEffect(() => {
async function load() {
@@ -149,6 +152,13 @@ export default function SubcontractorPODetail() {
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));
const tableItems = sort(sortedItems, (item, key) => {
if (key === 'project') return po.project?.name || '';
if (key === 'task') return item.task?.title || '';
if (key === 'description') return item.description || '';
if (key === 'amount') return Number(item.amount || 0);
return '';
});
return (
<Layout>
@@ -172,7 +182,7 @@ export default function SubcontractorPODetail() {
<div className="grid-2" style={{ marginBottom: 24 }}>
<div className="card">
<div className="card-title">Subcontractor</div>
<div style={{ fontSize: 15, fontWeight: 700 }}>{po.profile?.name || 'External Team Member'}</div>
<div style={{ fontSize: 15, fontWeight: 400 }}>{po.profile?.name || 'External Team Member'}</div>
<div style={{ color: 'var(--text-muted)', fontSize: 13, marginTop: 4 }}>{po.profile?.email || 'No email on file'}</div>
</div>
<div className="card">
@@ -183,7 +193,7 @@ export default function SubcontractorPODetail() {
<div className="detail-item"><label>Terms</label><p>{po.terms || 'Net 15'}</p></div>
<div className="detail-item"><label>Project</label><p>{po.project?.name || 'No project'}</p></div>
<div className="detail-item"><label>Client</label><p>{po.project?.company?.name || '—'}</p></div>
<div className="detail-item"><label>Total</label><p style={{ fontSize: 18, fontWeight: 700, color: 'var(--accent)' }}>${Number(po.amount).toFixed(2)}</p></div>
<div className="detail-item"><label>Total</label><p style={{ fontSize: 18, fontWeight: 400, color: 'var(--accent)' }}>${Number(po.amount).toFixed(2)}</p></div>
{po.paid_at && <div className="detail-item"><label>Paid On</label><p>{new Date(po.paid_at).toLocaleDateString()}</p></div>}
</div>
</div>
@@ -195,19 +205,19 @@ export default function SubcontractorPODetail() {
<table>
<thead>
<tr>
<th>Project</th>
<th>Task</th>
<th>Description</th>
<th style={{ textAlign: 'right' }}>Amount</th>
<SortTh col="project" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Project</SortTh>
<SortTh col="task" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Task</SortTh>
<SortTh col="description" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Description</SortTh>
<SortTh col="amount" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Amount</SortTh>
</tr>
</thead>
<tbody>
{sortedItems.map(item => (
{tableItems.map(item => (
<tr key={item.id}>
<td>{po.project?.name || 'No project'}</td>
<td>{item.task?.title || '—'}</td>
<td>{item.description}</td>
<td style={{ textAlign: 'right', fontWeight: 700 }}>${Number(item.amount).toFixed(2)}</td>
<td style={{ textAlign: 'right', fontWeight: 400 }}>${Number(item.amount).toFixed(2)}</td>
</tr>
))}
</tbody>
@@ -215,8 +225,8 @@ export default function SubcontractorPODetail() {
</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: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--accent)' }}>${Number(po.amount).toFixed(2)}</div>
<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(po.amount).toFixed(2)}</div>
</div>
</div>
</div>
+712
View File
@@ -0,0 +1,712 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import SortTh from '../../components/SortTh';
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';
// Helpers
const ICON_TONES = [
{ bg: 'rgba(245,165,35,0.15)', color: '#F5A523' },
{ bg: 'rgba(74,222,128,0.15)', color: '#4ade80' },
{ bg: 'rgba(96,165,250,0.15)', color: '#60a5fa' },
{ bg: 'rgba(167,139,250,0.15)', color: '#a78bfa' },
];
function iconTone(key) {
let h = 0;
for (let i = 0; i < (key || '').length; i++) h = (h * 31 + key.charCodeAt(i)) % ICON_TONES.length;
return ICON_TONES[h];
}
function fmtMoney(n) {
if (Math.abs(n) >= 1000000) return `$${(n / 1000000).toFixed(2)}M`;
if (Math.abs(n) >= 10000) return `$${(n / 1000).toFixed(1)}k`;
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"/> },
task_submitted: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: <><line x1="12" y1="19" x2="12" y2="5" strokeWidth="2" strokeLinecap="round"/><polyline points="5,12 12,5 19,12" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/></> },
task_approved: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: <polyline points="4,13 9,18 20,7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/> },
task_on_hold: { bg: 'rgba(239,68,68,0.15)', color: '#ef4444', path: <><rect x="6" y="4" width="4" height="16" rx="1" fill="currentColor"/><rect x="14" y="4" width="4" height="16" rx="1" fill="currentColor"/></> },
task_created: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: <><line x1="12" y1="5" x2="12" y2="19" strokeWidth="2" strokeLinecap="round"/><line x1="5" y1="12" x2="19" y2="12" strokeWidth="2" strokeLinecap="round"/></> },
project_created: { bg: 'rgba(245,165,35,0.15)', color: '#F5A523', path: <><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" fill="none" strokeWidth="1.5"/></> },
request_submitted: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: <><rect x="5" y="3" width="14" height="18" rx="2" fill="none" strokeWidth="1.5"/><line x1="9" y1="8" x2="15" y2="8" strokeWidth="1.5" strokeLinecap="round"/><line x1="9" y1="12" x2="15" y2="12" strokeWidth="1.5" strokeLinecap="round"/><line x1="9" y1="16" x2="12" y2="16" strokeWidth="1.5" strokeLinecap="round"/></> },
revision_requested: { bg: 'rgba(245,158,11,0.15)', color: '#f59e0b', path: <><path d="M4 12a8 8 0 018-8v0a8 8 0 018 8" fill="none" strokeWidth="1.5" strokeLinecap="round"/><polyline points="18,8 20,12 16,12" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none"/></> },
};
const ACTION_LABEL = {
task_created: 'created', task_started: 'started', task_on_hold: 'put on hold',
task_resumed: 'resumed', task_submitted: 'submitted', task_approved: 'approved',
project_created: 'created project', request_submitted: 'submitted', revision_requested: 'requested revision on',
};
function ActionIcon({ actionKey, size = 27 }) {
const cfg = ACTION_ICON[actionKey] || { bg: 'rgba(255,255,255,0.08)', color: 'var(--text-muted)', path: <circle cx="12" cy="12" r="3" fill="currentColor"/> };
return (
<div style={{ width: size, height: size, borderRadius: '50%', background: cfg.bg, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="13" height="13" viewBox="0 0 24 24" stroke={cfg.color} fill="none" style={{ color: cfg.color }}>{cfg.path}</svg>
</div>
);
}
function Avatar({ name, size = 27 }) {
const tone = iconTone(name);
return (
<div style={{ width: size, height: size, borderRadius: '50%', background: tone.bg, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill={tone.color}>
<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 }) {
const tone = iconTone(name);
return (
<div style={{ width: 27, height: 27, borderRadius: '50%', background: tone.bg, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<span style={{ fontSize: 12, fontWeight: 500, color: tone.color, 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)}`;
for (let i = 1; i < pts.length; i++) {
const [x0, y0] = pts[i - 1];
const [x1, y1] = pts[i];
const cpX = (x0 + x1) / 2;
d += ` C${cpX.toFixed(1)},${y0.toFixed(1)} ${cpX.toFixed(1)},${y1.toFixed(1)} ${x1.toFixed(1)},${y1.toFixed(1)}`;
}
return d;
}
function MiniAreaChart({ data }) {
const W = 90, H = 42;
if (!data || data.length < 2) return null;
const max = Math.max(...data, 1);
const pts = data.map((v, i) => [(i / (data.length - 1)) * W, 4 + (1 - v / max) * (H - 8)]);
const line = smoothCurve(pts);
const lastPt = pts[pts.length - 1];
const firstPt = pts[0];
const area = `${line} L${lastPt[0].toFixed(1)},${H} L${firstPt[0].toFixed(1)},${H} Z`;
return (
<svg width="100%" height={H} viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" style={{ display: 'block', overflow: 'hidden' }}>
<defs>
<linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#F5A523" stopOpacity="0.3" />
<stop offset="95%" stopColor="#F5A523" stopOpacity="0" />
</linearGradient>
</defs>
<path d={area} fill="url(#areaGrad)" />
<path d={line} fill="none" stroke="#F5A523" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
const DASH_ICONS = {
revenue: '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6"/>',
projects: '<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>',
tasks: '<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/>',
profit: '<line x1="12" y1="20" x2="12" y2="4"/><polyline points="5 11 12 4 19 11"/>',
};
function DashStatCard({ label, value, sub, iconBg, iconColor, iconPath, chartData }) {
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', alignItems: 'stretch', gap: 21, minHeight: 120 }}>
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', ...(chartData ? {} : { flex: 1 }) }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
</div>
{sub && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 5 }}>{sub}</div>}
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'space-between', ...(chartData ? { flex: 1, minWidth: 0 } : { flexShrink: 0 }) }}>
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<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>
{chartData && <MiniAreaChart data={chartData} />}
</div>
</div>
);
}
function ActivityFeed({ events }) {
const navigate = useNavigate();
const visible = events.slice(0, 5);
return (
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', flexShrink: 0 }}>
<div style={{ marginBottom: visible.length > 0 ? 14 : 0 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Recent Activity</span>
</div>
{visible.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 14 }}>No recent activity</div>
) : visible.map((e, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: i > 0 ? 10 : 0 }}>
<ActionIcon actionKey={e.actionKey} />
<div style={{ flex: 1, minWidth: 0, fontSize: 13, lineHeight: 1.4 }}>
{e.actorId
? <button type="button" className="dashboard-inline-link" onClick={() => navigate(`/profile/${e.actorId}`)}>{e.name}</button>
: <span style={{ color: 'var(--text-primary)' }}>{e.name}</span>
}
{e.action && <span style={{ color: 'var(--text-muted)' }}> {e.action}</span>}
{e.task && e.taskId
? <><span style={{ color: 'var(--text-muted)' }}> </span><button type="button" className="dashboard-inline-link" onClick={() => navigate(`/requests/${e.taskId}`)}>{e.task}</button></>
: e.task && <span style={{ color: 'var(--text-primary)' }}> {e.task}</span>
}
</div>
<span style={{ fontSize: 11, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>{e.time.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}</span>
</div>
))}
</div>
);
}
function getDateKey(date) {
const d = new Date(date);
if (Number.isNaN(d.getTime())) return null;
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function profilePath(id) {
return `/profile/${id}`;
}
function MiniCalendar({ items = [] }) {
const navigate = useNavigate();
const today = new Date();
const [view, setView] = useState({ year: today.getFullYear(), month: today.getMonth() });
const [hoveredKey, setHoveredKey] = useState(null);
const hoverTimeout = useRef(null);
const clearHover = () => { hoverTimeout.current = setTimeout(() => setHoveredKey(null), 120); };
const keepHover = (k) => { clearTimeout(hoverTimeout.current); if (k) setHoveredKey(k); };
const { year, month } = view;
const firstDay = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const cells = [];
for (let i = 0; i < firstDay; i++) cells.push(null);
for (let d = 1; d <= daysInMonth; d++) cells.push(d);
const isToday = (d) => d === today.getDate() && month === today.getMonth() && year === today.getFullYear();
const dayKey = (d) => `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
const itemsByDay = useMemo(() => {
const map = new Map();
(items || []).forEach(item => {
const key = getDateKey(item.deadline);
if (!key) return;
const list = map.get(key) || [];
list.push(item);
map.set(key, list);
});
return map;
}, [items]);
const activeKey = hoveredKey;
const activeItems = (itemsByDay.get(activeKey) || []).slice().sort((a, b) => {
if (a.isOverdue !== b.isOverdue) return a.isOverdue ? -1 : 1;
if (a.isHot !== b.isHot) return a.isHot ? -1 : 1;
return (a.title || '').localeCompare(b.title || '');
});
const monthLabel = new Date(year, month, 1).toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
const prev = () => setView(v => v.month === 0 ? { year: v.year - 1, month: 11 } : { year: v.year, month: v.month - 1 });
const next = () => setView(v => v.month === 11 ? { year: v.year + 1, month: 0 } : { year: v.year, month: v.month + 1 });
const dotColor = (item) => {
if (item.isOverdue) return '#ef4444';
if (item.isHot) return '#F5A523';
if (item.isDone) return '#4ade80';
return '#60a5fa';
};
const activeLabel = activeKey
? new Date(`${activeKey}T12:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
: 'Selected day';
return (
<div style={{ position: 'relative', zIndex: activeKey ? 1001 : 0, overflow: 'visible', background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{monthLabel}</span>
<div style={{ display: 'flex', gap: 6 }}>
<button onClick={prev} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 4px', color: 'var(--text-muted)', display: 'flex', alignItems: 'center' }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><polyline points="15,18 9,12 15,6"/></svg>
</button>
<button onClick={next} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 4px', color: 'var(--text-muted)', display: 'flex', alignItems: 'center' }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><polyline points="9,18 15,12 9,6"/></svg>
</button>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 2, textAlign: 'center' }}>
{['S','M','T','W','T','F','S'].map((d, i) => (
<div key={i} style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-muted)', letterSpacing: 0.5, paddingBottom: 6 }}>{d}</div>
))}
{cells.map((d, i) => {
const key = d ? dayKey(d) : null;
const dayItems = key ? itemsByDay.get(key) || [] : [];
const active = key && key === activeKey;
const hasItems = dayItems.length > 0;
return (
<div key={i} style={{ position: 'relative' }} onMouseEnter={() => keepHover(key)} onMouseLeave={clearHover}>
<button
type="button"
onClick={() => {
if (dayItems.length === 1) navigate(`/requests/${dayItems[0].id}`);
}}
disabled={!d}
style={{
position: 'relative',
width: 28,
height: 28,
borderRadius: '50%',
border: active && !isToday(d) && hasItems ? '1px solid rgba(245,165,35,0.5)' : '1px solid transparent',
color: d ? (isToday(d) ? '#0d0d0d' : 'var(--text-secondary)') : 'transparent',
background: d && isToday(d) ? '#F5A523' : hasItems ? 'rgba(255,255,255,0.035)' : 'transparent',
fontSize: 12,
lineHeight: 1,
fontWeight: isToday(d) ? 700 : 400,
cursor: d && hasItems ? 'pointer' : 'default',
fontFamily: 'inherit',
padding: 0,
}}
>
{d || ''}
{hasItems && (
<span style={{ position: 'absolute', left: '50%', bottom: 3, transform: 'translateX(-50%)', display: 'flex', gap: 2 }}>
{dayItems.slice(0, 3).map((item, idx) => (
<span key={`${item.id}-${idx}`} style={{ width: 3, height: 3, borderRadius: '50%', background: isToday(d) ? '#0d0d0d' : dotColor(item), display: 'block' }} />
))}
</span>
)}
</button>
{active && hasItems && (
<div onMouseEnter={() => keepHover(key)} onMouseLeave={clearHover} style={{ position: 'absolute', zIndex: 1002, right: 'calc(100% + 8px)', top: '50%', transform: 'translateY(-50%)', width: 210, padding: '10px 12px', background: 'var(--sidebar-bg)', border: '1px solid var(--border)', borderRadius: 8, boxShadow: '0 12px 32px rgba(0,0,0,0.45)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', pointerEvents: 'auto' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{activeLabel}</span>
<span style={{ fontSize: 11, color: '#F5A523' }}>{activeItems.length} due</span>
</div>
{activeItems.slice(0, 4).map(item => (
<button key={item.id} type="button" onClick={() => navigate(`/requests/${item.id}`)} style={{ display: 'flex', alignItems: 'center', gap: 7, width: '100%', padding: '5px 0', background: 'transparent', border: 'none', cursor: 'pointer', textAlign: 'left', fontFamily: 'inherit' }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: dotColor(item), flexShrink: 0 }} />
<span style={{ flex: 1, minWidth: 0, fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.title}</span>
</button>
))}
</div>
)}
</div>
);
})}
</div>
</div>
);
}
function HotItemsCard({ submissions, tasks }) {
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 => s.is_hot && !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 (key === 'task') return s.task?.title || '';
if (key === 'requested_by') return s.submitted_by_name || '';
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={{ marginBottom: hotItems.length > 0 ? 14 : 0, flexShrink: 0 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Hot Items</span>
</div>
{hotItems.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No hot items</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('/requests/' + 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>
);
}
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={{ 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 }) {
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
const allMonthOpts = useMemo(() => {
const opts = [];
const now = new Date();
for (let i = 0; i < 6; i++) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
opts.push({ value: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`, label: i === 0 ? 'This Month' : d.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }) });
}
return opts;
}, []);
const monthsWithData = useMemo(() => new Set(
(tasks || []).filter(t => doneStatuses.includes(t.status) && t.completed_at).map(t => {
const d = new Date(t.completed_at);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
})
), [tasks]); // eslint-disable-line react-hooks/exhaustive-deps
const monthOpts = allMonthOpts.filter(o => monthsWithData.has(o.value));
const [monthKey, setMonthKey] = useState(() => monthOpts[0]?.value || allMonthOpts[0].value);
const { people, totalDone } = useMemo(() => {
const [year, month] = monthKey.split('-').map(Number);
const map = new Map();
(tasks || []).forEach(t => {
if (!doneStatuses.includes(t.status) || !t.completed_at) return;
const d = new Date(t.completed_at);
if (d.getFullYear() !== year || d.getMonth() + 1 !== month) return;
if (!t.assigned_name) return;
const entry = map.get(t.assigned_name) || { name: t.assigned_name, newCount: 0, revCount: 0 };
if ((t.current_version || 0) === 0) entry.newCount += 1;
else entry.revCount += 1;
map.set(t.assigned_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) };
}, [tasks, monthKey]); // 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={{ 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 }}>
{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.slice(0, 5).length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)', flex: 1 }}>No completed tasks this month</div>
) : (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
{people.slice(0, 5).map((p, i) => {
const pct = totalDone > 0 ? Math.round((p.done / totalDone) * 100) : 0;
const tone = iconTone(p.name);
return (
<div key={p.name} style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: i > 0 ? 10 : 0 }}>
<Avatar name={p.name} />
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: 13, color: 'var(--text-primary)', fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
<span style={{ fontSize: 11, color: 'var(--text-muted)', flexShrink: 0, marginLeft: 8 }}>{p.newCount} new · {p.revCount} revision</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<div style={{ flex: 1, height: 4, borderRadius: 2, background: tone.bg, overflow: 'hidden' }}>
<div style={{ height: '100%', borderRadius: 2, background: tone.color, width: `${pct}%`, transition: 'width 0.3s ease' }} />
</div>
<span style={{ fontSize: 11, color: 'var(--text-muted)', minWidth: 28, textAlign: 'right' }}>{pct}%</span>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
// Page
const CACHE_KEY = 'team_dashboard_v2';
const CUTOFF_MONTHS = 6;
export default function TeamDashboard() {
useAuth(); // ensures auth context loaded
const cached = readPageCache(CACHE_KEY, 5 * 60_000);
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 [teamInvoices, setTeamInvoices] = useState(() => cached?.teamInvoices || []);
const [teamExpenses, setTeamExpenses] = useState([]);
const [loading, setLoading] = useState(!cached);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const cutoff = new Date();
cutoff.setMonth(cutoff.getMonth() - CUTOFF_MONTHS);
const cutoffStr = cutoff.toISOString();
const [
{ data: t },
{ data: p },
{ data: subs },
{ data: profiles },
{ data: activity },
{ data: cos },
{ data: memRows },
] = await withTimeout(Promise.all([
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at').gte('submitted_at', cutoffStr).order('submitted_at', { ascending: false }),
supabase.from('projects').select('id, name, status, company_id'),
supabase.from('submissions').select('task_id, version_number, deadline, type, submitted_by, submitted_by_name, is_hot, delivery:deliveries(sent_by, sent_at)').gte('submitted_at', cutoffStr).order('version_number', { ascending: false }),
supabase.from('profiles').select('id, role, name, email, company_id, brand_book_rate'),
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(20),
supabase.from('companies').select('id, name').order('name'),
supabase.from('company_members').select('company_id, profile_id'),
]), 30000, 'TeamDashboard load');
if (cancelled) return;
const roleById = new Map((profiles || []).map(pr => [pr.id, pr.role]));
const roleByName = new Map((profiles || []).map(pr => [pr.name, pr.role]));
const clients = (profiles || []).filter(pr => pr.role === 'client');
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,
}));
setTasks(tasksWithDeadlines);
setProjects(p || []);
setSubmissions(subsWithRole);
setClientProfiles(clients);
setAllCompanies(cos || []);
setCompanyMemberships(memRows || []);
setActivityLog(activity || []);
writePageCache(CACHE_KEY, {
tasks: tasksWithDeadlines, projects: p || [], submissions: subsWithRole,
clientProfiles: clients, companies: cos || [], companyMemberships: memRows || [],
activityLog: activity || [], teamInvoices: [],
});
supabase.from('invoices').select('total, status, company_id, created_at').in('status', ['sent', 'paid']).then(({ data: invs }) => {
if (invs && !cancelled) setTeamInvoices(invs);
});
supabase.from('expenses').select('amount').then(({ data: exps }) => {
if (exps && !cancelled) setTeamExpenses(exps);
});
} catch (err) {
console.error('TeamDashboard load failed:', err);
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => { cancelled = true; };
}, []);
const teamHighlights = useMemo(() =>
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),
[allCompanies, projects, tasks, clientProfiles, companyMemberships, teamInvoices]);
const activityEvents = useMemo(() =>
(activityLog || []).map(e => ({
time: new Date(e.created_at),
name: e.actor_name || 'Fourge',
actorId: e.actor_id || null,
actionKey: e.action,
action: ACTION_LABEL[e.action] || e.action,
task: e.task_title || null,
taskId: e.task_id || null,
project: e.project_name || null,
projectId: e.project_id || null,
})).filter(e => !isNaN(e.time)).slice(0, 10),
[activityLog]);
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
const activeTasks = tasks.filter(t => !doneStatuses.includes(t.status));
const activeProjects = projects.filter(p => p.status !== 'completed' && p.status !== 'cancelled');
const hotTaskIds = new Set((submissions || []).filter(s => s.is_hot).map(s => s.task_id));
const calendarItems = tasks
.filter(t => t.deadline)
.map(t => {
const deadlineKey = getDateKey(t.deadline);
return {
id: t.id,
title: t.title,
deadline: t.deadline,
isHot: hotTaskIds.has(t.id),
isDone: doneStatuses.includes(t.status),
isOverdue: deadlineKey && deadlineKey < getDateKey(new Date()) && !doneStatuses.includes(t.status),
};
});
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 revenueByMonth = Array.from({ length: 4 }, (_, i) => {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth() - (3 - i), 1);
const end = new Date(now.getFullYear(), now.getMonth() - (3 - i) + 1, 1);
return teamInvoices.filter(inv => inv.status === 'paid' && inv.created_at && new Date(inv.created_at) >= start && new Date(inv.created_at) < end).reduce((s, inv) => s + Number(inv.total || 0), 0);
});
return (
<Layout>
<div className="dash-stat-grid" style={{ gridTemplateColumns: '1fr 1fr 1fr 1.5fr', marginBottom: 0 }}>
<DashStatCard label="Open Tasks" value={activeTasks.length} sub="not complete" iconBg="rgba(167,139,250,0.15)" iconColor="#a78bfa" iconPath={DASH_ICONS.tasks} />
<DashStatCard label="Active Projects" value={activeProjects.length} sub={`${projects.length} total`} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={DASH_ICONS.projects} />
<DashStatCard label="Net Profit" value={fmtMoney(dashRevenue - dashExpensesTotal)} sub={`${fmtMoney(dashExpensesTotal)} expenses`} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={DASH_ICONS.profit} />
<DashStatCard label="Revenue" value={fmtMoney(dashRevenue)} sub={`${fmtMoney(dashOutstanding)} outstanding`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={DASH_ICONS.revenue} chartData={revenueByMonth} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 280px', gap: 24, marginTop: 24 }}>
<ActivityFeed events={activityEvents} />
<MiniCalendar items={calendarItems} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}>
<HotItemsCard submissions={submissions} tasks={tasks} />
<TeamPerformanceCard tasks={tasks} />
</div>
<div style={{ marginTop: 24 }}>
<ClientHighlightCard highlights={teamHighlights} />
</div>
</Layout>
);
}