Session 2026-05-20: UI fixes, invoice filtering, file browser, request approvals, sub invoice task scope

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Krao Hasanee
2026-05-20 21:32:55 -04:00
parent ff159c5937
commit 565d2ed4bc
34 changed files with 3384 additions and 1161 deletions
+57 -78
View File
@@ -1,13 +1,15 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import Layout from '../components/Layout';
import StatusBadge from '../components/StatusBadge';
import FileBrowser from '../components/FileBrowser';
import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext';
import { serviceTypes } from '../data/mockData';
import { cleanupTaskStorage } from '../lib/deleteHelpers';
import { addDaysToDateOnly, getTodayDateOnlyEST } from '../lib/dates';
const safeFbName = v => String(v || '').trim().replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-').replace(/\s+/g, ' ').replace(/^-+|-+$/g, '');
const rLabel = (v) => 'R' + String(v || 0).padStart(2, '0');
const emptyJobForm = () => ({ title: '', serviceType: '', deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3), description: '', requestedBy: '', isHot: false });
@@ -27,7 +29,6 @@ export default function ProjectDetailPage() {
const [submissions, setSubmissions] = useState([]);
const [members, setMembers] = useState([]);
const [externalProfiles, setExternalProfiles] = useState([]);
const [projectFiles, setProjectFiles] = useState([]);
const [loading, setLoading] = useState(true);
const [editingName, setEditingName] = useState(false);
@@ -41,8 +42,6 @@ export default function ProjectDetailPage() {
const [selectedExternal, setSelectedExternal] = useState('');
const [addingMember, setAddingMember] = useState(false);
const [uploadingFile, setUploadingFile] = useState(false);
const fileInputRef = useRef(null);
const [filter, setFilter] = useState('all');
@@ -59,27 +58,29 @@ export default function ProjectDetailPage() {
setProject(p);
if (isClient) {
const { data: t } = await supabase.from('tasks').select('*').eq('project_id', id).order('submitted_at', { ascending: false });
const [{ data: co }, { data: t }] = await Promise.all([
supabase.from('companies').select('*').eq('id', p.company_id).single(),
supabase.from('tasks').select('*').eq('project_id', id).order('submitted_at', { ascending: false }),
]);
setCompany(co);
setTasks(t || []);
if (t && t.length > 0) {
const { data: subs } = await supabase.from('submissions').select('id, task_id, submitted_by, submitted_by_name, version_number, type').in('task_id', t.map(task => task.id)).order('version_number');
setSubmissions(subs || []);
}
} else {
const [{ data: co }, { data: t }, { data: users }, { data: pm }, { data: ext }, { data: pf }] = await Promise.all([
const [{ data: co }, { data: t }, { data: users }, { data: pm }, { data: ext }] = await Promise.all([
supabase.from('companies').select('*').eq('id', p.company_id).single(),
supabase.from('tasks').select('*').eq('project_id', id).order('submitted_at', { ascending: false }),
supabase.from('profiles').select('id, name, email').eq('company_id', p.company_id).eq('role', 'client'),
supabase.from('project_members').select('*, profile:profiles(id, name, email)').eq('project_id', id),
supabase.from('profiles').select('id, name, email').eq('role', 'external').order('name'),
supabase.from('project_files').select('*').eq('project_id', id).order('created_at', { ascending: false }),
]);
setCompany(co);
setTasks(t || []);
setCompanyUsers(users || []);
setMembers(pm || []);
setExternalProfiles(ext || []);
setProjectFiles(pf || []);
}
} catch (error) {
console.error('ProjectDetailPage load failed:', error);
@@ -102,16 +103,36 @@ export default function ProjectDetailPage() {
const handleDeleteProject = async () => {
if (!window.confirm(`Delete project "${project.name}"? All jobs and files will be permanently deleted.`)) return;
await cleanupTaskStorage(tasks.map(t => t.id));
await supabase.from('projects').delete().eq('id', id);
navigate(`/company/${company?.id}`);
try {
const { data: { session } } = await supabase.auth.getSession();
const res = await fetch(`/api/delete-project?id=${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${session?.access_token}` },
});
if (!res.ok) { const d = await res.json(); throw new Error(d.error || 'Delete failed'); }
} catch (err) {
alert(`Failed to delete project: ${err.message}`);
return;
}
navigate(isClient ? '/projects' : `/company/${company?.id}`);
};
const handleDeleteTask = async (taskId, e) => {
e.stopPropagation();
if (!window.confirm('Delete this job? All submissions and files will be permanently deleted.')) return;
await cleanupTaskStorage([taskId]);
await supabase.from('tasks').delete().eq('id', taskId);
try {
const { data: { session } } = await supabase.auth.getSession();
const res = await fetch(`/api/delete-task?id=${taskId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${session?.access_token}` },
});
if (!res.ok) { const d = await res.json(); throw new Error(d.error || 'Delete failed'); }
const d = await res.json();
if (d.archiveError) console.warn('[delete-task] archive error:', d.archiveError);
} catch (err) {
alert(`Failed to delete job: ${err.message}`);
return;
}
setTasks(prev => prev.filter(t => t.id !== taskId));
};
@@ -141,30 +162,6 @@ export default function ProjectDetailPage() {
setMembers(prev => prev.filter(m => m.profile_id !== profileId));
};
const handleUploadFile = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setUploadingFile(true);
const path = `${id}/${Date.now()}_${file.name}`;
const { error: upErr } = await supabase.storage.from('project-files').upload(path, file);
if (upErr) { alert('Upload failed: ' + upErr.message); setUploadingFile(false); return; }
const { data: rec } = await supabase.from('project_files').insert({ project_id: id, name: file.name, storage_path: path, size: file.size, uploaded_by: currentUser.id, uploaded_by_name: currentUser.name }).select().single();
if (rec) setProjectFiles(prev => [rec, ...prev]);
if (fileInputRef.current) fileInputRef.current.value = '';
setUploadingFile(false);
};
const handleDeleteFile = async (file) => {
if (!window.confirm(`Delete "${file.name}"?`)) return;
await supabase.storage.from('project-files').remove([file.storage_path]);
await supabase.from('project_files').delete().eq('id', file.id);
setProjectFiles(prev => prev.filter(f => f.id !== file.id));
};
const handleDownloadFile = async (file) => {
const { data } = await supabase.storage.from('project-files').createSignedUrl(file.storage_path, 60);
if (data?.signedUrl) window.open(data.signedUrl, '_blank');
};
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
if (!project) return <Layout><p>Project not found.</p></Layout>;
@@ -217,7 +214,10 @@ export default function ProjectDetailPage() {
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<StatusBadge status={project.status} />
{isClient && (
<Link to={`/new-request?project=${encodeURIComponent(project.name)}`} className="btn btn-primary">+ Add Request</Link>
<>
<button className="btn btn-outline btn-sm" style={{ color: 'var(--danger, #dc2626)', borderColor: 'var(--danger, #dc2626)' }} onClick={handleDeleteProject}>Delete Project</button>
<Link to={`/new-request?project=${encodeURIComponent(project.name)}`} className="btn btn-primary">+ Add Request</Link>
</>
)}
{isTeam && (
<>
@@ -300,43 +300,20 @@ export default function ProjectDetailPage() {
</div>
)}
{/* Team/External: Project Files */}
{!isClient && (
<>
<div className="card-title">Project Files</div>
{/* Project Folder (FileBrowser) — team + client */}
{!isExternal && company?.name && project?.name && (() => {
const co = safeFbName(company.name);
const proj = safeFbName(project.name);
const fbRoot = isClient
? `/${co}/Projects/${proj}/00 Project Files`
: `/Clients/${co}/Projects/${proj}/00 Project Files`;
return (
<div className="card" style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: projectFiles.length > 0 ? 14 : 0 }}>
<div />
{isTeam && (
<>
<button className="btn btn-outline btn-sm" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile}>{uploadingFile ? 'Uploading...' : '+ Upload File'}</button>
<input ref={fileInputRef} type="file" style={{ display: 'none' }} onChange={handleUploadFile} />
</>
)}
</div>
{projectFiles.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)', margin: 0 }}>No files uploaded yet.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{projectFiles.map(f => (
<div key={f.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{f.name}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{f.uploaded_by_name && `${f.uploaded_by_name} · `}{new Date(f.created_at).toLocaleDateString()}{f.size ? ` · ${(f.size / 1024).toFixed(0)} KB` : ''}</div>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button className="btn btn-outline btn-sm" onClick={() => handleDownloadFile(f)}>Download</button>
{isTeam && (
<button onClick={() => handleDeleteFile(f)} style={{ background: 'none', border: 'none', color: 'var(--danger)', cursor: 'pointer', fontSize: 16, padding: '0 4px' }} title="Delete file"></button>
)}
</div>
</div>
))}
</div>
)}
<div className="card-title">Project Files</div>
<FileBrowser initialPath={fbRoot} rootPath={fbRoot} />
</div>
</>
)}
);
})()}
{/* Client: mine/all filter */}
{isClient && (
@@ -354,7 +331,8 @@ export default function ProjectDetailPage() {
)}
{/* Tasks / Requests */}
<div className="card-title">Tasks</div>
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">{isClient ? 'Requests' : 'Tasks'}</div>
{filteredTasks.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon">📋</div>
@@ -409,14 +387,14 @@ export default function ProjectDetailPage() {
<tbody>
{filteredTasks.map(task => (
<tr key={task.id} onClick={() => navigate(`/requests/${task.id}`)} style={{ cursor: 'pointer' }}>
<td>
<td style={{ fontWeight: 600 }}>
{task.title}
<span style={{ marginLeft: 6, fontWeight: 600, color: 'var(--text-muted)', fontSize: 12 }}>{'R' + String(task.current_version || 0).padStart(2, '0')}</span>
<span style={{ marginLeft: 6, fontWeight: 400, color: 'var(--text-muted)', fontSize: 12 }}>{'R' + String(task.current_version || 0).padStart(2, '0')}</span>
</td>
<td style={{ color: task.assigned_name ? 'var(--text-primary)' : 'var(--text-muted)' }}>{task.assigned_name || 'Unassigned'}</td>
<td><span className="badge badge-not_started">R{String(task.current_version || 0).padStart(2, '0')}</span></td>
<td><StatusBadge status={task.status} /></td>
<td style={{ color: 'var(--text-secondary)' }}>{new Date(task.submitted_at).toLocaleDateString()}</td>
<td style={{ color: 'var(--text-muted)' }}>{new Date(task.submitted_at).toLocaleDateString()}</td>
{isTeam && (
<td onClick={e => e.stopPropagation()}>
<button type="button" onClick={e => handleDeleteTask(task.id, e)} style={{ background: 'none', border: 'none', color: 'var(--danger, #dc2626)', cursor: 'pointer', fontSize: 16, padding: '0 4px' }} title="Delete job"></button>
@@ -428,6 +406,7 @@ export default function ProjectDetailPage() {
</table>
</div>
)}
</div>
{/* Team: External members */}
{isTeam && (