From 770bb1c05b21b54fa7acba3ed47a1c06b411db32 Mon Sep 17 00:00:00 2001 From: Krao Hasanee Date: Tue, 2 Jun 2026 14:08:11 -0400 Subject: [PATCH] fix: upload source=srv, expense detail popup, invoice/expense filters, pie charts, date timezone fixes --- layout.md | 2 + src/components/FileBrowser.jsx | 4 +- src/pages/Tasks.jsx | 10 +- src/pages/team/TeamInvoices.jsx | 359 ++++++++++++++++++++++---------- 4 files changed, 253 insertions(+), 122 deletions(-) diff --git a/layout.md b/layout.md index 19741d0..e381e07 100644 --- a/layout.md +++ b/layout.md @@ -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: `
` — `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 `` 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) diff --git a/src/components/FileBrowser.jsx b/src/components/FileBrowser.jsx index 1067e62..aa99ea5 100644 --- a/src/components/FileBrowser.jsx +++ b/src/components/FileBrowser.jsx @@ -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]); diff --git a/src/pages/Tasks.jsx b/src/pages/Tasks.jsx index e277b74..057fcc1 100644 --- a/src/pages/Tasks.jsx +++ b/src/pages/Tasks.jsx @@ -630,8 +630,11 @@ export default function RequestsPage() { ))}
+ {(isTeam || isClient) && ( + + )} {isExternal && projectOptions.length > 0 && ( -
+
- )}
diff --git a/src/pages/team/TeamInvoices.jsx b/src/pages/team/TeamInvoices.jsx index 901f96d..81f8594 100644 --- a/src/pages/team/TeamInvoices.jsx +++ b/src/pages/team/TeamInvoices.jsx @@ -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' && (
+
+ + {expYearMenuOpen && ( +
+ + {expenseYears.map(y => ( + + ))} +
+ )} +
)} {financeTab === 'invoices' && (
+
+ + {invYearMenuOpen && ( +
+
Year
+ + {[...new Set(invoices.map(i => i.invoice_date?.slice(0,4)).filter(Boolean))].sort((a,b) => b-a).map(y => ( + + ))} +
+
Company
+ + {[...new Set(invoices.map(i => i.company?.name || i.bill_to).filter(Boolean))].sort().map(c => ( + + ))} +
+ )} +
)}
@@ -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() {
{fmtAmt(thisMonthTotal)}
- - - - {thisCatTotals.length ? thisCatTotals.map((_, i) => ) : } - - {thisCatTotals.length > 0 && { - if (!active || !payload?.length) return null; - return
{payload[0].name}
{fmtAmt(payload[0].value)}
; - }} />} -
-
+
+ + + + {thisCatTotals.length ? thisCatTotals.map((_, i) => ) : } + + {thisCatTotals.length > 0 && { + if (!active || !payload?.length) return null; + return
{payload[0].name}
{fmtAmt(payload[0].value)}
; + }} />} +
+
+
+
{thisMonthExp.length}
+
items
+
+
{thisCatTotals.length > 0 && ({ name: c.cat, value: c.total }))} colors={PIE_COLORS} />}
@@ -735,17 +771,23 @@ export default function Invoices() {
{fmtAmt(pendingSubsTotal)}
- - - - {subPieData.length ? subPieData.map((_, i) => ) : } - - {subPieData.length > 0 && { - if (!active || !payload?.length) return null; - return
{payload[0].name}
{fmtAmt(payload[0].value)}
; - }} />} -
-
+
+ + + + {subPieData.length ? subPieData.map((_, i) => ) : } + + {subPieData.length > 0 && { + if (!active || !payload?.length) return null; + return
{payload[0].name}
{fmtAmt(payload[0].value)}
; + }} />} +
+
+
+
{pendingSubs.length}
+
invoices
+
+
{subPieData.length > 0 && } @@ -756,17 +798,23 @@ export default function Invoices() {
{fmtAmt(outstandingTotal)}
- - - - {invPieData.length ? invPieData.map((_, i) => ) : } - - {invPieData.length > 0 && { - if (!active || !payload?.length) return null; - return
{payload[0].name}
{fmtAmt(payload[0].value)}
; - }} />} -
-
+
+ + + + {invPieData.length ? invPieData.map((_, i) => ) : } + + {invPieData.length > 0 && { + if (!active || !payload?.length) return null; + return
{payload[0].name}
{fmtAmt(payload[0].value)}
; + }} />} +
+
+
+
{monthInvoices.length}
+
invoices
+
+
{invPieData.length > 0 && } @@ -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 (
@@ -814,7 +862,7 @@ export default function Invoices() { {fmtDate(exp.date)} - @@ -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() {
- - - - + + + + + @@ -1030,6 +1087,7 @@ export default function Invoices() { DateDueStatus + FeesTotal{sorted.map(inv => ( @@ -1043,10 +1101,10 @@ export default function Invoices() { +
{fmtDate(inv.invoice_date)} {fmtDate(inv.due_date)} - + + 0 ? '#ef4444' : 'var(--text-muted)' }}> + {Number(inv.stripe_fee) > 0 ? `-${fmtAmt(inv.stripe_fee)}` : '$0.00'} {fmtAmt(inv.total)} @@ -1074,7 +1132,11 @@ export default function Invoices() {
-
0 ? '#F5A523' : 'var(--text-muted)' }}>{fmtAmt(g.outstanding)} outstanding · {fmtAmt(g.paid)} paid
+
+ 0 ? '#F5A523' : 'var(--text-muted)' }}>{fmtAmt(g.outstanding)} outstanding + {' · '}{fmtAmt(g.paid)} paid + {g.fees > 0 && · -{fmtAmt(g.fees)} fees} +
); })} @@ -1133,27 +1195,6 @@ export default function Invoices() { ))} -
- - {expYearMenuOpen && ( -
- - {expenseYears.map(y => ( - - ))} -
- )} -
)} @@ -1345,6 +1386,94 @@ export default function Invoices() { )} }{/* 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 ( +
{ if (!expenseDetailEditing) setViewingExpense(null); }}> +
e.stopPropagation()}> + {/* Left: details / edit */} +
+
Expense Detail
+ {expenseDetailEditing ? ( + setNewExpense(p => ({ ...p, amount: e.target.value }))} /> + ) : ( +
+ ${Number(viewingExpense.amount).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +
+ )} +
+
Description
+ {expenseDetailEditing + ? setNewExpense(p => ({ ...p, description: e.target.value }))} /> + :
{viewingExpense.description || '—'}
} +
+
+
Date
+ {expenseDetailEditing + ? setNewExpense(p => ({ ...p, date: e.target.value }))} /> + :
{viewingExpense.date ? new Date(viewingExpense.date + 'T12:00:00').toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }) : '—'}
} +
+
+
Category
+ {expenseDetailEditing + ? + :
{viewingExpense.category || '—'}
} +
+
+
Notes
+ {expenseDetailEditing + ? setNewExpense(p => ({ ...p, notes: e.target.value }))} /> + :
{viewingExpense.notes || '—'}
} +
+ {expenseDetailEditing && ( +
+
Receipt / Photo
+ setNewExpense(p => ({ ...p, receipt: e.target.files?.[0] || null, removeReceipt: false }))} /> + {!newExpense.receipt && newExpense.receipt_path && ( +
+ {newExpense.receipt_name || 'Existing receipt'} + +
+ )} +
+ )} +
+ {expenseDetailEditing ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+ {/* Right: receipt preview — same height as left column */} + {viewingExpense.receipt_path && ( +
+
Receipt
+ {expensePreviewUrl ? ( + isPdf + ? + : Receipt + ) : ( +
Loading preview…
+ )} + {expensePreviewUrl && ( + Download Receipt + )} +
+ )} +
+
+ ); + })()} + {showExpenseForm && (
e.stopPropagation()}>