/*********************************************************************************************************************************************************************** * 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 )}
0 && completedCount >= provided.length ? "[&>div]:bg-green-500" : ""}`} />
); }; // ─── 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 && (