feat: expense/invoice popups, task row hover fix, delivery submissions
- Expense detail popup: 80vw×80vh, inline editing, save gated on dirty state, Download Receipt in footer - Add expense form: same layout with FileAttachment drag-drop on right - New invoice popup: 80vw×80vh inline form replacing navigate-away page - Tasks table: suppress row hover background, only Name/Project table-links highlight - TaskDetail Submissions tab: reads from deliveries table, shows R## version, sent_by, message - Submit for Review: writes to deliveries + delivery_files (deliveries bucket) - deliveries.version_number column added and backfilled from revision history - Port 4173 for dev server Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+33
-18
@@ -229,6 +229,7 @@ export default function TaskDetail() {
|
||||
const [comments, setComments] = useState([]);
|
||||
const [commentBody, setCommentBody] = useState('');
|
||||
const [commentSaving, setCommentSaving] = useState(false);
|
||||
const [deliveries, setDeliveries] = useState([]);
|
||||
const [dlProgress, setDlProgress] = useState({ active: false, label: '', percent: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
@@ -240,7 +241,7 @@ export default function TaskDetail() {
|
||||
.single(),
|
||||
supabase
|
||||
.from('submissions')
|
||||
.select('id, type, version_number, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, files:submission_files(id, name, storage_path, size)')
|
||||
.select('id, type, version_number, request_key, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, files:submission_files(id, name, storage_path, size)')
|
||||
.eq('task_id', id)
|
||||
.order('submitted_at', { ascending: true }),
|
||||
supabase
|
||||
@@ -254,11 +255,20 @@ export default function TaskDetail() {
|
||||
.select('id, author_id, author_name, body, created_at')
|
||||
.eq('task_id', id)
|
||||
.order('created_at', { ascending: true }),
|
||||
]).then(([{ data: taskData }, { data: subRows }, { data: actData }, { data: commentData }]) => {
|
||||
]).then(async ([{ data: taskData }, { data: subRows }, { data: actData }, { data: commentData }]) => {
|
||||
setTask(taskData);
|
||||
setSubmissions(subRows || []);
|
||||
setActivityLog(actData || []);
|
||||
setComments(commentData || []);
|
||||
const subIds = (subRows || []).map(s => s.id);
|
||||
if (subIds.length > 0) {
|
||||
const { data: delRows } = await supabase
|
||||
.from('deliveries')
|
||||
.select('*, files:delivery_files(id, name, storage_path, size)')
|
||||
.in('submission_id', subIds)
|
||||
.order('sent_at', { ascending: false });
|
||||
setDeliveries(delRows || []);
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
}, [id, refreshKey]);
|
||||
@@ -269,7 +279,7 @@ export default function TaskDetail() {
|
||||
const submission = submissions.find(s => s.type === 'initial') || submissions[0] || null;
|
||||
const amendments = submissions.filter(s => s.type === 'amendment');
|
||||
const revisions = submissions.filter(s => s.type === 'revision').sort((a, b) => a.version_number - b.version_number);
|
||||
const reviewSubmissions = submissions.filter(s => s.type === 'initial').sort((a, b) => new Date(b.submitted_at) - new Date(a.submitted_at));
|
||||
const reviewSubmissions = deliveries;
|
||||
|
||||
const assignee = task.assignee;
|
||||
const assigneeName = assignee?.name || task.assigned_name || null;
|
||||
@@ -304,19 +314,20 @@ export default function TaskDetail() {
|
||||
const handleConfirmReview = async () => {
|
||||
setReviewSaving(true);
|
||||
try {
|
||||
const { data: newSub } = await supabase.from('submissions').insert({ task_id: id, type: 'initial', version_number: task.current_version || 0, service_type: task.title, description: reviewNotes.trim() || null, submitted_by: currentUser.id, submitted_by_name: currentUser.name }).select().single();
|
||||
if (newSub && reviewFiles.length > 0) {
|
||||
const briefId = submissions.find(s => s.type === 'initial')?.id || submissions[0]?.id;
|
||||
const { data: newDel } = await supabase.from('deliveries').insert({ submission_id: briefId, sent_by: currentUser.name, message: reviewNotes.trim() || '', version_number: task.current_version || 0 }).select().single();
|
||||
if (newDel && reviewFiles.length > 0) {
|
||||
const uploaded = [];
|
||||
for (const file of reviewFiles) {
|
||||
const path = `${id}/${Date.now()}_${file.name}`;
|
||||
const { data: up } = await supabase.storage.from('submissions').upload(path, file);
|
||||
if (up) uploaded.push({ name: file.name, storage_path: path, size: file.size, submission_id: newSub.id });
|
||||
const { data: up } = await supabase.storage.from('deliveries').upload(path, file);
|
||||
if (up) uploaded.push({ name: file.name, storage_path: path, size: file.size, delivery_id: newDel.id });
|
||||
}
|
||||
if (uploaded.length > 0) await supabase.from('submission_files').insert(uploaded);
|
||||
if (project?.name && task?.title) uploadFilesToRequestInfo(reviewFiles, company?.name, project.name, task.title, task.current_version || 0).catch(() => {});
|
||||
if (uploaded.length > 0) await supabase.from('delivery_files').insert(uploaded);
|
||||
}
|
||||
const { data: subRows } = await supabase.from('submissions').select('id, type, version_number, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, files:submission_files(id, name, storage_path, size)').eq('task_id', id).order('submitted_at', { ascending: true });
|
||||
setSubmissions(subRows || []);
|
||||
const subIds = submissions.map(s => s.id);
|
||||
const { data: delRows } = await supabase.from('deliveries').select('*, files:delivery_files(id, name, storage_path, size)').in('submission_id', subIds).order('sent_at', { ascending: false });
|
||||
setDeliveries(delRows || []);
|
||||
await updateStatus('client_review', {}, 'task_submitted');
|
||||
setReviewModal(false);
|
||||
setReviewFiles([]);
|
||||
@@ -655,25 +666,29 @@ export default function TaskDetail() {
|
||||
? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 1, minHeight: 120, color: 'var(--text-muted)' }}>No submissions yet.</div>
|
||||
: <div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
{reviewSubmissions.map((sub, i, arr) => {
|
||||
const fmtDate = sub.submitted_at
|
||||
? new Date(sub.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
const fmtDate = sub.sent_at
|
||||
? new Date(sub.sent_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
: '—';
|
||||
return (
|
||||
<div key={sub.id} style={{ display: 'flex', flexDirection: 'column', gap: 20, borderBottom: i < arr.length - 1 ? '1px solid var(--border)' : 'none', paddingBottom: i < arr.length - 1 ? 20 : 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 32 }}>
|
||||
<div>
|
||||
<div style={META_LABEL}>Submitted By</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{sub.submitted_by_name || '—'}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{sub.sent_by || '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={META_LABEL}>Date</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{fmtDate}</div>
|
||||
</div>
|
||||
<div style={{ marginLeft: 'auto' }}>
|
||||
<div style={META_LABEL}>Version</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)', fontWeight: 500 }}>{`R${String(sub.version_number ?? 0).padStart(2, '0')}`}</div>
|
||||
</div>
|
||||
</div>
|
||||
{sub.description
|
||||
{sub.message
|
||||
? <div>
|
||||
<div style={META_LABEL}>Notes</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{sub.description}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{sub.message}</div>
|
||||
</div>
|
||||
: <div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No notes provided.</div>
|
||||
}
|
||||
@@ -817,8 +832,8 @@ export default function TaskDetail() {
|
||||
)}
|
||||
|
||||
{revisionModal && (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'var(--overlay-scrim, rgba(0,0,0,0.58))', zIndex: 1200, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
|
||||
<div className="card" style={{ ...CARD, width: '100%', maxWidth: 480, display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'var(--overlay-scrim, rgba(0,0,0,0.58))', zIndex: 1200, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }} onClick={() => { if (!revisionSaving) { setRevisionModal(false); setRevisionForm({ description: '', deadline: '' }); setRevisionFiles([]); } }}>
|
||||
<div className="card" style={{ ...CARD, width: '100%', maxWidth: 480, display: 'flex', flexDirection: 'column', gap: 18 }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--text-primary)', letterSpacing: 0.2, lineHeight: 1.1 }}>Request Revision</div>
|
||||
<div className="form-group">
|
||||
<label style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Revision Notes</label>
|
||||
|
||||
Reference in New Issue
Block a user