Refactor: clients → companies schema v2
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Layout from '../../components/Layout';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { generateInvoicePDF } from '../../lib/invoice';
|
||||
|
||||
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
|
||||
|
||||
export default function MyInvoices() {
|
||||
const [invoices, setInvoices] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const { data } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, company:companies(name, email), items:invoice_items(*)')
|
||||
.order('created_at', { ascending: false });
|
||||
setInvoices((data || []).filter(inv => inv.status !== 'draft'));
|
||||
setLoading(false);
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const handleDownload = async (invoice) => {
|
||||
await generateInvoicePDF(invoice, invoice.company, invoice.items || []);
|
||||
};
|
||||
|
||||
const outstanding = invoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total), 0);
|
||||
const paid = invoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total), 0);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Invoices</div>
|
||||
<div className="page-subtitle">{invoices.length} invoice{invoices.length !== 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stats-grid" style={{ marginBottom: 24 }}>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ fontSize: 22 }}>${outstanding.toFixed(2)}</div>
|
||||
<div className="stat-label">Outstanding</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ fontSize: 22 }}>${paid.toFixed(2)}</div>
|
||||
<div className="stat-label">Paid</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p style={{ color: 'var(--text-muted)' }}>Loading...</p>
|
||||
) : invoices.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No invoices yet</h3>
|
||||
<p>Your invoices will appear here once they are sent.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{invoices.map(inv => {
|
||||
const isOverdue = inv.status !== 'paid' && new Date(inv.due_date) < new Date();
|
||||
return (
|
||||
<div key={inv.id} className="request-card">
|
||||
<div className="request-card-header">
|
||||
<div>
|
||||
<div className="request-card-title">{inv.invoice_number}</div>
|
||||
<div className="request-card-meta">
|
||||
Issued {new Date(inv.invoice_date).toLocaleDateString()} · Due{' '}
|
||||
<span style={{ color: isOverdue ? 'var(--danger)' : 'inherit' }}>
|
||||
{new Date(inv.due_date).toLocaleDateString()}
|
||||
</span>
|
||||
{isOverdue && ' · Overdue'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span className={`badge badge-${statusColor[inv.status]}`} style={{ textTransform: 'capitalize' }}>{inv.status}</span>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--accent)' }}>${Number(inv.total).toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
{inv.items && inv.items.length > 0 && (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 10 }}>
|
||||
{inv.items.map(i => i.description).join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
<button className="btn btn-outline btn-sm" onClick={() => handleDownload(inv)}>
|
||||
Download PDF
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
const vLabel = (v) => 'v' + String(v).padStart(2, '0');
|
||||
|
||||
export default function MyProjectDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { currentUser } = useAuth();
|
||||
|
||||
const [project, setProject] = useState(null);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [submissions, setSubmissions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState('all'); // 'all' | 'mine'
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const { data: p } = await supabase.from('projects').select('*').eq('id', id).single();
|
||||
if (!p) { setLoading(false); return; }
|
||||
setProject(p);
|
||||
|
||||
const { data: t } = await supabase
|
||||
.from('tasks').select('*').eq('project_id', id).order('submitted_at', { ascending: false });
|
||||
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 || []);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
load();
|
||||
}, [id]);
|
||||
|
||||
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 filteredTasks = 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;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<button className="back-link" onClick={() => navigate('/my-projects')}>← Back to Projects</button>
|
||||
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">{project.name}</div>
|
||||
<div className="page-subtitle">
|
||||
{tasks.length} request{tasks.length !== 1 ? 's' : ''} · Started {new Date(project.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Link to={`/new-request?project=${encodeURIComponent(project.name)}`} className="btn btn-primary">
|
||||
+ Add Request
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter toggle */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
|
||||
<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>
|
||||
|
||||
{filteredTasks.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>{filter === 'mine' ? "You haven't submitted any requests to this project" : 'No requests yet'}</h3>
|
||||
<Link to={`/new-request?project=${encodeURIComponent(project.name)}`} className="btn btn-primary" style={{ marginTop: 16 }}>
|
||||
Add Request
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{filteredTasks.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 (
|
||||
<div key={task.id} className="request-card">
|
||||
<div className="request-card-header">
|
||||
<div>
|
||||
<div className="request-card-title">
|
||||
{task.title}{' '}
|
||||
<span style={{ fontWeight: 400, color: 'var(--text-muted)', fontSize: 13 }}>
|
||||
{vLabel(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: 600 }}>
|
||||
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>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<StatusBadge status={task.status} />
|
||||
<Link to={`/my-requests/${task.id}`} className="btn btn-outline btn-sm">Details</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Executable
+197
@@ -0,0 +1,197 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
const vLabel = (v) => 'v' + String(v).padStart(2, '0');
|
||||
|
||||
function ProjectGroup({ project, tasks, submissions, currentUserId, filter }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
const filteredTasks = filter === 'mine'
|
||||
? tasks.filter(task => {
|
||||
const initial = submissions.find(s => s.task_id === task.id && s.type === 'initial');
|
||||
return initial?.submitted_by === currentUserId;
|
||||
})
|
||||
: tasks;
|
||||
|
||||
if (filter === 'mine' && filteredTasks.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', marginBottom: 8 }}>
|
||||
{/* Project header — clickable to collapse */}
|
||||
<button
|
||||
onClick={() => setOpen(o => !o)}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'space-between', padding: '12px 16px',
|
||||
background: 'var(--card-bg-2)', border: 'none', cursor: 'pointer',
|
||||
borderBottom: open ? '1px solid var(--border)' : 'none',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
{project.name}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 4, background: 'rgba(245,165,35,0.15)', color: 'var(--accent)' }}>
|
||||
{filteredTasks.length} request{filteredTasks.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<StatusBadge status={project.status} />
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{open ? '▲' : '▼'}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div style={{ background: 'var(--card-bg)' }}>
|
||||
{filteredTasks.length === 0 ? (
|
||||
<div style={{ padding: '16px', fontSize: 13, color: 'var(--text-muted)', textAlign: 'center' }}>
|
||||
No requests in this project yet.
|
||||
</div>
|
||||
) : (
|
||||
filteredTasks.map((task, i) => {
|
||||
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 === currentUserId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '12px 16px',
|
||||
borderBottom: i < filteredTasks.length - 1 ? '1px solid var(--border)' : 'none',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
{task.title}
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{vLabel(task.current_version)}
|
||||
</span>
|
||||
{isMine && (
|
||||
<span style={{ fontSize: 11, background: 'rgba(245,165,35,0.15)', color: 'var(--accent)', padding: '1px 7px', borderRadius: 4, fontWeight: 600 }}>
|
||||
Mine
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 3 }}>
|
||||
{initialSub?.submitted_by_name && <>By {initialSub.submitted_by_name}</>}
|
||||
{hasRevision && <> · Updated by {latestSub.submitted_by_name}</>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||
<StatusBadge status={task.status} />
|
||||
<Link to={`/my-requests/${task.id}`} className="btn btn-outline btn-sm">Details</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<div style={{ padding: '10px 16px', borderTop: filteredTasks.length > 0 ? '1px solid var(--border)' : 'none' }}>
|
||||
<Link
|
||||
to={`/new-request?project=${encodeURIComponent(project.name)}`}
|
||||
className="btn btn-outline btn-sm"
|
||||
>
|
||||
+ Add Request to {project.name}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MyProjects() {
|
||||
const { currentUser } = useAuth();
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [submissions, setSubmissions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState('all'); // 'all' | 'mine'
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const { data: p } = await supabase
|
||||
.from('projects').select('*').order('created_at', { ascending: false });
|
||||
setProjects(p || []);
|
||||
|
||||
if (!p || p.length === 0) { setLoading(false); return; }
|
||||
|
||||
const { data: t } = await supabase
|
||||
.from('tasks').select('*').in('project_id', p.map(pr => pr.id))
|
||||
.order('submitted_at', { ascending: false });
|
||||
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 || []);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Projects</div>
|
||||
<div className="page-subtitle">All work for your company.</div>
|
||||
</div>
|
||||
<Link to="/new-request" className="btn btn-primary">+ New Request</Link>
|
||||
</div>
|
||||
|
||||
{/* Filter toggle */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
|
||||
<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>
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No projects yet</h3>
|
||||
<p>Submit a request and a project will be created automatically.</p>
|
||||
<Link to="/new-request" className="btn btn-primary" style={{ marginTop: 16 }}>Submit Request</Link>
|
||||
</div>
|
||||
) : (
|
||||
projects.map(project => (
|
||||
<ProjectGroup
|
||||
key={project.id}
|
||||
project={project}
|
||||
tasks={tasks.filter(t => t.project_id === project.id)}
|
||||
submissions={submissions}
|
||||
currentUserId={currentUser.id}
|
||||
filter={filter}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Executable
+122
@@ -0,0 +1,122 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
export default function MyRequests() {
|
||||
const { currentUser } = useAuth();
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [submissions, setSubmissions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
// Only fetch tasks where current user was the original submitter
|
||||
const { data: mySubs } = await supabase
|
||||
.from('submissions')
|
||||
.select('task_id, submitted_by_name, version_number, type')
|
||||
.eq('submitted_by', currentUser.id)
|
||||
.eq('type', 'initial');
|
||||
|
||||
if (!mySubs || mySubs.length === 0) { setLoading(false); return; }
|
||||
|
||||
const myTaskIds = mySubs.map(s => s.task_id);
|
||||
|
||||
const { data: t } = await supabase
|
||||
.from('tasks').select('*, project:projects(id, name, created_at, status)')
|
||||
.in('id', myTaskIds);
|
||||
setTasks(t || []);
|
||||
|
||||
// Also fetch all submissions for these tasks (to show revision history)
|
||||
const { data: allSubs } = await supabase
|
||||
.from('submissions')
|
||||
.select('task_id, submitted_by_name, version_number, type')
|
||||
.in('task_id', myTaskIds)
|
||||
.order('version_number');
|
||||
setSubmissions(allSubs || []);
|
||||
|
||||
// Group tasks by project
|
||||
const projectMap = {};
|
||||
(t || []).forEach(task => {
|
||||
const p = task.project;
|
||||
if (!p) return;
|
||||
if (!projectMap[p.id]) projectMap[p.id] = { ...p, id: p.id };
|
||||
});
|
||||
setProjects(Object.values(projectMap));
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
load();
|
||||
}, [currentUser.id]);
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">My Requests</div>
|
||||
<div className="page-subtitle">Requests you have submitted.</div>
|
||||
</div>
|
||||
<Link to="/new-request" className="btn btn-primary">+ New Request</Link>
|
||||
</div>
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">📋</div>
|
||||
<h3>No requests yet</h3>
|
||||
<p>Submit a new request to get started.</p>
|
||||
<Link to="/new-request" className="btn btn-primary" style={{ marginTop: 16 }}>Submit Request</Link>
|
||||
</div>
|
||||
) : (
|
||||
projects.map(project => {
|
||||
const projectTasks = tasks.filter(t => t.project?.id === project.id);
|
||||
return (
|
||||
<div key={project.id} className="request-card">
|
||||
<div className="request-card-header">
|
||||
<div>
|
||||
<div className="request-card-title">{project.name}</div>
|
||||
<div className="request-card-meta">
|
||||
Started {new Date(project.created_at).toLocaleDateString()} · {projectTasks.length} job{projectTasks.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={project.status} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{projectTasks.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 = taskSubs.length > 1 && latestSub?.submitted_by_name !== initialSub?.submitted_by_name;
|
||||
return (
|
||||
<div key={task.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', background: 'var(--bg)', borderRadius: 8 }}>
|
||||
<div>
|
||||
<span style={{ fontWeight: 600, fontSize: 13 }}>
|
||||
{task.title}{' '}
|
||||
<span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>
|
||||
{'v' + String(task.current_version).padStart(2, '0')}
|
||||
</span>
|
||||
</span>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>
|
||||
Submitted by {initialSub?.submitted_by_name || 'Unknown'}
|
||||
{hasRevision && ` · Last updated by ${latestSub.submitted_by_name}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusBadge status={task.status} />
|
||||
<Link to={`/my-requests/${task.id}`} className="btn btn-outline btn-sm">Details</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Executable
+265
@@ -0,0 +1,265 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import FileAttachment from '../../components/FileAttachment';
|
||||
import { serviceTypes } from '../../data/mockData';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { sendEmail } from '../../lib/email';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
export default function NewRequest() {
|
||||
const { currentUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const preselectedProject = searchParams.get('project') || '';
|
||||
|
||||
const [existingProjects, setExistingProjects] = useState([]);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState({ project: preselectedProject, serviceType: '', title: '', deadline: '', description: '' });
|
||||
const [files, setFiles] = useState([]);
|
||||
const [customProjects, setCustomProjects] = useState([]);
|
||||
const [isTypingProject, setIsTypingProject] = useState(false);
|
||||
const [newProjectName, setNewProjectName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
if (!currentUser.company_id) return;
|
||||
const { data: p } = await supabase
|
||||
.from('projects')
|
||||
.select('id, name')
|
||||
.eq('company_id', currentUser.company_id)
|
||||
.order('created_at', { ascending: false });
|
||||
setExistingProjects((p || []).map(pr => ({ id: pr.id, name: pr.name })));
|
||||
}
|
||||
load();
|
||||
}, [currentUser.company_id]);
|
||||
|
||||
const allProjectNames = [
|
||||
...existingProjects.map(p => p.name),
|
||||
...customProjects.filter(name => !existingProjects.some(p => p.name === name)),
|
||||
];
|
||||
|
||||
const set = (field) => (e) => setForm(f => ({ ...f, [field]: e.target.value }));
|
||||
|
||||
const handleProjectSelect = (e) => {
|
||||
if (e.target.value === '__new__') {
|
||||
setIsTypingProject(true);
|
||||
setForm(f => ({ ...f, project: '' }));
|
||||
} else {
|
||||
setForm(f => ({ ...f, project: e.target.value }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddProject = () => {
|
||||
const name = newProjectName.trim();
|
||||
if (!name) return;
|
||||
if (!customProjects.includes(name) && !existingProjects.some(p => p.name === name)) {
|
||||
setCustomProjects(prev => [...prev, name]);
|
||||
}
|
||||
setForm(f => ({ ...f, project: name }));
|
||||
setIsTypingProject(false);
|
||||
setNewProjectName('');
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!currentUser.company_id) {
|
||||
alert('Your account is not yet assigned to a company. Please contact support.');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
|
||||
// Find existing project by name within this company, or create new one
|
||||
let projectId;
|
||||
const existing = existingProjects.find(p => p.name === form.project);
|
||||
if (existing) {
|
||||
projectId = existing.id;
|
||||
} else {
|
||||
const { data: newProject } = await supabase.from('projects').insert({
|
||||
company_id: currentUser.company_id,
|
||||
name: form.project,
|
||||
status: 'active',
|
||||
}).select().single();
|
||||
projectId = newProject?.id;
|
||||
}
|
||||
|
||||
if (!projectId) { setSaving(false); return; }
|
||||
|
||||
// Create task
|
||||
const { data: task } = await supabase.from('tasks').insert({
|
||||
project_id: projectId,
|
||||
title: form.title.trim() || form.serviceType,
|
||||
status: 'not_started',
|
||||
current_version: 0,
|
||||
}).select().single();
|
||||
|
||||
if (!task) { setSaving(false); return; }
|
||||
|
||||
// Create submission
|
||||
const { data: submission } = await supabase.from('submissions').insert({
|
||||
task_id: task.id,
|
||||
version_number: 1,
|
||||
type: 'initial',
|
||||
service_type: form.serviceType,
|
||||
deadline: form.deadline || null,
|
||||
description: form.description,
|
||||
submitted_by: currentUser.id,
|
||||
submitted_by_name: currentUser.name,
|
||||
}).select().single();
|
||||
|
||||
// Upload files
|
||||
if (submission && files.length > 0) {
|
||||
for (const file of files) {
|
||||
const path = `${task.id}/${Date.now()}_${file.name}`;
|
||||
const { data: uploaded } = await supabase.storage.from('submissions').upload(path, file);
|
||||
if (uploaded) {
|
||||
await supabase.from('submission_files').insert({
|
||||
submission_id: submission.id,
|
||||
name: file.name,
|
||||
storage_path: path,
|
||||
size: file.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sendEmail('new_request', 'hello@fourgebranding.com', {
|
||||
clientName: currentUser.name,
|
||||
clientEmail: currentUser.email,
|
||||
company: currentUser.company?.name || '',
|
||||
serviceType: form.serviceType,
|
||||
projectName: form.project,
|
||||
deadline: form.deadline,
|
||||
description: form.description,
|
||||
taskId: task.id,
|
||||
});
|
||||
|
||||
setSaving(false);
|
||||
setSubmitted(true);
|
||||
};
|
||||
|
||||
if (!currentUser.company_id) {
|
||||
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: 700, 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: 700, 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 <strong>{form.serviceType}</strong>
|
||||
{form.project && <> under <strong>{form.project}</strong></>}.
|
||||
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); setForm({ project: '', serviceType: '', title: '', deadline: '', description: '' }); setFiles([]); }}>
|
||||
Submit Another
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
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 }}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Project *</label>
|
||||
{isTypingProject ? (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter project name..."
|
||||
value={newProjectName}
|
||||
onChange={e => setNewProjectName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAddProject(); } }}
|
||||
autoFocus
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button type="button" className="btn btn-primary" onClick={handleAddProject} disabled={!newProjectName.trim()}>Add</button>
|
||||
<button type="button" className="btn btn-outline" onClick={() => { setIsTypingProject(false); setNewProjectName(''); }}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<select value={form.project} onChange={handleProjectSelect} required>
|
||||
<option value="">Select a project...</option>
|
||||
{allProjectNames.map(name => <option key={name} value={name}>{name}</option>)}
|
||||
<option value="__new__">+ Create new project...</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Service Type *</label>
|
||||
<select value={form.serviceType} onChange={set('serviceType')} required>
|
||||
<option value="">Select a service...</option>
|
||||
{serviceTypes.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Desired Deadline</label>
|
||||
<input type="date" value={form.deadline} onChange={set('deadline')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Request Title
|
||||
<span style={{ fontWeight: 400, color: 'var(--text-muted)', marginLeft: 6 }}>optional — defaults to service type if left blank</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={form.serviceType ? `e.g. ${form.serviceType} — 2026` : 'e.g. Brand Book — 2026'}
|
||||
value={form.title}
|
||||
onChange={set('title')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Project Description *</label>
|
||||
<textarea
|
||||
placeholder="Tell us about your project — what you need, your brand, style preferences, any references..."
|
||||
value={form.description}
|
||||
onChange={set('description')}
|
||||
style={{ minHeight: 140 }}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FileAttachment files={files} onChange={setFiles} />
|
||||
|
||||
<div className="notification notification-info" style={{ marginBottom: 16 }}>
|
||||
Submitting as <strong>{currentUser?.name}</strong> · {currentUser?.company?.name}
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn-primary btn-lg" disabled={saving}>
|
||||
{saving ? 'Submitting...' : 'Submit Request'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Executable
+350
@@ -0,0 +1,350 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import FileAttachment from '../../components/FileAttachment';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { sendEmail } from '../../lib/email';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { serviceTypes } from '../../data/mockData';
|
||||
|
||||
const vLabel = (v) => 'v' + String(v).padStart(2, '0');
|
||||
|
||||
export default function RequestDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { currentUser } = useAuth();
|
||||
|
||||
const [task, setTask] = useState(null);
|
||||
const [project, setProject] = useState(null);
|
||||
const [submissions, setSubmissions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [action, setAction] = useState(null);
|
||||
const [revisionForm, setRevisionForm] = useState({ serviceType: '', deadline: '', description: '' });
|
||||
const [revisionFiles, setRevisionFiles] = useState([]);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const { data: t } = await supabase.from('tasks').select('*').eq('id', id).single();
|
||||
if (!t) { setLoading(false); return; }
|
||||
setTask(t);
|
||||
|
||||
const [{ data: p }, { data: subs }] = await Promise.all([
|
||||
supabase.from('projects').select('*').eq('id', t.project_id).single(),
|
||||
supabase.from('submissions').select('*, delivery:deliveries(*, files:delivery_files(*))').eq('task_id', id).order('version_number'),
|
||||
]);
|
||||
setProject(p);
|
||||
setSubmissions(subs || []);
|
||||
setLoading(false);
|
||||
}
|
||||
load();
|
||||
}, [id]);
|
||||
|
||||
const handleApprove = async () => {
|
||||
setSaving(true);
|
||||
await supabase.from('tasks').update({ status: 'client_approved', completed_at: new Date().toISOString() }).eq('id', id);
|
||||
setTask(t => ({ ...t, status: 'client_approved' }));
|
||||
setAction('approved');
|
||||
sendEmail('client_approved', 'hello@fourgebranding.com', {
|
||||
clientName: currentUser.name,
|
||||
serviceType: task.title,
|
||||
projectName: project?.name,
|
||||
taskId: id,
|
||||
});
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setSaving(true);
|
||||
|
||||
const { data: subs } = await supabase.from('submissions').select('id').eq('task_id', id);
|
||||
if (subs && subs.length > 0) {
|
||||
const { data: storageFiles } = await supabase
|
||||
.from('submission_files').select('storage_path').in('submission_id', subs.map(s => s.id));
|
||||
if (storageFiles && storageFiles.length > 0) {
|
||||
await supabase.storage.from('submissions').remove(storageFiles.map(f => f.storage_path));
|
||||
}
|
||||
const { data: deliveries } = await supabase
|
||||
.from('deliveries').select('id').in('submission_id', subs.map(s => s.id));
|
||||
if (deliveries && deliveries.length > 0) {
|
||||
const { data: deliveryFiles } = await supabase
|
||||
.from('delivery_files').select('storage_path').in('delivery_id', deliveries.map(d => d.id));
|
||||
if (deliveryFiles && deliveryFiles.length > 0) {
|
||||
await supabase.storage.from('deliveries').remove(deliveryFiles.map(f => f.storage_path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await supabase.from('tasks').delete().eq('id', id);
|
||||
|
||||
const { data: remaining } = await supabase.from('tasks').select('id').eq('project_id', task.project_id);
|
||||
if (!remaining || remaining.length === 0) {
|
||||
await supabase.from('projects').delete().eq('id', task.project_id);
|
||||
}
|
||||
|
||||
navigate('/my-projects');
|
||||
};
|
||||
|
||||
const handleRevisionSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
|
||||
if (action === 'edit') {
|
||||
const latestSub = submissions[submissions.length - 1];
|
||||
const updatedDescription = latestSub
|
||||
? `${latestSub.description}\n\n─── Edited ${new Date().toLocaleDateString()} ───\n${revisionForm.description}`
|
||||
: revisionForm.description;
|
||||
|
||||
await supabase.from('submissions').update({
|
||||
description: updatedDescription,
|
||||
deadline: revisionForm.deadline || latestSub?.deadline || null,
|
||||
}).eq('id', latestSub.id);
|
||||
|
||||
if (revisionFiles.length > 0) {
|
||||
for (const file of revisionFiles) {
|
||||
const path = `${id}/${Date.now()}_${file.name}`;
|
||||
const { data: uploaded } = await supabase.storage.from('submissions').upload(path, file);
|
||||
if (uploaded) {
|
||||
await supabase.from('submission_files').insert({
|
||||
submission_id: latestSub.id, name: file.name, storage_path: path, size: file.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const newVersion = (task.current_version || 0) + 1;
|
||||
await supabase.from('tasks').update({ status: 'not_started', current_version: newVersion }).eq('id', id);
|
||||
|
||||
const { data: newSub } = await supabase.from('submissions').insert({
|
||||
task_id: id,
|
||||
version_number: newVersion + 1,
|
||||
type: 'revision',
|
||||
service_type: revisionForm.serviceType,
|
||||
deadline: revisionForm.deadline || null,
|
||||
description: revisionForm.description,
|
||||
submitted_by: currentUser.id,
|
||||
submitted_by_name: currentUser.name,
|
||||
}).select().single();
|
||||
|
||||
if (newSub && revisionFiles.length > 0) {
|
||||
for (const file of revisionFiles) {
|
||||
const path = `${id}/${Date.now()}_${file.name}`;
|
||||
const { data: uploaded } = await supabase.storage.from('submissions').upload(path, file);
|
||||
if (uploaded) {
|
||||
await supabase.from('submission_files').insert({
|
||||
submission_id: newSub.id, name: file.name, storage_path: path, size: file.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setTask(t => ({ ...t, status: 'not_started', current_version: newVersion }));
|
||||
|
||||
sendEmail('revision_submitted', 'hello@fourgebranding.com', {
|
||||
clientName: currentUser.name,
|
||||
serviceType: task.title,
|
||||
projectName: project?.name,
|
||||
version: vLabel(newVersion),
|
||||
deadline: revisionForm.deadline,
|
||||
description: revisionForm.description,
|
||||
taskId: id,
|
||||
});
|
||||
}
|
||||
|
||||
const { data: refreshed } = await supabase
|
||||
.from('submissions')
|
||||
.select('*, delivery:deliveries(*, files:delivery_files(*))')
|
||||
.eq('task_id', id)
|
||||
.order('version_number');
|
||||
setSubmissions(refreshed || []);
|
||||
|
||||
setSubmitted(true);
|
||||
setAction(null);
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const set = (field) => (e) => setRevisionForm(f => ({ ...f, [field]: e.target.value }));
|
||||
|
||||
const getFileUrl = async (path) => {
|
||||
const { data } = await supabase.storage.from('deliveries').createSignedUrl(path, 3600);
|
||||
if (data?.signedUrl) window.open(data.signedUrl, '_blank');
|
||||
};
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
if (!task) return <Layout><p>Job not found.</p></Layout>;
|
||||
|
||||
const canEdit = ['not_started', 'in_progress'].includes(task.status);
|
||||
const canReview = task.status === 'client_review';
|
||||
const canReopen = task.status === 'client_approved';
|
||||
const titleWithVersion = `${task.title} ${vLabel(task.current_version)}`;
|
||||
|
||||
const formTitle = action === 'edit'
|
||||
? `Edit Request — will become ${vLabel((task.current_version || 0) + 1)}`
|
||||
: action === 'reopen'
|
||||
? `Request New Revision — will become ${vLabel((task.current_version || 0) + 1)}`
|
||||
: `Request a Revision — will become ${vLabel((task.current_version || 0) + 1)}`;
|
||||
|
||||
const formPlaceholder = action === 'edit'
|
||||
? "Describe what you'd like to update or change..."
|
||||
: "Describe exactly what you'd like us to change or improve...";
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<button className="back-link" onClick={() => navigate('/my-projects')}>← Back to Projects</button>
|
||||
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">{titleWithVersion}</div>
|
||||
<div className="page-subtitle">{project?.name}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<StatusBadge status={task.status} />
|
||||
{action !== 'confirm-delete' && (
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
style={{ background: '#ef4444', color: 'white', border: 'none' }}
|
||||
onClick={() => setAction('confirm-delete')}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{action === 'confirm-delete' && (
|
||||
<div className="card" style={{ background: '#fef2f2', borderColor: '#fecaca', marginBottom: 24 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>⚠ Delete this request?</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
|
||||
This will permanently delete <strong>{titleWithVersion}</strong> and all its history. This cannot be undone.
|
||||
</p>
|
||||
<div className="action-buttons">
|
||||
<button className="btn" style={{ background: '#ef4444', color: 'white', border: 'none' }} onClick={handleDelete} disabled={saving}>
|
||||
{saving ? 'Deleting...' : 'Yes, Delete'}
|
||||
</button>
|
||||
<button className="btn btn-outline" onClick={() => setAction(null)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{submitted && (
|
||||
<div className="notification notification-success">
|
||||
✓ Your {action === 'edit' ? 'changes have' : 'revision request has'} been submitted as {vLabel(task.current_version)}. Our team will get started shortly.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{action === 'approved' && (
|
||||
<div className="notification notification-success">
|
||||
✓ You've approved {vLabel(task.current_version)}. This job is now complete!
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canReview && !submitted && action !== 'confirm-delete' && action !== 'revision' && (
|
||||
<div className="card" style={{ background: '#fffbeb', borderColor: '#fde68a', marginBottom: 24 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>🎨 Your work is ready for review!</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
|
||||
Please review the delivered work for <strong>{titleWithVersion}</strong> and let us know if you're happy or need changes.
|
||||
</p>
|
||||
<div className="action-buttons">
|
||||
<button className="btn btn-success" onClick={handleApprove} disabled={saving}>✓ Approve — I'm Happy!</button>
|
||||
<button className="btn btn-warning" onClick={() => { setAction('revision'); setRevisionForm(f => ({ ...f, serviceType: task.title })); }}>✏️ Request Revision</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canEdit && !submitted && action !== 'confirm-delete' && action !== 'edit' && (
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>✏️ Need to make changes?</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
|
||||
Your request is still being worked on. You can update the details or requirements.
|
||||
</p>
|
||||
<button className="btn btn-warning" onClick={() => { setAction('edit'); setRevisionForm(f => ({ ...f, serviceType: task.title })); }}>Edit Request</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canReopen && !submitted && action !== 'confirm-delete' && action !== 'reopen' && (
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>🔄 Need more changes?</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
|
||||
This job was approved but you can still request a new revision if needed.
|
||||
</p>
|
||||
<button className="btn btn-warning" onClick={() => { setAction('reopen'); setRevisionForm(f => ({ ...f, serviceType: task.title })); }}>Request New Revision</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(action === 'revision' || action === 'edit' || action === 'reopen') && (
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div className="card-title">{formTitle}</div>
|
||||
<form onSubmit={handleRevisionSubmit}>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Service Type</label>
|
||||
<input type="text" value={revisionForm.serviceType} readOnly disabled style={{ opacity: 0.6, cursor: 'not-allowed' }} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Deadline</label>
|
||||
<input type="date" value={revisionForm.deadline} onChange={set('deadline')} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{action === 'edit' ? 'What would you like to change? *' : 'What needs to be changed? *'}</label>
|
||||
<textarea placeholder={formPlaceholder} value={revisionForm.description} onChange={set('description')} required />
|
||||
</div>
|
||||
<FileAttachment files={revisionFiles} onChange={setRevisionFiles} />
|
||||
<div className="action-buttons">
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>{saving ? 'Submitting...' : 'Submit'}</button>
|
||||
<button type="button" className="btn btn-outline" onClick={() => setAction(null)}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card-title">Version History</div>
|
||||
<div className="version-timeline">
|
||||
{submissions.map(sub => {
|
||||
const delivery = sub.delivery;
|
||||
return (
|
||||
<div key={sub.id} className="version-item">
|
||||
<div className="version-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div className="version-number">{vLabel(sub.version_number - 1)}</div>
|
||||
<StatusBadge status={sub.type} />
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
{sub.submitted_by_name && <span>{sub.submitted_by_name} · </span>}
|
||||
{new Date(sub.submitted_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="detail-grid">
|
||||
<div className="detail-item"><label>Service</label><p>{sub.service_type}</p></div>
|
||||
<div className="detail-item"><label>Deadline</label><p>{sub.deadline || '—'}</p></div>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<label>Description</label>
|
||||
<p style={{ marginTop: 4, lineHeight: 1.6, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap' }}>{sub.description}</p>
|
||||
</div>
|
||||
{delivery && delivery.files && delivery.files.length > 0 && (
|
||||
<div style={{ marginTop: 12, padding: '10px 14px', background: '#f0fdf4', borderRadius: 8, border: '1px solid #bbf7d0' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', color: '#16a34a', marginBottom: 8 }}>
|
||||
✓ Delivered {new Date(delivery.sent_at).toLocaleDateString()}
|
||||
</div>
|
||||
{delivery.files.map((file, fi) => (
|
||||
<div key={fi} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'white', borderRadius: 6, border: '1px solid #bbf7d0', marginBottom: 4 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span>📄</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 500 }}>{file.name}</span>
|
||||
</div>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => getFileUrl(file.storage_path)}>📥 View</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user