fix: crash bugs in TeamInvoices + faster load

Bug fixes:
- TeamInvoices: add useAuth import/currentUser (invoice-create crash)
- TeamInvoices: setChartYear -> setExportYear (year-dropdown crash)
- TeamDashboard: drop redundant setState-in-effect (cascading renders)
- Companies: delete dead _UnusedClientCompanies (illegal hook calls)
- annotate intentional empty PDF-fallback catches

Load speed:
- preconnect/dns-prefetch to Supabase origin
- lazy-load heic-to in Converters: page chunk 2737KB -> 9KB
- split recharts into its own 'charts' vendor chunk

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Krao Hasanee
2026-06-08 12:59:11 -04:00
parent 85625f4d95
commit 04e0911e9f
101 changed files with 11786 additions and 7445 deletions
+238 -135
View File
@@ -1,23 +1,36 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { useState, useEffect, useRef, useMemo } from 'react';
import { Link } from 'react-router-dom';
import Layout from '../components/Layout';
import PageLoader from '../components/PageLoader';
import ProfileAvatar from '../components/ProfileAvatar';
import StatusBadge from '../components/StatusBadge';
import RequestForm from '../components/RequestForm';
import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext';
import { readPageCache, writePageCache } from '../lib/pageCache';
import { withTimeout } from '../lib/withTimeout';
import { getCurrentVersionForTask, getDeadlineSourceSubmission } from '../lib/taskDeadlines';
import { getTaskDerivedState } from '../lib/taskVersions';
import { logActivity } from '../lib/activityLog';
import { fmtShortDate } from '../lib/dates';
import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../lib/requestSubmission';
import { ensureProjectFolder, ensureRequestFolder, uploadRequestFileToSurvey } from '../lib/folderSync';
import { sendEmail } from '../lib/email';
import { uploadFilesToRequestInfo, createTaskFolder } from '../lib/filebrowserFolders';
import SortTh from '../components/SortTh';
import { useSortable } from '../hooks/useSortable';
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
import { useRefetchOnFocus } from '../hooks/useRefetchOnFocus';
import { useRealtimeSubscription } from '../hooks/useRealtimeSubscription';
import { popupOverlayStyle } from '../lib/popupStyles';
import { useLiveRefresh } from '../hooks/useLiveRefresh';
import { resolveScopedWorkIds } from '../lib/workScope';
import { mergeSubmissionDisplayNames } from '../lib/submissionDisplay';
import {
TASK_TABLE_TH_STYLE,
TASK_TABLE_TD_BASE,
TASK_MODAL_TITLE_STYLE,
TASK_MODAL_LABEL_STYLE,
TASK_MODAL_INPUT_STYLE,
TASK_MODAL_BUTTON_STYLE,
TASK_MODAL_SHELL_STYLE,
PROJECT_MODAL_SHELL_STYLE,
} from '../lib/taskUi';
const TASK_STAT_ICONS = {
total: '<rect x="3" y="4" width="18" height="16" rx="2"/><line x1="7" y1="9" x2="17" y2="9"/><line x1="7" y1="13" x2="17" y2="13"/>',
@@ -28,19 +41,11 @@ const TASK_STAT_ICONS = {
done: '<polyline points="4,12 9,17 20,6"/>',
};
const TASK_TABLE_TH_STYLE = { fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.6, color: 'var(--text-muted)', textAlign: 'left', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' };
const TASK_TABLE_TD_BASE = { padding: '5px', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
const TASK_MODAL_TITLE_STYLE = { fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' };
const TASK_MODAL_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 6 };
const TASK_MODAL_INPUT_STYLE = { fontSize: 13, padding: '0 5px', textAlign: 'left', lineHeight: 1 };
const TASK_MODAL_BUTTON_STYLE = { lineHeight: 1, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' };
const TASK_MODAL_SHELL_STYLE = { ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(720px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto' };
const PROJECT_MODAL_SHELL_STYLE = { ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(434px, 100%)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto' };
const TASK_STATUS_SORT_RANK = { not_started: 0, in_progress: 1, on_hold: 2, client_review: 3, client_approved: 4, invoiced: 5, paid: 6 };
function TaskStatCard({ label, value, iconBg, iconColor, iconPath }) {
function TaskStatCard({ label, value, sub, iconBg, iconColor, iconPath }) {
return (
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '14px 21px', minHeight: 86 }}>
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', minHeight: 120 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
@@ -48,6 +53,7 @@ function TaskStatCard({ label, value, iconBg, iconColor, iconPath }) {
</div>
</div>
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 5 }}>{sub}</div>
</div>
);
}
@@ -58,14 +64,17 @@ function TasksStatsRow({ tasks = [] }) {
const onHold = tasks.filter(t => t?.status === 'on_hold').length;
const inReview = tasks.filter(t => t?.status === 'client_review').length;
const completed = tasks.filter(t => ['client_approved', 'invoiced', 'paid'].includes(t?.status)).length;
const total = tasks.length;
const open = Math.max(total - completed, 0);
const pct = (count) => total > 0 ? `${Math.round((count / total) * 100)}% of total` : '0% of total';
return (
<div className="dash-stat-grid" style={{ gridTemplateColumns: '1fr 1fr 1fr 1fr 1fr 1fr' }}>
<TaskStatCard label="To Do" value={toDo} iconBg="rgba(167,139,250,0.15)" iconColor="#a78bfa" iconPath={TASK_STAT_ICONS.todo} />
<TaskStatCard label="In Progress" value={inProgress} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={TASK_STAT_ICONS.progress} />
<TaskStatCard label="On Hold" value={onHold} iconBg="rgba(239,68,68,0.15)" iconColor="#ef4444" iconPath={TASK_STAT_ICONS.hold} />
<TaskStatCard label="In Review" value={inReview} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={TASK_STAT_ICONS.review} />
<TaskStatCard label="Completed" value={completed} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={TASK_STAT_ICONS.done} />
<TaskStatCard label="Total Tasks" value={tasks.length} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={TASK_STAT_ICONS.total} />
<TaskStatCard label="To Do" value={toDo} sub={pct(toDo)} iconBg="rgba(167,139,250,0.15)" iconColor="#a78bfa" iconPath={TASK_STAT_ICONS.todo} />
<TaskStatCard label="In Progress" value={inProgress} sub={pct(inProgress)} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={TASK_STAT_ICONS.progress} />
<TaskStatCard label="On Hold" value={onHold} sub={pct(onHold)} iconBg="rgba(239,68,68,0.15)" iconColor="#ef4444" iconPath={TASK_STAT_ICONS.hold} />
<TaskStatCard label="In Review" value={inReview} sub={pct(inReview)} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={TASK_STAT_ICONS.review} />
<TaskStatCard label="Completed" value={completed} sub={pct(completed)} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={TASK_STAT_ICONS.done} />
<TaskStatCard label="Total Tasks" value={total} sub={`${open} still open`} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={TASK_STAT_ICONS.total} />
</div>
);
}
@@ -80,7 +89,7 @@ function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, c
};
const completedStatuses = new Set(['completed', 'cancelled', 'archived', 'done']);
const projectTabs = [
{ id: 'all', label: 'All Projects' },
{ id: 'all', label: 'All' },
{ id: 'active', label: 'Active' },
{ id: 'completed', label: 'Completed' },
];
@@ -89,6 +98,11 @@ function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, c
const done = completedStatuses.has((p?.status || '').toLowerCase());
return projTab === 'completed' ? done : !done;
});
const projectTabCounts = {
all: projects.length,
active: projects.filter((p) => !completedStatuses.has((p?.status || '').toLowerCase())).length,
completed: projects.filter((p) => completedStatuses.has((p?.status || '').toLowerCase())).length,
};
const sortedProjects = [...visibleProjects].sort((a, b) => {
if (projSortKey === 'status') {
const ra = completedStatuses.has((a.status || '').toLowerCase()) ? 1 : 0;
@@ -119,7 +133,14 @@ function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, c
<div style={{ flex: '0 0 calc(30% - 24px)', minWidth: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10, flexShrink: 0 }}>
{projectTabs.map(t => (
<button key={t.id} onClick={() => setProjTab(t.id)} className={`section-tab-btn${projTab === t.id ? ' is-active' : ''}`}>{t.label}</button>
<button key={t.id} onClick={() => setProjTab(t.id)} className={`section-tab-btn${projTab === t.id ? ' is-active' : ''}`}>
{t.label}
{projectTabCounts[t.id] > 0 && (
<span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: projTab === t.id ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}>
{projectTabCounts[t.id]}
</span>
)}
</button>
))}
{onAddProject && (
<div style={{ marginLeft: 'auto' }}>
@@ -128,39 +149,41 @@ function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, c
)}
</div>
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', height: '100%', boxSizing: 'border-box', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '60%' }} />
<col style={{ width: '40%' }} />
</colgroup>
<thead>
<tr>
<SortTh col="name" sortKey={projSortKey} sortDir={projSortDir} onSort={toggleProjSort} style={TASK_TABLE_TH_STYLE}>Name</SortTh>
<SortTh col="status" sortKey={projSortKey} sortDir={projSortDir} onSort={toggleProjSort} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Status</SortTh>
</tr>
</thead>
<tbody>
{sortedProjects.length === 0 ? (
<tr><td colSpan={2} style={{ fontSize: 13, color: 'var(--text-muted)', padding: '28px 5px', border: 'none', background: 'transparent', textAlign: 'center' }}>No projects.</td></tr>
) : sortedProjects.map(p => (
<tr key={p.id}>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)', textAlign: 'left' }}>
<Link to={`/projects/${p.id}`} className="table-link">{p.name}</Link>
</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-muted)', textAlign: 'center' }}>
<Link to={`/projects/${p.id}`} className="table-link" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6, width: '100%' }}>
<span style={{ width: 68, height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden', position: 'relative', flexShrink: 0 }}>
<span style={{ position: 'absolute', inset: 0, width: `${projectCompletionPct(p)}%`, background: 'var(--accent)', borderRadius: 2 }} />
</span>
<span style={{ minWidth: 28, textAlign: 'right', fontSize: 12, color: 'var(--text-secondary)' }}>{projectCompletionPct(p)}%</span>
</Link>
</td>
{sortedProjects.length === 0 ? (
<div className="card-empty-center">No projects</div>
) : (
<div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '60%' }} />
<col style={{ width: '40%' }} />
</colgroup>
<thead>
<tr>
<SortTh col="name" sortKey={projSortKey} sortDir={projSortDir} onSort={toggleProjSort} style={TASK_TABLE_TH_STYLE}>Name</SortTh>
<SortTh col="status" sortKey={projSortKey} sortDir={projSortDir} onSort={toggleProjSort} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Status</SortTh>
</tr>
))}
</tbody>
</table>
</div>
</thead>
<tbody>
{sortedProjects.map(p => (
<tr key={p.id}>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)', textAlign: 'left' }}>
<Link to={`/projects/${p.id}`} className="table-link">{p.name}</Link>
</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-muted)', textAlign: 'center' }}>
<Link to={`/projects/${p.id}`} className="table-link" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6, width: '100%' }}>
<span style={{ width: 68, height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden', position: 'relative', flexShrink: 0 }}>
<span style={{ position: 'absolute', inset: 0, width: `${projectCompletionPct(p)}%`, background: 'var(--accent)', borderRadius: 2 }} />
</span>
<span style={{ minWidth: 28, textAlign: 'right', fontSize: 12, color: 'var(--text-secondary)' }}>{projectCompletionPct(p)}%</span>
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
@@ -174,10 +197,7 @@ export default function RequestsPage() {
const isExternal = currentUser?.role === 'external';
const isClient = currentUser?.role === 'client';
const [refreshKey, setRefreshKey] = useState(0);
const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
useRefetchOnFocus(refresh);
useRealtimeSubscription(['tasks', 'projects', 'submissions'], refresh);
const { refreshKey } = useLiveRefresh(['tasks', 'projects', 'submissions']);
const teamCached = isTeam ? readPageCache('team_requests') : null;
const extCached = isExternal ? readPageCache(`ext-requests:${currentUser?.id}`, 3 * 60_000) : null;
@@ -185,6 +205,7 @@ export default function RequestsPage() {
const [projects, setProjects] = useState(() => teamCached?.projects || extCached?.projects || []);
const [tasks, setTasks] = useState(() => teamCached?.tasks || extCached?.tasks || []);
const [submissions, setSubmissions] = useState(() => teamCached?.submissions || extCached?.submissions || []);
const [deliveries, setDeliveries] = useState([]);
const [companies, setCompanies] = useState(() => teamCached?.companies || []);
const [loading, setLoading] = useState(() => {
if (isTeam) return !teamCached;
@@ -193,7 +214,7 @@ export default function RequestsPage() {
});
const [error, setError] = useState('');
const [loadError, setLoadError] = useState(false);
const [activeTab, setActiveTab] = useState('not_started');
const [activeTab, setActiveTab] = useState('new_requests');
const [filterCompany, setFilterCompany] = useState('');
const [filterProject, setFilterProject] = useState('');
const { sortKey, sortDir, toggle, sort } = useSortable('status', 'asc');
@@ -238,37 +259,11 @@ export default function RequestsPage() {
try {
setError(''); setLoadError(false);
let scopedProjectIds = null;
let scopedTaskIds = null;
if (isClient) {
const clientCos = currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : []);
const companyIds = clientCos.map(c => c.id).filter(Boolean);
if (companyIds.length > 0) {
const { data: projRows } = await withTimeout(
supabase.from('projects').select('id, tasks(id)').in('company_id', companyIds), 10000, 'Client project scope'
);
scopedProjectIds = (projRows || []).map(p => p.id);
scopedTaskIds = (projRows || []).flatMap(p => (p.tasks || []).map(t => t.id));
} else {
scopedProjectIds = []; scopedTaskIds = [];
}
}
if (isExternal) {
const { data: memberData } = await withTimeout(
supabase.from('project_members').select('project_id').eq('profile_id', currentUser.id), 10000, 'External project scope'
);
scopedProjectIds = (memberData || []).map(r => r.project_id).filter(Boolean);
if (scopedProjectIds.length > 0) {
const { data: taskRows } = await withTimeout(
supabase.from('tasks').select('id').in('project_id', scopedProjectIds), 10000, 'External task scope'
);
scopedTaskIds = (taskRows || []).map(r => r.id).filter(Boolean);
} else {
scopedTaskIds = [];
}
}
const { scopedProjectIds, scopedTaskIds } = await withTimeout(
resolveScopedWorkIds(currentUser, { isClient, isExternal }),
10000,
'Tasks scope'
);
const tasksQ = supabase.from('tasks')
.select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at, submitted_at, assignee:profiles!assigned_to(avatar_url)')
@@ -298,12 +293,38 @@ export default function RequestsPage() {
);
if (cancelled) return;
setProjects(p || []); setTasks(t || []); setSubmissions(subs || []); setCompanies(co || []);
const allSubs = subs || [];
const submitterIds = [...new Set(allSubs.map((submission) => submission.submitted_by).filter(Boolean))];
let submitterProfiles = [];
if (submitterIds.length > 0) {
const { data: profileRows } = await withTimeout(
supabase.from('profiles').select('id, name').in('id', submitterIds),
10000,
'Tasks submitter profiles load'
);
submitterProfiles = profileRows || [];
}
const hydratedSubs = mergeSubmissionDisplayNames(allSubs, submitterProfiles);
let deliveryRows = [];
if (hydratedSubs.length > 0) {
const { data: delRows } = await withTimeout(
supabase.from('deliveries').select('id, submission_id, version_number, sent_at').in('submission_id', hydratedSubs.map(sub => sub.id)),
10000,
'Tasks deliveries load'
);
deliveryRows = delRows || [];
}
setProjects(p || []);
setTasks(t || []);
setSubmissions(hydratedSubs);
setDeliveries(deliveryRows);
setCompanies(co || []);
if (isTeam) {
writePageCache('team_requests', { submissions: subs || [], tasks: t || [], projects: p || [], companies: co || [] });
writePageCache('team_requests', { submissions: hydratedSubs, tasks: t || [], projects: p || [], companies: co || [] });
} else if (isExternal) {
writePageCache(`ext-requests:${currentUser.id}`, { projects: p || [], tasks: t || [], submissions: subs || [] });
writePageCache(`ext-requests:${currentUser.id}`, { projects: p || [], tasks: t || [], submissions: hydratedSubs });
}
} catch (err) {
console.error('Tasks page load failed:', err);
@@ -317,7 +338,7 @@ export default function RequestsPage() {
return () => { cancelled = true; };
}, [isTeam, isExternal, isClient, currentUser?.id, refreshKey]); // eslint-disable-line react-hooks/exhaustive-deps
const handleAddRequest = async (formData, _files, existingProjects) => {
const handleAddRequest = async (formData, files, existingProjects) => {
if (addSaving) return;
setAddSaving(true); setAddError('');
try {
@@ -326,7 +347,6 @@ export default function RequestsPage() {
const { task } = await createTaskForRequest({ projectId: resolvedProject.id, title: formData.title.trim(), requestKey: addRequestKey });
if (!task) throw new Error('Failed to create task.');
const taskCompany = companies?.find(c => c.id === formData.companyId);
createTaskFolder(taskCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
const { submission: sub } = await createInitialSubmissionForRequest({
taskId: task.id, requestKey: addRequestKey, isHot: formData.isHot,
serviceType: formData.serviceType, signFamily: formData.signFamily,
@@ -335,12 +355,67 @@ export default function RequestsPage() {
description: formData.description, submittedBy: formData.requestedBy, submittedByName: formData.requestedByName,
});
if (!sub) throw new Error('Failed to create submission.');
try {
await ensureRequestFolder({ projectId: resolvedProject.id, taskTitle: formData.title.trim() });
} catch (folderError) {
console.error('Request folder sync failed:', folderError);
}
if (files.length > 0) {
const taskCompany = companies?.find(c => c.id === formData.companyId);
setShowAddForm(false);
setUploadProgress({ active: true, label: 'Uploading files…', percent: 0 });
for (let fi = 0; fi < files.length; fi++) {
const file = files[fi];
setUploadProgress({ active: true, label: file.name, percent: Math.round((fi / files.length) * 100) });
const path = `${task.id}/Survey/${Date.now()}_${file.name}`;
const { data: uploaded, error: uploadError } = await supabase.storage.from('submissions').upload(path, file);
if (uploadError) {
await supabase.from('tasks').delete().eq('id', task.id);
setUploadProgress({ active: false, label: '', percent: 0 });
throw new Error(`Upload failed: ${uploadError.message}`);
}
if (uploaded) {
const { error: fileErr } = await supabase.from('submission_files').insert({ submission_id: sub.id, name: file.name, storage_path: path, size: file.size });
if (fileErr) {
await supabase.from('tasks').delete().eq('id', task.id);
setUploadProgress({ active: false, label: '', percent: 0 });
throw new Error(`File record failed: ${fileErr.message}`);
}
try {
await uploadRequestFileToSurvey({
projectId: resolvedProject.id,
taskTitle: formData.title.trim(),
storagePath: path,
fileName: file.name,
});
} catch (mirrorError) {
console.error('Survey folder mirror failed:', mirrorError);
}
}
}
setUploadProgress({ active: false, label: '', percent: 0 });
}
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'request_submitted', taskId: task.id, taskTitle: task.title, projectId: resolvedProject.id, projectName: resolvedProject.name }).catch(() => {});
sendEmail('new_request', 'hello@fourgebranding.com', {
clientName: formData.requestedByName || currentUser.name,
clientEmail: currentUser.email || 'hello@fourgebranding.com',
company: taskCompany?.name || '',
serviceType: formData.serviceType,
projectName: resolvedProject.name,
projectId: resolvedProject.id,
deadline: formData.deadline,
description: formData.description,
taskId: task.id,
}).catch(() => {});
const [{ data: newSubs }, { data: newTasks }] = await Promise.all([
supabase.from('submissions').select('id, task_id, submitted_at, submitted_by, submitted_by_name, is_hot, service_type, deadline, version_number, type').order('submitted_at', { ascending: false }),
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at, submitted_at, assignee:profiles!assigned_to(avatar_url)').order('submitted_at', { ascending: false }),
]);
setSubmissions(newSubs || []); setTasks(newTasks || []);
const submitterIds = [...new Set((newSubs || []).map((submission) => submission.submitted_by).filter(Boolean))];
const { data: submitterProfiles } = submitterIds.length > 0
? await supabase.from('profiles').select('id, name').in('id', submitterIds)
: { data: [] };
setSubmissions(mergeSubmissionDisplayNames(newSubs || [], submitterProfiles || [])); setTasks(newTasks || []);
setShowAddForm(false); setAddFormKey(k => k + 1); setAddRequestKey(crypto.randomUUID());
} catch (err) { setAddError(err.message); }
finally { setAddSaving(false); }
@@ -355,7 +430,6 @@ export default function RequestsPage() {
const resolvedProject = await findOrCreateProject(formData.companyId, formData.project.trim(), existingProjects);
const { task } = await createTaskForRequest({ projectId: resolvedProject.id, title: formData.title.trim(), requestKey: addRequestKey });
if (!task) throw new Error('Failed to create task.');
createTaskFolder(selectedCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
const { submission } = await createInitialSubmissionForRequest({
taskId: task.id, requestKey: addRequestKey, isHot: formData.isHot,
serviceType: formData.serviceType, signFamily: formData.signFamily,
@@ -363,28 +437,42 @@ export default function RequestsPage() {
deadline: formData.deadline,
description: formData.description, submittedBy: currentUser.id, submittedByName: currentUser.name,
});
try {
await ensureRequestFolder({ projectId: resolvedProject.id, taskTitle: formData.title.trim() });
} catch (folderError) {
console.error('Request folder sync failed:', folderError);
}
if (submission && files.length > 0) {
setShowAddForm(false);
setUploadProgress({ active: true, label: 'Uploading files…', percent: 0 });
for (let fi = 0; fi < files.length; fi++) {
const file = files[fi];
setUploadProgress({ active: true, label: file.name, percent: Math.round((fi / files.length) * 100) });
const path = `${task.id}/${Date.now()}_${file.name}`;
const path = `${task.id}/Survey/${Date.now()}_${file.name}`;
const { data: uploaded, error: uploadError } = await supabase.storage.from('submissions').upload(path, file);
if (uploadError) { await supabase.from('tasks').delete().eq('id', task.id); setUploadProgress({ active: false, label: '', percent: 0 }); throw new Error(`Upload failed: ${uploadError.message}`); }
if (uploaded) {
const { error: fileErr } = await supabase.from('submission_files').insert({ submission_id: submission.id, name: file.name, storage_path: path, size: file.size });
if (fileErr) { await supabase.from('tasks').delete().eq('id', task.id); setUploadProgress({ active: false, label: '', percent: 0 }); throw new Error(`File record failed: ${fileErr.message}`); }
try {
await uploadRequestFileToSurvey({
projectId: resolvedProject.id,
taskTitle: formData.title.trim(),
storagePath: path,
fileName: file.name,
});
} catch (mirrorError) {
console.error('Survey folder mirror failed:', mirrorError);
}
}
}
uploadFilesToRequestInfo(files, selectedCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
setUploadProgress({ active: false, label: '', percent: 0 });
}
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'request_submitted', taskId: task.id, taskTitle: task.title, projectId: resolvedProject.id, projectName: resolvedProject.name }).catch(() => {});
sendEmail('new_request', 'hello@fourgebranding.com', {
clientName: currentUser.name, clientEmail: currentUser.email,
company: selectedCompany?.name || '', serviceType: formData.serviceType,
projectName: formData.project, deadline: formData.deadline,
projectName: formData.project, projectId: resolvedProject.id, deadline: formData.deadline,
description: formData.description, taskId: task.id,
}).catch(() => {});
const refreshCompanyIds = clientCos.map(c => c.id).filter(Boolean);
@@ -399,7 +487,11 @@ export default function RequestsPage() {
.select('id, task_id, submitted_at, submitted_by, submitted_by_name, is_hot, service_type, deadline, version_number, type')
.in('task_id', refreshTaskIds.length > 0 ? refreshTaskIds : ['__none__'])
.order('submitted_at', { ascending: false });
setProjects(refreshProjects || []); setTasks(newTasks || []); setSubmissions(newSubs || []);
const submitterIds = [...new Set((newSubs || []).map((submission) => submission.submitted_by).filter(Boolean))];
const { data: submitterProfiles } = submitterIds.length > 0
? await supabase.from('profiles').select('id, name').in('id', submitterIds)
: { data: [] };
setProjects(refreshProjects || []); setTasks(newTasks || []); setSubmissions(mergeSubmissionDisplayNames(newSubs || [], submitterProfiles || []));
setShowAddForm(false); setAddFormKey(k => k + 1); setAddRequestKey(crypto.randomUUID());
} catch (err) { setAddError(err.message); }
finally { setAddSaving(false); }
@@ -417,6 +509,11 @@ export default function RequestsPage() {
.from('projects').insert({ name, company_id: addProjectForm.companyId, status: 'active' })
.select('id, name, status, company_id, company:companies(id, name)').single();
if (insertError) throw insertError;
try {
await ensureProjectFolder({ projectId: newProject.id });
} catch (folderError) {
console.error('Project folder sync failed:', folderError);
}
setProjects(prev => [...prev, newProject]);
setShowAddProjectForm(false); setAddProjectForm({ name: '', companyId: '' });
} catch (err) { setAddProjectError(err.message || 'Failed to create project.'); }
@@ -436,37 +533,29 @@ export default function RequestsPage() {
const allRows = useMemo(() => {
if (isClient) {
return tasks.map(task => {
const sub = submissions.find(s => s.task_id === task.id && s.type === 'initial');
const derived = getTaskDerivedState(task, submissions, deliveries);
return {
id: task.id, title: task.title, status: task.status, projectId: task.project_id,
serviceType: sub?.service_type || '—', deadline: sub?.deadline || null,
version: task.current_version || 0, isHot: false,
serviceType: derived.serviceType, deadline: derived.initialSubmission?.deadline || derived.deadline || null,
version: derived.currentVersion, isHot: false,
assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null, assignedTo: task.assigned_to || null,
submittedAt: task.submitted_at ? new Date(task.submitted_at).getTime() : 0,
submittedAt: derived.latestActivityAt,
};
});
}
return tasks.map(task => {
const taskSubs = submissions.filter(s => s.task_id === task.id);
const deadlineSource = getDeadlineSourceSubmission(task, taskSubs);
if (!deadlineSource) return null;
const currentVersion = getCurrentVersionForTask(task, taskSubs);
const latestGroup = taskSubs.filter(s => s.version_number === currentVersion);
const serviceType = submissions.find(s => s.task_id === task.id && s.type === 'initial')?.service_type
|| submissions.find(s => s.task_id === task.id && s.service_type)?.service_type
|| deadlineSource.service_type || '—';
const derived = getTaskDerivedState(task, submissions, deliveries);
if (derived.visibleTaskSubs.length === 0 || !derived.deadlineSource) return null;
return {
id: task.id, title: task.title, status: task.status, projectId: task.project_id,
serviceType, deadline: deadlineSource.deadline,
version: deadlineSource.version_number ?? 0,
isHot: deadlineSource.is_hot || false,
serviceType: derived.serviceType, deadline: derived.deadline,
version: derived.currentVersion,
isHot: derived.isHot,
assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null, assignedTo: task.assigned_to || null,
submittedAt: latestGroup.length > 0
? Math.max(...latestGroup.map(s => new Date(s.submitted_at).getTime()))
: (task.submitted_at ? new Date(task.submitted_at).getTime() : 0),
submittedAt: derived.latestActivityAt,
};
}).filter(Boolean);
}, [tasks, submissions, isClient]); // eslint-disable-line react-hooks/exhaustive-deps
}, [tasks, submissions, deliveries, isClient]); // eslint-disable-line react-hooks/exhaustive-deps
const filteredRows = useMemo(() => {
return allRows
@@ -482,16 +571,29 @@ export default function RequestsPage() {
const doneStatuses = new Set(['client_approved', 'invoiced', 'paid']);
const tabs = [
{ id: 'all', label: 'All Tasks' },
{ id: 'not_started', label: 'To Do' },
{ id: 'all', label: 'All' },
{ id: 'new_requests', label: 'New Requests' },
{ id: 'revisions', label: 'Revisions' },
{ id: 'in_progress', label: 'In Progress' },
{ id: 'on_hold', label: 'On Hold' },
{ id: 'client_review', label: 'In Review' },
{ id: 'completed', label: 'Completed' },
];
const tabRows = activeTab === 'all' ? filteredRows
const tabCounts = {
all: filteredRows.length,
new_requests: filteredRows.filter(r => r.status === 'not_started' && Number(r.version || 0) === 0).length,
revisions: filteredRows.filter(r => r.status === 'not_started' && Number(r.version || 0) >= 1).length,
in_progress: filteredRows.filter(r => r.status === 'in_progress').length,
on_hold: filteredRows.filter(r => r.status === 'on_hold').length,
client_review: filteredRows.filter(r => r.status === 'client_review').length,
completed: filteredRows.filter(r => doneStatuses.has(r.status)).length,
};
const tabRows = activeTab === 'all' ? filteredRows
: activeTab === 'completed' ? filteredRows.filter(r => doneStatuses.has(r.status))
: activeTab === 'new_requests' ? filteredRows.filter(r => r.status === 'not_started' && Number(r.version || 0) === 0)
: activeTab === 'revisions' ? filteredRows.filter(r => r.status === 'not_started' && Number(r.version || 0) >= 1)
: filteredRows.filter(r => r.status === activeTab);
const sortedRows = sort(tabRows, (row, key) => {
@@ -517,7 +619,6 @@ export default function RequestsPage() {
const renderRow = (row) => {
const project = projects.find(p => p.id === row.projectId);
const initials = row.assignedName ? row.assignedName.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase() : null;
return (
<tr key={row.id}>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>
@@ -531,14 +632,11 @@ export default function RequestsPage() {
</td>
<td style={{ ...TASK_TABLE_TD_BASE, textAlign: 'center' }}>
{(() => {
const avatarStyle = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: '50%', overflow: 'hidden', verticalAlign: 'middle', flexShrink: 0 };
if (row.assignedName && row.assignedTo) {
const portrait = row.assigneeAvatar
? <div title={row.assignedName} style={avatarStyle}><img src={row.assigneeAvatar} alt={row.assignedName} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div>
: <div title={row.assignedName} style={{ ...avatarStyle, background: 'var(--accent)' }}><span style={{ fontSize: 10, fontWeight: 600, color: '#000', lineHeight: 1 }}>{initials}</span></div>;
const portrait = <ProfileAvatar name={row.assignedName} avatarUrl={row.assigneeAvatar} size={26} fontSize={10} />;
return <Link to={`/profile/${row.assignedTo}`} style={{ display: 'inline-flex' }}>{portrait}</Link>;
}
return <div title="Unassigned" style={{ ...avatarStyle, background: 'rgba(255,255,255,0.08)' }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.3)" 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>;
return <div title="Unassigned" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: '50%', overflow: 'hidden', verticalAlign: 'middle', flexShrink: 0, background: 'rgba(255,255,255,0.08)' }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.3)" 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={{ ...TASK_TABLE_TD_BASE, fontSize: 11, textAlign: 'center', color: row.isHot ? '#ef4444' : 'var(--text-primary)', textTransform: 'uppercase', letterSpacing: 0.5 }}>
@@ -554,7 +652,7 @@ export default function RequestsPage() {
);
};
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
if (loading) return <Layout><PageLoader /></Layout>;
if (loadError) return <Layout><div style={{ padding: 24 }}><p style={{ color: 'var(--text-muted)', marginBottom: 12 }}>Failed to load. Check your connection and try again.</p><button className="btn btn-outline" onClick={() => window.location.reload()}>Retry</button></div></Layout>;
const filteredStatTasks = filteredRows.map(r => tasks.find(t => t.id === r.id)).filter(Boolean);
@@ -590,7 +688,7 @@ export default function RequestsPage() {
<input type="text" placeholder="e.g. Brand Identity 2026" value={addProjectForm.name} onChange={e => setAddProjectForm(f => ({ ...f, name: e.target.value }))} required autoFocus style={TASK_MODAL_INPUT_STYLE} />
</div>
{addProjectError && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>{addProjectError}</div>}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 20 }}>
<div className="modal-action-row" style={{ marginTop: 20 }}>
<button type="submit" className="btn btn-outline" style={TASK_MODAL_BUTTON_STYLE} disabled={addProjectSaving}>{addProjectSaving ? 'Saving...' : 'Save'}</button>
<button type="button" className="btn btn-outline" style={TASK_MODAL_BUTTON_STYLE} onClick={() => { setShowAddProjectForm(false); setAddProjectForm({ name: '', companyId: '' }); setAddProjectError(''); }}>Cancel</button>
</div>
@@ -627,6 +725,11 @@ export default function RequestsPage() {
{tabs.map(t => (
<button key={t.id} onClick={() => setActiveTab(t.id)} className={`section-tab-btn${activeTab === t.id ? ' is-active' : ''}`}>
{t.label}
{tabCounts[t.id] > 0 && (
<span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: activeTab === t.id ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}>
{tabCounts[t.id]}
</span>
)}
</button>
))}
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }} ref={companyFilterMenuRef}>
@@ -692,9 +795,9 @@ export default function RequestsPage() {
{/* Task table */}
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', padding: '18px 21px' }}>
{allRows.length === 0 ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No tasks yet.</div>
<div className="card-empty-center">No tasks</div>
) : sortedRows.length === 0 ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No tasks.</div>
<div className="card-empty-center">No tasks</div>
) : (
<div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head table-no-row-hover" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>