/***********************************************************************************************************************************************************************
* File Name : ViewTask.jsx
* Type : Page (Client)
* Description : Displays a single task within a task list, with all its
* requirements and the user's completion status.
* Wired to TaskContext + TaskProgressContext.
* Route: /group/:groupId/view/:taskListId/task/:taskId (index)
***********************************************************************************************************************************************************************/
import { useEffect, useState, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Trophy, Clock, Paperclip, Plus,
Link, BookOpen, LayoutList, FileText, House,
Image, Video, Music, PenLine, ClipboardCheck, Hourglass, XCircle,
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Skeleton } from '@/components/ui/skeleton';
import AppBreadcrumb from '@/components/generic/Breadcrumb/AppBreadcrumb';
import ResponsiveModal from '@/components/generic/ResponsiveModal';
import { toast } from 'sonner';
import FileUpload from '../components/blocks/FileUpload';
import FilePreview from '../components/FilePreview';
import VisitLink from '../components/blocks/VisitLink';
import ReadCourse from '../components/blocks/ReadCourse';
import ReadUnit from '../components/blocks/ReadUnit';
import ReadLesson from '../components/blocks/ReadLesson';
import PassQuiz from '../components/blocks/PassQuiz';
import { useTask } from '@/contexts/ClientTaskContext';
import { PageMeta } from '@/contexts/MetadataContext';
import { useTaskProgress } from '@/contexts/ClientTaskProgressContext';
import { useGroup } from '@/contexts/ClientGroupContext';
import { formatDate, formatDateTime } from '@/utils/table.util';
import api from '@/utils/api.util';
// ─── Status badge ─────────────────────────────────────────────────────────────
const StatusBadge = ({ latestCompletion, requiresReview }) => {
if (!latestCompletion) return Assigned;
if (requiresReview) {
if (latestCompletion.status === 'approved') {
return Approved;
}
if (latestCompletion.status === 'rejected') {
return Rejected — resubmit;
}
return Pending Review;
}
return Turned in;
};
// ─── Requirements status panel ────────────────────────────────────────────────
const RequirementsStatusPanel = ({ requirements = [], latestCompletion, isVisited, isCompleted }) => {
const reqTypes = requirements.map((r) => r.type);
// upload_file and submit_text share one TaskCompletion — "done" also needs
// status === 'approved' when either requirement opted into requires_review.
const submissionDone = (type) => {
const reqs = requirements.filter((r) => r.type === type);
if (!reqs.length || !latestCompletion) return false;
const needsReview = reqs.some((r) => r.requires_review);
return needsReview ? latestCompletion.status === 'approved' : true;
};
const items = [
{
key: 'upload_file',
label: 'File upload',
icon: ,
getValue: () => {
const done = submissionDone('upload_file');
return { done: done ? 1 : 0, total: 1, binary: true };
},
},
{
key: 'submit_text',
label: 'Written response',
icon: ,
getValue: () => {
const done = submissionDone('submit_text');
return { done: done ? 1 : 0, total: 1, binary: true };
},
},
{
key: 'pass_quiz',
label: 'Pass quizzes',
icon: ,
getValue: () => {
const reqs = requirements.filter((r) => r.type === 'pass_quiz');
const done = reqs.filter((r) => isCompleted(r.requirement_id, r.reference_id)).length;
return { done, total: reqs.length, binary: false };
},
},
{
key: 'visit_link',
label: 'Visit links',
icon: ,
getValue: () => {
const reqs = requirements.filter((r) => r.type === 'visit_link');
const done = reqs.filter((r) => isVisited(r.requirement_id)).length;
return { done, total: reqs.length, binary: false };
},
},
{
key: 'read_course',
label: 'Read courses',
icon: ,
getValue: () => {
const reqs = requirements.filter((r) => r.type === 'read_course');
const done = reqs.filter((r) => isCompleted(r.requirement_id, r.reference_id)).length;
return { done, total: reqs.length, binary: false };
},
},
{
key: 'read_unit',
label: 'Read units',
icon: ,
getValue: () => {
const reqs = requirements.filter((r) => r.type === 'read_unit');
const done = reqs.filter((r) => isCompleted(r.requirement_id, r.reference_id)).length;
return { done, total: reqs.length, binary: false };
},
},
{
key: 'read_lesson',
label: 'Read lessons',
icon: ,
getValue: () => {
const reqs = requirements.filter((r) => r.type === 'read_lesson');
const done = reqs.filter((r) => isCompleted(r.requirement_id, r.reference_id)).length;
return { done, total: reqs.length, binary: false };
},
},
];
const provided = items.filter(({ key }) => reqTypes.includes(key));
const completedCount = provided.filter(({ getValue }) => {
const { done, total } = getValue();
return total > 0 && done >= total;
}).length;
const overallPercent = provided.length > 0
? Math.round((completedCount / provided.length) * 100)
: 0;
return (
Requirements
{provided.map(({ key, label, icon, getValue }) => {
const { done, total, binary } = getValue();
const complete = total > 0 && done >= total;
return (
{icon}
{label}
{binary ? (
{complete ? 'Done' : 'Missing'}
) : (
{done} / {total}
)}
);
})}
Overall progress
{provided.length > 0 && completedCount >= provided.length && (
Completed
)}
);
};
// ─── File row — clickable, opens FilePreview ─────────────────────────────────
const FileRow = ({ file, onClick }) => {
const Icon = (() => {
const mime = file.mime_type ?? '';
if (mime.startsWith('image/')) return Image;
if (mime.startsWith('video/')) return Video;
if (mime.startsWith('audio/')) return Music;
if (mime === 'application/pdf') return FileText;
return Paperclip;
})();
return (
);
};
// ─── Submission panel (Your Work) — upload_file and/or submit_text ────────────
const SubmissionPanel = ({ latestCompletion, onAddAttachment, submitting, onFileClick, requiresReview, hasTextRequirement, acceptsSubmissions }) => {
const files = latestCompletion?.files ?? [];
return (
Your work
{files.length >= 2 && (
+{files.length} Attachments
)}
{latestCompletion?.status === 'rejected' && latestCompletion?.review_note && (
{latestCompletion.review_note}
)}
{hasTextRequirement && latestCompletion?.response_text && (
{latestCompletion.response_text}
)}
{files.length > 0 ? (
files.length >= 2 ? (
{files.map((f) => (
onFileClick(f)} />
))}
) : (
{files.map((f) => (
onFileClick(f)} />
))}
)
) : !(hasTextRequirement && latestCompletion?.response_text) ? (
No work attached
) : null}
{acceptsSubmissions ? (
) : (
This task no longer accepts submissions
)}
);
};
// ─── Main page ────────────────────────────────────────────────────────────────
const ViewTask = () => {
const { groupId, taskListId, taskId } = useParams();
const navigate = useNavigate();
const {
task, taskList, loading,
fetchTask, fetchTaskList,
latestCompletion,
completeTask, completionLoading,
} = useTask();
const {
fetchProgress,
isVisited, isCompleted,
visitLink,
unvisitLink,
resetProgress,
} = useTaskProgress();
const { group, fetchGroup } = useGroup();
const [taskModal, setTaskModal] = useState(false);
const [uploadState, setUploadState] = useState({ files: [], isUploading: false });
const [note, setNote] = useState('');
const [responseText, setResponseText] = useState('');
const [submitting, setSubmitting] = useState(false);
const [previewFile, setPreviewFile] = useState(null);
// ── Fetch task list (for breadcrumb), task, and progress ──────────────────
useEffect(() => {
fetchTaskList(groupId, taskListId);
fetchTask(groupId, taskListId, taskId);
fetchProgress(groupId, taskListId, taskId);
fetchGroup(groupId);
return () => resetProgress();
}, [groupId, taskListId, taskId]);
// ── Loading state ──────────────────────────────────────────────────────────
const isResolving = loading || !task;
// ── Requirements from task ────────────────────────────────────────────────
const requirements = task?.requirements ?? [];
const reqTypes = requirements.map((r) => r.type);
const hasFileUpload = reqTypes.includes('upload_file');
const hasTextReq = reqTypes.includes('submit_text');
const hasSubmission = hasFileUpload || hasTextReq;
const requiresReview = requirements.some((r) => ['upload_file', 'submit_text'].includes(r.type) && r.requires_review);
// ── Upload file requirement config (allowed types, max count) ─────────────
const uploadFileReq = requirements.find((r) => r.type === 'upload_file');
const allowedFileTypes = uploadFileReq?.allowed_file_types ?? [];
const maxFileCount = uploadFileReq?.max_file_count ?? null;
const textReq = requirements.find((r) => r.type === 'submit_text');
// ── Visit link handler (passed to VisitLink block) ────────────────────────
const handleVisitLink = useCallback(async (requirementId) => {
await visitLink(groupId, taskListId, taskId, requirementId);
}, [groupId, taskListId, taskId, visitLink]);
const handleUnvisitLink = useCallback(async (requirementId) => {
await unvisitLink(groupId, taskListId, taskId, requirementId);
}, [groupId, taskListId, taskId, unvisitLink]);
// ── Submit handler ────────────────────────────────────────────────────────
const handleSubmit = async () => {
if (uploadState.isUploading) return;
if (hasFileUpload && !uploadState.files.length) return;
if (!hasFileUpload && hasTextReq && !responseText.trim()) return;
setSubmitting(true);
try {
// 1. Upload each file to the task-scoped upload endpoint first
const uploadedFiles = [];
for (const f of uploadState.files) {
if (f.status !== 'done' || !f.rawFile) continue;
const formData = new FormData();
formData.append('file', f.rawFile); // ← rawFile exposed by FileUpload onChange
const res = await api.post(
`/client/groups/${groupId}/task-lists/${taskListId}/tasks/${taskId}/upload`,
formData,
{ headers: { 'Content-Type': 'multipart/form-data' } }
);
const data = res.data?.data;
uploadedFiles.push({
file_url: data.file_url,
file_name: data.file_name,
file_size: data.file_size,
mime_type: data.mime_type,
storage_key: data.storage_key,
});
}
if (hasFileUpload && !uploadedFiles.length) {
toast('No files were uploaded successfully.');
return;
}
// 2. Submit completion with uploaded file references + response text
await completeTask(groupId, taskListId, taskId, {
note: note.trim() || null,
files: uploadedFiles,
response_text: hasTextReq ? (responseText.trim() || null) : undefined,
});
setTaskModal(false);
setNote('');
setResponseText('');
setUploadState({ files: [], isUploading: false });
} catch (err) {
toast('Failed to submit. Please try again.');
} finally {
setSubmitting(false);
}
};
// ── Derived data ──────────────────────────────────────────────────────────
const visitLinkReqs = requirements.filter((r) => r.type === 'visit_link');
const readCourseReqs = requirements.filter((r) => r.type === 'read_course');
const readUnitReqs = requirements.filter((r) => r.type === 'read_unit');
const readLessonReqs = requirements.filter((r) => r.type === 'read_lesson');
const passQuizReqs = requirements.filter((r) => r.type === 'pass_quiz');
const breadcrumbItems = [
{ label: 'Home', icon: , to: '/dashboard' },
{ label: group?.name ?? '…', to: `/group/${groupId}` },
{ label: taskList?.name ?? '…', to: `/group/${groupId}/view/${taskListId}` },
{ label: task?.name ?? '…' },
];
return (
{/* ── Submission modal (files and/or text response) ────────────────── */}
{hasSubmission && (
>
}
>
{hasTextReq && (
)}
{/* Optional note */}
{hasFileUpload && (
setUploadState(state)}
/>
)}
)}
{/* ── Left ──────────────────────────────────────────────── */}
{/* Task header card */}
{isResolving ? (
<>
>
) : (
<>
{taskList?.name} · {task?.createdAt ? formatDate(task.createdAt) : ''}
{task?.name}
{task?.deadline && (
Due {formatDateTime(task.deadline)}
)}
>
)}
{isResolving
?
:
{task?.description ?? '—'}
}
{/* Requirements status panel — mobile only, right after the header */}
{!isResolving && requirements.length > 0 && (
)}
{/* Requirements section */}
{!isResolving && requirements.length > 0 && (
<>
{/* visit_link */}
{visitLinkReqs.length > 0 && (
({
id: r.requirement_id,
requirement_id: r.requirement_id,
label: r.link_label ?? r.reference_label ?? 'Link',
url: r.link_url,
}))}
visitedMap={
Object.fromEntries(
visitLinkReqs.map((r) => [r.requirement_id, isVisited(r.requirement_id)])
)
}
onVisit={handleVisitLink}
onUnvisit={handleUnvisitLink}
/>
)}
{/* read_course */}
{readCourseReqs.length > 0 && (
({
id: r.requirement_id,
reference_id: r.reference_id,
title: r.reference_label ?? 'Course',
description: r.description ?? '',
completed: isCompleted(r.requirement_id, r.reference_id),
}))}
groupId={groupId}
taskListId={taskListId}
taskId={taskId}
/>
)}
{/* read_unit */}
{readUnitReqs.length > 0 && (
({
id: r.requirement_id,
reference_id: r.reference_id,
title: r.reference_label ?? 'Unit',
description: r.description ?? '',
completed: isCompleted(r.requirement_id, r.reference_id),
}))}
groupId={groupId}
taskListId={taskListId}
taskId={taskId}
/>
)}
{/* read_lesson */}
{readLessonReqs.length > 0 && (
({
id: r.requirement_id,
reference_id: r.reference_id,
title: r.reference_label ?? 'Lesson',
description: r.description ?? '',
completed: isCompleted(r.requirement_id, r.reference_id),
}))}
groupId={groupId}
taskListId={taskListId}
taskId={taskId}
/>
)}
{/* pass_quiz */}
{passQuizReqs.length > 0 && (
({
id: r.requirement_id,
requirement_id: r.requirement_id,
reference_id: r.reference_id,
title: r.reference_label ?? 'Quiz',
completed: isCompleted(r.requirement_id, r.reference_id),
}))}
groupId={groupId}
taskListId={taskListId}
taskId={taskId}
/>
)}
>
)}
{/* Your work — mobile only, at the very end */}
{hasSubmission && (
setTaskModal(true)}
submitting={submitting}
onFileClick={(file) => setPreviewFile(file)}
requiresReview={requiresReview}
hasTextRequirement={hasTextReq}
acceptsSubmissions={task?.accepts_submissions !== false}
/>
)}
{/* ── Right (desktop sidebar only) ───────────────────────── */}
{hasSubmission && (
setTaskModal(true)}
submitting={submitting}
onFileClick={(file) => setPreviewFile(file)}
requiresReview={requiresReview}
hasTextRequirement={hasTextReq}
acceptsSubmissions={task?.accepts_submissions !== false}
/>
)}
{/* ── File preview dialog ──────────────────────────────────────── */}
!v && setPreviewFile(null)}
streamUrl={
previewFile && latestCompletion?.completion_id
? `${import.meta.env.VITE_API_URL ?? ''}/client/groups/${groupId}/task-lists/${taskListId}/tasks/${taskId}/completions/${latestCompletion.completion_id}/files/${previewFile.file_id}/stream`
: undefined
}
downloadUrl={
previewFile && latestCompletion?.completion_id
? `${import.meta.env.VITE_API_URL ?? ''}/client/groups/${groupId}/task-lists/${taskListId}/tasks/${taskId}/completions/${latestCompletion.completion_id}/files/${previewFile.file_id}/download`
: undefined
}
/>
);
};
export default ViewTask;