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
View File
@@ -69,6 +69,8 @@ This is the single source of truth for dashboard/profile visual structure and UI
- action buttons group sits on the right using: `margin-left: auto`, `display: flex`, `align-items: center`, `gap: 8` - action buttons group sits on the right using: `margin-left: auto`, `display: flex`, `align-items: center`, `gap: 8`
- do not use hardcoded spacer blocks (`width` filler divs) to force alignment - do not use hardcoded spacer blocks (`width` filler divs) to force alignment
- icon-only filter/action buttons share the same row and align vertically with add buttons - icon-only filter/action buttons share the same row and align vertically with add buttons
- filter button wrapper: `<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>``display: flex` prevents block stretching that misaligns the button vertically
- filter button: `className="btn btn-outline"` + `style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}`, icon only (no label), funnel SVG `<path d="M2 4h12M4 8h8M6 12h4" />` at `13×13`; dropdown uses `site-header-avatar-menu` + `site-header-avatar-item` classes
- when the control bar uses a multi-column grid (to align with split-card layouts below), add `align-items: center` to the grid and `min-height: var(--btn-height)` to every column so row height is stable across all tab states - when the control bar uses a multi-column grid (to align with split-card layouts below), add `align-items: center` to the grid and `min-height: var(--btn-height)` to every column so row height is stable across all tab states
## 7) Dashboard Grids (Team) ## 7) Dashboard Grids (Team)
+2 -2
View File
@@ -214,7 +214,7 @@ export default function FileBrowser({ initialPath = '/', rootPath = '/' }) {
async function uploadOneFile(url, token, fbPath, file, retries = 3) { async function uploadOneFile(url, token, fbPath, file, retries = 3) {
for (let attempt = 1; attempt <= retries; attempt++) { for (let attempt = 1; attempt <= retries; attempt++) {
try { 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, 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); } 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 (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)) { 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++) { for (let i = 0; i < sel.length; i++) {
await uploadOneFile(url, token, joinVirtualPath(fbPath, sel[i].webkitRelativePath), sel[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> </button>
))} ))}
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }} ref={companyFilterMenuRef}> <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 && ( {isExternal && projectOptions.length > 0 && (
<div style={{ position: 'relative' }} ref={projectFilterMenuRef}> <div style={{ position: 'relative', display: 'flex', alignItems: 'center' }} ref={projectFilterMenuRef}>
<button <button
className="btn btn-outline" className="btn btn-outline"
aria-label="Filter projects" title="Filter projects" aria-label="Filter projects" title="Filter projects"
@@ -653,7 +656,7 @@ export default function RequestsPage() {
</div> </div>
)} )}
{(isTeam || isClient) && ( {(isTeam || isClient) && (
<div style={{ position: 'relative' }}> <div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
<button <button
className="btn btn-outline" className="btn btn-outline"
aria-label="Filter companies" title="Filter companies" aria-label="Filter companies" title="Filter companies"
@@ -683,9 +686,6 @@ export default function RequestsPage() {
)} )}
</div> </div>
)} )}
{(isTeam || isClient) && (
<button className="btn btn-outline" onClick={() => { setShowAddForm(s => !s); setAddError(''); }}>+ Task</button>
)}
</div> </div>
</div> </div>
+244 -115
View File
@@ -55,7 +55,7 @@ function getFileExt(name = '') {
return clean && clean !== name ? clean.replace(/[^a-z0-9]/g, '') : 'bin'; 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 }) { function FinancesChartTooltip({ active, payload, label, year }) {
if (!active || !payload?.length) return null; if (!active || !payload?.length) return null;
@@ -100,9 +100,16 @@ export default function Invoices() {
const [editingExpenseId, setEditingExpenseId] = useState(''); const [editingExpenseId, setEditingExpenseId] = useState('');
const [showExpenseForm, setShowExpenseForm] = useState(false); const [showExpenseForm, setShowExpenseForm] = useState(false);
const [expenseFilter, setExpenseFilter] = useState('all'); 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 [expYearFilter, setExpYearFilter] = useState(String(new Date().getFullYear()));
const [expYearMenuOpen, setExpYearMenuOpen] = useState(false); const [expYearMenuOpen, setExpYearMenuOpen] = useState(false);
const expYearMenuRef = useRef(null); 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 [subcontractorPOs, setSubcontractorPOs] = useState([]);
const [subcontractorLoading, setSubcontractorLoading] = useState(true); const [subcontractorLoading, setSubcontractorLoading] = useState(true);
const [subcontractorError, setSubcontractorError] = useState(''); const [subcontractorError, setSubcontractorError] = useState('');
@@ -164,6 +171,21 @@ export default function Invoices() {
return () => document.removeEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler);
}, [expYearMenuOpen]); }, [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(() => { useEffect(() => {
async function loadSubInvoices() { async function loadSubInvoices() {
const { data, error: err } = await supabase const { data, error: err } = await supabase
@@ -201,8 +223,7 @@ export default function Invoices() {
setShowExpenseForm(false); setShowExpenseForm(false);
}; };
const handleAddExpense = async (e) => { const saveExpense = async () => {
e.preventDefault();
if (!newExpense.description.trim() || !newExpense.amount) return; if (!newExpense.description.trim() || !newExpense.amount) return;
setAddingExpense(true); setAddingExpense(true);
setExpensesError(''); setExpensesError('');
@@ -210,54 +231,25 @@ export default function Invoices() {
const existingExpense = editingExpenseId ? expenses.find(expense => expense.id === editingExpenseId) : null; const existingExpense = editingExpenseId ? expenses.find(expense => expense.id === editingExpenseId) : null;
let receiptPath = existingExpense?.receipt_path || newExpense.receipt_path || null; let receiptPath = existingExpense?.receipt_path || newExpense.receipt_path || null;
let receiptName = existingExpense?.receipt_name || newExpense.receipt_name || null; let receiptName = existingExpense?.receipt_name || newExpense.receipt_name || null;
if (newExpense.removeReceipt && existingExpense?.receipt_path) { if (newExpense.removeReceipt && existingExpense?.receipt_path) {
await supabase.storage.from(RECEIPT_BUCKET).remove([existingExpense.receipt_path]); await supabase.storage.from(RECEIPT_BUCKET).remove([existingExpense.receipt_path]);
receiptPath = null; receiptPath = null; receiptName = null;
receiptName = null;
} }
if (newExpense.receipt) { if (newExpense.receipt) {
const ext = getFileExt(newExpense.receipt.name); const ext = getFileExt(newExpense.receipt.name);
receiptName = newExpense.receipt.name; receiptName = newExpense.receipt.name;
receiptPath = `${newExpense.date}/${Date.now()}-${crypto.randomUUID()}.${ext}`; receiptPath = `${newExpense.date}/${Date.now()}-${crypto.randomUUID()}.${ext}`;
const { error: uploadError } = await supabase.storage.from(RECEIPT_BUCKET).upload(receiptPath, newExpense.receipt, { upsert: false }); const { error: uploadError } = await supabase.storage.from(RECEIPT_BUCKET).upload(receiptPath, newExpense.receipt, { upsert: false });
if (uploadError) throw uploadError; if (uploadError) throw uploadError;
if (existingExpense?.receipt_path) { if (existingExpense?.receipt_path) await supabase.storage.from(RECEIPT_BUCKET).remove([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 payload = { const query = editingExpenseId ? supabase.from('expenses').update(payload).eq('id', editingExpenseId) : supabase.from('expenses').insert(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(); 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) { if (data) {
setExpenses(prev => editingExpenseId setExpenses(prev => editingExpenseId ? prev.map(expense => expense.id === editingExpenseId ? data : expense) : [data, ...prev]);
? prev.map(expense => expense.id === editingExpenseId ? data : expense) setNewExpense(blankExpense()); setEditingExpenseId(''); setShowExpenseForm(false);
: [data, ...prev]
);
setNewExpense(blankExpense());
setEditingExpenseId('');
setShowExpenseForm(false);
} }
} catch (error) { } catch (error) {
console.error(`Failed to ${editingExpenseId ? 'update' : 'add'} expense:`, error); console.error(`Failed to ${editingExpenseId ? 'update' : 'add'} expense:`, error);
@@ -267,6 +259,11 @@ export default function Invoices() {
setAddingExpense(false); setAddingExpense(false);
}; };
const handleAddExpense = async (e) => {
e.preventDefault();
await saveExpense();
};
const handleDeleteExpense = async (id) => { const handleDeleteExpense = async (id) => {
if (!window.confirm('Delete this expense?')) return; if (!window.confirm('Delete this expense?')) return;
const expense = expenses.find(e => e.id === id); const expense = expenses.find(e => e.id === id);
@@ -607,11 +604,44 @@ export default function Invoices() {
{financeTab === 'expenses' && ( {financeTab === 'expenses' && (
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
<button className="btn btn-outline" onClick={() => { setEditingExpenseId(''); setNewExpense(blankExpense()); setExpensesError(''); setShowExpenseForm(true); }}>+ Expense</button> <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> </div>
)} )}
{financeTab === 'invoices' && ( {financeTab === 'invoices' && (
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
<button className="btn btn-outline" onClick={() => navigate('/invoices/new')}>+ Invoice</button> <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>
)} )}
</div> </div>
@@ -648,7 +678,7 @@ export default function Invoices() {
// Card 1: Expenses // Card 1: Expenses
const m1 = ovMonth1.month, y1 = ovMonth1.year; 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 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); 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 // Card 3: All invoices issued this month
const m3 = ovMonth3.month, y3 = ovMonth3.year; 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 outstandingTotal = monthInvoices.reduce((s, i) => s + Number(i.total || 0), 0);
const PIE_COLORS = ['#F5A523', '#4ade80', '#60a5fa', '#c084fc', '#f472b6', '#fb923c', '#34d399', '#a78bfa']; const PIE_COLORS = ['#F5A523', '#4ade80', '#60a5fa', '#c084fc', '#f472b6', '#fb923c', '#34d399', '#a78bfa'];
@@ -714,17 +744,23 @@ export default function Invoices() {
<MonthNav m={ovMonth1} setter={setOvMonth1} /> <MonthNav m={ovMonth1} setter={setOvMonth1} />
</div> </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> <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}> <div style={{ position: 'relative' }}>
<PieChart> <ResponsiveContainer width="100%" height={260}>
<Pie data={thisCatTotals.length ? thisCatTotals : [{ cat: 'None', total: 1 }]} dataKey="total" nameKey="cat" cx="50%" cy="50%" innerRadius={45} outerRadius={72} strokeWidth={0}> <PieChart>
{thisCatTotals.length ? thisCatTotals.map((_, i) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />) : <Cell fill="var(--border)" />} <Pie data={thisCatTotals.length ? thisCatTotals : [{ cat: 'None', total: 1 }]} dataKey="total" nameKey="cat" cx="50%" cy="50%" innerRadius={72} outerRadius={115} strokeWidth={0}>
</Pie> {thisCatTotals.length ? thisCatTotals.map((_, i) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />) : <Cell fill="var(--border)" />}
{thisCatTotals.length > 0 && <Tooltip content={({ active, payload }) => { </Pie>
if (!active || !payload?.length) return null; {thisCatTotals.length > 0 && <Tooltip content={({ active, payload }) => {
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>; 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> </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} />} {thisCatTotals.length > 0 && <PieLegend data={thisCatTotals.map(c => ({ name: c.cat, value: c.total }))} colors={PIE_COLORS} />}
</div> </div>
@@ -735,17 +771,23 @@ export default function Invoices() {
<MonthNav m={ovMonth2} setter={setOvMonth2} /> <MonthNav m={ovMonth2} setter={setOvMonth2} />
</div> </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> <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}> <div style={{ position: 'relative' }}>
<PieChart> <ResponsiveContainer width="100%" height={260}>
<Pie data={subPieData.length ? subPieData : [{ name: 'None', value: 1 }]} dataKey="value" nameKey="name" cx="50%" cy="50%" innerRadius={45} outerRadius={72} strokeWidth={0}> <PieChart>
{subPieData.length ? subPieData.map((_, i) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />) : <Cell fill="var(--border)" />} <Pie data={subPieData.length ? subPieData : [{ name: 'None', value: 1 }]} dataKey="value" nameKey="name" cx="50%" cy="50%" innerRadius={72} outerRadius={115} strokeWidth={0}>
</Pie> {subPieData.length ? subPieData.map((_, i) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />) : <Cell fill="var(--border)" />}
{subPieData.length > 0 && <Tooltip content={({ active, payload }) => { </Pie>
if (!active || !payload?.length) return null; {subPieData.length > 0 && <Tooltip content={({ active, payload }) => {
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>; 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> </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} />} {subPieData.length > 0 && <PieLegend data={subPieData} colors={PIE_COLORS} />}
</div> </div>
@@ -756,17 +798,23 @@ export default function Invoices() {
<MonthNav m={ovMonth3} setter={setOvMonth3} /> <MonthNav m={ovMonth3} setter={setOvMonth3} />
</div> </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> <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}> <div style={{ position: 'relative' }}>
<PieChart> <ResponsiveContainer width="100%" height={260}>
<Pie data={invPieData.length ? invPieData : [{ name: 'None', value: 1 }]} dataKey="value" nameKey="name" cx="50%" cy="50%" innerRadius={45} outerRadius={72} strokeWidth={0}> <PieChart>
{invPieData.length ? invPieData.map((_, i) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />) : <Cell fill="var(--border)" />} <Pie data={invPieData.length ? invPieData : [{ name: 'None', value: 1 }]} dataKey="value" nameKey="name" cx="50%" cy="50%" innerRadius={72} outerRadius={115} strokeWidth={0}>
</Pie> {invPieData.length ? invPieData.map((_, i) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />) : <Cell fill="var(--border)" />}
{invPieData.length > 0 && <Tooltip content={({ active, payload }) => { </Pie>
if (!active || !payload?.length) return null; {invPieData.length > 0 && <Tooltip content={({ active, payload }) => {
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>; 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> </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} />} {invPieData.length > 0 && <PieLegend data={invPieData} colors={PIE_COLORS} />}
</div> </div>
</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 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 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 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 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 === 'amount') return Number(exp.amount) || 0;
if (key === 'date') return exp.date || ''; if (key === 'date') return exp.date || '';
return exp[key] || ''; return exp[key] || '';
}); });
const categoryTotals = CATEGORIES 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); .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 ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, flex: 1, minHeight: 0 }}> <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 }}> <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}> <tr key={exp.id}>
<td style={{ ...TD, fontSize: 12 }}>{fmtDate(exp.date)}</td> <td style={{ ...TD, fontSize: 12 }}>{fmtDate(exp.date)}</td>
<td style={{ ...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 || '—'} {exp.description || '—'}
</button> </button>
</td> </td>
@@ -987,19 +1035,27 @@ export default function Invoices() {
const fmtAmt = v => `$${Number(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; const fmtAmt = v => `$${Number(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' }; const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
const sorted = invSort(invoices, (inv, key) => { const filteredInvoices = invoices.filter(i => {
if (key === 'total') return Number(inv.total) || 0; 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 === 'invoice_date' || key === 'due_date') return new Date(inv[key]).getTime();
if (key === 'company') return inv.company?.name || ''; if (key === 'company') return inv.company?.name || '';
return inv[key] || ''; 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'; 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; acc[name].total += Number(inv.total) || 0;
if (inv.status === 'paid') acc[name].paid += Number(inv.total) || 0; if (inv.status === 'paid') {
else if (inv.status === 'sent') acc[name].outstanding += Number(inv.total) || 0; 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; return acc;
}, {})).sort((a, b) => b.total - a.total); }, {})).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' }}> <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' }}> <table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup> <colgroup>
<col style={{ width: '20%' }} /> <col style={{ width: '18%' }} />
<col style={{ width: '27%' }} /> <col style={{ width: '24%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '13%' }} /> <col style={{ width: '13%' }} />
<col style={{ width: '13%' }} />
<col style={{ width: '12%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '10%' }} /> <col style={{ width: '10%' }} />
</colgroup> </colgroup>
<thead><tr> <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="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="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="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> <SortTh col="total" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={{ ...TH, textAlign: 'right' }}>Total</SortTh>
</tr></thead> </tr></thead>
<tbody>{sorted.map(inv => ( <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 }}>{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, 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' }}> <td style={{ ...TD, textAlign: 'center' }}>
<StatusBadge <StatusBadge status={statusColor[inv.status] || 'not_started'} label={invoiceStatusBadgeLabel(inv.status)} />
status={statusColor[inv.status] || 'not_started'} </td>
label={invoiceStatusBadgeLabel(inv.status)} <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>
<td style={{ ...TD, textAlign: 'right', color: inv.status === 'paid' ? '#4ade80' : inv.status === 'sent' ? '#F5A523' : 'var(--text-primary)' }}> <td style={{ ...TD, textAlign: 'right', color: inv.status === 'paid' ? '#4ade80' : inv.status === 'sent' ? '#F5A523' : 'var(--text-primary)' }}>
{fmtAmt(inv.total)} {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: 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 style={{ height: '100%', width: `${pct}%`, background: '#4ade80', borderRadius: 2, opacity: 0.7, transition: 'width 400ms ease' }} />
</div> </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> </div>
); );
})} })}
@@ -1133,27 +1195,6 @@ export default function Invoices() {
<option key={c} value={c}>{c}</option> <option key={c} value={c}>{c}</option>
))} ))}
</select> </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> <button className="btn btn-primary btn-sm" onClick={() => { setEditingExpenseId(''); setNewExpense(blankExpense()); setExpensesError(''); setShowExpenseForm(true); }}>+ Add Expense</button>
</div> </div>
)} )}
@@ -1345,6 +1386,94 @@ export default function Invoices() {
)} )}
</div>}{/* end legacy wrapper */} </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 && ( {showExpenseForm && (
<div style={popupOverlayStyle} onClick={cancelExpenseEdit}> <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()}> <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()}>