diff --git a/src/pages/TaskDetail.jsx b/src/pages/TaskDetail.jsx index 56723dc..bed9159 100644 --- a/src/pages/TaskDetail.jsx +++ b/src/pages/TaskDetail.jsx @@ -10,6 +10,7 @@ import { logActivity } from '../lib/activityLog'; import JSZip from 'jszip'; import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles'; import { useLiveRefresh } from '../hooks/useLiveRefresh'; +import { useActionLock } from '../hooks/useActionLock'; import FileAttachment from '../components/FileAttachment'; import { sendTaskStatusUpdate } from '../lib/taskNotifications'; import { encodeRejectedNote, parseRejectedNote, isReviewShadowSubmission, REVIEW_SHADOW_PREFIX, getVisibleTaskSubmissions, getTaskDerivedState } from '../lib/taskVersions'; @@ -254,6 +255,7 @@ export default function TaskDetail() { const [loading, setLoading] = useState(true); const [activeTab, setActiveTab] = useState('Overview'); const [statusSaving, setStatusSaving] = useState(false); + const guard = useActionLock(); const [downloading, setDownloading] = useState(''); const [revisionModal, setRevisionModal] = useState(false); const [revisionForm, setRevisionForm] = useState({ description: '', deadline: '' }); @@ -388,7 +390,7 @@ export default function TaskDetail() { const log = (action) => logActivity({ actorId: currentUser.id, actorName: currentUser.name, action, taskId: id, taskTitle: task?.title, projectId: task?.project?.id, projectName: task?.project?.name }); - const updateStatus = async (newStatus, extra = {}, action = null) => { + const updateStatus = guard('status', async (newStatus, extra = {}, action = null) => { setStatusSaving(true); const prevTask = task; setTask(t => ({ ...t, status: newStatus, ...extra })); @@ -409,7 +411,7 @@ export default function TaskDetail() { } finally { setStatusSaving(false); } - }; + }); const handleStart = () => updateStatus('in_progress', { assigned_to: currentUser.id, assigned_name: currentUser.name }, 'task_started'); const handleOnHold = async () => { @@ -428,7 +430,7 @@ export default function TaskDetail() { const handleResume = () => updateStatus('in_progress', {}, 'task_resumed'); const handleSendToReview = () => { setReviewFiles([]); setReviewNotes(''); setReviewModal(true); }; const handleRemoveFromReview = () => updateStatus('in_progress', {}, 'task_resumed'); - const handleConfirmReview = async () => { + const handleConfirmReview = guard('review', async () => { setReviewSaving(true); try { const targetVersion = task.current_version || 0; @@ -505,8 +507,8 @@ export default function TaskDetail() { } finally { setReviewSaving(false); } - }; - const handleClientApprove = async () => { + }); + const handleClientApprove = guard('approve', async () => { await updateStatus('client_approved', { completed_at: new Date().toISOString() }, 'task_approved'); sendTaskStatusUpdate({ ...notificationBase, @@ -518,14 +520,14 @@ export default function TaskDetail() { includeAssigned: true, includeClient: true, }).catch(() => {}); - }; + }); const handleClientReject = () => { setRejectNote(''); setRejectModal(true); }; - const handleSubmitReject = async () => { + const handleSubmitReject = guard('reject', async () => { if (!rejectNote.trim()) return; setRejectSaving(true); try { const rejectedVersion = task.current_version || 0; - const { data: rejectedNoteSubmission } = await supabase + const { data: rejectedNoteSubmission, error: rejectNoteError } = await supabase .from('submissions') .insert({ task_id: id, @@ -538,7 +540,15 @@ export default function TaskDetail() { }) .select() .single(); - if (rejectedNoteSubmission) setSubmissions(prev => [...prev, rejectedNoteSubmission]); + // The rejection reason lives only in this submission row. If it didn't + // save, do NOT flip status or log the rejection — otherwise the task + // shows as "rejected" with no reason. Surface the error and bail. + if (rejectNoteError || !rejectedNoteSubmission) { + console.error('Reject note save failed:', rejectNoteError); + window.alert(rejectNoteError?.message || 'Failed to save the rejection reason. The task was not rejected — please try again.'); + return; + } + setSubmissions(prev => [...prev, rejectedNoteSubmission]); await updateStatus('in_progress', {}, 'revision_requested'); sendTaskStatusUpdate({ ...notificationBase, @@ -555,10 +565,10 @@ export default function TaskDetail() { } finally { setRejectSaving(false); } - }; + }); const handleRelease = () => updateStatus('not_started', { assigned_to: null, assigned_name: null }, 'task_released'); - const handleSubmitRevision = async () => { + const handleSubmitRevision = guard('revision', async () => { setRevisionSaving(true); const baseline = Math.max(task.current_version || 0, ...submissions.map(s => s.version_number || 0)); const newVersion = baseline + 1; @@ -602,9 +612,9 @@ export default function TaskDetail() { setRevisionForm({ description: '', deadline: '' }); setRevisionFiles([]); setRevisionSaving(false); - }; + }); - const handleSubmitAmendment = async () => { + const handleSubmitAmendment = guard('amend', async () => { if (!amendForm.description.trim()) return; setAmendSaving(true); setAmendModal(false); @@ -638,16 +648,16 @@ export default function TaskDetail() { setAmendForm({ description: '' }); setAmendFiles([]); setAmendSaving(false); - }; + }); - const handlePostComment = async () => { + const handlePostComment = guard('comment', async () => { if (!commentBody.trim()) return; setCommentSaving(true); const { data } = await supabase.from('task_comments').insert({ task_id: id, author_id: currentUser.id, author_name: currentUser.name, body: commentBody.trim() }).select().single(); if (data) setComments(prev => [...prev, data]); setCommentBody(''); setCommentSaving(false); - }; + }); const handleDeleteComment = async (commentId) => { await supabase.from('task_comments').delete().eq('id', commentId); diff --git a/supabase/migrations/20260623120000_auto_complete_project_on_tasks_done.sql b/supabase/migrations/20260623120000_auto_complete_project_on_tasks_done.sql new file mode 100644 index 0000000..deb2b49 --- /dev/null +++ b/supabase/migrations/20260623120000_auto_complete_project_on_tasks_done.sql @@ -0,0 +1,95 @@ +-- Auto-complete a project when all its tasks are done, and reopen it when they are not. +-- +-- Context: projects.status was only ever set to 'active' on insert and never +-- transitioned by application code. The 100% progress bar in the UI is derived +-- purely from task statuses (done / total) and was never written back to +-- projects.status, so projects sat at 'active' forever once all tasks were done. +-- This trigger keeps projects.status in sync with their tasks. +-- +-- "Done" mirrors the UI's doneStatuses set. In the live DB the terminal task +-- status is 'client_approved'; 'invoiced'/'paid' are included for parity with the +-- app in case those are ever used as task statuses. +-- +-- Only flips between 'active' and 'completed'. Any other status (e.g. a future +-- manual 'cancelled') is left untouched. + +create or replace function public.recompute_project_status(p_project_id uuid) +returns void as $$ +declare + total_count integer; + done_count integer; +begin + if p_project_id is null then + return; + end if; + + select + count(*), + count(*) filter (where status in ('client_approved', 'invoiced', 'paid')) + into total_count, done_count + from public.tasks + where project_id = p_project_id; + + if total_count > 0 and done_count = total_count then + update public.projects + set status = 'completed' + where id = p_project_id + and status = 'active'; + else + update public.projects + set status = 'active' + where id = p_project_id + and status = 'completed'; + end if; +end; +$$ language plpgsql security definer +set search_path = public; + +create or replace function public.sync_project_status_from_task() +returns trigger as $$ +begin + if tg_op = 'DELETE' then + perform public.recompute_project_status(old.project_id); + return old; + end if; + + -- INSERT or UPDATE + perform public.recompute_project_status(new.project_id); + + -- A task moved between projects: also recompute the old project. + if tg_op = 'UPDATE' and new.project_id is distinct from old.project_id then + perform public.recompute_project_status(old.project_id); + end if; + + return new; +end; +$$ language plpgsql security definer +set search_path = public; + +drop trigger if exists sync_project_status on public.tasks; +create trigger sync_project_status + after insert or update or delete on public.tasks + for each row execute function public.sync_project_status_from_task(); + +-- Backfill existing data to the correct state. +update public.projects p + set status = 'completed' + where p.status = 'active' + and exists (select 1 from public.tasks t where t.project_id = p.id) + and not exists ( + select 1 from public.tasks t + where t.project_id = p.id + and t.status not in ('client_approved', 'invoiced', 'paid') + ); + +update public.projects p + set status = 'active' + where p.status = 'completed' + and ( + not exists (select 1 from public.tasks t where t.project_id = p.id) + or exists ( + select 1 from public.tasks t + where t.project_id = p.id + and t.status not in ('client_approved', 'invoiced', 'paid') + ) + );