fix: project auto-complete trigger + reject reason must persist before flipping status
- Add sync_project_status trigger so projects flip active<->completed with their tasks; backfill existing rows. - handleSubmitReject now aborts (and alerts) if the rejection-reason submission fails to save, instead of silently flipping status to in_progress and logging a reason-less rejection. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+26
-16
@@ -10,6 +10,7 @@ import { logActivity } from '../lib/activityLog';
|
|||||||
import JSZip from 'jszip';
|
import JSZip from 'jszip';
|
||||||
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
|
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
|
||||||
import { useLiveRefresh } from '../hooks/useLiveRefresh';
|
import { useLiveRefresh } from '../hooks/useLiveRefresh';
|
||||||
|
import { useActionLock } from '../hooks/useActionLock';
|
||||||
import FileAttachment from '../components/FileAttachment';
|
import FileAttachment from '../components/FileAttachment';
|
||||||
import { sendTaskStatusUpdate } from '../lib/taskNotifications';
|
import { sendTaskStatusUpdate } from '../lib/taskNotifications';
|
||||||
import { encodeRejectedNote, parseRejectedNote, isReviewShadowSubmission, REVIEW_SHADOW_PREFIX, getVisibleTaskSubmissions, getTaskDerivedState } from '../lib/taskVersions';
|
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 [loading, setLoading] = useState(true);
|
||||||
const [activeTab, setActiveTab] = useState('Overview');
|
const [activeTab, setActiveTab] = useState('Overview');
|
||||||
const [statusSaving, setStatusSaving] = useState(false);
|
const [statusSaving, setStatusSaving] = useState(false);
|
||||||
|
const guard = useActionLock();
|
||||||
const [downloading, setDownloading] = useState('');
|
const [downloading, setDownloading] = useState('');
|
||||||
const [revisionModal, setRevisionModal] = useState(false);
|
const [revisionModal, setRevisionModal] = useState(false);
|
||||||
const [revisionForm, setRevisionForm] = useState({ description: '', deadline: '' });
|
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 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);
|
setStatusSaving(true);
|
||||||
const prevTask = task;
|
const prevTask = task;
|
||||||
setTask(t => ({ ...t, status: newStatus, ...extra }));
|
setTask(t => ({ ...t, status: newStatus, ...extra }));
|
||||||
@@ -409,7 +411,7 @@ export default function TaskDetail() {
|
|||||||
} finally {
|
} finally {
|
||||||
setStatusSaving(false);
|
setStatusSaving(false);
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
const handleStart = () => updateStatus('in_progress', { assigned_to: currentUser.id, assigned_name: currentUser.name }, 'task_started');
|
const handleStart = () => updateStatus('in_progress', { assigned_to: currentUser.id, assigned_name: currentUser.name }, 'task_started');
|
||||||
const handleOnHold = async () => {
|
const handleOnHold = async () => {
|
||||||
@@ -428,7 +430,7 @@ export default function TaskDetail() {
|
|||||||
const handleResume = () => updateStatus('in_progress', {}, 'task_resumed');
|
const handleResume = () => updateStatus('in_progress', {}, 'task_resumed');
|
||||||
const handleSendToReview = () => { setReviewFiles([]); setReviewNotes(''); setReviewModal(true); };
|
const handleSendToReview = () => { setReviewFiles([]); setReviewNotes(''); setReviewModal(true); };
|
||||||
const handleRemoveFromReview = () => updateStatus('in_progress', {}, 'task_resumed');
|
const handleRemoveFromReview = () => updateStatus('in_progress', {}, 'task_resumed');
|
||||||
const handleConfirmReview = async () => {
|
const handleConfirmReview = guard('review', async () => {
|
||||||
setReviewSaving(true);
|
setReviewSaving(true);
|
||||||
try {
|
try {
|
||||||
const targetVersion = task.current_version || 0;
|
const targetVersion = task.current_version || 0;
|
||||||
@@ -505,8 +507,8 @@ export default function TaskDetail() {
|
|||||||
} finally {
|
} finally {
|
||||||
setReviewSaving(false);
|
setReviewSaving(false);
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
const handleClientApprove = async () => {
|
const handleClientApprove = guard('approve', async () => {
|
||||||
await updateStatus('client_approved', { completed_at: new Date().toISOString() }, 'task_approved');
|
await updateStatus('client_approved', { completed_at: new Date().toISOString() }, 'task_approved');
|
||||||
sendTaskStatusUpdate({
|
sendTaskStatusUpdate({
|
||||||
...notificationBase,
|
...notificationBase,
|
||||||
@@ -518,14 +520,14 @@ export default function TaskDetail() {
|
|||||||
includeAssigned: true,
|
includeAssigned: true,
|
||||||
includeClient: true,
|
includeClient: true,
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
};
|
});
|
||||||
const handleClientReject = () => { setRejectNote(''); setRejectModal(true); };
|
const handleClientReject = () => { setRejectNote(''); setRejectModal(true); };
|
||||||
const handleSubmitReject = async () => {
|
const handleSubmitReject = guard('reject', async () => {
|
||||||
if (!rejectNote.trim()) return;
|
if (!rejectNote.trim()) return;
|
||||||
setRejectSaving(true);
|
setRejectSaving(true);
|
||||||
try {
|
try {
|
||||||
const rejectedVersion = task.current_version || 0;
|
const rejectedVersion = task.current_version || 0;
|
||||||
const { data: rejectedNoteSubmission } = await supabase
|
const { data: rejectedNoteSubmission, error: rejectNoteError } = await supabase
|
||||||
.from('submissions')
|
.from('submissions')
|
||||||
.insert({
|
.insert({
|
||||||
task_id: id,
|
task_id: id,
|
||||||
@@ -538,7 +540,15 @@ export default function TaskDetail() {
|
|||||||
})
|
})
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.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');
|
await updateStatus('in_progress', {}, 'revision_requested');
|
||||||
sendTaskStatusUpdate({
|
sendTaskStatusUpdate({
|
||||||
...notificationBase,
|
...notificationBase,
|
||||||
@@ -555,10 +565,10 @@ export default function TaskDetail() {
|
|||||||
} finally {
|
} finally {
|
||||||
setRejectSaving(false);
|
setRejectSaving(false);
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
const handleRelease = () => updateStatus('not_started', { assigned_to: null, assigned_name: null }, 'task_released');
|
const handleRelease = () => updateStatus('not_started', { assigned_to: null, assigned_name: null }, 'task_released');
|
||||||
|
|
||||||
const handleSubmitRevision = async () => {
|
const handleSubmitRevision = guard('revision', async () => {
|
||||||
setRevisionSaving(true);
|
setRevisionSaving(true);
|
||||||
const baseline = Math.max(task.current_version || 0, ...submissions.map(s => s.version_number || 0));
|
const baseline = Math.max(task.current_version || 0, ...submissions.map(s => s.version_number || 0));
|
||||||
const newVersion = baseline + 1;
|
const newVersion = baseline + 1;
|
||||||
@@ -602,9 +612,9 @@ export default function TaskDetail() {
|
|||||||
setRevisionForm({ description: '', deadline: '' });
|
setRevisionForm({ description: '', deadline: '' });
|
||||||
setRevisionFiles([]);
|
setRevisionFiles([]);
|
||||||
setRevisionSaving(false);
|
setRevisionSaving(false);
|
||||||
};
|
});
|
||||||
|
|
||||||
const handleSubmitAmendment = async () => {
|
const handleSubmitAmendment = guard('amend', async () => {
|
||||||
if (!amendForm.description.trim()) return;
|
if (!amendForm.description.trim()) return;
|
||||||
setAmendSaving(true);
|
setAmendSaving(true);
|
||||||
setAmendModal(false);
|
setAmendModal(false);
|
||||||
@@ -638,16 +648,16 @@ export default function TaskDetail() {
|
|||||||
setAmendForm({ description: '' });
|
setAmendForm({ description: '' });
|
||||||
setAmendFiles([]);
|
setAmendFiles([]);
|
||||||
setAmendSaving(false);
|
setAmendSaving(false);
|
||||||
};
|
});
|
||||||
|
|
||||||
const handlePostComment = async () => {
|
const handlePostComment = guard('comment', async () => {
|
||||||
if (!commentBody.trim()) return;
|
if (!commentBody.trim()) return;
|
||||||
setCommentSaving(true);
|
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();
|
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]);
|
if (data) setComments(prev => [...prev, data]);
|
||||||
setCommentBody('');
|
setCommentBody('');
|
||||||
setCommentSaving(false);
|
setCommentSaving(false);
|
||||||
};
|
});
|
||||||
|
|
||||||
const handleDeleteComment = async (commentId) => {
|
const handleDeleteComment = async (commentId) => {
|
||||||
await supabase.from('task_comments').delete().eq('id', commentId);
|
await supabase.from('task_comments').delete().eq('id', commentId);
|
||||||
|
|||||||
@@ -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')
|
||||||
|
)
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user