fix: upload source=srv, expense detail popup, invoice/expense filters, pie charts, date timezone fixes
This commit is contained in:
@@ -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`
|
||||
- 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
|
||||
- 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
|
||||
|
||||
## 7) Dashboard Grids (Team)
|
||||
|
||||
@@ -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
@@ -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
@@ -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()}>
|
||||
|
||||
Reference in New Issue
Block a user