Session 2026-05-30: tasks/projects unification, code consolidation, file renames
- Unified Tasks page: 3 role render blocks → 1, normalized row shape, single renderRow/sort/tabs - Fixed role scoping: external = all tasks in member projects, client = all tasks in company projects - Fixed client bug: was scoped by submitted_by, now company→project→tasks - Aligned dashboard client scope to match tasks page - Hot tasks dashboard: now filters to active statuses only (not completed/invoiced/paid) - Removed Projects.jsx (dead), fixed /project/ → /projects/ links - Renamed all page files to match folder path (Team*, External*, Client* prefixes) - Renamed: RequestsPage→Tasks, Settings→Profile, CompaniesPage→Companies, ProjectDetailPage→ProjectDetail - Dropped dead fetches from Tasks page (invoices, invoice_items, subcontractor_invoice_items) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+143
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import SortTh from '../../components/SortTh';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
||||
import { useSortable } from '../../hooks/useSortable';
|
||||
|
||||
const STATUS_BADGE = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
|
||||
const STATUS_LABEL = { draft: 'Draft', submitted: 'Submitted', paid: 'Paid' };
|
||||
|
||||
function fmt(val) {
|
||||
return `$${Number(val || 0).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function invoiceTotal(items) {
|
||||
return (items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
|
||||
}
|
||||
|
||||
export default function MyInvoices() {
|
||||
const { currentUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const cacheKey = `ext-invoices:${currentUser?.id}`;
|
||||
const cached = readPageCache(cacheKey, 3 * 60_000);
|
||||
const [invoices, setInvoices] = useState(() => cached || []);
|
||||
const [loading, setLoading] = useState(() => !cached);
|
||||
const [error, setError] = useState('');
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('created_at');
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser?.id) { setLoading(false); return; }
|
||||
supabase
|
||||
.from('subcontractor_invoices')
|
||||
.select('*, items:subcontractor_invoice_items(*)')
|
||||
.order('created_at', { ascending: false })
|
||||
.then(({ data, error: err }) => {
|
||||
if (err) setError(err.message);
|
||||
else { setInvoices(data || []); writePageCache(cacheKey, data || []); }
|
||||
setLoading(false);
|
||||
});
|
||||
}, [currentUser?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||
const chartYear = new Date().getFullYear();
|
||||
const chartData = useMemo(() => MONTHS.map((month, mi) => {
|
||||
const submittedAmt = invoices.filter(i => i.status === 'submitted' && new Date(i.created_at).getFullYear() === chartYear && new Date(i.created_at).getMonth() === mi).reduce((s, i) => s + invoiceTotal(i.items), 0);
|
||||
const paidAmt = invoices.filter(i => i.status === 'paid' && new Date(i.created_at).getFullYear() === chartYear && new Date(i.created_at).getMonth() === mi).reduce((s, i) => s + invoiceTotal(i.items), 0);
|
||||
return { month, Submitted: +submittedAmt.toFixed(2), Paid: +paidAmt.toFixed(2) };
|
||||
}), [invoices, chartYear]);
|
||||
const hasChartData = chartData.some(d => d.Submitted > 0 || d.Paid > 0);
|
||||
const totalPaid = invoices.filter(i => i.status === 'paid').reduce((s, i) => s + invoiceTotal(i.items), 0);
|
||||
const totalPending = invoices.filter(i => i.status === 'submitted').reduce((s, i) => s + invoiceTotal(i.items), 0);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Invoices</div>
|
||||
<div className="page-subtitle">Submit invoices to Fourge Branding for your completed work.</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4 }}>Payment terms: NET 30 from the date Fourge receives payment from the client.</div>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={() => navigate('/my-invoices-sub/new')} disabled={loading}>
|
||||
+ New Invoice
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="notification notification-info" style={{ marginBottom: 16 }}>{error}</div>}
|
||||
|
||||
{!loading && invoices.length > 0 && (
|
||||
<>
|
||||
<div className="stat-bar" style={{ marginBottom: 18 }}>
|
||||
<div className="stat-bar-item"><div className="stat-bar-header"><div className="stat-bar-label">Total Paid</div><div className="stat-bar-dot" style={{ background: '#4ade80' }} /></div><div className="stat-bar-value">{fmt(totalPaid)}</div></div>
|
||||
<div className="stat-bar-item"><div className="stat-bar-header"><div className="stat-bar-label">Pending Payment</div><div className="stat-bar-dot" style={{ background: '#F5A523' }} /></div><div className="stat-bar-value">{fmt(totalPending)}</div></div>
|
||||
<div className="stat-bar-item"><div className="stat-bar-header"><div className="stat-bar-label">Total Invoices</div><div className="stat-bar-dot" style={{ background: '#60a5fa' }} /></div><div className="stat-bar-value">{invoices.length}</div></div>
|
||||
</div>
|
||||
{hasChartData && (
|
||||
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '16px 16px 8px', marginBottom: 18 }}>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="gradSubAmt" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#F5A523" stopOpacity={0.25}/><stop offset="95%" stopColor="#F5A523" stopOpacity={0}/></linearGradient>
|
||||
<linearGradient id="gradSubPaid" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#4ade80" stopOpacity={0.25}/><stop offset="95%" stopColor="#4ade80" stopOpacity={0}/></linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} tickFormatter={v => `$${v >= 1000 ? (v/1000).toFixed(0)+'k' : v}`} width={45} />
|
||||
<Tooltip formatter={(v) => [`$${Number(v).toLocaleString('en-US', { minimumFractionDigits: 2 })}`, undefined]} contentStyle={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, fontSize: 12 }} />
|
||||
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} />
|
||||
<Area type="monotone" dataKey="Submitted" stroke="#F5A523" strokeWidth={2} fill="url(#gradSubAmt)" dot={false} activeDot={{ r: 4 }} />
|
||||
<Area type="monotone" dataKey="Paid" stroke="#4ade80" strokeWidth={2} fill="url(#gradSubPaid)" dot={false} activeDot={{ r: 4 }} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="empty-state">Loading invoices...</div>
|
||||
) : invoices.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No invoices yet</h3>
|
||||
<p>Create your first invoice to get paid for your completed work.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card">
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="invoice_number" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Invoice #</SortTh>
|
||||
<SortTh col="submitted_at" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Submitted</SortTh>
|
||||
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Status</SortTh>
|
||||
<SortTh col="total" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Total</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sort(invoices, (inv, key) => {
|
||||
if (key === 'total') return invoiceTotal(inv.items);
|
||||
if (key === 'submitted_at') return inv.submitted_at ? new Date(inv.submitted_at).getTime() : 0;
|
||||
return inv[key] || '';
|
||||
}).map(inv => {
|
||||
const total = invoiceTotal(inv.items);
|
||||
return (
|
||||
<tr key={inv.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/my-invoices-sub/${inv.id}`)}>
|
||||
<td style={{ fontWeight: 400 }}>{inv.invoice_number}</td>
|
||||
<td style={{ color: 'var(--text-muted)' }}>{inv.submitted_at ? new Date(inv.submitted_at).toLocaleDateString() : '—'}</td>
|
||||
<td><StatusBadge status={STATUS_BADGE[inv.status]} label={STATUS_LABEL[inv.status]} /></td>
|
||||
<td style={{ textAlign: 'right', fontWeight: 400, color: 'var(--accent)' }}>{fmt(total)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user