redesign: Layout2 design-system + dashboard/tasks rework + perf
Design system (Layout2): - Solid token-driven theme (no animated grid); single-source tokens for bg, cards (bg/border/radius), popups, fonts (Inter), and full semantic + data-viz color palette. Swept ~168 hardcoded colors to tokens. Dashboard: - Reflow: To Do + Calendar row (fixed right rail) over Activity / In-Progress / Team Performance; CSS height-match (To Do = Calendar); responsive stacking. - 'Tasks Not Started' -> 'To Do' card with R#/Assigned columns; compact money fmt. Tasks page: - Combined tabs into Tasks + Completed with a Status column. - Column-header sort + filter (Status, R#/new-vs-revision, Project, Task Type, Company) via portaled, scrollable FilterDropdown; removed toolbar filters and the projects side panel; headers stay visible when empty; renamed to 'Tasks'. Perf: - FK/filter index migration; removed redundant client/external scope waterfall (rely on RLS); parallel follow-up queries. Mobile: - Responsive grids; mobile topbar = hamburger only, blends with bg, proper offset. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+154
-146
@@ -11,16 +11,18 @@ import { withTimeout } from '../../lib/withTimeout';
|
||||
import { getDeadlineSourceSubmission } from '../../lib/taskDeadlines';
|
||||
import { useSortable } from '../../hooks/useSortable';
|
||||
import { useLiveRefresh } from '../../hooks/useLiveRefresh';
|
||||
import { resolveScopedWorkIds } from '../../lib/workScope';
|
||||
import { getCurrentUserCompanyIds } from '../../lib/workScope';
|
||||
import { getSubmissionDisplayName, mergeSubmissionDisplayNames } from '../../lib/submissionDisplay';
|
||||
import { isCompletedVersionEligible } from '../../lib/invoiceVersionRules';
|
||||
import { isReviewShadowDescription } from '../../lib/taskVersions';
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
const ICON_TONES = [
|
||||
{ bg: 'rgba(245,165,35,0.15)', color: '#F5A523' },
|
||||
{ bg: 'rgba(74,222,128,0.15)', color: '#4ade80' },
|
||||
{ bg: 'rgba(96,165,250,0.15)', color: '#60a5fa' },
|
||||
{ bg: 'rgba(167,139,250,0.15)', color: '#a78bfa' },
|
||||
{ bg: 'color-mix(in srgb, var(--accent) 15%, transparent)', color: 'var(--accent)' },
|
||||
{ bg: 'color-mix(in srgb, var(--positive) 15%, transparent)', color: 'var(--positive)' },
|
||||
{ bg: 'color-mix(in srgb, var(--info) 15%, transparent)', color: 'var(--info)' },
|
||||
{ bg: 'color-mix(in srgb, var(--violet) 15%, transparent)', color: 'var(--violet)' },
|
||||
];
|
||||
|
||||
function iconTone(key) {
|
||||
@@ -31,20 +33,20 @@ function iconTone(key) {
|
||||
|
||||
function fmtMoney(n) {
|
||||
if (Math.abs(n) >= 1000000) return `$${(n / 1000000).toFixed(2)}M`;
|
||||
if (Math.abs(n) >= 10000) return `$${(n / 1000).toFixed(1)}k`;
|
||||
if (Math.abs(n) >= 1000) return `$${(n / 1000).toFixed(1)}k`;
|
||||
return `$${Number(n).toFixed(2)}`;
|
||||
}
|
||||
|
||||
const ACTION_ICON = {
|
||||
task_started: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> },
|
||||
task_resumed: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> },
|
||||
task_submitted: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: <><line x1="12" y1="19" x2="12" y2="5" strokeWidth="2" strokeLinecap="round"/><polyline points="5,12 12,5 19,12" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/></> },
|
||||
task_approved: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: <polyline points="4,13 9,18 20,7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/> },
|
||||
task_on_hold: { bg: 'rgba(239,68,68,0.15)', color: '#ef4444', path: <><rect x="6" y="4" width="4" height="16" rx="1" fill="currentColor"/><rect x="14" y="4" width="4" height="16" rx="1" fill="currentColor"/></> },
|
||||
task_created: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: <><line x1="12" y1="5" x2="12" y2="19" strokeWidth="2" strokeLinecap="round"/><line x1="5" y1="12" x2="19" y2="12" strokeWidth="2" strokeLinecap="round"/></> },
|
||||
project_created: { bg: 'rgba(245,165,35,0.15)', color: '#F5A523', path: <><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" fill="none" strokeWidth="1.5"/></> },
|
||||
request_submitted: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: <><rect x="5" y="3" width="14" height="18" rx="2" fill="none" strokeWidth="1.5"/><line x1="9" y1="8" x2="15" y2="8" strokeWidth="1.5" strokeLinecap="round"/><line x1="9" y1="12" x2="15" y2="12" strokeWidth="1.5" strokeLinecap="round"/><line x1="9" y1="16" x2="12" y2="16" strokeWidth="1.5" strokeLinecap="round"/></> },
|
||||
revision_requested: { bg: 'rgba(245,158,11,0.15)', color: '#f59e0b', path: <><path d="M4 12a8 8 0 018-8v0a8 8 0 018 8" fill="none" strokeWidth="1.5" strokeLinecap="round"/><polyline points="18,8 20,12 16,12" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none"/></> },
|
||||
task_started: { bg: 'color-mix(in srgb, var(--info) 15%, transparent)', color: 'var(--info)', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> },
|
||||
task_resumed: { bg: 'color-mix(in srgb, var(--info) 15%, transparent)', color: 'var(--info)', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> },
|
||||
task_submitted: { bg: 'color-mix(in srgb, var(--positive) 15%, transparent)', color: 'var(--positive)', path: <><line x1="12" y1="19" x2="12" y2="5" strokeWidth="2" strokeLinecap="round"/><polyline points="5,12 12,5 19,12" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/></> },
|
||||
task_approved: { bg: 'color-mix(in srgb, var(--positive) 15%, transparent)', color: 'var(--positive)', path: <polyline points="4,13 9,18 20,7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/> },
|
||||
task_on_hold: { bg: 'color-mix(in srgb, var(--danger) 15%, transparent)', color: 'var(--danger)', path: <><rect x="6" y="4" width="4" height="16" rx="1" fill="currentColor"/><rect x="14" y="4" width="4" height="16" rx="1" fill="currentColor"/></> },
|
||||
task_created: { bg: 'color-mix(in srgb, var(--violet) 15%, transparent)', color: 'var(--violet)', path: <><line x1="12" y1="5" x2="12" y2="19" strokeWidth="2" strokeLinecap="round"/><line x1="5" y1="12" x2="19" y2="12" strokeWidth="2" strokeLinecap="round"/></> },
|
||||
project_created: { bg: 'color-mix(in srgb, var(--accent) 15%, transparent)', color: 'var(--accent)', path: <><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" fill="none" strokeWidth="1.5"/></> },
|
||||
request_submitted: { bg: 'color-mix(in srgb, var(--violet) 15%, transparent)', color: 'var(--violet)', path: <><rect x="5" y="3" width="14" height="18" rx="2" fill="none" strokeWidth="1.5"/><line x1="9" y1="8" x2="15" y2="8" strokeWidth="1.5" strokeLinecap="round"/><line x1="9" y1="12" x2="15" y2="12" strokeWidth="1.5" strokeLinecap="round"/><line x1="9" y1="16" x2="12" y2="16" strokeWidth="1.5" strokeLinecap="round"/></> },
|
||||
revision_requested: { bg: 'color-mix(in srgb, var(--warning) 15%, transparent)', color: 'var(--warning)', path: <><path d="M4 12a8 8 0 018-8v0a8 8 0 018 8" fill="none" strokeWidth="1.5" strokeLinecap="round"/><polyline points="18,8 20,12 16,12" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none"/></> },
|
||||
};
|
||||
|
||||
const ACTION_LABEL = {
|
||||
@@ -87,12 +89,12 @@ function MiniAreaChart({ data }) {
|
||||
<svg width="100%" height={H} viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" style={{ display: 'block', overflow: 'hidden' }}>
|
||||
<defs>
|
||||
<linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#F5A523" stopOpacity="0.3" />
|
||||
<stop offset="95%" stopColor="#F5A523" stopOpacity="0" />
|
||||
<stop offset="5%" stopColor="var(--accent)" stopOpacity="0.3" />
|
||||
<stop offset="95%" stopColor="var(--accent)" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={area} fill="url(#areaGrad)" />
|
||||
<path d={line} fill="none" stroke="#F5A523" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d={line} fill="none" stroke="var(--accent)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -106,11 +108,11 @@ const DASH_ICONS = {
|
||||
|
||||
function DashStatCard({ label, value, sub, iconBg, iconColor, iconPath, chartData }) {
|
||||
return (
|
||||
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', alignItems: 'stretch', gap: 21, minHeight: 120 }}>
|
||||
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', ...(chartData ? {} : { flex: 1 }) }}>
|
||||
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', display: 'flex', alignItems: 'stretch', gap: 21, height: 124, boxSizing: 'border-box' }}>
|
||||
<div style={{ minWidth: 0, display: 'flex', flexDirection: 'column', ...(chartData ? {} : { flex: 1 }) }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
|
||||
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', minWidth: 0 }}>
|
||||
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '100%' }}>{value}</div>
|
||||
</div>
|
||||
{sub && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 5 }}>{sub}</div>}
|
||||
</div>
|
||||
@@ -128,7 +130,7 @@ function ActivityFeed({ events }) {
|
||||
const navigate = useNavigate();
|
||||
const visible = events.slice(0, 5);
|
||||
return (
|
||||
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', flexShrink: 0 }}>
|
||||
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', flexShrink: 0 }}>
|
||||
<div style={{ marginBottom: visible.length > 0 ? 14 : 0 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Recent Activity</span>
|
||||
</div>
|
||||
@@ -202,16 +204,16 @@ function MiniCalendar({ items = [] }) {
|
||||
const prev = () => setView(v => v.month === 0 ? { year: v.year - 1, month: 11 } : { year: v.year, month: v.month - 1 });
|
||||
const next = () => setView(v => v.month === 11 ? { year: v.year + 1, month: 0 } : { year: v.year, month: v.month + 1 });
|
||||
const dotColor = (item) => {
|
||||
if (item.isOverdue) return '#ef4444';
|
||||
if (item.isHot) return '#F5A523';
|
||||
if (item.isDone) return '#4ade80';
|
||||
return '#60a5fa';
|
||||
if (item.isOverdue) return 'var(--danger)';
|
||||
if (item.isHot) return 'var(--accent)';
|
||||
if (item.isDone) return 'var(--positive)';
|
||||
return 'var(--info)';
|
||||
};
|
||||
const activeLabel = activeKey
|
||||
? new Date(`${activeKey}T12:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
: 'Selected day';
|
||||
return (
|
||||
<div style={{ position: 'relative', zIndex: activeKey ? 1001 : 0, overflow: 'visible', background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', flexShrink: 0 }}>
|
||||
<div style={{ position: 'relative', zIndex: activeKey ? 1001 : 0, overflow: 'visible', background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{monthLabel}</span>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
@@ -242,12 +244,12 @@ function MiniCalendar({ items = [] }) {
|
||||
disabled={!d}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: 28,
|
||||
height: 28,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '50%',
|
||||
border: active && !isToday(d) && hasItems ? '1px solid rgba(245,165,35,0.5)' : '1px solid transparent',
|
||||
border: active && !isToday(d) && hasItems ? '1px solid color-mix(in srgb, var(--accent) 50%, transparent)' : '1px solid transparent',
|
||||
color: d ? (isToday(d) ? '#0d0d0d' : 'var(--text-secondary)') : 'transparent',
|
||||
background: d && isToday(d) ? '#F5A523' : hasItems ? 'rgba(255,255,255,0.035)' : 'transparent',
|
||||
background: d && isToday(d) ? 'var(--accent)' : hasItems ? 'rgba(255,255,255,0.035)' : 'transparent',
|
||||
fontSize: 12,
|
||||
lineHeight: 1,
|
||||
fontWeight: isToday(d) ? 700 : 400,
|
||||
@@ -269,7 +271,7 @@ function MiniCalendar({ items = [] }) {
|
||||
<div onMouseEnter={() => keepHover(key)} onMouseLeave={clearHover} style={{ position: 'absolute', zIndex: 1002, right: 'calc(100% + 8px)', top: '50%', transform: 'translateY(-50%)', width: 210, padding: '10px 12px', background: 'var(--sidebar-bg)', border: '1px solid var(--border)', borderRadius: 8, boxShadow: '0 12px 32px rgba(0,0,0,0.45)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', pointerEvents: 'auto' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{activeLabel}</span>
|
||||
<span style={{ fontSize: 11, color: '#F5A523' }}>{activeItems.length} due</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--accent)' }}>{activeItems.length} due</span>
|
||||
</div>
|
||||
{activeItems.slice(0, 4).map(item => (
|
||||
<button key={item.id} type="button" onClick={() => navigate(`/tasks/${item.id}`)} style={{ display: 'flex', alignItems: 'center', gap: 7, width: '100%', padding: '5px 0', background: 'transparent', border: 'none', cursor: 'pointer', textAlign: 'left', fontFamily: 'inherit' }}>
|
||||
@@ -298,7 +300,7 @@ function TasksInProgressCard({ tasks = [] }) {
|
||||
});
|
||||
const visibleRows = showAll ? rows : rows.slice(0, 5);
|
||||
return (
|
||||
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ marginBottom: rows.length > 0 ? 14 : 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Tasks In Progress</span>
|
||||
{rows.length > 5 && (
|
||||
@@ -361,7 +363,7 @@ function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false
|
||||
.filter(s => {
|
||||
const task = taskMap.get(s.task_id);
|
||||
if (isClient) return task?.status === 'client_review' && !seen.has(s.task_id) && seen.add(s.task_id);
|
||||
return s.is_hot && activeStatuses.includes(task?.status) && !seen.has(s.task_id) && seen.add(s.task_id);
|
||||
return task?.status === 'not_started' && !seen.has(s.task_id) && seen.add(s.task_id);
|
||||
})
|
||||
.map(s => ({ ...s, task: taskMap.get(s.task_id) }))
|
||||
.filter(s => s.task)
|
||||
@@ -371,7 +373,7 @@ function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false
|
||||
if (!b.deadline) return -1;
|
||||
return new Date(a.deadline) - new Date(b.deadline);
|
||||
})
|
||||
.slice(0, 7);
|
||||
.slice(0, isClient ? 7 : 50);
|
||||
const sortedHotItems = sort(hotItems, (s, key) => {
|
||||
if (isExternal) {
|
||||
if (key === 'task') return s.title || '';
|
||||
@@ -380,21 +382,23 @@ function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false
|
||||
if (key === 'deadline') return s.deadline || '';
|
||||
return '';
|
||||
}
|
||||
if (key === 'revision') return s.task?.current_version || 0;
|
||||
if (key === 'task') return s.task?.title || '';
|
||||
if (key === 'requested_by') return getSubmissionDisplayName(s) || '';
|
||||
if (key === 'assigned') return s.task?.assigned_name || '';
|
||||
if (key === 'deadline') return s.deadline || '';
|
||||
return '';
|
||||
});
|
||||
return (
|
||||
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column', height: cardHeight || 'auto', minHeight: cardHeight ? 0 : 280 }}>
|
||||
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column', height: cardHeight || 'auto', minHeight: cardHeight ? 0 : 280 }}>
|
||||
<div style={{ marginBottom: hotItems.length > 0 ? 14 : 0, flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>
|
||||
{isClient ? 'Tasks Ready For Review' : isExternal ? 'My Tasks' : 'Hot Tasks'}
|
||||
{isClient ? 'Tasks Ready For Review' : isExternal ? 'My Tasks' : 'To Do'}
|
||||
</span>
|
||||
</div>
|
||||
{hotItems.length === 0 ? (
|
||||
<div style={{ flex: 1, minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>
|
||||
{isClient ? 'No tasks ready for review' : isExternal ? 'No active tasks assigned to you' : 'No hot tasks'}
|
||||
{isClient ? 'No tasks ready for review' : isExternal ? 'No active tasks assigned to you' : 'No tasks waiting to start'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
@@ -432,31 +436,25 @@ function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false
|
||||
<>
|
||||
<colgroup>
|
||||
<col style={{ width: '10%' }} />
|
||||
<col style={{ width: '40%' }} />
|
||||
<col style={{ width: '35%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
<col style={{ width: '32%' }} />
|
||||
<col style={{ width: '24%' }} />
|
||||
<col style={{ width: '14%' }} />
|
||||
<col style={{ width: '20%' }} />
|
||||
</colgroup>
|
||||
<thead style={{ background: 'transparent' }}>
|
||||
<tr style={{ background: 'transparent' }}>
|
||||
<th style={{ padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top', textAlign: 'center' }} />
|
||||
<SortTh col="revision" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'left', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' }}>R#</SortTh>
|
||||
<SortTh col="task" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'left', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Task</SortTh>
|
||||
<SortTh col="requested_by" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Requested By</SortTh>
|
||||
<SortTh col="assigned" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Assigned</SortTh>
|
||||
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Due By</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedHotItems.map(s => (
|
||||
<tr key={s.task_id} style={{ verticalAlign: 'middle', background: 'transparent' }}>
|
||||
<td style={{ padding: '3px 5px 7px', border: 'none', background: 'transparent', textAlign: 'center', verticalAlign: 'middle' }}>
|
||||
{s.task.status === 'client_approved' ? (
|
||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="rgba(74,222,128,0.8)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'block', margin: '0 auto' }}>
|
||||
<rect x="1.5" y="1.5" width="13" height="13" rx="2"/><polyline points="4,8 6.5,10.5 12,5.5"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" style={{ display: 'block', margin: '0 auto' }}>
|
||||
<rect x="1.5" y="1.5" width="13" height="13" rx="2"/>
|
||||
</svg>
|
||||
)}
|
||||
<td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'left', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{`R${String(s.task.current_version || 0).padStart(2, '0')}`}
|
||||
</td>
|
||||
<td style={{ fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'left' }}>
|
||||
<button type="button" className="dashboard-inline-link" onClick={() => navigate('/tasks/' + s.task_id)}>{s.task.title}</button>
|
||||
@@ -466,6 +464,15 @@ function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false
|
||||
<button type="button" className="dashboard-inline-link" onClick={() => navigate(profilePath(s.submitted_by, s.submitter_role))}>{getSubmissionDisplayName(s) || 'Profile'}</button>
|
||||
) : (getSubmissionDisplayName(s) || '—')}
|
||||
</td>
|
||||
<td style={{ padding: '5px', border: 'none', background: 'transparent', textAlign: 'center' }}>
|
||||
{s.task.assigned_name && s.task.assigned_to ? (
|
||||
<button type="button" onClick={() => navigate('/profile/' + s.task.assigned_to)} style={{ display: 'inline-flex', background: 'none', border: 'none', padding: 0, cursor: 'pointer' }}>
|
||||
<ProfileAvatar name={s.task.assigned_name} avatarUrl={s.task.assignee?.avatar_url} size={24} fontSize={10} />
|
||||
</button>
|
||||
) : (
|
||||
<div title="Unassigned" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 24, height: 24, borderRadius: '50%', background: 'var(--card-bg-2)' }}><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg></div>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', textAlign: 'center', padding: '5px', border: 'none', background: 'transparent' }}>{s.deadline ? new Date(s.deadline).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -479,7 +486,7 @@ function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false
|
||||
);
|
||||
}
|
||||
|
||||
function TeamPerformanceCard({ tasks, profiles, deliveries, cardHeight = null }) {
|
||||
function TeamPerformanceCard({ tasks, profiles, deliveries, submissions, cardHeight = null }) {
|
||||
const navigate = useNavigate();
|
||||
const profileMap = useMemo(() => {
|
||||
const m = new Map();
|
||||
@@ -494,14 +501,55 @@ function TeamPerformanceCard({ tasks, profiles, deliveries, cardHeight = null })
|
||||
});
|
||||
return m;
|
||||
}, [profiles]);
|
||||
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
|
||||
const toEST = (dateStr) => new Date(new Date(dateStr).toLocaleString('en-US', { timeZone: 'America/New_York' }));
|
||||
const monthsWithData = useMemo(() => new Set(
|
||||
(deliveries || []).filter(d => d.submission?.task).map(d => {
|
||||
const dt = toEST(d.sent_at);
|
||||
return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}`;
|
||||
})
|
||||
), [deliveries]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Delivery facts per task/version: who sent it (real person — submissions carry the generic
|
||||
// "Fourge Branding" name) and when it was delivered to the client (the "completed" date used
|
||||
// for the month bucket, since approval/billing happens on delivery, not on sub upload).
|
||||
const { delByVer, delByTask, delAtVer, delAtTask } = useMemo(() => {
|
||||
const byVer = new Map(), byTask = new Map(), atVer = new Map(), atTask = new Map();
|
||||
const sorted = [...(deliveries || [])].filter(d => d.sent_at).sort((a, b) => (a.sent_at < b.sent_at ? -1 : 1));
|
||||
sorted.forEach(d => {
|
||||
const tid = d.submission?.task_id;
|
||||
if (!tid) return;
|
||||
const vk = `${tid}:${d.version_number || 0}`;
|
||||
if (d.sent_by && !byVer.has(vk)) byVer.set(vk, d.sent_by);
|
||||
if (d.sent_by && !byTask.has(tid)) byTask.set(tid, d.sent_by);
|
||||
if (!atVer.has(vk)) atVer.set(vk, d.sent_at);
|
||||
if (!atTask.has(tid)) atTask.set(tid, d.sent_at);
|
||||
});
|
||||
return { delByVer: byVer, delByTask: byTask, delAtVer: atVer, delAtTask: atTask };
|
||||
}, [deliveries]);
|
||||
|
||||
// Countable items = invoice line items. One per task/version, gated by the SAME rule the
|
||||
// invoice uses (isCompletedVersionEligible: client-approved). v0 = new, v1+ = revision.
|
||||
const eligibleItems = useMemo(() => {
|
||||
const vm = new Map();
|
||||
(submissions || []).forEach(s => {
|
||||
if (isReviewShadowDescription(s.description)) return;
|
||||
const task = s.task;
|
||||
if (!task) return;
|
||||
const version = Number(s.version_number || 0);
|
||||
const key = `${s.task_id}:${version}`;
|
||||
const existing = vm.get(key);
|
||||
if (!existing || (s.submitted_at && s.submitted_at < existing.submitted_at)) {
|
||||
vm.set(key, { task, tid: s.task_id, version, submitted_at: s.submitted_at, submittedBy: s.submitted_by_name });
|
||||
}
|
||||
});
|
||||
const items = [];
|
||||
vm.forEach(e => {
|
||||
if (!isCompletedVersionEligible(e.task, e.version)) return;
|
||||
// Month = when delivered to client (completed), falling back to upload date if never sent.
|
||||
const dateStr = delAtVer.get(`${e.tid}:${e.version}`) || delAtTask.get(e.tid) || e.submitted_at;
|
||||
const dt = dateStr ? toEST(dateStr) : null;
|
||||
const monthKey = dt ? `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}` : null;
|
||||
const person = delByVer.get(`${e.tid}:${e.version}`) || delByTask.get(e.tid) || e.submittedBy || e.task.assigned_name || null;
|
||||
items.push({ task: e.task, kind: e.version === 0 ? 'new' : 'rev', monthKey, person });
|
||||
});
|
||||
return items;
|
||||
}, [submissions, delByVer, delByTask, delAtVer, delAtTask]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const monthsWithData = useMemo(() => new Set(eligibleItems.map(i => i.monthKey).filter(Boolean)), [eligibleItems]);
|
||||
const allMonthOpts = useMemo(() => {
|
||||
const opts = [];
|
||||
const now = toEST(new Date().toISOString());
|
||||
@@ -512,20 +560,16 @@ function TeamPerformanceCard({ tasks, profiles, deliveries, cardHeight = null })
|
||||
return opts;
|
||||
}, []);
|
||||
const monthOpts = allMonthOpts.filter(o => monthsWithData.has(o.value));
|
||||
const displayOpts = [{ value: 'all', label: 'All' }, ...monthOpts];
|
||||
const [monthKey, setMonthKey] = useState(() => monthOpts[0]?.value || allMonthOpts[0].value);
|
||||
const effectiveMonthKey = monthOpts.some(option => option.value === monthKey)
|
||||
const effectiveMonthKey = displayOpts.some(option => option.value === monthKey)
|
||||
? monthKey
|
||||
: (monthOpts[0]?.value || allMonthOpts[0]?.value || '');
|
||||
|
||||
const { people, totalDone } = useMemo(() => {
|
||||
const [year, month] = effectiveMonthKey.split('-').map(Number);
|
||||
const isAll = effectiveMonthKey === 'all';
|
||||
const map = new Map();
|
||||
(deliveries || []).forEach(d => {
|
||||
const task = d.submission?.task;
|
||||
if (!task) return;
|
||||
const dt = toEST(d.sent_at);
|
||||
if (dt.getFullYear() !== year || dt.getMonth() + 1 !== month) return;
|
||||
const name = d.sent_by || task.assigned_name;
|
||||
const credit = (name, task, field) => {
|
||||
if (!name) return;
|
||||
const matchedProfile = (task.assigned_to && profileMap.get(task.assigned_to)) || profileNameMap.get(String(name).trim().toLowerCase()) || null;
|
||||
const entry = map.get(name) || {
|
||||
@@ -535,27 +579,30 @@ function TeamPerformanceCard({ tasks, profiles, deliveries, cardHeight = null })
|
||||
newCount: 0,
|
||||
revCount: 0,
|
||||
};
|
||||
if ((d.version_number || 0) === 0) entry.newCount += 1;
|
||||
else entry.revCount += 1;
|
||||
entry[field] += 1;
|
||||
map.set(name, entry);
|
||||
};
|
||||
eligibleItems.forEach(it => {
|
||||
if (!isAll && it.monthKey !== effectiveMonthKey) return;
|
||||
credit(it.person, it.task, it.kind === 'new' ? 'newCount' : 'revCount');
|
||||
});
|
||||
const people = [...map.values()].map(p => ({ ...p, done: p.newCount + p.revCount })).sort((a, b) => b.done - a.done);
|
||||
return { people, totalDone: people.reduce((s, p) => s + p.done, 0) };
|
||||
}, [deliveries, effectiveMonthKey, profileMap, profileNameMap]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [eligibleItems, effectiveMonthKey, profileMap, profileNameMap]);
|
||||
|
||||
return (
|
||||
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column', height: cardHeight || 'auto', minHeight: cardHeight ? 0 : 280 }}>
|
||||
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column', height: cardHeight || 'auto', minHeight: cardHeight ? 0 : 280 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Team Performance</span>
|
||||
<div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center' }}>
|
||||
<select value={effectiveMonthKey} onChange={e => setMonthKey(e.target.value)} style={{ fontSize: 11, color: 'var(--text-muted)', background: 'transparent', border: 'none', boxShadow: 'none', cursor: 'pointer', outline: 'none', fontFamily: 'inherit', appearance: 'none', WebkitAppearance: 'none', MozAppearance: 'none', paddingRight: 14 }}>
|
||||
{monthOpts.map(o => <option key={o.value} value={o.value} style={{ background: '#1a1a1a' }}>{o.label}</option>)}
|
||||
{displayOpts.map(o => <option key={o.value} value={o.value} style={{ background: '#1a1a1a' }}>{o.label}</option>)}
|
||||
</select>
|
||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" style={{ position: 'absolute', right: 0, pointerEvents: 'none' }} stroke="rgba(255,255,255,0.5)" strokeWidth="1.5" strokeLinecap="round"><polyline points="1,2 4,6 7,2"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
{people.length === 0 ? (
|
||||
<div className="card-empty-center" style={{ flex: 1 }}>No completed tasks this month</div>
|
||||
<div className="card-empty-center" style={{ flex: 1 }}>{effectiveMonthKey === 'all' ? 'No completed tasks' : 'No completed tasks this month'}</div>
|
||||
) : (
|
||||
<div className="table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||
{people.slice(0, 5).map((p, i) => {
|
||||
@@ -577,7 +624,7 @@ function TeamPerformanceCard({ tasks, profiles, deliveries, cardHeight = null })
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', flexShrink: 0, marginLeft: 8 }}>{p.newCount} new · {p.revCount} revision</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<div style={{ flex: 1, height: 4, borderRadius: 2, background: 'rgba(245,165,35,0.15)', overflow: 'hidden' }}>
|
||||
<div style={{ flex: 1, height: 4, borderRadius: 2, background: 'color-mix(in srgb, var(--accent) 15%, transparent)', overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', borderRadius: 2, background: 'var(--accent)', width: `${pct}%`, transition: 'width 0.3s ease' }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', minWidth: 28, textAlign: 'right' }}>{pct}%</span>
|
||||
@@ -618,25 +665,8 @@ export default function TeamDashboard() {
|
||||
const [teamSubInvoices, setTeamSubInvoices] = useState([]);
|
||||
const [myInvoices, setMyInvoices] = useState([]);
|
||||
const [perfDeliveries, setPerfDeliveries] = useState([]);
|
||||
const [perfSubmissions, setPerfSubmissions] = useState([]);
|
||||
const [loading, setLoading] = useState(!cached);
|
||||
const bottomCardsRowRef = useRef(null);
|
||||
const [bottomCardsHeight, setBottomCardsHeight] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
function updateBottomCardsHeight() {
|
||||
const row = bottomCardsRowRef.current;
|
||||
if (!row) return;
|
||||
const rect = row.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
|
||||
const available = Math.floor(viewportHeight - rect.top - 24);
|
||||
const nextHeight = available > 0 ? `${available}px` : null;
|
||||
setBottomCardsHeight(prev => (prev === nextHeight ? prev : nextHeight));
|
||||
}
|
||||
|
||||
updateBottomCardsHeight();
|
||||
window.addEventListener('resize', updateBottomCardsHeight);
|
||||
return () => window.removeEventListener('resize', updateBottomCardsHeight);
|
||||
}, [loading, isTeam, isClient, isExternal]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -647,49 +677,23 @@ export default function TeamDashboard() {
|
||||
cutoff.setMonth(cutoff.getMonth() - CUTOFF_MONTHS);
|
||||
const cutoffStr = cutoff.toISOString();
|
||||
|
||||
const {
|
||||
scopedTaskIds,
|
||||
scopedProjectIds,
|
||||
scopedCompanyIds,
|
||||
} = isTeam
|
||||
? { scopedTaskIds: null, scopedProjectIds: null, scopedCompanyIds: null }
|
||||
: await resolveScopedWorkIds(currentUser, { isClient, isExternal });
|
||||
// Row scope (tasks/projects/submissions/deliveries) is enforced by RLS:
|
||||
// team = all, client = has_company_access, external = project_members.
|
||||
// Only company IDs are needed for the financial queries, derived synchronously
|
||||
// from currentUser (no round trip).
|
||||
const scopedCompanyIds = isTeam ? null : getCurrentUserCompanyIds(currentUser);
|
||||
|
||||
const tasksQuery = supabase.from('tasks')
|
||||
.select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at, project:projects(name), assignee:profiles!assigned_to(avatar_url)')
|
||||
.gte('submitted_at', cutoffStr)
|
||||
.order('submitted_at', { ascending: false });
|
||||
if (isClient) {
|
||||
if ((scopedProjectIds || []).length > 0) tasksQuery.in('project_id', scopedProjectIds);
|
||||
else tasksQuery.eq('id', '__none__');
|
||||
}
|
||||
if (isExternal) {
|
||||
if ((scopedProjectIds || []).length > 0) tasksQuery.in('project_id', scopedProjectIds);
|
||||
else tasksQuery.eq('id', '__none__');
|
||||
}
|
||||
|
||||
const projectsQuery = supabase.from('projects').select('id, name, status, company_id');
|
||||
if (isClient) {
|
||||
if ((scopedCompanyIds || []).length > 0) projectsQuery.in('company_id', scopedCompanyIds);
|
||||
else projectsQuery.eq('id', '__none__');
|
||||
}
|
||||
if (isExternal) {
|
||||
if ((scopedProjectIds || []).length > 0) projectsQuery.in('id', scopedProjectIds);
|
||||
else projectsQuery.eq('id', '__none__');
|
||||
}
|
||||
|
||||
const submissionsQuery = supabase.from('submissions')
|
||||
.select('task_id, version_number, deadline, type, submitted_by, submitted_by_name, is_hot, delivery:deliveries(sent_by, sent_at)')
|
||||
.gte('submitted_at', cutoffStr)
|
||||
.order('version_number', { ascending: false });
|
||||
if (isClient) {
|
||||
if ((scopedTaskIds || []).length > 0) submissionsQuery.in('task_id', scopedTaskIds);
|
||||
else submissionsQuery.eq('task_id', '__none__');
|
||||
}
|
||||
if (isExternal) {
|
||||
if ((scopedTaskIds || []).length > 0) submissionsQuery.in('task_id', scopedTaskIds);
|
||||
else submissionsQuery.eq('task_id', '__none__');
|
||||
}
|
||||
|
||||
const invoicesPromise = isTeam
|
||||
? supabase.from('invoices').select('total, stripe_fee, status, company_id, created_at').in('status', ['sent', 'paid'])
|
||||
@@ -711,7 +715,7 @@ export default function TeamDashboard() {
|
||||
? supabase.from('subcontractor_invoices').select('status, paid_at, items:subcontractor_invoice_items(unit_price, quantity)')
|
||||
: Promise.resolve({ data: [] });
|
||||
|
||||
const [{ data: t }, { data: p }, { data: subs }, { data: profiles }, { data: activity }, { data: invs }, { data: exps }, { data: subcontractorPOs }, { data: subcontractorInvoices }, { data: delivs }] = await withTimeout(Promise.all([
|
||||
const [{ data: t }, { data: p }, { data: subs }, { data: profiles }, { data: activity }, { data: invs }, { data: exps }, { data: subcontractorPOs }, { data: subcontractorInvoices }, { data: delivs }, { data: perfSubs }] = await withTimeout(Promise.all([
|
||||
tasksQuery,
|
||||
projectsQuery,
|
||||
submissionsQuery,
|
||||
@@ -722,6 +726,8 @@ export default function TeamDashboard() {
|
||||
subcontractorPOsPromise,
|
||||
subcontractorInvoicesPromise,
|
||||
supabase.from('deliveries').select('sent_by, sent_at, version_number, submission:submissions!inner(task_id, task:tasks!inner(assigned_to, assigned_name))').gte('sent_at', cutoffStr),
|
||||
// Performance card counts invoice line items: gated by client-approval (see TeamPerformanceCard).
|
||||
supabase.from('submissions').select('task_id, version_number, submitted_by_name, submitted_at, description, task:tasks!inner(status, current_version, assigned_to, assigned_name)').gte('submitted_at', cutoffStr),
|
||||
]), 30000, 'Dashboard load');
|
||||
if (cancelled) return;
|
||||
|
||||
@@ -751,11 +757,9 @@ export default function TeamDashboard() {
|
||||
setTeamExpenses(exps || []);
|
||||
setTeamSubcontractorPOs(subcontractorPOs || []);
|
||||
setTeamSubInvoices(subcontractorInvoices || []);
|
||||
const scopedDeliveryTaskIds = new Set(scopedTaskIds || []);
|
||||
const scopedDeliveries = isTeam
|
||||
? (delivs || [])
|
||||
: (delivs || []).filter(delivery => scopedDeliveryTaskIds.has(delivery.submission?.task_id));
|
||||
setPerfDeliveries(scopedDeliveries);
|
||||
// RLS already scopes deliveries/submissions to the viewer; no client-side filter needed.
|
||||
setPerfDeliveries(delivs || []);
|
||||
setPerfSubmissions(perfSubs || []);
|
||||
|
||||
if (isTeam) {
|
||||
writePageCache(CACHE_KEY, {
|
||||
@@ -835,33 +839,37 @@ export default function TeamDashboard() {
|
||||
const myOutstanding = myInvoices.filter(i => i.status !== 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
|
||||
|
||||
const card3 = isTeam
|
||||
? { label: 'Net Profit', value: fmtMoney(dashNetReceived - dashExpensesTotal), sub: `${fmtMoney(dashExpensesTotal)} expenses + sub costs`, iconBg: 'rgba(74,222,128,0.15)', iconColor: '#4ade80', iconPath: DASH_ICONS.profit }
|
||||
? { label: 'Net Profit', value: fmtMoney(dashNetReceived - dashExpensesTotal), sub: 'expenses + subs', iconBg: 'color-mix(in srgb, var(--positive) 15%, transparent)', iconColor: 'var(--positive)', iconPath: DASH_ICONS.profit }
|
||||
: isClient
|
||||
? { label: 'Outstanding', value: fmtMoney(myOutstanding), sub: 'invoices due', iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: DASH_ICONS.revenue }
|
||||
: { label: 'Pending', value: fmtMoney(myOutstanding), sub: 'awaiting payment', iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: DASH_ICONS.revenue };
|
||||
? { label: 'Outstanding', value: fmtMoney(myOutstanding), sub: 'invoices due', iconBg: 'color-mix(in srgb, var(--accent) 15%, transparent)', iconColor: 'var(--accent)', iconPath: DASH_ICONS.revenue }
|
||||
: { label: 'Pending', value: fmtMoney(myOutstanding), sub: 'awaiting payment', iconBg: 'color-mix(in srgb, var(--accent) 15%, transparent)', iconColor: 'var(--accent)', iconPath: DASH_ICONS.revenue };
|
||||
|
||||
const card4 = isTeam
|
||||
? { label: 'Revenue', value: fmtMoney(dashRevenue), sub: `${fmtMoney(dashOutstanding)} outstanding`, iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: DASH_ICONS.revenue, chartData: revenueByMonth }
|
||||
? { label: 'Revenue', value: fmtMoney(dashRevenue), sub: `${fmtMoney(dashOutstanding)} outstanding`, iconBg: 'color-mix(in srgb, var(--accent) 15%, transparent)', iconColor: 'var(--accent)', iconPath: DASH_ICONS.revenue, chartData: revenueByMonth }
|
||||
: isClient
|
||||
? { label: 'Paid', value: fmtMoney(myPaid), sub: 'total paid to Fourge', iconBg: 'rgba(74,222,128,0.15)', iconColor: '#4ade80', iconPath: DASH_ICONS.profit }
|
||||
: { label: 'Paid', value: fmtMoney(myPaid), sub: 'total paid by Fourge', iconBg: 'rgba(74,222,128,0.15)', iconColor: '#4ade80', iconPath: DASH_ICONS.profit };
|
||||
? { label: 'Paid', value: fmtMoney(myPaid), sub: 'total paid to Fourge', iconBg: 'color-mix(in srgb, var(--positive) 15%, transparent)', iconColor: 'var(--positive)', iconPath: DASH_ICONS.profit }
|
||||
: { label: 'Paid', value: fmtMoney(myPaid), sub: 'total paid by Fourge', iconBg: 'color-mix(in srgb, var(--positive) 15%, transparent)', iconColor: 'var(--positive)', iconPath: DASH_ICONS.profit };
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="dash-stat-grid" style={{ gridTemplateColumns: '1fr 1fr 1fr 1.5fr', marginBottom: 0 }}>
|
||||
<DashStatCard label="Open Tasks" value={activeTasks.length} sub="not complete" iconBg="rgba(167,139,250,0.15)" iconColor="#a78bfa" iconPath={DASH_ICONS.tasks} />
|
||||
<DashStatCard label="Active Projects" value={activeProjects.length} sub={`${projects.length} total`} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={DASH_ICONS.projects} />
|
||||
<div className="dash-stat-grid" style={{ marginBottom: 0 }}>
|
||||
<DashStatCard label="Open Tasks" value={activeTasks.length} sub="not complete" iconBg="color-mix(in srgb, var(--violet) 15%, transparent)" iconColor="var(--violet)" iconPath={DASH_ICONS.tasks} />
|
||||
<DashStatCard label="Active Projects" value={activeProjects.length} sub={`${projects.length} total`} iconBg="color-mix(in srgb, var(--info) 15%, transparent)" iconColor="var(--info)" iconPath={DASH_ICONS.projects} />
|
||||
<DashStatCard label={card3.label} value={card3.value} sub={card3.sub} iconBg={card3.iconBg} iconColor={card3.iconColor} iconPath={card3.iconPath} />
|
||||
<DashStatCard label={card4.label} value={card4.value} sub={card4.sub} iconBg={card4.iconBg} iconColor={card4.iconColor} iconPath={card4.iconPath} chartData={card4.chartData} />
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 280px', gap: 24, marginTop: 24 }}>
|
||||
<ActivityFeed events={activityEvents} />
|
||||
<TasksInProgressCard tasks={inProgressTasks} />
|
||||
<div className="dash-row-todo">
|
||||
<div className="dash-todo-cell">
|
||||
<div className="dash-todo-inner">
|
||||
<HotItemsCard submissions={submissions} tasks={tasks} isClient={isClient} isExternal={isExternal} currentUserId={currentUser?.id || null} cardHeight="100%" />
|
||||
</div>
|
||||
</div>
|
||||
<MiniCalendar items={calendarItems} />
|
||||
</div>
|
||||
<div ref={bottomCardsRowRef} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}>
|
||||
<HotItemsCard submissions={submissions} tasks={tasks} isClient={isClient} isExternal={isExternal} currentUserId={currentUser?.id || null} cardHeight={bottomCardsHeight} />
|
||||
<TeamPerformanceCard tasks={tasks} profiles={allProfiles} deliveries={perfDeliveries} cardHeight={bottomCardsHeight} />
|
||||
<div className="dash-row-trio">
|
||||
<ActivityFeed events={activityEvents} />
|
||||
<TasksInProgressCard tasks={inProgressTasks} />
|
||||
<TeamPerformanceCard tasks={tasks} profiles={allProfiles} deliveries={perfDeliveries} submissions={perfSubmissions} />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
@@ -439,7 +439,7 @@ export function TeamInvoiceDetailPanel({
|
||||
<div style={F}>Total</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--accent)', lineHeight: 1.1 }}>${Number(invoice.total).toFixed(2)}</div>
|
||||
</div>
|
||||
{invoice.paid_at && <div><div style={F}>Paid On</div><div style={{ fontSize: 13, color: 'var(--success, #16a34a)' }}>{new Date(invoice.paid_at).toLocaleDateString()}</div></div>}
|
||||
{invoice.paid_at && <div><div style={F}>Paid On</div><div style={{ fontSize: 13, color: 'var(--success, var(--success-strong))' }}>{new Date(invoice.paid_at).toLocaleDateString()}</div></div>}
|
||||
{invoice.status === 'paid' && invoice.stripe_fee != null && <>
|
||||
<div><div style={F}>Stripe Fee</div><div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>−${Number(invoice.stripe_fee).toFixed(2)}</div></div>
|
||||
<div><div style={F}>Net Received</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>${(Number(invoice.total) - Number(invoice.stripe_fee)).toFixed(2)}</div></div>
|
||||
@@ -559,7 +559,7 @@ export function TeamInvoiceDetailPanel({
|
||||
</div>
|
||||
<div className="detail-item"><label>Total</label><p style={{ fontSize: 18, fontWeight: 400, color: 'var(--accent)' }}>${Number(invoice.total).toFixed(2)}</p></div>
|
||||
{invoice.paid_at && (
|
||||
<div className="detail-item"><label>Paid On</label><p style={{ color: 'var(--success, #16a34a)', fontWeight: 400 }}>{new Date(invoice.paid_at).toLocaleDateString()}</p></div>
|
||||
<div className="detail-item"><label>Paid On</label><p style={{ color: 'var(--success, var(--success-strong))', fontWeight: 400 }}>{new Date(invoice.paid_at).toLocaleDateString()}</p></div>
|
||||
)}
|
||||
{invoice.status === 'paid' && invoice.stripe_fee != null && (
|
||||
<>
|
||||
|
||||
@@ -6,6 +6,7 @@ import Layout from '../../components/Layout';
|
||||
import SortTh from '../../components/SortTh';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { useSortable } from '../../hooks/useSortable';
|
||||
import { useActionLock } from '../../hooks/useActionLock';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
||||
@@ -219,7 +220,7 @@ const MONTHS = ['January','February','March','April','May','June','July','August
|
||||
function FinancesChartTooltip({ active, payload, label, year }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '10px 14px', fontSize: 12 }}>
|
||||
<div style={{ background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 4, padding: '10px 14px', fontSize: 12 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 6, color: 'var(--text-primary)' }}>{label} {year}</div>
|
||||
{payload.map(p => (
|
||||
<div key={p.dataKey} style={{ color: p.color, marginBottom: 2 }}>{p.name}: <strong>${p.value.toLocaleString('en-US', { minimumFractionDigits: 2 })}</strong></div>
|
||||
@@ -297,6 +298,7 @@ export default function Invoices() {
|
||||
const [invItems, setInvItems] = useState([invNewItem()]);
|
||||
const [invNotes, setInvNotes] = useState('');
|
||||
const [invSaving, setInvSaving] = useState(false);
|
||||
const guard = useActionLock();
|
||||
const [invLoadingTasks, setInvLoadingTasks] = useState(false);
|
||||
const invDragItem = useRef(null);
|
||||
const pageLoading = loading || expensesLoading || subcontractorLoading || subInvoicesLoading;
|
||||
@@ -395,7 +397,7 @@ export default function Invoices() {
|
||||
|
||||
const invClose = () => { setShowInvoiceForm(false); setInvCompanyId(''); setInvBillTo(''); setInvEmail(''); setInvRecipients([]); setInvUnbilledTasks([]); setInvUnbilledRevisions([]); setInvPriceList([]); setInvItems([invNewItem()]); setInvNotes(''); };
|
||||
|
||||
const invHandleSave = async (status) => {
|
||||
const invHandleSave = guard('invoice-save', async (status) => {
|
||||
if (!invCompanyId) return alert('Select a company.');
|
||||
if (invItems.every(i => !i.description)) return alert('Add at least one line item.');
|
||||
if (status === 'sent' && !invEmail.trim()) return alert('Enter an email recipient before sending.');
|
||||
@@ -437,7 +439,7 @@ export default function Invoices() {
|
||||
setViewingInvoice(createdInvoice);
|
||||
} catch (e) { alert(`Failed to save invoice: ${e.message || 'Unknown error'}`); }
|
||||
setInvSaving(false);
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
@@ -863,7 +865,7 @@ export default function Invoices() {
|
||||
const ye = chartData.reduce((s, m) => s + m.Expenses, 0);
|
||||
const yo = chartData.reduce((s, m) => s + m.Outstanding, 0);
|
||||
const fmt = v => v >= 100000 ? `$${(v/1000).toLocaleString('en-US')}k` : `$${v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
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: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' };
|
||||
const StatCard = ({ label, value, sub, iconBg, iconColor, iconSvg }) => (
|
||||
<div style={{ ...CARD, display: 'flex', alignItems: 'stretch', gap: 21, minHeight: 120 }}>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
@@ -892,28 +894,28 @@ export default function Invoices() {
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="gc2Revenue" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#F5A523" stopOpacity={0.25}/><stop offset="95%" stopColor="#F5A523" stopOpacity={0}/></linearGradient>
|
||||
<linearGradient id="gc2Profit" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#4ade80" stopOpacity={0.25}/><stop offset="95%" stopColor="#4ade80" stopOpacity={0}/></linearGradient>
|
||||
<linearGradient id="gc2Expenses" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#ef4444" stopOpacity={0.2}/><stop offset="95%" stopColor="#ef4444" stopOpacity={0}/></linearGradient>
|
||||
<linearGradient id="gc2Outstanding" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#60a5fa" stopOpacity={0.2}/><stop offset="95%" stopColor="#60a5fa" stopOpacity={0}/></linearGradient>
|
||||
<linearGradient id="gc2Revenue" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="var(--accent)" stopOpacity={0.25}/><stop offset="95%" stopColor="var(--accent)" stopOpacity={0}/></linearGradient>
|
||||
<linearGradient id="gc2Profit" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="var(--positive)" stopOpacity={0.25}/><stop offset="95%" stopColor="var(--positive)" stopOpacity={0}/></linearGradient>
|
||||
<linearGradient id="gc2Expenses" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="var(--danger)" stopOpacity={0.2}/><stop offset="95%" stopColor="var(--danger)" stopOpacity={0}/></linearGradient>
|
||||
<linearGradient id="gc2Outstanding" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="var(--info)" stopOpacity={0.2}/><stop offset="95%" stopColor="var(--info)" stopOpacity={0}/></linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} tickFormatter={v => `$${v >= 1000 ? (v/1000).toFixed(0)+'k' : v}`} width={45} />
|
||||
<Tooltip content={<FinancesChartTooltip year={chartYear} />} />
|
||||
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} />
|
||||
<Area type="monotone" dataKey="Revenue" stroke="#F5A523" strokeWidth={2} fill="url(#gc2Revenue)" dot={false} activeDot={{ r: 4 }} />
|
||||
<Area type="monotone" dataKey="Profit" stroke="#4ade80" strokeWidth={2} fill="url(#gc2Profit)" dot={false} activeDot={{ r: 4 }} />
|
||||
<Area type="monotone" dataKey="Expenses" stroke="#ef4444" strokeWidth={2} fill="url(#gc2Expenses)" dot={false} activeDot={{ r: 4 }} />
|
||||
<Area type="monotone" dataKey="Outstanding" stroke="#60a5fa" strokeWidth={2} fill="url(#gc2Outstanding)" dot={false} activeDot={{ r: 4 }} />
|
||||
<Area type="monotone" dataKey="Revenue" stroke="var(--accent)" strokeWidth={2} fill="url(#gc2Revenue)" dot={false} activeDot={{ r: 4 }} />
|
||||
<Area type="monotone" dataKey="Profit" stroke="var(--positive)" strokeWidth={2} fill="url(#gc2Profit)" dot={false} activeDot={{ r: 4 }} />
|
||||
<Area type="monotone" dataKey="Expenses" stroke="var(--danger)" strokeWidth={2} fill="url(#gc2Expenses)" dot={false} activeDot={{ r: 4 }} />
|
||||
<Area type="monotone" dataKey="Outstanding" stroke="var(--info)" strokeWidth={2} fill="url(#gc2Outstanding)" dot={false} activeDot={{ r: 4 }} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, alignContent: 'start' }}>
|
||||
<StatCard label="Revenue" value={fmt(yr)} sub={`${chartYear} · paid invoices`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconSvg={<><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5,12 12,5 19,12"/></>} />
|
||||
<StatCard label="Outstanding" value={fmt(yo)} sub={`${chartYear} · sent & unpaid`} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconSvg={<><circle cx="12" cy="12" r="8"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></>} />
|
||||
<StatCard label="Expenses" value={fmt(ye)} sub={`${chartYear} · total spend`} iconBg="rgba(239,68,68,0.15)" iconColor="#ef4444" iconSvg={<><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19,12 12,19 5,12"/></>} />
|
||||
<StatCard label="Net Profit" value={fmt(yp)} sub={`${chartYear} · after expenses`} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconSvg={<><polyline points="4,13 9,18 20,7"/></>} />
|
||||
<StatCard label="Revenue" value={fmt(yr)} sub={`${chartYear} · paid invoices`} iconBg="color-mix(in srgb, var(--accent) 15%, transparent)" iconColor="var(--accent)" iconSvg={<><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5,12 12,5 19,12"/></>} />
|
||||
<StatCard label="Outstanding" value={fmt(yo)} sub={`${chartYear} · sent & unpaid`} iconBg="color-mix(in srgb, var(--info) 15%, transparent)" iconColor="var(--info)" iconSvg={<><circle cx="12" cy="12" r="8"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></>} />
|
||||
<StatCard label="Expenses" value={fmt(ye)} sub={`${chartYear} · total spend`} iconBg="color-mix(in srgb, var(--danger) 15%, transparent)" iconColor="var(--danger)" iconSvg={<><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19,12 12,19 5,12"/></>} />
|
||||
<StatCard label="Net Profit" value={fmt(yp)} sub={`${chartYear} · after expenses`} iconBg="color-mix(in srgb, var(--positive) 15%, transparent)" iconColor="var(--positive)" iconSvg={<><polyline points="4,13 9,18 20,7"/></>} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -943,9 +945,9 @@ export default function Invoices() {
|
||||
</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>
|
||||
<button onClick={() => { setExpYearFilter('all'); setExpYearMenuOpen(false); }} className="site-header-avatar-item" style={{ background: expYearFilter === 'all' ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : '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>
|
||||
<button key={y} onClick={() => { setExpYearFilter(y); setExpYearMenuOpen(false); }} className="site-header-avatar-item" style={{ background: expYearFilter === y ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', color: expYearFilter === y ? 'var(--accent)' : 'var(--text-primary)' }}>{y}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -962,15 +964,15 @@ export default function Invoices() {
|
||||
{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>
|
||||
<button onClick={() => setInvYearFilter('all')} className="site-header-avatar-item" style={{ background: invYearFilter === 'all' ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : '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>
|
||||
<button key={y} onClick={() => setInvYearFilter(y)} className="site-header-avatar-item" style={{ background: invYearFilter === y ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : '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>
|
||||
<button onClick={() => setInvCompanyFilter('all')} className="site-header-avatar-item" style={{ background: invCompanyFilter === 'all' ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : '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>
|
||||
<button key={c} onClick={() => setInvCompanyFilter(c)} className="site-header-avatar-item" style={{ background: invCompanyFilter === c ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', color: invCompanyFilter === c ? 'var(--accent)' : 'var(--text-primary)' }}>{c}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -982,7 +984,7 @@ export default function Invoices() {
|
||||
</div>
|
||||
|
||||
{financeTab === 'overview' && (() => {
|
||||
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: 'var(--card-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 sortRows = (arr, key, dir, getter) => [...arr].sort((a, b) => {
|
||||
@@ -1026,7 +1028,7 @@ export default function Invoices() {
|
||||
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'];
|
||||
const PIE_COLORS = ['var(--accent)', 'var(--positive)', 'var(--info)', 'var(--data-purple-soft)', 'var(--data-pink-soft)', 'var(--data-orange-soft)', 'var(--data-emerald-soft)', 'var(--violet)'];
|
||||
|
||||
// pie by subcontractor
|
||||
const subPieData = Object.values(pendingSubs.reduce((acc, inv) => {
|
||||
@@ -1047,7 +1049,7 @@ export default function Invoices() {
|
||||
const PieTooltipContent = ({ 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={{ background: 'var(--card-bg)', border: 'var(--card-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>
|
||||
@@ -1076,7 +1078,7 @@ export default function Invoices() {
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{MONTHS[m1]} {y1 !== curY ? y1 : ''} Expenses</span>
|
||||
<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>
|
||||
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: 'var(--danger)', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(thisMonthTotal)}</div>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<PieChart>
|
||||
@@ -1085,7 +1087,7 @@ export default function Invoices() {
|
||||
</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>;
|
||||
return <div style={{ background: 'var(--card-bg)', border: 'var(--card-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>
|
||||
@@ -1103,7 +1105,7 @@ export default function Invoices() {
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{MONTHS[m2]} {y2 !== curY ? y2 : ''} Sub Payments</span>
|
||||
<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>
|
||||
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: 'var(--accent)', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(pendingSubsTotal)}</div>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<PieChart>
|
||||
@@ -1112,7 +1114,7 @@ export default function Invoices() {
|
||||
</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>;
|
||||
return <div style={{ background: 'var(--card-bg)', border: 'var(--card-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>
|
||||
@@ -1130,7 +1132,7 @@ export default function Invoices() {
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{MONTHS[m3]} {y3 !== curY ? y3 : ''} Invoiced</span>
|
||||
<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>
|
||||
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: 'var(--positive)', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(outstandingTotal)}</div>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<PieChart>
|
||||
@@ -1139,7 +1141,7 @@ export default function Invoices() {
|
||||
</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>;
|
||||
return <div style={{ background: 'var(--card-bg)', border: 'var(--card-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>
|
||||
@@ -1155,7 +1157,7 @@ export default function Invoices() {
|
||||
})()}
|
||||
|
||||
{financeTab === 'expenses' && (() => {
|
||||
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: 'var(--card-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 + 'T12:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
|
||||
@@ -1201,7 +1203,7 @@ export default function Invoices() {
|
||||
</td>
|
||||
<td style={{ ...TD, fontSize: 12 }}>{exp.category || '—'}</td>
|
||||
<td style={{ ...TD, fontSize: 12 }}>{exp.notes || '—'}</td>
|
||||
<td style={{ ...TD, textAlign: 'right', color: '#ef4444' }}>{fmtAmt(exp.amount)}</td>
|
||||
<td style={{ ...TD, textAlign: 'right', color: 'var(--danger)' }}>{fmtAmt(exp.amount)}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
@@ -1211,7 +1213,7 @@ export default function Invoices() {
|
||||
{/* Right: category breakdown */}
|
||||
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5, flexShrink: 0 }}>Category</div>
|
||||
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#ef4444', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div>
|
||||
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: 'var(--danger)', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div>
|
||||
{categoryTotals.length === 0 ? <div className="card-empty-center">No expenses</div> : (
|
||||
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
@@ -1221,10 +1223,10 @@ export default function Invoices() {
|
||||
<div key={cat}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-primary)' }}>{cat}</span>
|
||||
<span style={{ fontSize: 12, color: '#ef4444', fontVariantNumeric: 'tabular-nums' }}>{fmtAmt(total)}</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--danger)', fontVariantNumeric: 'tabular-nums' }}>{fmtAmt(total)}</span>
|
||||
</div>
|
||||
<div style={{ height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', width: `${pct}%`, background: '#ef4444', borderRadius: 2, opacity: 0.7, transition: 'width 400ms ease' }} />
|
||||
<div style={{ height: '100%', width: `${pct}%`, background: 'var(--danger)', borderRadius: 2, opacity: 0.7, transition: 'width 400ms ease' }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1239,7 +1241,7 @@ export default function Invoices() {
|
||||
})()}
|
||||
|
||||
{financeTab === 'subcontractors' && (() => {
|
||||
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: 'var(--card-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' }) : '—';
|
||||
@@ -1319,7 +1321,7 @@ export default function Invoices() {
|
||||
label={inv.status ? inv.status.charAt(0).toUpperCase() + inv.status.slice(1) : '—'}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ ...TD, textAlign: 'right', color: inv.status === 'paid' ? '#4ade80' : inv.status === 'submitted' ? '#F5A523' : 'var(--text-primary)' }}>
|
||||
<td style={{ ...TD, textAlign: 'right', color: inv.status === 'paid' ? 'var(--positive)' : inv.status === 'submitted' ? 'var(--accent)' : 'var(--text-primary)' }}>
|
||||
{fmtAmt(total)}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1332,7 +1334,7 @@ export default function Invoices() {
|
||||
{/* Right: by subcontractor */}
|
||||
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5, flexShrink: 0 }}>Subcontractors</div>
|
||||
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#F5A523', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div>
|
||||
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: 'var(--accent)', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div>
|
||||
{bySubcontractor.length === 0 ? <div className="card-empty-center">No data</div> : (
|
||||
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
@@ -1342,12 +1344,12 @@ export default function Invoices() {
|
||||
<div key={g.name}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '65%' }}>{g.name}</span>
|
||||
<span style={{ fontSize: 12, color: '#F5A523', fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>{fmtAmt(g.total)}</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--accent)', fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>{fmtAmt(g.total)}</span>
|
||||
</div>
|
||||
<div style={{ height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', width: `${pct}%`, background: '#F5A523', borderRadius: 2, opacity: 0.7, transition: 'width 400ms ease' }} />
|
||||
<div style={{ height: '100%', width: `${pct}%`, background: 'var(--accent)', borderRadius: 2, opacity: 0.7, transition: 'width 400ms ease' }} />
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 3 }}><span style={{ color: g.pending > 0 ? '#F5A523' : 'var(--text-muted)' }}>{fmtAmt(g.pending)} pending</span> · {fmtAmt(g.paid)} paid</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 3 }}><span style={{ color: g.pending > 0 ? 'var(--accent)' : 'var(--text-muted)' }}>{fmtAmt(g.pending)} pending</span> · {fmtAmt(g.paid)} paid</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -1361,7 +1363,7 @@ export default function Invoices() {
|
||||
})()}
|
||||
|
||||
{financeTab === '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: 'var(--card-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' }) : '—';
|
||||
@@ -1436,10 +1438,10 @@ export default function Invoices() {
|
||||
<td style={{ ...TD, textAlign: 'center' }}>
|
||||
<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)' }}>
|
||||
<td style={{ ...TD, textAlign: 'right', fontSize: 12, color: Number(inv.stripe_fee) > 0 ? 'var(--danger)' : '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)' }}>
|
||||
<td style={{ ...TD, textAlign: 'right', color: inv.status === 'paid' ? 'var(--positive)' : inv.status === 'sent' ? 'var(--accent)' : 'var(--text-primary)' }}>
|
||||
{fmtAmt(inv.total)}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1450,7 +1452,7 @@ export default function Invoices() {
|
||||
</div>
|
||||
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5, flexShrink: 0 }}>Companies</div>
|
||||
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#4ade80', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div>
|
||||
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: 'var(--positive)', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div>
|
||||
{byCompany.length === 0 ? <div className="card-empty-center">No data</div> : (
|
||||
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
@@ -1460,15 +1462,15 @@ export default function Invoices() {
|
||||
<div key={g.name}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '65%' }}>{g.name}</span>
|
||||
<span style={{ fontSize: 12, color: '#4ade80', fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>{fmtAmt(g.total)}</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--positive)', fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>{fmtAmt(g.total)}</span>
|
||||
</div>
|
||||
<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: 'var(--positive)', 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>
|
||||
<span style={{ color: g.outstanding > 0 ? 'var(--accent)' : 'var(--text-muted)' }}>{fmtAmt(g.outstanding)} outstanding</span>
|
||||
{' · '}{fmtAmt(g.paid)} paid
|
||||
{g.fees > 0 && <span style={{ color: '#ef4444' }}> · -{fmtAmt(g.fees)} fees</span>}
|
||||
{g.fees > 0 && <span style={{ color: 'var(--danger)' }}> · -{fmtAmt(g.fees)} fees</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1800,7 +1802,7 @@ export default function Invoices() {
|
||||
);
|
||||
return (
|
||||
<div style={popupOverlayStyle} onClick={() => { setViewingExpense(null); setExpenseDetailEditing(false); }}>
|
||||
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
|
||||
{/* Main content row */}
|
||||
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0 }}>
|
||||
{/* Left: details (editable in-place) */}
|
||||
@@ -1895,7 +1897,7 @@ export default function Invoices() {
|
||||
const INPUT = FINANCE_MODAL_INPUT_STYLE;
|
||||
return (
|
||||
<div style={popupOverlayStyle} onClick={cancelExpenseEdit}>
|
||||
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
|
||||
<form onSubmit={handleAddExpense} style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, gap: 0 }}>
|
||||
{/* Main content row */}
|
||||
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0 }}>
|
||||
@@ -1951,7 +1953,7 @@ export default function Invoices() {
|
||||
const INPUT = FINANCE_MODAL_INPUT_STYLE;
|
||||
return (
|
||||
<div style={popupOverlayStyle} onClick={() => { if (!invSaving) invClose(); }}>
|
||||
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0 }}>
|
||||
|
||||
{/* Left: meta fields */}
|
||||
|
||||
@@ -158,7 +158,7 @@ export default function SubInvoiceDetail() {
|
||||
<div className="invoice-detail-meta-item"><label>Invoice #</label><p>{invoice.invoice_number}</p></div>
|
||||
<div className="invoice-detail-meta-item"><label>Status</label><p>{invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'}</p></div>
|
||||
<div className="invoice-detail-meta-item"><label>Submitted</label><p>{invoice.submitted_at ? new Date(invoice.submitted_at).toLocaleDateString() : '—'}</p></div>
|
||||
{invoice.paid_at ? <div className="invoice-detail-meta-item"><label>Paid On</label><p style={{ color: 'var(--success, #16a34a)' }}>{new Date(invoice.paid_at).toLocaleDateString()}</p></div> : null}
|
||||
{invoice.paid_at ? <div className="invoice-detail-meta-item"><label>Paid On</label><p style={{ color: 'var(--success, var(--success-strong))' }}>{new Date(invoice.paid_at).toLocaleDateString()}</p></div> : null}
|
||||
<div className="invoice-detail-meta-item"><label>Line Items</label><p>{sortedItems.length}</p></div>
|
||||
<div className="invoice-detail-meta-item"><label>Total</label><p style={{ fontSize: 18, color: 'var(--accent)' }}>${total.toFixed(2)}</p></div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user