Session 2026-05-29: profile layout 2-col, file browser copy/paste, fbq-proxy/backfill fns, migrations, UI updates
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,12 @@ const buildRevisionItemDescription = (revision) => {
|
||||
return `${projectName} • ${taskTitle} • Revision ${versionLabel}`;
|
||||
};
|
||||
|
||||
const getRevisionChargeQuantity = (revision) => {
|
||||
const version = Number(revision?.version_number || 0);
|
||||
// Incremental billing: R01 is free, each uninvoiced revision from R02+ is one charge unit.
|
||||
return version >= 2 ? 1 : 0;
|
||||
};
|
||||
|
||||
export default function CreateInvoice() {
|
||||
const navigate = useNavigate();
|
||||
const { currentUser } = useAuth();
|
||||
@@ -90,23 +96,22 @@ export default function CreateInvoice() {
|
||||
setInvoiceEmail(recipients[0]?.email || companies.find(c => c.id === selectedCompanyId)?.contact_email || '');
|
||||
if (projects && projects.length > 0) {
|
||||
const projectIds = projects.map(p => p.id);
|
||||
const [{ data: tasks }, { data: revisions }] = await Promise.all([
|
||||
supabase
|
||||
.from('tasks')
|
||||
.select('*, project:projects(name), submissions(service_type, type, version_number)')
|
||||
.in('project_id', projectIds)
|
||||
.eq('invoiced', false)
|
||||
.eq('status', 'client_approved'),
|
||||
supabase
|
||||
const { data: tasks } = await supabase
|
||||
.from('tasks')
|
||||
.select('*, project:projects(name), submissions(service_type, type, version_number)')
|
||||
.in('project_id', projectIds)
|
||||
.eq('invoiced', false)
|
||||
.eq('status', 'client_approved');
|
||||
const approvedTaskIds = (tasks || []).map((t) => t.id).filter(Boolean);
|
||||
const { data: revisions } = approvedTaskIds.length > 0
|
||||
? await supabase
|
||||
.from('submissions')
|
||||
.select('*, task:tasks(id, title, project:projects(name), submissions(service_type, type))')
|
||||
.eq('type', 'revision')
|
||||
.or('revision_type.eq.client_revision,revision_type.is.null')
|
||||
.eq('invoiced', false)
|
||||
.in('task_id',
|
||||
(await supabase.from('tasks').select('id').in('project_id', projectIds)).data?.map(t => t.id) || []
|
||||
),
|
||||
]);
|
||||
.in('task_id', approvedTaskIds)
|
||||
: { data: [] };
|
||||
const tasksWithService = (tasks || []).map(t => {
|
||||
const initial = (t.submissions || []).find(s => s.type === 'initial') || (t.submissions || [])[0];
|
||||
return { ...t, service_type: initial?.service_type || t.title };
|
||||
@@ -141,11 +146,14 @@ export default function CreateInvoice() {
|
||||
const serviceLabel = getRevisionServiceType(revision);
|
||||
const description = buildRevisionItemDescription(revision);
|
||||
const price = priceList.find(p => p.service_type === serviceLabel && p.price_type === 'revision');
|
||||
const revisionChargeQty = getRevisionChargeQuantity(revision);
|
||||
const quantity = revisionChargeQty > 0 ? revisionChargeQty : 1;
|
||||
const unitPrice = revisionChargeQty > 0 ? (price?.price || '') : 0;
|
||||
setItems(prev => {
|
||||
if (prev.length === 1 && !prev[0].description && !prev[0].unit_price) {
|
||||
return [newItem(description, price?.price || '', 1, revision.task_id, revision.id)];
|
||||
return [newItem(description, unitPrice, quantity, revision.task_id, revision.id)];
|
||||
}
|
||||
return [...prev, newItem(description, price?.price || '', 1, revision.task_id, revision.id)];
|
||||
return [...prev, newItem(description, unitPrice, quantity, revision.task_id, revision.id)];
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -319,6 +319,60 @@ function MiniCalendar({ items = [] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function TasksInProgressCard({ tasks = [] }) {
|
||||
const navigate = useNavigate();
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('task');
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const rows = sort(tasks.slice(0, 10), (t, key) => {
|
||||
if (key === 'task') return t.title || '';
|
||||
if (key === 'assigned_to') return t.assigned_name || '';
|
||||
return '';
|
||||
});
|
||||
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={{ 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 && (
|
||||
<button type="button" className="dashboard-inline-link" style={{ fontSize: 11, fontWeight: 500, letterSpacing: 0.4, textTransform: 'uppercase' }} onClick={() => setShowAll((v) => !v)}>
|
||||
{showAll ? 'Show less' : 'Show all'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{rows.length === 0 ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No tasks in progress</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '55%' }} />
|
||||
<col style={{ width: '45%' }} />
|
||||
</colgroup>
|
||||
<thead style={{ background: 'transparent' }}>
|
||||
<tr style={{ background: 'transparent' }}>
|
||||
<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="assigned_to" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'right', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 5px 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Assigned To</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleRows.map((t) => (
|
||||
<tr key={t.id} style={{ background: 'transparent' }}>
|
||||
<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('/requests/' + t.id)}>{t.title}</button>
|
||||
</td>
|
||||
<td style={{ fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'right' }}>
|
||||
{t.assigned_to
|
||||
? <button type="button" className="dashboard-inline-link" onClick={() => navigate(profilePath(t.assigned_to, t.assignee_role))}>{t.assigned_name || 'Profile'}</button>
|
||||
: (t.assigned_name || '—')}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HotItemsCard({ submissions, tasks }) {
|
||||
const navigate = useNavigate();
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('deadline');
|
||||
@@ -344,10 +398,10 @@ function HotItemsCard({ submissions, tasks }) {
|
||||
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={{ marginBottom: hotItems.length > 0 ? 14 : 0, flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Hot Items</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Hot Tasks</span>
|
||||
</div>
|
||||
{hotItems.length === 0 ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No hot items</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No hot tasks</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
@@ -467,7 +521,13 @@ function ClientHighlightCard({ highlights }) {
|
||||
);
|
||||
}
|
||||
|
||||
function TeamPerformanceCard({ tasks }) {
|
||||
function TeamPerformanceCard({ tasks, profiles }) {
|
||||
const navigate = useNavigate();
|
||||
const profileMap = useMemo(() => {
|
||||
const m = new Map();
|
||||
(profiles || []).forEach(p => m.set(p.id, p));
|
||||
return m;
|
||||
}, [profiles]);
|
||||
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
|
||||
const allMonthOpts = useMemo(() => {
|
||||
const opts = [];
|
||||
@@ -495,7 +555,7 @@ function TeamPerformanceCard({ tasks }) {
|
||||
const d = new Date(t.completed_at);
|
||||
if (d.getFullYear() !== year || d.getMonth() + 1 !== month) return;
|
||||
if (!t.assigned_name) return;
|
||||
const entry = map.get(t.assigned_name) || { name: t.assigned_name, newCount: 0, revCount: 0 };
|
||||
const entry = map.get(t.assigned_name) || { name: t.assigned_name, id: t.assigned_to || null, newCount: 0, revCount: 0 };
|
||||
if ((t.current_version || 0) === 0) entry.newCount += 1;
|
||||
else entry.revCount += 1;
|
||||
map.set(t.assigned_name, entry);
|
||||
@@ -524,10 +584,10 @@ function TeamPerformanceCard({ tasks }) {
|
||||
const tone = iconTone(p.name);
|
||||
return (
|
||||
<div key={p.name} style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: i > 0 ? 10 : 0 }}>
|
||||
<Avatar name={p.name} />
|
||||
{(() => { const prof = p.id ? profileMap.get(p.id) : null; const tone = iconTone(p.name); return prof?.avatar_url ? <div style={{ width: 27, height: 27, borderRadius: '50%', flexShrink: 0, overflow: 'hidden' }}><img src={prof.avatar_url} alt={p.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div> : <Avatar name={p.name} />; })()}
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-primary)', fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
|
||||
{p.id ? <button type="button" className="dashboard-inline-link" style={{ fontSize: 13, fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} onClick={() => navigate(`/profile/${p.id}`)}>{p.name}</button> : <span style={{ fontSize: 13, color: 'var(--text-primary)', fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>}
|
||||
<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 }}>
|
||||
@@ -562,6 +622,7 @@ export default function TeamDashboard() {
|
||||
const [clientProfiles, setClientProfiles] = useState(() => cached?.clientProfiles || []);
|
||||
const [companyMemberships, setCompanyMemberships] = useState(() => cached?.companyMemberships || []);
|
||||
const [activityLog, setActivityLog] = useState(() => cached?.activityLog || []);
|
||||
const [allProfiles, setAllProfiles] = useState(() => cached?.allProfiles || []);
|
||||
const [teamInvoices, setTeamInvoices] = useState(() => cached?.teamInvoices || []);
|
||||
const [teamExpenses, setTeamExpenses] = useState([]);
|
||||
const [loading, setLoading] = useState(!cached);
|
||||
@@ -586,7 +647,7 @@ export default function TeamDashboard() {
|
||||
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at').gte('submitted_at', cutoffStr).order('submitted_at', { ascending: false }),
|
||||
supabase.from('projects').select('id, name, status, company_id'),
|
||||
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 }),
|
||||
supabase.from('profiles').select('id, role, name, email, company_id, brand_book_rate'),
|
||||
supabase.from('profiles').select('id, role, name, email, company_id, brand_book_rate, avatar_url'),
|
||||
supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action, task_id, task_title, project_name, project_id').order('created_at', { ascending: false }).limit(20),
|
||||
supabase.from('companies').select('id, name').order('name'),
|
||||
supabase.from('company_members').select('company_id, profile_id'),
|
||||
@@ -613,6 +674,7 @@ export default function TeamDashboard() {
|
||||
setProjects(p || []);
|
||||
setSubmissions(subsWithRole);
|
||||
setClientProfiles(clients);
|
||||
setAllProfiles(profiles || []);
|
||||
setAllCompanies(cos || []);
|
||||
setCompanyMemberships(memRows || []);
|
||||
setActivityLog(activity || []);
|
||||
@@ -662,6 +724,7 @@ export default function TeamDashboard() {
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
|
||||
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
|
||||
const inProgressTasks = tasks.filter(t => t.status === 'in_progress');
|
||||
const activeTasks = tasks.filter(t => !doneStatuses.includes(t.status));
|
||||
const activeProjects = projects.filter(p => p.status !== 'completed' && p.status !== 'cancelled');
|
||||
const hotTaskIds = new Set((submissions || []).filter(s => s.is_hot).map(s => s.task_id));
|
||||
@@ -696,13 +759,14 @@ export default function TeamDashboard() {
|
||||
<DashStatCard label="Net Profit" value={fmtMoney(dashRevenue - dashExpensesTotal)} sub={`${fmtMoney(dashExpensesTotal)} expenses`} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={DASH_ICONS.profit} />
|
||||
<DashStatCard label="Revenue" value={fmtMoney(dashRevenue)} sub={`${fmtMoney(dashOutstanding)} outstanding`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={DASH_ICONS.revenue} chartData={revenueByMonth} />
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 280px', gap: 24, marginTop: 24 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 280px', gap: 24, marginTop: 24 }}>
|
||||
<ActivityFeed events={activityEvents} />
|
||||
<TasksInProgressCard tasks={inProgressTasks} />
|
||||
<MiniCalendar items={calendarItems} />
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}>
|
||||
<HotItemsCard submissions={submissions} tasks={tasks} />
|
||||
<TeamPerformanceCard tasks={tasks} />
|
||||
<TeamPerformanceCard tasks={tasks} profiles={allProfiles} />
|
||||
</div>
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<ClientHighlightCard highlights={teamHighlights} />
|
||||
|
||||
Reference in New Issue
Block a user