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
+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>
);
}