fix: upload source=srv, expense detail popup, invoice/expense filters, pie charts, date timezone fixes

This commit is contained in:
Krao Hasanee
2026-06-02 14:08:11 -04:00
parent 1f09ce0a1d
commit 770bb1c05b
4 changed files with 253 additions and 122 deletions
+2 -2
View File
@@ -214,7 +214,7 @@ export default function FileBrowser({ initialPath = '/', rootPath = '/' }) {
async function uploadOneFile(url, token, fbPath, file, retries = 3) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const res = await fetch(`${url}/api/resources?source=files&path=${encodeURIComponent(fbPath)}&override=true`, {
const res = await fetch(`${url}/api/resources?source=srv&path=${encodeURIComponent(fbPath)}&override=true`, {
method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/octet-stream' }, body: file,
});
if (!res.ok) { const t = await res.text(); throw new Error(t || res.status); }
@@ -258,7 +258,7 @@ export default function FileBrowser({ initialPath = '/', rootPath = '/' }) {
for (let i = 1; i <= parts.length; i++) dirsNeeded.add(parts.slice(0, i).join('/'));
}
for (const dir of [...dirsNeeded].sort((a, b) => a.split('/').length - b.split('/').length)) {
await fetch(`${url}/api/resources?source=files&path=${encodeURIComponent(joinVirtualPath(fbPath, dir))}&isDir=true`, { method: 'POST', headers: { Authorization: `Bearer ${token}` } }).catch(() => {});
await fetch(`${url}/api/resources?source=srv&path=${encodeURIComponent(joinVirtualPath(fbPath, dir))}&isDir=true`, { method: 'POST', headers: { Authorization: `Bearer ${token}` } }).catch(() => {});
}
for (let i = 0; i < sel.length; i++) {
await uploadOneFile(url, token, joinVirtualPath(fbPath, sel[i].webkitRelativePath), sel[i]);
+5 -5
View File
@@ -630,8 +630,11 @@ export default function RequestsPage() {
</button>
))}
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }} ref={companyFilterMenuRef}>
{(isTeam || isClient) && (
<button className="btn btn-outline" onClick={() => { setShowAddForm(s => !s); setAddError(''); }}>+ Task</button>
)}
{isExternal && projectOptions.length > 0 && (
<div style={{ position: 'relative' }} ref={projectFilterMenuRef}>
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }} ref={projectFilterMenuRef}>
<button
className="btn btn-outline"
aria-label="Filter projects" title="Filter projects"
@@ -653,7 +656,7 @@ export default function RequestsPage() {
</div>
)}
{(isTeam || isClient) && (
<div style={{ position: 'relative' }}>
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
<button
className="btn btn-outline"
aria-label="Filter companies" title="Filter companies"
@@ -683,9 +686,6 @@ export default function RequestsPage() {
)}
</div>
)}
{(isTeam || isClient) && (
<button className="btn btn-outline" onClick={() => { setShowAddForm(s => !s); setAddError(''); }}>+ Task</button>
)}
</div>
</div>
+244 -115
View File
@@ -55,7 +55,7 @@ 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'];
const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
function FinancesChartTooltip({ active, payload, label, year }) {
if (!active || !payload?.length) return null;
@@ -100,9 +100,16 @@ export default function Invoices() {
const [editingExpenseId, setEditingExpenseId] = useState('');
const [showExpenseForm, setShowExpenseForm] = useState(false);
const [expenseFilter, setExpenseFilter] = useState('all');
const [viewingExpense, setViewingExpense] = useState(null);
const [expensePreviewUrl, setExpensePreviewUrl] = useState(null);
const [expenseDetailEditing, setExpenseDetailEditing] = useState(false);
const [expYearFilter, setExpYearFilter] = useState(String(new Date().getFullYear()));
const [expYearMenuOpen, setExpYearMenuOpen] = useState(false);
const expYearMenuRef = useRef(null);
const [invYearFilter, setInvYearFilter] = useState(String(new Date().getFullYear()));
const [invCompanyFilter, setInvCompanyFilter] = useState('all');
const [invYearMenuOpen, setInvYearMenuOpen] = useState(false);
const invYearMenuRef = useRef(null);
const [subcontractorPOs, setSubcontractorPOs] = useState([]);
const [subcontractorLoading, setSubcontractorLoading] = useState(true);
const [subcontractorError, setSubcontractorError] = useState('');
@@ -164,6 +171,21 @@ export default function Invoices() {
return () => document.removeEventListener('mousedown', handler);
}, [expYearMenuOpen]);
useEffect(() => {
if (!invYearMenuOpen) return;
const handler = e => { if (invYearMenuRef.current && !invYearMenuRef.current.contains(e.target)) setInvYearMenuOpen(false); };
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [invYearMenuOpen]);
useEffect(() => {
setExpensePreviewUrl(null);
setExpenseDetailEditing(false);
if (!viewingExpense?.receipt_path) return;
supabase.storage.from(RECEIPT_BUCKET).createSignedUrl(viewingExpense.receipt_path, 3600)
.then(({ data }) => { if (data?.signedUrl) setExpensePreviewUrl(data.signedUrl); });
}, [viewingExpense]);
useEffect(() => {
async function loadSubInvoices() {
const { data, error: err } = await supabase
@@ -201,8 +223,7 @@ export default function Invoices() {
setShowExpenseForm(false);
};
const handleAddExpense = async (e) => {
e.preventDefault();
const saveExpense = async () => {
if (!newExpense.description.trim() || !newExpense.amount) return;
setAddingExpense(true);
setExpensesError('');
@@ -210,54 +231,25 @@ export default function Invoices() {
const existingExpense = editingExpenseId ? expenses.find(expense => expense.id === editingExpenseId) : null;
let receiptPath = existingExpense?.receipt_path || newExpense.receipt_path || null;
let receiptName = existingExpense?.receipt_name || newExpense.receipt_name || null;
if (newExpense.removeReceipt && existingExpense?.receipt_path) {
await supabase.storage.from(RECEIPT_BUCKET).remove([existingExpense.receipt_path]);
receiptPath = null;
receiptName = null;
receiptPath = null; receiptName = null;
}
if (newExpense.receipt) {
const ext = getFileExt(newExpense.receipt.name);
receiptName = newExpense.receipt.name;
receiptPath = `${newExpense.date}/${Date.now()}-${crypto.randomUUID()}.${ext}`;
const { error: uploadError } = await supabase.storage.from(RECEIPT_BUCKET).upload(receiptPath, newExpense.receipt, { upsert: false });
if (uploadError) throw uploadError;
if (existingExpense?.receipt_path) {
await supabase.storage.from(RECEIPT_BUCKET).remove([existingExpense.receipt_path]);
}
if (existingExpense?.receipt_path) await supabase.storage.from(RECEIPT_BUCKET).remove([existingExpense.receipt_path]);
}
const payload = {
date: newExpense.date,
description: newExpense.description.trim(),
category: newExpense.category,
amount: Number(newExpense.amount),
notes: newExpense.notes.trim(),
receipt_path: receiptPath,
receipt_name: receiptName,
};
const query = editingExpenseId
? supabase.from('expenses').update(payload).eq('id', editingExpenseId)
: supabase.from('expenses').insert(payload);
const payload = { date: newExpense.date, description: newExpense.description.trim(), category: newExpense.category, amount: Number(newExpense.amount), notes: newExpense.notes.trim(), receipt_path: receiptPath, receipt_name: receiptName };
const query = editingExpenseId ? supabase.from('expenses').update(payload).eq('id', editingExpenseId) : supabase.from('expenses').insert(payload);
const { data, error } = await query.select().single();
if (error) {
if (newExpense.receipt && receiptPath) {
await supabase.storage.from(RECEIPT_BUCKET).remove([receiptPath]);
}
throw error;
}
if (error) { if (newExpense.receipt && receiptPath) await supabase.storage.from(RECEIPT_BUCKET).remove([receiptPath]); throw error; }
if (data) {
setExpenses(prev => editingExpenseId
? prev.map(expense => expense.id === editingExpenseId ? data : expense)
: [data, ...prev]
);
setNewExpense(blankExpense());
setEditingExpenseId('');
setShowExpenseForm(false);
setExpenses(prev => editingExpenseId ? prev.map(expense => expense.id === editingExpenseId ? data : expense) : [data, ...prev]);
setNewExpense(blankExpense()); setEditingExpenseId(''); setShowExpenseForm(false);
}
} catch (error) {
console.error(`Failed to ${editingExpenseId ? 'update' : 'add'} expense:`, error);
@@ -267,6 +259,11 @@ export default function Invoices() {
setAddingExpense(false);
};
const handleAddExpense = async (e) => {
e.preventDefault();
await saveExpense();
};
const handleDeleteExpense = async (id) => {
if (!window.confirm('Delete this expense?')) return;
const expense = expenses.find(e => e.id === id);
@@ -607,11 +604,44 @@ export default function Invoices() {
{financeTab === 'expenses' && (
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
<button className="btn btn-outline" onClick={() => { setEditingExpenseId(''); setNewExpense(blankExpense()); setExpensesError(''); setShowExpenseForm(true); }}>+ Expense</button>
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }} ref={expYearMenuRef}>
<button className="btn btn-outline" onClick={() => setExpYearMenuOpen(o => !o)} style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }} title="Filter by year">
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M2 4h12M4 8h8M6 12h4" /></svg>
</button>
{expYearMenuOpen && (
<div className="site-header-avatar-menu" style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 220, minWidth: 130 }}>
<button onClick={() => { setExpYearFilter('all'); setExpYearMenuOpen(false); }} className="site-header-avatar-item" style={{ background: expYearFilter === 'all' ? 'rgba(245,165,35,0.08)' : 'transparent', color: expYearFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Years</button>
{expenseYears.map(y => (
<button key={y} onClick={() => { setExpYearFilter(y); setExpYearMenuOpen(false); }} className="site-header-avatar-item" style={{ background: expYearFilter === y ? 'rgba(245,165,35,0.08)' : 'transparent', color: expYearFilter === y ? 'var(--accent)' : 'var(--text-primary)' }}>{y}</button>
))}
</div>
)}
</div>
</div>
)}
{financeTab === 'invoices' && (
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
<button className="btn btn-outline" onClick={() => navigate('/invoices/new')}>+ Invoice</button>
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }} ref={invYearMenuRef}>
<button className="btn btn-outline" onClick={() => setInvYearMenuOpen(o => !o)} style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }} title="Filter by year">
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M2 4h12M4 8h8M6 12h4" /></svg>
</button>
{invYearMenuOpen && (
<div className="site-header-avatar-menu" style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 220, minWidth: 150 }}>
<div style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-muted)', padding: '6px 14px 4px' }}>Year</div>
<button onClick={() => setInvYearFilter('all')} className="site-header-avatar-item" style={{ background: invYearFilter === 'all' ? 'rgba(245,165,35,0.08)' : 'transparent', color: invYearFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Years</button>
{[...new Set(invoices.map(i => i.invoice_date?.slice(0,4)).filter(Boolean))].sort((a,b) => b-a).map(y => (
<button key={y} onClick={() => setInvYearFilter(y)} className="site-header-avatar-item" style={{ background: invYearFilter === y ? 'rgba(245,165,35,0.08)' : 'transparent', color: invYearFilter === y ? 'var(--accent)' : 'var(--text-primary)' }}>{y}</button>
))}
<div style={{ borderTop: '1px solid var(--border)', margin: '4px 0' }} />
<div style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-muted)', padding: '4px 14px 4px' }}>Company</div>
<button onClick={() => setInvCompanyFilter('all')} className="site-header-avatar-item" style={{ background: invCompanyFilter === 'all' ? 'rgba(245,165,35,0.08)' : 'transparent', color: invCompanyFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Companies</button>
{[...new Set(invoices.map(i => i.company?.name || i.bill_to).filter(Boolean))].sort().map(c => (
<button key={c} onClick={() => setInvCompanyFilter(c)} className="site-header-avatar-item" style={{ background: invCompanyFilter === c ? 'rgba(245,165,35,0.08)' : 'transparent', color: invCompanyFilter === c ? 'var(--accent)' : 'var(--text-primary)' }}>{c}</button>
))}
</div>
)}
</div>
</div>
)}
</div>
@@ -648,7 +678,7 @@ export default function Invoices() {
// Card 1: Expenses
const m1 = ovMonth1.month, y1 = ovMonth1.year;
const thisMonthExp = expenses.filter(e => { const d = new Date(e.date); return d.getFullYear() === y1 && d.getMonth() === m1; });
const thisMonthExp = expenses.filter(e => e.date && parseInt(e.date.slice(0,4)) === y1 && parseInt(e.date.slice(5,7)) - 1 === m1);
const thisMonthTotal = thisMonthExp.reduce((s, e) => s + Number(e.amount || 0), 0);
const thisCatTotals = CATEGORIES.map(cat => ({ cat, total: thisMonthExp.filter(e => e.category === cat).reduce((s, e) => s + Number(e.amount || 0), 0) })).filter(c => c.total > 0).sort((a, b) => b.total - a.total).slice(0, 4);
@@ -660,7 +690,7 @@ export default function Invoices() {
// Card 3: All invoices issued this month
const m3 = ovMonth3.month, y3 = ovMonth3.year;
const monthInvoices = [...invoices.filter(i => { const d = new Date(i.invoice_date); return d.getFullYear() === y3 && d.getMonth() === m3; })];
const monthInvoices = [...invoices.filter(i => i.invoice_date && parseInt(i.invoice_date.slice(0,4)) === y3 && parseInt(i.invoice_date.slice(5,7)) - 1 === m3)];
const outstandingTotal = monthInvoices.reduce((s, i) => s + Number(i.total || 0), 0);
const PIE_COLORS = ['#F5A523', '#4ade80', '#60a5fa', '#c084fc', '#f472b6', '#fb923c', '#34d399', '#a78bfa'];
@@ -714,17 +744,23 @@ export default function Invoices() {
<MonthNav m={ovMonth1} setter={setOvMonth1} />
</div>
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#ef4444', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(thisMonthTotal)}</div>
<ResponsiveContainer width="100%" height={160}>
<PieChart>
<Pie data={thisCatTotals.length ? thisCatTotals : [{ cat: 'None', total: 1 }]} dataKey="total" nameKey="cat" cx="50%" cy="50%" innerRadius={45} outerRadius={72} strokeWidth={0}>
{thisCatTotals.length ? thisCatTotals.map((_, i) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />) : <Cell fill="var(--border)" />}
</Pie>
{thisCatTotals.length > 0 && <Tooltip content={({ active, payload }) => {
if (!active || !payload?.length) return null;
return <div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '8px 12px', fontSize: 12 }}><div style={{ color: payload[0].payload.fill, fontWeight: 500 }}>{payload[0].name}</div><div style={{ color: 'var(--text-primary)' }}>{fmtAmt(payload[0].value)}</div></div>;
}} />}
</PieChart>
</ResponsiveContainer>
<div style={{ position: 'relative' }}>
<ResponsiveContainer width="100%" height={260}>
<PieChart>
<Pie data={thisCatTotals.length ? thisCatTotals : [{ cat: 'None', total: 1 }]} dataKey="total" nameKey="cat" cx="50%" cy="50%" innerRadius={72} outerRadius={115} strokeWidth={0}>
{thisCatTotals.length ? thisCatTotals.map((_, i) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />) : <Cell fill="var(--border)" />}
</Pie>
{thisCatTotals.length > 0 && <Tooltip content={({ active, payload }) => {
if (!active || !payload?.length) return null;
return <div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '8px 12px', fontSize: 12 }}><div style={{ color: payload[0].payload.fill, fontWeight: 500 }}>{payload[0].name}</div><div style={{ color: 'var(--text-primary)' }}>{fmtAmt(payload[0].value)}</div></div>;
}} />}
</PieChart>
</ResponsiveContainer>
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', textAlign: 'center', pointerEvents: 'none' }}>
<div style={{ fontSize: 22, fontWeight: 500, color: 'var(--text-primary)', lineHeight: 1.1 }}>{thisMonthExp.length}</div>
<div style={{ fontSize: 10, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6 }}>items</div>
</div>
</div>
{thisCatTotals.length > 0 && <PieLegend data={thisCatTotals.map(c => ({ name: c.cat, value: c.total }))} colors={PIE_COLORS} />}
</div>
@@ -735,17 +771,23 @@ export default function Invoices() {
<MonthNav m={ovMonth2} setter={setOvMonth2} />
</div>
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#F5A523', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(pendingSubsTotal)}</div>
<ResponsiveContainer width="100%" height={160}>
<PieChart>
<Pie data={subPieData.length ? subPieData : [{ name: 'None', value: 1 }]} dataKey="value" nameKey="name" cx="50%" cy="50%" innerRadius={45} outerRadius={72} strokeWidth={0}>
{subPieData.length ? subPieData.map((_, i) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />) : <Cell fill="var(--border)" />}
</Pie>
{subPieData.length > 0 && <Tooltip content={({ active, payload }) => {
if (!active || !payload?.length) return null;
return <div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '8px 12px', fontSize: 12 }}><div style={{ color: payload[0].payload.fill, fontWeight: 500 }}>{payload[0].name}</div><div style={{ color: 'var(--text-primary)' }}>{fmtAmt(payload[0].value)}</div></div>;
}} />}
</PieChart>
</ResponsiveContainer>
<div style={{ position: 'relative' }}>
<ResponsiveContainer width="100%" height={260}>
<PieChart>
<Pie data={subPieData.length ? subPieData : [{ name: 'None', value: 1 }]} dataKey="value" nameKey="name" cx="50%" cy="50%" innerRadius={72} outerRadius={115} strokeWidth={0}>
{subPieData.length ? subPieData.map((_, i) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />) : <Cell fill="var(--border)" />}
</Pie>
{subPieData.length > 0 && <Tooltip content={({ active, payload }) => {
if (!active || !payload?.length) return null;
return <div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '8px 12px', fontSize: 12 }}><div style={{ color: payload[0].payload.fill, fontWeight: 500 }}>{payload[0].name}</div><div style={{ color: 'var(--text-primary)' }}>{fmtAmt(payload[0].value)}</div></div>;
}} />}
</PieChart>
</ResponsiveContainer>
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', textAlign: 'center', pointerEvents: 'none' }}>
<div style={{ fontSize: 22, fontWeight: 500, color: 'var(--text-primary)', lineHeight: 1.1 }}>{pendingSubs.length}</div>
<div style={{ fontSize: 10, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6 }}>invoices</div>
</div>
</div>
{subPieData.length > 0 && <PieLegend data={subPieData} colors={PIE_COLORS} />}
</div>
@@ -756,17 +798,23 @@ export default function Invoices() {
<MonthNav m={ovMonth3} setter={setOvMonth3} />
</div>
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#4ade80', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(outstandingTotal)}</div>
<ResponsiveContainer width="100%" height={160}>
<PieChart>
<Pie data={invPieData.length ? invPieData : [{ name: 'None', value: 1 }]} dataKey="value" nameKey="name" cx="50%" cy="50%" innerRadius={45} outerRadius={72} strokeWidth={0}>
{invPieData.length ? invPieData.map((_, i) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />) : <Cell fill="var(--border)" />}
</Pie>
{invPieData.length > 0 && <Tooltip content={({ active, payload }) => {
if (!active || !payload?.length) return null;
return <div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '8px 12px', fontSize: 12 }}><div style={{ color: payload[0].payload.fill, fontWeight: 500 }}>{payload[0].name}</div><div style={{ color: 'var(--text-primary)' }}>{fmtAmt(payload[0].value)}</div></div>;
}} />}
</PieChart>
</ResponsiveContainer>
<div style={{ position: 'relative' }}>
<ResponsiveContainer width="100%" height={260}>
<PieChart>
<Pie data={invPieData.length ? invPieData : [{ name: 'None', value: 1 }]} dataKey="value" nameKey="name" cx="50%" cy="50%" innerRadius={72} outerRadius={115} strokeWidth={0}>
{invPieData.length ? invPieData.map((_, i) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />) : <Cell fill="var(--border)" />}
</Pie>
{invPieData.length > 0 && <Tooltip content={({ active, payload }) => {
if (!active || !payload?.length) return null;
return <div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '8px 12px', fontSize: 12 }}><div style={{ color: payload[0].payload.fill, fontWeight: 500 }}>{payload[0].name}</div><div style={{ color: 'var(--text-primary)' }}>{fmtAmt(payload[0].value)}</div></div>;
}} />}
</PieChart>
</ResponsiveContainer>
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', textAlign: 'center', pointerEvents: 'none' }}>
<div style={{ fontSize: 22, fontWeight: 500, color: 'var(--text-primary)', lineHeight: 1.1 }}>{monthInvoices.length}</div>
<div style={{ fontSize: 10, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6 }}>invoices</div>
</div>
</div>
{invPieData.length > 0 && <PieLegend data={invPieData} colors={PIE_COLORS} />}
</div>
</div>
@@ -777,17 +825,17 @@ export default function Invoices() {
const CARD = { background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' };
const TH = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6, padding: '0 0 12px 0', border: 'none', background: 'transparent', textAlign: 'left' };
const TD = { fontSize: 13, color: 'var(--text-primary)', padding: '5px 0', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
const fmtDate = d => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
const fmtDate = d => d ? new Date(d + 'T12:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
const fmtAmt = v => `$${Number(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
const sortedExpenses = expSort(expenses, (exp, key) => {
const sortedExpenses = expSort(filteredExpenses, (exp, key) => {
if (key === 'amount') return Number(exp.amount) || 0;
if (key === 'date') return exp.date || '';
return exp[key] || '';
});
const categoryTotals = CATEGORIES
.map(cat => ({ cat, total: expenses.filter(e => e.category === cat).reduce((s, e) => s + Number(e.amount || 0), 0) }))
.map(cat => ({ cat, total: filteredExpenses.filter(e => e.category === cat).reduce((s, e) => s + Number(e.amount || 0), 0) }))
.sort((a, b) => b.total - a.total);
const grandTotal = expenses.reduce((s, e) => s + Number(e.amount || 0), 0);
const grandTotal = filteredExpenses.reduce((s, e) => s + Number(e.amount || 0), 0);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, flex: 1, minHeight: 0 }}>
<div style={{ display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 24, flex: 1, minHeight: 0 }}>
@@ -814,7 +862,7 @@ export default function Invoices() {
<tr key={exp.id}>
<td style={{ ...TD, fontSize: 12 }}>{fmtDate(exp.date)}</td>
<td style={{ ...TD }}>
<button type="button" className="dashboard-inline-link" onClick={() => startEditExpense(exp)} style={{ fontSize: 13, fontWeight: 400 }}>
<button type="button" className="dashboard-inline-link" onClick={() => setViewingExpense(exp)} style={{ fontSize: 13, fontWeight: 400 }}>
{exp.description || '—'}
</button>
</td>
@@ -987,19 +1035,27 @@ export default function Invoices() {
const fmtAmt = v => `$${Number(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
const sorted = invSort(invoices, (inv, key) => {
if (key === 'total') return Number(inv.total) || 0;
const filteredInvoices = invoices.filter(i => {
if (invYearFilter !== 'all' && i.invoice_date?.slice(0,4) !== invYearFilter) return false;
if (invCompanyFilter !== 'all' && (i.company?.name || i.bill_to) !== invCompanyFilter) return false;
return true;
});
const sorted = invSort(filteredInvoices, (inv, key) => {
if (key === 'total' || key === 'stripe_fee') return Number(inv[key]) || 0;
if (key === 'invoice_date' || key === 'due_date') return new Date(inv[key]).getTime();
if (key === 'company') return inv.company?.name || '';
return inv[key] || '';
});
const byCompany = Object.values(invoices.reduce((acc, inv) => {
const byCompany = Object.values(filteredInvoices.reduce((acc, inv) => {
const name = inv.company?.name || inv.bill_to || 'Unknown';
if (!acc[name]) acc[name] = { name, total: 0, paid: 0, outstanding: 0 };
if (!acc[name]) acc[name] = { name, total: 0, paid: 0, outstanding: 0, fees: 0 };
acc[name].total += Number(inv.total) || 0;
if (inv.status === 'paid') acc[name].paid += Number(inv.total) || 0;
else if (inv.status === 'sent') acc[name].outstanding += Number(inv.total) || 0;
if (inv.status === 'paid') {
acc[name].paid += Number(inv.total) || 0;
acc[name].fees += Number(inv.stripe_fee) || 0;
} else if (inv.status === 'sent') acc[name].outstanding += Number(inv.total) || 0;
return acc;
}, {})).sort((a, b) => b.total - a.total);
@@ -1017,11 +1073,12 @@ export default function Invoices() {
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '20%' }} />
<col style={{ width: '27%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '18%' }} />
<col style={{ width: '24%' }} />
<col style={{ width: '13%' }} />
<col style={{ width: '13%' }} />
<col style={{ width: '12%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '10%' }} />
</colgroup>
<thead><tr>
@@ -1030,6 +1087,7 @@ export default function Invoices() {
<SortTh col="invoice_date" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={TH}>Date</SortTh>
<SortTh col="due_date" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={TH}>Due</SortTh>
<SortTh col="status" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={{ ...TH, textAlign: 'center' }}>Status</SortTh>
<SortTh col="stripe_fee" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={{ ...TH, textAlign: 'right' }}>Fees</SortTh>
<SortTh col="total" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={{ ...TH, textAlign: 'right' }}>Total</SortTh>
</tr></thead>
<tbody>{sorted.map(inv => (
@@ -1043,10 +1101,10 @@ export default function Invoices() {
<td style={{ ...TD, fontSize: 12 }}>{fmtDate(inv.invoice_date)}</td>
<td style={{ ...TD, fontSize: 12, color: inv.status !== 'paid' && new Date(inv.due_date) < new Date() ? 'var(--danger)' : 'var(--text-primary)' }}>{fmtDate(inv.due_date)}</td>
<td style={{ ...TD, textAlign: 'center' }}>
<StatusBadge
status={statusColor[inv.status] || 'not_started'}
label={invoiceStatusBadgeLabel(inv.status)}
/>
<StatusBadge status={statusColor[inv.status] || 'not_started'} label={invoiceStatusBadgeLabel(inv.status)} />
</td>
<td style={{ ...TD, textAlign: 'right', fontSize: 12, color: Number(inv.stripe_fee) > 0 ? '#ef4444' : 'var(--text-muted)' }}>
{Number(inv.stripe_fee) > 0 ? `-${fmtAmt(inv.stripe_fee)}` : '$0.00'}
</td>
<td style={{ ...TD, textAlign: 'right', color: inv.status === 'paid' ? '#4ade80' : inv.status === 'sent' ? '#F5A523' : 'var(--text-primary)' }}>
{fmtAmt(inv.total)}
@@ -1074,7 +1132,11 @@ export default function Invoices() {
<div style={{ height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${pct}%`, background: '#4ade80', borderRadius: 2, opacity: 0.7, transition: 'width 400ms ease' }} />
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 3 }}><span style={{ color: g.outstanding > 0 ? '#F5A523' : 'var(--text-muted)' }}>{fmtAmt(g.outstanding)} outstanding</span> · {fmtAmt(g.paid)} paid</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 3 }}>
<span style={{ color: g.outstanding > 0 ? '#F5A523' : 'var(--text-muted)' }}>{fmtAmt(g.outstanding)} outstanding</span>
{' · '}{fmtAmt(g.paid)} paid
{g.fees > 0 && <span style={{ color: '#ef4444' }}> · -{fmtAmt(g.fees)} fees</span>}
</div>
</div>
);
})}
@@ -1133,27 +1195,6 @@ export default function Invoices() {
<option key={c} value={c}>{c}</option>
))}
</select>
<div style={{ position: 'relative' }} ref={expYearMenuRef}>
<button
className="btn btn-outline"
onClick={() => setExpYearMenuOpen(o => !o)}
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}
title="Filter by year"
>
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 4h12M4 8h8M6 12h4" />
</svg>
{expYearFilter !== 'all' ? expYearFilter : 'All Years'}
</button>
{expYearMenuOpen && (
<div className="site-header-avatar-menu" style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 220, minWidth: 130 }}>
<button onClick={() => { setExpYearFilter('all'); setExpYearMenuOpen(false); }} className="site-header-avatar-item" style={{ background: expYearFilter === 'all' ? 'rgba(245,165,35,0.08)' : 'transparent', color: expYearFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Years</button>
{expenseYears.map(y => (
<button key={y} onClick={() => { setExpYearFilter(y); setExpYearMenuOpen(false); }} className="site-header-avatar-item" style={{ background: expYearFilter === y ? 'rgba(245,165,35,0.08)' : 'transparent', color: expYearFilter === y ? 'var(--accent)' : 'var(--text-primary)' }}>{y}</button>
))}
</div>
)}
</div>
<button className="btn btn-primary btn-sm" onClick={() => { setEditingExpenseId(''); setNewExpense(blankExpense()); setExpensesError(''); setShowExpenseForm(true); }}>+ Add Expense</button>
</div>
)}
@@ -1345,6 +1386,94 @@ export default function Invoices() {
)}
</div>}{/* end legacy wrapper */}
{viewingExpense && (() => {
const LABEL = { fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 3 };
const INPUT = { fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' };
const isPdf = viewingExpense.receipt_name?.toLowerCase().endsWith('.pdf');
return (
<div style={popupOverlayStyle} onClick={() => { if (!expenseDetailEditing) setViewingExpense(null); }}>
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: viewingExpense.receipt_path ? 'min(680px, 96vw)' : 'min(500px, 96vw)', maxHeight: 'calc(100vh - 48px)', display: 'flex', gap: 24, alignItems: 'stretch' }} onClick={e => e.stopPropagation()}>
{/* Left: details / edit */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: '0 0 220px', minHeight: 560, overflowY: 'auto' }}>
<div style={LABEL}>Expense Detail</div>
{expenseDetailEditing ? (
<input type="number" step="0.01" min="0" required style={{ ...INPUT, fontSize: 22, fontWeight: 500, color: '#ef4444' }} value={newExpense.amount} onChange={e => setNewExpense(p => ({ ...p, amount: e.target.value }))} />
) : (
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#ef4444', fontVariantNumeric: 'tabular-nums' }}>
${Number(viewingExpense.amount).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
)}
<div>
<div style={LABEL}>Description</div>
{expenseDetailEditing
? <input type="text" required style={INPUT} value={newExpense.description} onChange={e => setNewExpense(p => ({ ...p, description: e.target.value }))} />
: <div style={{ fontSize: 13 }}>{viewingExpense.description || '—'}</div>}
</div>
<div>
<div style={LABEL}>Date</div>
{expenseDetailEditing
? <input type="date" required style={INPUT} value={newExpense.date} onChange={e => setNewExpense(p => ({ ...p, date: e.target.value }))} />
: <div style={{ fontSize: 13 }}>{viewingExpense.date ? new Date(viewingExpense.date + 'T12:00:00').toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }) : '—'}</div>}
</div>
<div>
<div style={LABEL}>Category</div>
{expenseDetailEditing
? <select style={INPUT} value={newExpense.category} onChange={e => setNewExpense(p => ({ ...p, category: e.target.value }))}>{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}</select>
: <div style={{ fontSize: 13 }}>{viewingExpense.category || '—'}</div>}
</div>
<div>
<div style={LABEL}>Notes</div>
{expenseDetailEditing
? <input type="text" style={INPUT} value={newExpense.notes} onChange={e => setNewExpense(p => ({ ...p, notes: e.target.value }))} />
: <div style={{ fontSize: 13 }}>{viewingExpense.notes || '—'}</div>}
</div>
{expenseDetailEditing && (
<div>
<div style={LABEL}>Receipt / Photo</div>
<input type="file" accept="image/*,.pdf" style={{ ...INPUT, padding: '4px 10px' }} onChange={e => setNewExpense(p => ({ ...p, receipt: e.target.files?.[0] || null, removeReceipt: false }))} />
{!newExpense.receipt && newExpense.receipt_path && (
<div style={{ marginTop: 6, display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{newExpense.receipt_name || 'Existing receipt'}</span>
<button type="button" className="btn btn-outline" style={{ fontSize: 11, color: 'var(--danger)' }} onClick={() => setNewExpense(p => ({ ...p, removeReceipt: true, receipt_path: null, receipt_name: null }))}>Remove</button>
</div>
)}
</div>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 'auto', paddingTop: 4 }}>
{expenseDetailEditing ? (
<>
<button className="btn btn-outline" disabled={addingExpense} onClick={async () => { await saveExpense(); setExpenseDetailEditing(false); setViewingExpense(null); }}>{addingExpense ? 'Saving…' : 'Save'}</button>
<button className="btn btn-outline" onClick={() => setExpenseDetailEditing(false)}>Cancel</button>
</>
) : (
<>
<button type="button" className="btn btn-outline" onClick={() => { setEditingExpenseId(viewingExpense.id); setNewExpense({ date: viewingExpense.date, description: viewingExpense.description || '', category: viewingExpense.category || 'Other', amount: String(viewingExpense.amount ?? ''), notes: viewingExpense.notes || '', receipt: null, receipt_path: viewingExpense.receipt_path || null, receipt_name: viewingExpense.receipt_name || null }); setExpensesError(''); setExpenseDetailEditing(true); }}>Edit</button>
<button type="button" className="btn btn-outline" onClick={() => setViewingExpense(null)}>Close</button>
</>
)}
</div>
</div>
{/* Right: receipt preview — same height as left column */}
{viewingExpense.receipt_path && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, flex: 1, minHeight: 0, overflow: 'hidden' }}>
<div style={LABEL}>Receipt</div>
{expensePreviewUrl ? (
isPdf
? <embed src={`${expensePreviewUrl}#toolbar=0&navpanes=0&scrollbar=0`} type="application/pdf" style={{ flex: 1, width: '100%', border: '1px solid var(--border)', borderRadius: 6 }} />
: <img src={expensePreviewUrl} alt="Receipt" style={{ flex: 1, width: '100%', minHeight: 0, objectFit: 'contain', objectPosition: 'top', display: 'block', border: '1px solid var(--border)', borderRadius: 6, background: 'var(--card-bg-2)' }} />
) : (
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>Loading preview</div>
)}
{expensePreviewUrl && (
<a href={expensePreviewUrl} download={viewingExpense.receipt_name || 'receipt'} className="btn btn-outline" style={{ display: 'inline-flex', alignSelf: 'flex-start' }}>Download Receipt</a>
)}
</div>
)}
</div>
</div>
);
})()}
{showExpenseForm && (
<div style={popupOverlayStyle} onClick={cancelExpenseEdit}>
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(520px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>