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:
Krao Hasanee
2026-05-30 10:19:23 -04:00
parent 6fe7ab1059
commit 13ef1f4ded
30 changed files with 1177 additions and 1258 deletions
+163
View File
@@ -0,0 +1,163 @@
import { useState, useEffect, useMemo } from 'react';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import Layout from '../../components/Layout';
import LoadingButton from '../../components/LoadingButton';
import SortTh from '../../components/SortTh';
import { supabase } from '../../lib/supabase';
import { generateInvoicePDF } from '../../lib/invoice';
import { useAuth } from '../../context/AuthContext';
import { useSortable } from '../../hooks/useSortable';
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
export default function MyInvoices() {
const { currentUser } = useAuth();
const companies = (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : [])).slice().sort((a, b) => a.name.localeCompare(b.name));
const [activeCompanyId, setActiveCompanyId] = useState(companies[0]?.id || null);
const [invoices, setInvoices] = useState([]);
const [loading, setLoading] = useState(true);
const [generatingInvoiceId, setGeneratingInvoiceId] = useState('');
const { sortKey, sortDir, toggle, sort } = useSortable('invoice_date');
useEffect(() => {
async function load() {
const { data } = await supabase
.from('invoices')
.select('*, company:companies(name), items:invoice_items(*)')
.order('created_at', { ascending: false });
setInvoices((data || []).filter(inv => inv.status !== 'draft'));
setLoading(false);
}
load();
}, []);
const handleDownload = async (invoice) => {
if (generatingInvoiceId) return;
setGeneratingInvoiceId(invoice.id);
try {
await generateInvoicePDF(invoice, invoice.company, invoice.items || []);
} finally {
setGeneratingInvoiceId('');
}
};
const visible = companies.length > 1 && activeCompanyId
? invoices.filter(inv => inv.company_id === activeCompanyId)
: invoices;
const outstanding = visible.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total), 0);
const paid = visible.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total), 0);
const overdueCount = visible.filter(inv => inv.status !== 'paid' && new Date(inv.due_date) < new Date()).length;
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const chartYear = new Date().getFullYear();
const chartData = useMemo(() => MONTHS.map((month, mi) => {
const paidAmt = visible.filter(i => i.status === 'paid' && new Date(i.invoice_date).getFullYear() === chartYear && new Date(i.invoice_date).getMonth() === mi).reduce((s, i) => s + Number(i.total || 0), 0);
const outAmt = visible.filter(i => i.status === 'sent' && new Date(i.invoice_date).getFullYear() === chartYear && new Date(i.invoice_date).getMonth() === mi).reduce((s, i) => s + Number(i.total || 0), 0);
return { month, Paid: +paidAmt.toFixed(2), Outstanding: +outAmt.toFixed(2) };
}), [visible, chartYear]);
const hasChartData = chartData.some(d => d.Paid > 0 || d.Outstanding > 0);
const sorted = sort(visible, (inv, key) => {
if (key === 'invoice_date' || key === 'due_date') return new Date(inv[key] || 0).getTime();
if (key === 'total') return Number(inv.total || 0);
return inv[key] || '';
});
const th = { sortKey, sortDir, onSort: toggle };
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">Invoices</div>
<div className="page-subtitle">{visible.length} invoice{visible.length !== 1 ? 's' : ''}</div>
</div>
</div>
{hasChartData && (
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '16px 16px 8px', marginBottom: 18 }}>
<ResponsiveContainer width="100%" height={180}>
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="gradPaidC" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#4ade80" stopOpacity={0.25}/><stop offset="95%" stopColor="#4ade80" stopOpacity={0}/></linearGradient>
<linearGradient id="gradOutC" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#60a5fa" stopOpacity={0.2}/><stop offset="95%" stopColor="#60a5fa" stopOpacity={0}/></linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
<XAxis dataKey="month" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} tickFormatter={v => `$${v >= 1000 ? (v/1000).toFixed(0)+'k' : v}`} width={45} />
<Tooltip formatter={(v) => [`$${Number(v).toLocaleString('en-US', { minimumFractionDigits: 2 })}`, undefined]} contentStyle={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, fontSize: 12 }} />
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} />
<Area type="monotone" dataKey="Paid" stroke="#4ade80" strokeWidth={2} fill="url(#gradPaidC)" dot={false} activeDot={{ r: 4 }} />
<Area type="monotone" dataKey="Outstanding" stroke="#60a5fa" strokeWidth={2} fill="url(#gradOutC)" dot={false} activeDot={{ r: 4 }} />
</AreaChart>
</ResponsiveContainer>
</div>
)}
{companies.length > 1 && (
<div style={{ marginBottom: 16 }}>
<select className="filter-select" value={activeCompanyId || ''} onChange={e => setActiveCompanyId(e.target.value)}>
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
)}
{loading ? (
<p style={{ color: 'var(--text-muted)' }}>Loading...</p>
) : visible.length === 0 ? (
<div className="empty-state">
<h3>No invoices yet</h3>
<p>Your invoices will appear here once they are sent.</p>
</div>
) : (
<div className="card">
<div className="table-wrapper">
<table>
<thead>
<tr>
<SortTh col="invoice_number" {...th}>Invoice #</SortTh>
<SortTh col="invoice_date" {...th}>Issued</SortTh>
<SortTh col="due_date" {...th}>Due</SortTh>
<SortTh col="status" {...th}>Status</SortTh>
<SortTh col="total" {...th}>Total</SortTh>
<th></th>
</tr>
</thead>
<tbody>
{sorted.map(inv => {
const isOverdue = inv.status !== 'paid' && new Date(inv.due_date) < new Date();
return (
<tr key={inv.id}>
<td style={{ fontWeight: 400 }}>{inv.invoice_number}</td>
<td style={{ color: 'var(--text-muted)' }}>{new Date(inv.invoice_date).toLocaleDateString()}</td>
<td style={{ color: isOverdue ? 'var(--danger)' : 'var(--text-muted)' }}>
{inv.due_date ? new Date(inv.due_date).toLocaleDateString() : '—'}
{isOverdue && <span style={{ marginLeft: 6, fontSize: 11 }}>Overdue</span>}
</td>
<td><span className={`badge badge-${statusColor[inv.status]}`} style={{ textTransform: 'capitalize' }}>{inv.status}</span></td>
<td style={{ fontWeight: 400, color: 'var(--accent)' }}>${Number(inv.total).toFixed(2)}</td>
<td>
<LoadingButton
className="btn btn-outline btn-sm"
loading={generatingInvoiceId === inv.id}
disabled={Boolean(generatingInvoiceId)}
loadingText="Generating..."
onClick={() => handleDownload(inv)}
>
Download PDF
</LoadingButton>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</Layout>
);
}