Session 2026-05-31: tasks table overhaul, FileBrowser redesign, folder path fix
- Tasks table: added R#, Assigned To (avatar), Priority (HOT/NO) columns; removed Status column; sortable columns; adjustable widths - FileBrowser: full visual rewrite to match FileSharing page (table layout, colored ext badges, SVG folder icon, sortable columns, multi-select, context menu, drag-drop) - TaskDetail folder tab: fix path with safeName(), rootPath set to project folder level - filebrowserFolders: export safeName for reuse Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -462,7 +462,7 @@ export default function ProfilePage() {
|
||||
{visibleRows.map((task) => (
|
||||
<tr key={task.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/${task.id}`)}>{task.title}</button>
|
||||
<button type="button" className="dashboard-inline-link" onClick={() => navigate(`/tasks/${task.id}`)}>{task.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' }}>
|
||||
{formatTaskStatus(task.status)}
|
||||
@@ -490,7 +490,7 @@ export default function ProfilePage() {
|
||||
<span style={{ color: 'var(--text-muted)' }}>{toSentenceTitle(ACTION_LABEL[e.action] || e.action)}</span>
|
||||
{e.task_title && e.task_id && (
|
||||
<><span style={{ color: 'var(--text-muted)' }}> </span>
|
||||
<button type="button" className="dashboard-inline-link" onClick={() => navigate(`/requests/${e.task_id}`)}>{e.task_title}</button></>
|
||||
<button type="button" className="dashboard-inline-link" onClick={() => navigate(`/tasks/${e.task_id}`)}>{e.task_title}</button></>
|
||||
)}
|
||||
{e.task_title && !e.task_id && <span style={{ color: 'var(--text-primary)' }}> {e.task_title}</span>}
|
||||
</div>
|
||||
|
||||
+311
-344
@@ -1,95 +1,86 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import Layout from '../components/Layout';
|
||||
import PageLoader from '../components/PageLoader';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import SortTh from '../components/SortTh';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { serviceTypes } from '../data/mockData';
|
||||
import { addDaysToDateOnly, getTodayDateOnlyEST } from '../lib/dates';
|
||||
import { logActivity } from '../lib/activityLog';
|
||||
import { createTaskFolder } from '../lib/filebrowserFolders';
|
||||
import { useSortable } from '../hooks/useSortable';
|
||||
import { serviceTypes } from '../data/mockData';
|
||||
import { addDaysToDateOnly, getTodayDateOnlyEST } from '../lib/dates';
|
||||
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
|
||||
|
||||
const CARD = { padding: '18px 21px', borderRadius: 8 };
|
||||
const LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 14 };
|
||||
const META_LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 4 };
|
||||
const MODAL_LBL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 6 };
|
||||
const MODAL_IN = { fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' };
|
||||
const TAB_STYLE = (active) => ({
|
||||
fontFamily: "Fourge, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
fontSize: 13, fontWeight: 500, letterSpacing: 0.2, padding: '2px 0 5px', margin: '0 8px',
|
||||
borderRadius: 0, border: 'none', borderBottom: active ? '2px solid var(--accent)' : '2px solid transparent',
|
||||
cursor: 'pointer', background: 'transparent',
|
||||
color: active ? 'var(--text-primary)' : 'var(--text-secondary)', transition: 'all 160ms',
|
||||
});
|
||||
|
||||
const rLabel = (v) => 'R' + String(v || 0).padStart(2, '0');
|
||||
const emptyJobForm = () => ({ title: '', serviceType: '', deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3), description: '', requestedBy: '', isHot: false });
|
||||
const emptyJobForm = () => ({ title: '', serviceType: '', deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3), description: '', requestedBy: '' });
|
||||
|
||||
export default function ProjectDetailPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { currentUser } = useAuth();
|
||||
|
||||
const isClient = currentUser?.role === 'client';
|
||||
const isClient = currentUser?.role === 'client';
|
||||
const isExternal = currentUser?.role === 'external';
|
||||
const isTeam = currentUser?.role === 'team';
|
||||
const isTeam = currentUser?.role === 'team';
|
||||
|
||||
const [project, setProject] = useState(null);
|
||||
const [company, setCompany] = useState(null);
|
||||
const [project, setProject] = useState(null);
|
||||
const [company, setCompany] = useState(null);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [members, setMembers] = useState([]);
|
||||
const [externalProfiles, setExtProfs] = useState([]);
|
||||
const [companyUsers, setCompanyUsers] = useState([]);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [submissions, setSubmissions] = useState([]);
|
||||
const [members, setMembers] = useState([]);
|
||||
const [externalProfiles, setExternalProfiles] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState('tasks');
|
||||
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [nameVal, setNameVal] = useState('');
|
||||
const [savingName, setSavingName] = useState(false);
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [nameVal, setNameVal] = useState('');
|
||||
const [savingName, setSavingName] = useState(false);
|
||||
|
||||
const [showAddJob, setShowAddJob] = useState(false);
|
||||
const [jobForm, setJobForm] = useState(emptyJobForm);
|
||||
const [savingJob, setSavingJob] = useState(false);
|
||||
const [showAddJob, setShowAddJob] = useState(false);
|
||||
const [jobForm, setJobForm] = useState(emptyJobForm());
|
||||
const [savingJob, setSavingJob] = useState(false);
|
||||
|
||||
const [selectedExternal, setSelectedExternal] = useState('');
|
||||
const [selectedExt, setSelectedExt] = useState('');
|
||||
const [addingMember, setAddingMember] = useState(false);
|
||||
|
||||
|
||||
const [filter, setFilter] = useState('all');
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('title');
|
||||
|
||||
const requesterOptions = [
|
||||
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)`, email: currentUser.email || '' }] : []),
|
||||
...companyUsers.filter(u => u.id !== currentUser?.id),
|
||||
];
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('submitted_at');
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const { data: p } = await supabase.from('projects').select('*').eq('id', id).single();
|
||||
if (!p) return;
|
||||
setProject(p);
|
||||
|
||||
if (isClient) {
|
||||
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 }] = 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, role)').eq('project_id', id),
|
||||
supabase.from('profiles').select('id, name, email').eq('role', 'external').order('name'),
|
||||
]);
|
||||
setCompany(co);
|
||||
setTasks(t || []);
|
||||
setCompanyUsers(users || []);
|
||||
setMembers(pm || []);
|
||||
setExternalProfiles(ext || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('ProjectDetailPage load failed:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
const { data: p } = await supabase.from('projects').select('*').eq('id', id).single();
|
||||
if (!p) { setLoading(false); return; }
|
||||
setProject(p);
|
||||
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 (isTeam) {
|
||||
const [{ data: users }, { data: pm }, { data: ext }] = await Promise.all([
|
||||
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, role)').eq('project_id', id),
|
||||
supabase.from('profiles').select('id, name, email').eq('role', 'external').order('name'),
|
||||
]);
|
||||
setCompanyUsers(users || []);
|
||||
setMembers(pm || []);
|
||||
setExtProfs(ext || []);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
load();
|
||||
}, [id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
@@ -98,44 +89,26 @@ export default function ProjectDetailPage() {
|
||||
e.preventDefault();
|
||||
if (!nameVal.trim()) return;
|
||||
setSavingName(true);
|
||||
const { error } = await supabase.from('projects').update({ name: nameVal.trim() }).eq('id', id);
|
||||
if (!error) { setProject(p => ({ ...p, name: nameVal.trim() })); setEditingName(false); }
|
||||
else alert('Failed to save name.');
|
||||
await supabase.from('projects').update({ name: nameVal.trim() }).eq('id', id);
|
||||
setProject(p => ({ ...p, name: nameVal.trim() }));
|
||||
setEditingName(false);
|
||||
setSavingName(false);
|
||||
};
|
||||
|
||||
const handleDeleteProject = async () => {
|
||||
if (!window.confirm(`Delete project "${project.name}"? All jobs and files will be permanently deleted.`)) return;
|
||||
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}`);
|
||||
if (!window.confirm(`Delete project "${project.name}"?`)) return;
|
||||
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(); alert(d.error || 'Delete failed'); return; }
|
||||
navigate(isClient ? '/tasks' : `/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;
|
||||
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;
|
||||
}
|
||||
if (!window.confirm('Delete this task?')) return;
|
||||
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(); alert(d.error || 'Delete failed'); return; }
|
||||
setTasks(prev => prev.filter(t => t.id !== taskId));
|
||||
};
|
||||
|
||||
@@ -146,7 +119,7 @@ export default function ProjectDetailPage() {
|
||||
if (!requestor) { setSavingJob(false); return; }
|
||||
const { data: task } = await supabase.from('tasks').insert({ project_id: id, title: jobForm.title.trim(), status: 'not_started', current_version: 0 }).select().single();
|
||||
if (task) {
|
||||
await supabase.from('submissions').insert({ task_id: task.id, version_number: 0, type: 'initial', is_hot: jobForm.isHot, service_type: jobForm.serviceType, deadline: jobForm.deadline || null, description: jobForm.description.trim() || null, submitted_by: requestor.id, submitted_by_name: requestor.name.replace(' (You)', '') });
|
||||
await supabase.from('submissions').insert({ task_id: task.id, version_number: 0, type: 'initial', service_type: jobForm.serviceType, deadline: jobForm.deadline || null, description: jobForm.description.trim() || null, submitted_by: requestor.id, submitted_by_name: requestor.name.replace(' (You)', '') });
|
||||
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'task_created', taskId: task.id, taskTitle: task.title, projectId: id, projectName: project?.name });
|
||||
createTaskFolder(company?.name, project?.name, task.title).catch(() => {});
|
||||
setTasks(prev => [task, ...prev]);
|
||||
@@ -157,9 +130,9 @@ export default function ProjectDetailPage() {
|
||||
};
|
||||
|
||||
const handleAddMember = async () => {
|
||||
if (!selectedExternal) return;
|
||||
const { data } = await supabase.from('project_members').insert({ project_id: id, profile_id: selectedExternal }).select('*, profile:profiles(id, name, email)').single();
|
||||
if (data) { setMembers(prev => [...prev, data]); setSelectedExternal(''); setAddingMember(false); }
|
||||
if (!selectedExt) return;
|
||||
const { data } = await supabase.from('project_members').insert({ project_id: id, profile_id: selectedExt }).select('*, profile:profiles(id, name, email)').single();
|
||||
if (data) { setMembers(prev => [...prev, data]); setSelectedExt(''); setAddingMember(false); }
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (profileId) => {
|
||||
@@ -167,282 +140,276 @@ export default function ProjectDetailPage() {
|
||||
setMembers(prev => prev.filter(m => m.profile_id !== profileId));
|
||||
};
|
||||
|
||||
if (loading) return <Layout><PageLoader /></Layout>;
|
||||
if (!project) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Project not found.</p></Layout>;
|
||||
|
||||
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>;
|
||||
const requesterOptions = [
|
||||
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)` }] : []),
|
||||
...companyUsers.filter(u => u.id !== currentUser?.id),
|
||||
];
|
||||
|
||||
const filteredTasks = isClient && filter === 'mine'
|
||||
? tasks.filter(task => {
|
||||
const initial = submissions.find(s => s.task_id === task.id && s.type === 'initial');
|
||||
return initial?.submitted_by === currentUser.id;
|
||||
})
|
||||
: tasks;
|
||||
const sortedTasks = sort(filteredTasks, (task, key) => {
|
||||
if (key === 'title') return task.title || '';
|
||||
if (key === 'assigned_name') return task.assigned_name || '';
|
||||
const sortedTasks = sort(tasks, (task, key) => {
|
||||
if (key === 'title') return task.title || '';
|
||||
if (key === 'assigned_name') return task.assigned_name || '';
|
||||
if (key === 'current_version') return Number(task.current_version || 0);
|
||||
if (key === 'status') return task.status || '';
|
||||
if (key === 'submitted_at') return task.submitted_at || '';
|
||||
if (key === 'status') return task.status || '';
|
||||
if (key === 'submitted_at') return task.submitted_at || '';
|
||||
return '';
|
||||
});
|
||||
|
||||
const tabs = [
|
||||
{ id: 'tasks', label: 'Tasks' },
|
||||
...(isTeam ? [{ id: 'members', label: 'Members' }] : []),
|
||||
];
|
||||
|
||||
const fmtDate = (d) => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<button className="back-link" onClick={() => navigate(isClient ? '/projects' : isExternal ? '/dashboard' : `/company/${company?.id}`)}>
|
||||
← Back to {isClient ? 'Projects' : isExternal ? 'Dashboard' : company?.name}
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||
|
||||
<div className="page-header">
|
||||
<div>
|
||||
{editingName && (isTeam || isClient) ? (
|
||||
<form onSubmit={handleSaveName} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<input type="text" value={nameVal} onChange={e => setNameVal(e.target.value)} autoFocus required style={{ fontSize: 22, fontWeight: 400, padding: '4px 10px', margin: 0, width: 280 }} />
|
||||
<button type="submit" className="btn btn-primary btn-sm" disabled={savingName}>{savingName ? '...' : 'Save'}</button>
|
||||
<button type="button" className="btn btn-outline btn-sm" onClick={() => setEditingName(false)}>Cancel</button>
|
||||
</form>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div className="page-title">{project.name}</div>
|
||||
{(isTeam || isClient) && (
|
||||
<button className="btn btn-outline btn-sm" onClick={() => { setNameVal(project.name); setEditingName(true); }}>Edit</button>
|
||||
{/* Left 70% */}
|
||||
<div style={{ flex: '0 0 70%', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 24, minHeight: 0 }}>
|
||||
|
||||
{/* Header card */}
|
||||
<div className="card" style={CARD}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, marginBottom: 8 }}>
|
||||
{editingName && isTeam ? (
|
||||
<form onSubmit={handleSaveName} style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1 }}>
|
||||
<input autoFocus required value={nameVal} onChange={e => setNameVal(e.target.value)} style={{ ...MODAL_IN, fontSize: 20, padding: '4px 10px', flex: 1 }} />
|
||||
<button type="submit" className="btn btn-outline" disabled={savingName}>Save</button>
|
||||
<button type="button" className="btn btn-outline" onClick={() => setEditingName(false)}>Cancel</button>
|
||||
</form>
|
||||
) : (
|
||||
<div style={{ fontSize: 28, fontWeight: 500, color: 'var(--text-primary)', lineHeight: 1.2 }}>{project.name}</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
{isTeam && !editingName && (
|
||||
<button className="btn btn-outline" onClick={() => { setNameVal(project.name); setEditingName(true); }}>Edit</button>
|
||||
)}
|
||||
{isTeam && (
|
||||
<button className="btn btn-outline" onClick={() => setShowAddJob(s => !s)}>+ Task</button>
|
||||
)}
|
||||
{isClient && (
|
||||
<Link to="/tasks" className="btn btn-outline">+ Task</Link>
|
||||
)}
|
||||
{(isTeam || (isClient && tasks.length === 0)) && (
|
||||
<button className="btn btn-danger" onClick={handleDeleteProject}>Delete</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 20 }}>
|
||||
<StatusBadge status={project.status} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 30, flexWrap: 'wrap' }}>
|
||||
{!isClient && company && (
|
||||
<div>
|
||||
<div style={META_LABEL}>Company</div>
|
||||
{isTeam
|
||||
? <Link to={`/company/${company.id}`} className="table-link" style={{ fontSize: 13 }}>{company.name}</Link>
|
||||
: <div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{company.name}</div>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div style={META_LABEL}>Started</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{fmtDate(project.created_at)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={META_LABEL}>Tasks</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{tasks.length}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={META_LABEL}>In Progress</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{tasks.filter(t => t.status === 'in_progress').length}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={META_LABEL}>In Review</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{tasks.filter(t => t.status === 'client_review').length}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={META_LABEL}>Approved</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{tasks.filter(t => ['client_approved','invoiced','paid'].includes(t.status)).length}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs + content card */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10 }}>
|
||||
{tabs.map(tab => (
|
||||
<button key={tab.id} onClick={() => setActiveTab(tab.id)} style={TAB_STYLE(activeTab === tab.id)}>{tab.label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
{activeTab === 'tasks' && (
|
||||
tasks.length === 0
|
||||
? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 1, minHeight: 120, color: 'var(--text-muted)', fontSize: 13 }}>No tasks yet.</div>
|
||||
: <div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ overflowY: 'auto' }}>
|
||||
<table className="table-sticky-head" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '35%' }} />
|
||||
<col style={{ width: isTeam ? '18%' : '20%' }} />
|
||||
<col style={{ width: '12%' }} />
|
||||
<col style={{ width: '17%' }} />
|
||||
<col style={{ width: '14%' }} />
|
||||
{isTeam && <col style={{ width: '4%' }} />}
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Task</SortTh>
|
||||
<SortTh col="assigned_name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Assigned</SortTh>
|
||||
<SortTh col="current_version" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Rev</SortTh>
|
||||
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Status</SortTh>
|
||||
<SortTh col="submitted_at" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Created</SortTh>
|
||||
{isTeam && <th />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedTasks.map(task => (
|
||||
<tr key={task.id} onClick={() => navigate(`/tasks/${task.id}`)} style={{ cursor: 'pointer' }}>
|
||||
<td style={{ padding: 5 }}>
|
||||
<Link to={`/tasks/${task.id}`} className="table-link" style={{ fontSize: 13 }} onClick={e => e.stopPropagation()}>{task.title}</Link>
|
||||
</td>
|
||||
<td style={{ fontSize: 12, color: task.assigned_name ? 'var(--text-primary)' : 'var(--text-muted)', padding: 5 }}>{task.assigned_name || 'Unassigned'}</td>
|
||||
<td style={{ fontSize: 12, color: 'var(--text-primary)', padding: 5 }}>R{String(task.current_version || 0).padStart(2, '0')}</td>
|
||||
<td style={{ padding: 5 }}><StatusBadge status={task.status} /></td>
|
||||
<td style={{ fontSize: 12, color: 'var(--text-muted)', padding: 5 }}>{fmtDate(task.submitted_at)}</td>
|
||||
{isTeam && (
|
||||
<td style={{ padding: 5 }} onClick={e => e.stopPropagation()}>
|
||||
<button type="button" onClick={e => handleDeleteTask(task.id, e)} className="btn btn-danger" style={{ fontSize: 10, padding: '1px 8px', height: 22 }}>Del</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'members' && isTeam && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>External members assigned to this project.</div>
|
||||
{!addingMember && <button className="btn btn-outline" onClick={() => setAddingMember(true)}>+ Add</button>}
|
||||
</div>
|
||||
{addingMember && (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<select value={selectedExt} onChange={e => setSelectedExt(e.target.value)} style={{ ...MODAL_IN, flex: 1 }}>
|
||||
<option value="">Select external member…</option>
|
||||
{externalProfiles.filter(p => !members.find(m => m.profile_id === p.id)).map(p => <option key={p.id} value={p.id}>{p.name} — {p.email}</option>)}
|
||||
</select>
|
||||
<button className="btn btn-outline" onClick={handleAddMember} disabled={!selectedExt}>Add</button>
|
||||
<button className="btn btn-outline" onClick={() => { setAddingMember(false); setSelectedExt(''); }}>Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
{members.filter(m => m.profile?.role === 'external').length === 0
|
||||
? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 80, color: 'var(--text-muted)', fontSize: 13 }}>No external members.</div>
|
||||
: members.filter(m => m.profile?.role === 'external').map(m => (
|
||||
<div key={m.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', background: 'var(--card-bg-2)', borderRadius: 6, border: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)' }}>{m.profile?.name}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>{m.profile?.email}</div>
|
||||
</div>
|
||||
<button className="btn btn-danger" onClick={() => handleRemoveMember(m.profile_id)} style={{ fontSize: 10, padding: '1px 8px', height: 22 }}>Remove</button>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="page-subtitle">
|
||||
{!isClient && company && (
|
||||
<>
|
||||
{isExternal
|
||||
? <span style={{ color: 'var(--text-secondary)' }}>{company.name}</span>
|
||||
: <Link to={`/company/${company.id}`} style={{ color: 'var(--accent)' }}>{company.name}</Link>
|
||||
}
|
||||
{' · '}
|
||||
</>
|
||||
)}
|
||||
{isClient
|
||||
? `${tasks.length} request${tasks.length !== 1 ? 's' : ''} · Started ${new Date(project.created_at).toLocaleDateString()}`
|
||||
: `Started ${new Date(project.created_at).toLocaleDateString()}`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<StatusBadge status={project.status} />
|
||||
{isClient && (
|
||||
<>
|
||||
{tasks.length === 0 && (
|
||||
<button className="btn btn-outline btn-sm" style={{ color: 'var(--danger, #dc2626)', borderColor: 'var(--danger, #dc2626)' }} onClick={handleDeleteProject}>Delete Project</button>
|
||||
|
||||
{/* Right 30% */}
|
||||
<div style={{ flex: '0 0 calc(30% - 24px)', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<div className="card" style={CARD}>
|
||||
<div style={LABEL}>Project</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div>
|
||||
<div style={META_LABEL}>Status</div>
|
||||
<StatusBadge status={project.status} />
|
||||
</div>
|
||||
{company && (
|
||||
<div>
|
||||
<div style={META_LABEL}>Company</div>
|
||||
{isTeam
|
||||
? <Link to={`/company/${company.id}`} className="table-link" style={{ fontSize: 13 }}>{company.name}</Link>
|
||||
: <div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{company.name}</div>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
<Link to={`/new-request?project=${encodeURIComponent(project.name)}`} className="btn btn-primary">+ Add Request</Link>
|
||||
</>
|
||||
)}
|
||||
{isTeam && (
|
||||
<>
|
||||
<button className="btn btn-outline btn-sm" style={{ color: 'var(--danger, #dc2626)', borderColor: 'var(--danger, #dc2626)' }} onClick={handleDeleteProject}>Delete Project</button>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => setShowAddJob(s => !s)}>{showAddJob ? 'Cancel' : '+ Add Job'}</button>
|
||||
</>
|
||||
)}
|
||||
<div>
|
||||
<div style={META_LABEL}>Started</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{fmtDate(project.created_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ ...CARD, flex: 1 }}>
|
||||
<div style={LABEL}>Summary</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{[
|
||||
{ label: 'Total', value: tasks.length },
|
||||
{ label: 'Not Started', value: tasks.filter(t => t.status === 'not_started').length },
|
||||
{ label: 'In Progress', value: tasks.filter(t => t.status === 'in_progress').length },
|
||||
{ label: 'On Hold', value: tasks.filter(t => t.status === 'on_hold').length },
|
||||
{ label: 'In Review', value: tasks.filter(t => t.status === 'client_review').length },
|
||||
{ label: 'Approved', value: tasks.filter(t => ['client_approved','invoiced','paid'].includes(t.status)).length },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>{label}</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: value > 0 ? 'var(--text-primary)' : 'var(--text-muted)', fontVariantNumeric: 'tabular-nums' }}>{value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team: Add job form */}
|
||||
{isTeam && showAddJob && (
|
||||
<div className="card" style={{ marginBottom: 24, border: '1px solid var(--accent)', borderRadius: 4 }}>
|
||||
<div className="card-title">Add Job — {project.name}</div>
|
||||
<form onSubmit={handleAddJob}>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Job Title *</label>
|
||||
<input type="text" placeholder="e.g. Logo Design" value={jobForm.title} onChange={e => setJobForm(f => ({ ...f, title: e.target.value }))} required autoFocus />
|
||||
{/* Add Task modal — team only */}
|
||||
{showAddJob && isTeam && (
|
||||
<div style={popupOverlayStyle} onClick={() => { setShowAddJob(false); setJobForm(emptyJobForm()); }}>
|
||||
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(520px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Add Task — {project.name}</div>
|
||||
<form onSubmit={handleAddJob} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<div style={MODAL_LBL}>Task Title *</div>
|
||||
<input autoFocus required value={jobForm.title} onChange={e => setJobForm(f => ({ ...f, title: e.target.value }))} placeholder="e.g. Logo Design" style={MODAL_IN} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Service Type *</label>
|
||||
<select value={jobForm.serviceType} onChange={e => setJobForm(f => ({ ...f, serviceType: e.target.value }))} required>
|
||||
<option value="">Select a service...</option>
|
||||
<div>
|
||||
<div style={MODAL_LBL}>Service Type *</div>
|
||||
<select required value={jobForm.serviceType} onChange={e => setJobForm(f => ({ ...f, serviceType: e.target.value }))} style={MODAL_IN}>
|
||||
<option value="">Select a service…</option>
|
||||
{serviceTypes.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Deadline <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
|
||||
<input type="date" value={jobForm.deadline} onChange={e => setJobForm(f => ({ ...f, deadline: e.target.value }))} />
|
||||
<div>
|
||||
<div style={MODAL_LBL}>Deadline</div>
|
||||
<input type="date" value={jobForm.deadline} onChange={e => setJobForm(f => ({ ...f, deadline: e.target.value }))} style={MODAL_IN} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Requested By *</label>
|
||||
<select value={jobForm.requestedBy} onChange={e => setJobForm(f => ({ ...f, requestedBy: e.target.value }))} required>
|
||||
<option value="">Select requester...</option>
|
||||
{requesterOptions.map(u => <option key={u.id} value={u.id}>{u.name}{u.email ? ` (${u.email})` : ''}</option>)}
|
||||
<div>
|
||||
<div style={MODAL_LBL}>Requested By *</div>
|
||||
<select required value={jobForm.requestedBy} onChange={e => setJobForm(f => ({ ...f, requestedBy: e.target.value }))} style={MODAL_IN}>
|
||||
<option value="">Select requester…</option>
|
||||
{requesterOptions.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginTop: -4 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, fontWeight: 500, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={jobForm.isHot} onChange={e => setJobForm(f => ({ ...f, isHot: e.target.checked }))} />
|
||||
<span>Mark as Hot</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Notes <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
|
||||
<textarea placeholder="Any details about the job..." value={jobForm.description} onChange={e => setJobForm(f => ({ ...f, description: e.target.value }))} style={{ minHeight: 80 }} />
|
||||
</div>
|
||||
<div className="action-buttons">
|
||||
<button type="submit" className="btn btn-primary" disabled={savingJob}>{savingJob ? 'Adding...' : 'Add Job'}</button>
|
||||
<button type="button" className="btn btn-outline" onClick={() => { setShowAddJob(false); setJobForm(emptyJobForm()); }}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Team/External: Project info cards */}
|
||||
{!isClient && (
|
||||
<div className="grid-2" style={{ marginBottom: 24 }}>
|
||||
<div className="card">
|
||||
<div className="card-title">Project Info</div>
|
||||
<div className="detail-grid" style={{ marginBottom: 0 }}>
|
||||
<div className="detail-item"><label>Company</label><p>{company?.name || '—'}</p></div>
|
||||
<div className="detail-item"><label>Status</label><p><StatusBadge status={project.status} /></p></div>
|
||||
<div className="detail-item"><label>Started</label><p>{new Date(project.created_at).toLocaleDateString()}</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-title">Project Summary</div>
|
||||
<div className="detail-grid" style={{ marginBottom: 0 }}>
|
||||
<div className="detail-item"><label>Total Tasks</label><p>{tasks.length}</p></div>
|
||||
<div className="detail-item"><label>Completed</label><p>{tasks.filter(t => t.status === 'client_approved').length}</p></div>
|
||||
<div className="detail-item"><label>In Progress</label><p>{tasks.filter(t => t.status === 'in_progress').length}</p></div>
|
||||
<div className="detail-item"><label>Awaiting Review</label><p>{tasks.filter(t => t.status === 'client_review').length}</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Client: mine/all filter */}
|
||||
{isClient && (
|
||||
<div className="card page-toolbar" style={{ marginBottom: 16 }}>
|
||||
<div className="page-toolbar-grid">
|
||||
<div className="page-toolbar-section">
|
||||
<div className="card-title" style={{ marginBottom: 10 }}>Filter</div>
|
||||
<div className="page-toolbar-filters">
|
||||
<button className={`btn btn-sm ${filter === 'all' ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilter('all')}>All Requests</button>
|
||||
<button className={`btn btn-sm ${filter === 'mine' ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilter('mine')}>Mine Only</button>
|
||||
<div>
|
||||
<div style={MODAL_LBL}>Notes</div>
|
||||
<textarea value={jobForm.description} onChange={e => setJobForm(f => ({ ...f, description: e.target.value }))} placeholder="Any details…" rows={3} style={{ ...MODAL_IN, resize: 'vertical' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', paddingTop: 4 }}>
|
||||
<button type="submit" className="btn btn-outline" disabled={savingJob}>{savingJob ? 'Saving…' : 'Save'}</button>
|
||||
<button type="button" className="btn btn-outline" onClick={() => { setShowAddJob(false); setJobForm(emptyJobForm()); }}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tasks / Requests */}
|
||||
<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>
|
||||
<h3>{isClient && filter === 'mine' ? "You haven't submitted any requests to this project" : isClient ? 'No requests yet' : 'No jobs yet'}</h3>
|
||||
{isClient ? (
|
||||
<Link to={`/new-request?project=${encodeURIComponent(project.name)}`} className="btn btn-primary" style={{ marginTop: 16 }}>Add Request</Link>
|
||||
) : isTeam ? (
|
||||
<button className="btn btn-primary" style={{ marginTop: 16 }} onClick={() => setShowAddJob(true)}>+ Add Job</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : isClient ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{sortedTasks.map(task => {
|
||||
const taskSubs = submissions.filter(s => s.task_id === task.id);
|
||||
const initialSub = taskSubs.find(s => s.type === 'initial') || taskSubs[0];
|
||||
const latestSub = taskSubs[taskSubs.length - 1];
|
||||
const hasRevision = latestSub && initialSub && latestSub.id !== initialSub.id && latestSub.submitted_by_name !== initialSub.submitted_by_name;
|
||||
const isMine = initialSub?.submitted_by === currentUser.id;
|
||||
return (
|
||||
<Link key={task.id} to={`/requests/${task.id}`} className="request-card" style={{ textDecoration: 'none', cursor: 'pointer', display: 'block' }}>
|
||||
<div className="request-card-header">
|
||||
<div>
|
||||
<div className="request-card-title">
|
||||
{task.title}{' '}
|
||||
<span style={{ fontWeight: 400, color: 'var(--text-muted)', fontSize: 13 }}>{rLabel(task.current_version)}</span>
|
||||
{isMine && <span style={{ marginLeft: 8, fontSize: 11, background: 'rgba(245,165,35,0.15)', color: 'var(--accent)', padding: '2px 8px', borderRadius: 4, fontWeight: 400 }}>Mine</span>}
|
||||
</div>
|
||||
<div className="request-card-meta" style={{ marginTop: 4 }}>
|
||||
{initialSub?.submitted_by_name && <>By {initialSub.submitted_by_name}</>}
|
||||
{hasRevision && <> · Updated by {latestSub.submitted_by_name}</>}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={task.status} />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Job</SortTh>
|
||||
<SortTh col="assigned_name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Assigned To</SortTh>
|
||||
<SortTh col="current_version" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Revision</SortTh>
|
||||
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Status</SortTh>
|
||||
<SortTh col="submitted_at" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Submitted</SortTh>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedTasks.map(task => (
|
||||
<tr key={task.id} onClick={() => navigate(`/requests/${task.id}`)} style={{ cursor: 'pointer' }}>
|
||||
<td style={{ fontWeight: 400 }}>
|
||||
{task.title}
|
||||
<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-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>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Team: External members */}
|
||||
{isTeam && (
|
||||
<div className="card" style={{ marginTop: 24 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<div className="card-title" style={{ margin: 0 }}>External Members</div>
|
||||
{!addingMember && <button className="btn btn-outline btn-sm" onClick={() => setAddingMember(true)}>+ Add</button>}
|
||||
</div>
|
||||
{addingMember && (
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||
<select value={selectedExternal} onChange={e => setSelectedExternal(e.target.value)} style={{ flex: 1 }}>
|
||||
<option value="">Select external member...</option>
|
||||
{externalProfiles.filter(p => !members.find(m => m.profile_id === p.id)).map(p => <option key={p.id} value={p.id}>{p.name} — {p.email}</option>)}
|
||||
</select>
|
||||
<button className="btn btn-primary btn-sm" onClick={handleAddMember} disabled={!selectedExternal}>Add</button>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => { setAddingMember(false); setSelectedExternal(''); }}>Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
{members.filter(m => m.profile?.role === 'external').length === 0 ? (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', margin: 0 }}>No external members assigned to this project.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{members.filter(m => m.profile?.role === 'external').map(m => (
|
||||
<div key={m.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)' }}>{m.profile?.name}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{m.profile?.email}</div>
|
||||
</div>
|
||||
<button onClick={() => handleRemoveMember(m.profile_id)} style={{ background: 'none', border: 'none', color: 'var(--danger)', cursor: 'pointer', fontSize: 16, padding: '0 4px' }} title="Remove from project">✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+27
-11
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import Layout from '../components/Layout';
|
||||
import PageLoader from '../components/PageLoader';
|
||||
import FileBrowser from '../components/FileBrowser';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { supabase } from '../lib/supabase';
|
||||
@@ -9,12 +10,13 @@ import { logActivity } from '../lib/activityLog';
|
||||
import JSZip from 'jszip';
|
||||
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
|
||||
import { sendEmail } from '../lib/email';
|
||||
import { uploadFilesToRequestInfo } from '../lib/filebrowserFolders';
|
||||
import { uploadFilesToRequestInfo, safeName } from '../lib/filebrowserFolders';
|
||||
|
||||
const ACTION_LABEL = {
|
||||
task_created: 'created this task', task_started: 'started this task', task_on_hold: 'placed task on hold',
|
||||
task_resumed: 'resumed this task', task_submitted: 'submitted this task', task_approved: 'approved this task',
|
||||
revision_requested: 'rejected this task', request_submitted: 'submitted this request',
|
||||
task_released: 'released this task',
|
||||
};
|
||||
|
||||
const ACTION_ICON = {
|
||||
@@ -23,6 +25,7 @@ const ACTION_ICON = {
|
||||
task_submitted: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: 'M12 19V5M5 12l7-7 7 7' },
|
||||
task_approved: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: 'M4 13l5 5L20 7' },
|
||||
task_on_hold: { bg: 'rgba(239,68,68,0.15)', color: '#ef4444', path: 'M6 4h4v16H6zM14 4h4v16h-4z' },
|
||||
task_released: { bg: 'rgba(239,68,68,0.15)', color: '#ef4444', path: 'M12 5v14M5 12h14' },
|
||||
task_created: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: 'M12 5v14M5 12h14' },
|
||||
revision_requested: { bg: 'rgba(245,158,11,0.15)', color: '#f59e0b', path: 'M4 12a8 8 0 018-8v0a8 8 0 018 8' },
|
||||
request_submitted: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: 'M9 12h6M9 8h6M5 3h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2z' },
|
||||
@@ -292,6 +295,7 @@ export default function TaskDetail() {
|
||||
const handleSendToReview = () => updateStatus('client_review', {}, 'task_submitted');
|
||||
const handleClientApprove = () => updateStatus('client_approved', { completed_at: new Date().toISOString() }, 'task_approved');
|
||||
const handleClientReject = () => updateStatus('not_started', { current_version: (task.current_version || 0) + 1, assigned_to: null, assigned_name: null }, 'revision_requested');
|
||||
const handleRelease = () => updateStatus('not_started', { assigned_to: null, assigned_name: null }, 'task_released');
|
||||
|
||||
const handleSubmitRevision = async () => {
|
||||
setRevisionSaving(true);
|
||||
@@ -408,19 +412,23 @@ export default function TaskDetail() {
|
||||
{isTeamOrSub && status === 'not_started' && (
|
||||
<button className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px' }} disabled={statusSaving} onClick={handleStart}>Start Task</button>
|
||||
)}
|
||||
{isTeamOrSub && status === 'in_progress' && (
|
||||
{isTeamOrSub && status === 'in_progress' && task.assigned_to === currentUser?.id && (
|
||||
<>
|
||||
<button className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px' }} disabled={statusSaving} onClick={handleOnHold}>Place on Hold</button>
|
||||
<button className="btn btn-outline" style={{ fontSize: 12, padding: '4px 12px', color: 'var(--accent)', borderColor: 'var(--accent)' }} disabled={statusSaving} onClick={handleSendToReview}>Place in Review</button>
|
||||
<button className="btn btn-outline" disabled={statusSaving} onClick={handleRelease}>Release</button>
|
||||
<button className="btn btn-outline" disabled={statusSaving} onClick={handleOnHold}>Place on Hold</button>
|
||||
<button className="btn btn-primary" disabled={statusSaving} onClick={handleSendToReview}>Place in Review</button>
|
||||
</>
|
||||
)}
|
||||
{isTeamOrSub && status === 'on_hold' && (
|
||||
<button className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px' }} disabled={statusSaving} onClick={handleResume}>Resume</button>
|
||||
{isTeamOrSub && status === 'on_hold' && task.assigned_to === currentUser?.id && (
|
||||
<>
|
||||
<button className="btn btn-outline" disabled={statusSaving} onClick={handleRelease}>Release</button>
|
||||
<button className="btn btn-outline" disabled={statusSaving} onClick={handleResume}>Resume</button>
|
||||
</>
|
||||
)}
|
||||
{isClient && status === 'client_review' && (
|
||||
<>
|
||||
<button className="btn btn-outline" style={{ fontSize: 12, padding: '4px 12px', color: 'var(--accent)', borderColor: 'var(--accent)' }} disabled={statusSaving} onClick={handleClientApprove}>Approve</button>
|
||||
<button className="btn btn-danger" style={{ fontSize: 11, padding: '4px 12px' }} disabled={statusSaving} onClick={handleClientReject}>Reject</button>
|
||||
<button className="btn btn-primary" disabled={statusSaving} onClick={handleClientApprove}>Approve</button>
|
||||
<button className="btn btn-danger" disabled={statusSaving} onClick={handleClientReject}>Reject</button>
|
||||
</>
|
||||
)}
|
||||
{isClient && ['client_approved', 'invoiced', 'paid'].includes(status) && (
|
||||
@@ -494,8 +502,8 @@ export default function TaskDetail() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<div style={{ fontSize: 13 }}>
|
||||
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, overflowY: activeTab === 'Folder' ? 'hidden' : 'auto', padding: activeTab === 'Folder' ? 0 : CARD.padding, ...(activeTab === 'Folder' ? { display: 'flex', flexDirection: 'column' } : {}) }}>
|
||||
<div style={{ fontSize: 13, ...(activeTab === 'Folder' ? { flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 } : {}) }}>
|
||||
{activeTab === 'Overview' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 32 }}>
|
||||
@@ -645,7 +653,15 @@ export default function TaskDetail() {
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'Folder' && <div style={{ color: 'var(--text-muted)' }}>Folder view coming soon.</div>}
|
||||
{activeTab === 'Folder' && company?.name && project?.name && (
|
||||
<FileBrowser
|
||||
initialPath={`/Clients/${safeName(company.name)}/Projects/${safeName(project.name)}/${safeName(task.title)}`}
|
||||
rootPath={`/Clients/${safeName(company.name)}/Projects/${safeName(project.name)}`}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'Folder' && (!company?.name || !project?.name) && (
|
||||
<div style={{ color: 'var(--text-muted)' }}>No folder available — task needs a project and company.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+66
-21
@@ -210,6 +210,8 @@ export default function RequestsPage() {
|
||||
const [addProjectForm, setAddProjectForm] = useState({ name: '', companyId: '' });
|
||||
const [companyFilterMenuOpen, setCompanyFilterMenuOpen] = useState(false);
|
||||
const companyFilterMenuRef = useRef(null);
|
||||
const [projectFilterMenuOpen, setProjectFilterMenuOpen] = useState(false);
|
||||
const projectFilterMenuRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!companyFilterMenuOpen) return;
|
||||
@@ -220,6 +222,15 @@ export default function RequestsPage() {
|
||||
return () => document.removeEventListener('mousedown', onDocClick);
|
||||
}, [companyFilterMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectFilterMenuOpen) return;
|
||||
function onDocClick(e) {
|
||||
if (projectFilterMenuRef.current && !projectFilterMenuRef.current.contains(e.target)) setProjectFilterMenuOpen(false);
|
||||
}
|
||||
document.addEventListener('mousedown', onDocClick);
|
||||
return () => document.removeEventListener('mousedown', onDocClick);
|
||||
}, [projectFilterMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function loadTasksPage() {
|
||||
@@ -260,7 +271,7 @@ export default function RequestsPage() {
|
||||
}
|
||||
|
||||
const tasksQ = supabase.from('tasks')
|
||||
.select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at, submitted_at')
|
||||
.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 });
|
||||
if (!isTeam) {
|
||||
if ((scopedProjectIds || []).length > 0) tasksQ.in('project_id', scopedProjectIds);
|
||||
@@ -430,6 +441,7 @@ export default function RequestsPage() {
|
||||
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,
|
||||
assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null,
|
||||
submittedAt: task.submitted_at ? new Date(task.submitted_at).getTime() : 0,
|
||||
};
|
||||
});
|
||||
@@ -448,6 +460,7 @@ export default function RequestsPage() {
|
||||
serviceType, deadline: deadlineSource.deadline,
|
||||
version: deadlineSource.version_number ?? 0,
|
||||
isHot: deadlineSource.is_hot || false,
|
||||
assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || 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),
|
||||
@@ -489,6 +502,8 @@ export default function RequestsPage() {
|
||||
if (key === 'deadline') return row.deadline || '';
|
||||
if (key === 'status') return TASK_STATUS_SORT_RANK[row.status] ?? 99;
|
||||
if (key === 'submitted_at') return row.submittedAt || 0;
|
||||
if (key === 'assigned') return row.assignedName || '';
|
||||
if (key === 'priority') return row.isHot ? 0 : 1;
|
||||
return '';
|
||||
});
|
||||
|
||||
@@ -509,26 +524,36 @@ 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' }}>
|
||||
<Link to={`/tasks/${row.id}`} className="table-link">{`R${String(row.version).padStart(2, '0')}`}</Link>
|
||||
</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Link to={`/requests/${row.id}/v2`} className="table-link">{row.title || '—'}</Link>
|
||||
<span style={{ color: 'var(--text-muted)' }}>{`R${String(row.version).padStart(2, '0')}`}</span>
|
||||
{row.isHot && <span className="badge badge-needs_revision">HOT</span>}
|
||||
</div>
|
||||
<Link to={`/tasks/${row.id}`} className="table-link">{row.title || '—'}</Link>
|
||||
</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
{project ? <Link to={`/projects/${project.id}`} className="table-link">{project.name}</Link> : '—'}
|
||||
</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>
|
||||
<Link to={`/requests/${row.id}/v2`} className="table-link">{row.serviceType}</Link>
|
||||
</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>
|
||||
<Link to={`/requests/${row.id}/v2`} className="table-link">{fmtShortDate(row.deadline, 'Not specified')}</Link>
|
||||
</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, textAlign: 'center' }}>
|
||||
<Link to={`/requests/${row.id}/v2`} className="table-link"><StatusBadge status={row.status || 'not_started'} /></Link>
|
||||
{(() => {
|
||||
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.assigneeAvatar)
|
||||
return <div title={row.assignedName} style={avatarStyle}><img src={row.assigneeAvatar} alt={row.assignedName} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div>;
|
||||
if (row.assignedName)
|
||||
return <div title={row.assignedName} style={{ ...avatarStyle, background: 'var(--accent)' }}><span style={{ fontSize: 10, fontWeight: 600, color: '#000', lineHeight: 1 }}>{initials}</span></div>;
|
||||
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>;
|
||||
})()}
|
||||
</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, textAlign: 'center' }}>
|
||||
<Link to={`/tasks/${row.id}`} className="table-link" style={{ color: row.isHot ? '#ef4444' : 'var(--text-primary)', textTransform: 'uppercase', fontSize: 11, letterSpacing: 0.5 }}>{row.isHot ? 'HOT' : 'NO'}</Link>
|
||||
</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>
|
||||
<Link to={`/tasks/${row.id}`} className="table-link">{row.serviceType}</Link>
|
||||
</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>
|
||||
<Link to={`/tasks/${row.id}`} className="table-link">{fmtShortDate(row.deadline, 'Not specified')}</Link>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -611,10 +636,26 @@ export default function RequestsPage() {
|
||||
))}
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }} ref={companyFilterMenuRef}>
|
||||
{isExternal && projectOptions.length > 0 && (
|
||||
<select className="filter-select" style={{ width: 120 }} value={filterProject} onChange={e => setFilterProject(e.target.value)}>
|
||||
<option value="">All Projects</option>
|
||||
{projectOptions.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
<div style={{ position: 'relative' }} ref={projectFilterMenuRef}>
|
||||
<button
|
||||
className="btn btn-outline"
|
||||
aria-label="Filter projects" title="Filter projects"
|
||||
onClick={() => setProjectFilterMenuOpen(o => !o)}
|
||||
style={{ width: 30, minWidth: 30, padding: 0, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M2 4h12M4 8h8M6 12h4" />
|
||||
</svg>
|
||||
</button>
|
||||
{projectFilterMenuOpen && (
|
||||
<div className="site-header-avatar-menu" style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 220, minWidth: 170 }}>
|
||||
<button onClick={() => { setFilterProject(''); setProjectFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: !filterProject ? 'rgba(245,165,35,0.08)' : 'transparent', color: !filterProject ? 'var(--accent)' : 'var(--text-primary)' }}>All Projects</button>
|
||||
{projectOptions.map(p => (
|
||||
<button key={p.id} onClick={() => { setFilterProject(p.id); setProjectFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: filterProject === p.id ? 'rgba(245,165,35,0.08)' : 'transparent', color: filterProject === p.id ? 'var(--accent)' : 'var(--text-primary)' }}>{p.name}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(isTeam || isClient) && (
|
||||
<div style={{ position: 'relative' }}>
|
||||
@@ -663,19 +704,23 @@ export default function RequestsPage() {
|
||||
<div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<table className="table-sticky-head" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '28%' }} />
|
||||
<col style={{ width: '5%' }} />
|
||||
<col style={{ width: '25%' }} />
|
||||
<col style={{ width: '18%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
<col style={{ width: '7%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
<col style={{ width: '12%' }} />
|
||||
<col style={{ width: '20%' }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="revision" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>R#</SortTh>
|
||||
<SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>Name</SortTh>
|
||||
<SortTh col="project" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>Project</SortTh>
|
||||
<SortTh col="assigned" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Assigned</SortTh>
|
||||
<SortTh col="priority" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Priority</SortTh>
|
||||
<SortTh col="serviceType" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Task Type</SortTh>
|
||||
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Deadline</SortTh>
|
||||
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Status</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{sortedRows.map(renderRow)}</tbody>
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
export default function NewProject() {
|
||||
const { currentUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const companyOptions = currentUser.companies?.length
|
||||
? currentUser.companies
|
||||
: (currentUser.company ? [currentUser.company] : []);
|
||||
|
||||
const [selectedCompanyId, setSelectedCompanyId] = useState(companyOptions[0]?.id || '');
|
||||
const [name, setName] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
if (!companyOptions.length) {
|
||||
return (
|
||||
<Layout>
|
||||
<div style={{ maxWidth: 480, margin: '0 auto', textAlign: 'center', paddingTop: 48 }}>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 400, marginBottom: 8 }}>Account Not Yet Active</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
|
||||
Your account hasn't been linked to a company yet. Please contact the Fourge team to get set up.
|
||||
</p>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (saving) return;
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
const { data, error: insertError } = await supabase
|
||||
.from('projects')
|
||||
.insert({ company_id: selectedCompanyId, name: trimmedName })
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (insertError) {
|
||||
setError(insertError.message);
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
navigate(`/my-projects/${data.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">New Project</div>
|
||||
<div className="page-subtitle">Create a project to organise your work.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ maxWidth: 480 }}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
{companyOptions.length > 1 && (
|
||||
<div className="form-group">
|
||||
<label>Company *</label>
|
||||
<select
|
||||
value={selectedCompanyId}
|
||||
onChange={e => setSelectedCompanyId(e.target.value)}
|
||||
required
|
||||
>
|
||||
{companyOptions.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label>Project Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Brand Refresh 2026"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="notification notification-error" style={{ marginBottom: 16 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="action-buttons">
|
||||
<button type="submit" className="btn btn-primary btn-lg" disabled={saving || !name.trim()}>
|
||||
{saving ? 'Creating...' : 'Create Project'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-outline" onClick={() => navigate('/my-projects')}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import RequestForm from '../../components/RequestForm';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { sendEmail } from '../../lib/email';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../../lib/requestSubmission';
|
||||
import { uploadFilesToRequestInfo } from '../../lib/filebrowserFolders';
|
||||
import { logActivity } from '../../lib/activityLog';
|
||||
|
||||
export default function NewRequest() {
|
||||
const { currentUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const companyOptions = currentUser.companies?.length ? currentUser.companies : (currentUser.company ? [currentUser.company] : []);
|
||||
const initialCompanyId = companyOptions[0]?.id || '';
|
||||
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [requestKey, setRequestKey] = useState(() => crypto.randomUUID());
|
||||
const [formKey, setFormKey] = useState(0);
|
||||
const [lastServiceType, setLastServiceType] = useState('');
|
||||
const [lastProject, setLastProject] = useState('');
|
||||
|
||||
if (!companyOptions.length) {
|
||||
return (
|
||||
<Layout>
|
||||
<div style={{ maxWidth: 480, margin: '0 auto', textAlign: 'center', paddingTop: 48 }}>
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>⚠️</div>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 400, marginBottom: 8 }}>Account Not Yet Active</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
|
||||
Your account hasn't been linked to a company yet. Please contact the Fourge team to get set up.
|
||||
</p>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<Layout>
|
||||
<div style={{ maxWidth: 480, margin: '0 auto', textAlign: 'center', paddingTop: 48 }}>
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>✅</div>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 400, marginBottom: 8 }}>Request Submitted!</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', marginBottom: 24, fontSize: 14 }}>
|
||||
Thanks, {currentUser?.name?.split(' ')[0]}! We've received your request for <span style={{ color: "var(--text-primary)" }}>{lastServiceType}</span>
|
||||
{lastProject && <> under <span style={{ color: "var(--text-primary)" }}>{lastProject}</span></>}.
|
||||
Our team will review it and update you shortly.
|
||||
</p>
|
||||
<div className="action-buttons" style={{ justifyContent: 'center' }}>
|
||||
<button className="btn btn-primary" onClick={() => navigate('/my-projects')}>View Projects</button>
|
||||
<button className="btn btn-outline" onClick={() => { setSubmitted(false); setRequestKey(crypto.randomUUID()); setFormKey(k => k + 1); }}>
|
||||
Submit Another
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSubmit = async (formData, files, existingProjects) => {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const selectedCompany = companyOptions.find(c => c.id === formData.companyId) || companyOptions[0];
|
||||
const resolvedProject = await findOrCreateProject(formData.companyId, formData.project.trim(), existingProjects);
|
||||
|
||||
const { task } = await createTaskForRequest({
|
||||
projectId: resolvedProject.id,
|
||||
title: formData.title.trim(),
|
||||
requestKey,
|
||||
});
|
||||
if (!task) { setSaving(false); return; }
|
||||
|
||||
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'request_submitted', taskId: task.id, taskTitle: formData.title.trim(), projectId: resolvedProject.id, projectName: resolvedProject.name }).catch(() => {});
|
||||
|
||||
const { submission } = await createInitialSubmissionForRequest({
|
||||
taskId: task.id,
|
||||
requestKey,
|
||||
isHot: formData.isHot,
|
||||
serviceType: formData.serviceType,
|
||||
deadline: formData.deadline,
|
||||
description: formData.description,
|
||||
submittedBy: currentUser.id,
|
||||
submittedByName: currentUser.name,
|
||||
});
|
||||
|
||||
if (submission && files.length > 0) {
|
||||
for (const file of files) {
|
||||
const path = `${task.id}/${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);
|
||||
throw new Error(`Failed to upload "${file.name}": ${uploadError.message}`);
|
||||
}
|
||||
if (uploaded) {
|
||||
const { error: fileRecordError } = await supabase.from('submission_files').insert({
|
||||
submission_id: submission.id,
|
||||
name: file.name,
|
||||
storage_path: path,
|
||||
size: file.size,
|
||||
});
|
||||
if (fileRecordError) {
|
||||
await supabase.from('tasks').delete().eq('id', task.id);
|
||||
throw new Error(`Failed to save file record for "${file.name}": ${fileRecordError.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
uploadFilesToRequestInfo(files, selectedCompany?.name, resolvedProject.name, formData.title.trim()).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,
|
||||
description: formData.description,
|
||||
taskId: task.id,
|
||||
}).catch(() => {});
|
||||
|
||||
setLastServiceType(formData.serviceType);
|
||||
setLastProject(formData.project);
|
||||
setSubmitted(true);
|
||||
} catch (err) {
|
||||
console.error('Request submission failed:', err);
|
||||
setError(err.message || 'Something went wrong. Please try again.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">New Request</div>
|
||||
<div className="page-subtitle">Tell us what you need and we'll get to work.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ maxWidth: 600 }}>
|
||||
<RequestForm
|
||||
key={formKey}
|
||||
companies={companyOptions}
|
||||
initialCompanyId={initialCompanyId}
|
||||
showRequester={false}
|
||||
onSubmit={handleSubmit}
|
||||
saving={saving}
|
||||
error={error}
|
||||
submitLabel="Submit Request"
|
||||
/>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -176,7 +176,7 @@ function ActivityFeed({ events }) {
|
||||
}
|
||||
{e.action && <span style={{ color: 'var(--text-muted)' }}> {e.action}</span>}
|
||||
{e.task && e.taskId
|
||||
? <><span style={{ color: 'var(--text-muted)' }}> </span><button type="button" className="dashboard-inline-link" onClick={() => navigate(`/requests/${e.taskId}`)}>{e.task}</button></>
|
||||
? <><span style={{ color: 'var(--text-muted)' }}> </span><button type="button" className="dashboard-inline-link" onClick={() => navigate(`/tasks/${e.taskId}`)}>{e.task}</button></>
|
||||
: e.task && <span style={{ color: 'var(--text-primary)' }}> {e.task}</span>
|
||||
}
|
||||
</div>
|
||||
@@ -269,7 +269,7 @@ function MiniCalendar({ items = [] }) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (dayItems.length === 1) navigate(`/requests/${dayItems[0].id}`);
|
||||
if (dayItems.length === 1) navigate(`/tasks/${dayItems[0].id}`);
|
||||
}}
|
||||
disabled={!d}
|
||||
style={{
|
||||
@@ -304,7 +304,7 @@ function MiniCalendar({ items = [] }) {
|
||||
<span style={{ fontSize: 11, color: '#F5A523' }}>{activeItems.length} due</span>
|
||||
</div>
|
||||
{activeItems.slice(0, 4).map(item => (
|
||||
<button key={item.id} type="button" onClick={() => navigate(`/requests/${item.id}`)} style={{ display: 'flex', alignItems: 'center', gap: 7, width: '100%', padding: '5px 0', background: 'transparent', border: 'none', cursor: 'pointer', textAlign: 'left', fontFamily: 'inherit' }}>
|
||||
<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' }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: dotColor(item), flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, minWidth: 0, fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.title}</span>
|
||||
</button>
|
||||
@@ -357,7 +357,7 @@ function TasksInProgressCard({ tasks = [] }) {
|
||||
{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>
|
||||
<button type="button" className="dashboard-inline-link" onClick={() => navigate('/tasks/' + 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
|
||||
@@ -442,7 +442,7 @@ function HotItemsCard({ submissions, tasks, isClient = false }) {
|
||||
)}
|
||||
</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('/requests/' + s.task_id)}>{s.task.title}</button>
|
||||
<button type="button" className="dashboard-inline-link" onClick={() => navigate('/tasks/' + s.task_id)}>{s.task.title}</button>
|
||||
</td>
|
||||
<td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', padding: '5px', border: 'none', background: 'transparent', textAlign: 'center' }}>
|
||||
{s.submitted_by ? (
|
||||
|
||||
Reference in New Issue
Block a user