mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
681 lines
34 KiB
React
681 lines
34 KiB
React
/***********************************************************************************************************************************************************************
|
|
* 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 <Badge variant="outline">Assigned</Badge>;
|
|
if (requiresReview) {
|
|
if (latestCompletion.status === 'approved') {
|
|
return <Badge className="bg-green-100 text-green-700 border-green-400 dark:bg-green-900/40 dark:text-green-400 dark:border-green-700">Approved</Badge>;
|
|
}
|
|
if (latestCompletion.status === 'rejected') {
|
|
return <Badge variant="destructive" className="gap-1"><XCircle className="size-3" /> Rejected — resubmit</Badge>;
|
|
}
|
|
return <Badge className="bg-amber-100 text-amber-700 border-amber-400 dark:bg-amber-900/40 dark:text-amber-400 dark:border-amber-700 gap-1"><Hourglass className="size-3" /> Pending Review</Badge>;
|
|
}
|
|
return <Badge variant="outline">Turned in</Badge>;
|
|
};
|
|
|
|
// ─── 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: <Paperclip className="size-4 shrink-0 text-muted-foreground" />,
|
|
getValue: () => {
|
|
const done = submissionDone('upload_file');
|
|
return { done: done ? 1 : 0, total: 1, binary: true };
|
|
},
|
|
},
|
|
{
|
|
key: 'submit_text',
|
|
label: 'Written response',
|
|
icon: <PenLine className="size-4 shrink-0 text-muted-foreground" />,
|
|
getValue: () => {
|
|
const done = submissionDone('submit_text');
|
|
return { done: done ? 1 : 0, total: 1, binary: true };
|
|
},
|
|
},
|
|
{
|
|
key: 'pass_quiz',
|
|
label: 'Pass quizzes',
|
|
icon: <ClipboardCheck className="size-4 shrink-0 text-muted-foreground" />,
|
|
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: <Link className="size-4 shrink-0 text-muted-foreground" />,
|
|
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: <BookOpen className="size-4 shrink-0 text-muted-foreground" />,
|
|
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: <LayoutList className="size-4 shrink-0 text-muted-foreground" />,
|
|
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: <FileText className="size-4 shrink-0 text-muted-foreground" />,
|
|
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 (
|
|
<div className="border shadow-lg bg-card rounded-lg p-5 flex flex-col gap-4">
|
|
<h2 className="font-semibold text-base">Requirements</h2>
|
|
|
|
<div className="flex flex-col gap-3">
|
|
{provided.map(({ key, label, icon, getValue }) => {
|
|
const { done, total, binary } = getValue();
|
|
const complete = total > 0 && done >= total;
|
|
return (
|
|
<div key={key} className="flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2">
|
|
{icon}
|
|
<span className="text-sm">{label}</span>
|
|
</div>
|
|
{binary ? (
|
|
<Badge variant="secondary" className="text-xs">
|
|
{complete ? 'Done' : 'Missing'}
|
|
</Badge>
|
|
) : (
|
|
<Badge variant="secondary" className="text-xs">
|
|
{done} / {total}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2 pt-2 border-t">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm">Overall progress</span>
|
|
{provided.length > 0 && completedCount >= provided.length && (
|
|
<span className="text-sm">Completed</span>
|
|
)}
|
|
</div>
|
|
<Progress
|
|
value={overallPercent}
|
|
className={`h-1.5 ${provided.length > 0 && completedCount >= provided.length ? "[&>div]:bg-green-500" : ""}`}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─── 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 (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
className="flex items-center gap-2 border rounded-md px-3 py-2 text-sm bg-muted hover:bg-muted/70 transition-colors text-left w-full"
|
|
>
|
|
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
|
<span className="truncate flex-1">{file.file_name}</span>
|
|
</button>
|
|
);
|
|
};
|
|
|
|
// ─── Submission panel (Your Work) — upload_file and/or submit_text ────────────
|
|
const SubmissionPanel = ({ latestCompletion, onAddAttachment, submitting, onFileClick, requiresReview, hasTextRequirement, acceptsSubmissions }) => {
|
|
const files = latestCompletion?.files ?? [];
|
|
|
|
return (
|
|
<div className="border shadow-lg bg-card rounded-lg p-5 flex flex-col gap-4 xs:order-1 lg:order-0">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="font-semibold text-base">Your work</h2>
|
|
<div className="flex items-center gap-2">
|
|
<StatusBadge latestCompletion={latestCompletion} requiresReview={requiresReview} />
|
|
{files.length >= 2 && (
|
|
<Badge>
|
|
<Paperclip className="size-3 mr-1" />
|
|
+{files.length} Attachments
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{latestCompletion?.status === 'rejected' && latestCompletion?.review_note && (
|
|
<p className="text-sm text-destructive bg-destructive/5 border border-destructive/30 rounded-md p-3">
|
|
{latestCompletion.review_note}
|
|
</p>
|
|
)}
|
|
|
|
{hasTextRequirement && latestCompletion?.response_text && (
|
|
<p className="text-sm border rounded-md p-3 whitespace-pre-wrap bg-muted/40">{latestCompletion.response_text}</p>
|
|
)}
|
|
|
|
{files.length > 0 ? (
|
|
files.length >= 2 ? (
|
|
<ScrollArea className="max-h-[210px]">
|
|
<div className="flex flex-col gap-2">
|
|
{files.map((f) => (
|
|
<FileRow key={f.file_id} file={f} onClick={() => onFileClick(f)} />
|
|
))}
|
|
</div>
|
|
</ScrollArea>
|
|
) : (
|
|
<div className="flex flex-col gap-2">
|
|
{files.map((f) => (
|
|
<FileRow key={f.file_id} file={f} onClick={() => onFileClick(f)} />
|
|
))}
|
|
</div>
|
|
)
|
|
) : !(hasTextRequirement && latestCompletion?.response_text) ? (
|
|
<p className="text-sm text-center py-6 text-muted-foreground">No work attached</p>
|
|
) : null}
|
|
|
|
<div className="flex flex-col gap-3">
|
|
{acceptsSubmissions ? (
|
|
<Button className="w-full" onClick={onAddAttachment} disabled={submitting}>
|
|
<Plus /> {latestCompletion ? 'Resubmit' : 'Turn In'}
|
|
</Button>
|
|
) : (
|
|
<p className="text-sm text-center text-muted-foreground py-2">
|
|
This task no longer accepts submissions
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─── 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: <House className="size-4" />, to: '/dashboard' },
|
|
{ label: group?.name ?? '…', to: `/group/${groupId}` },
|
|
{ label: taskList?.name ?? '…', to: `/group/${groupId}/view/${taskListId}` },
|
|
{ label: task?.name ?? '…' },
|
|
];
|
|
|
|
return (
|
|
<div className="mt-17">
|
|
<PageMeta title={task ? `${task.name} - STARR` : undefined} />
|
|
|
|
{/* ── Submission modal (files and/or text response) ────────────────── */}
|
|
{hasSubmission && (
|
|
<ResponsiveModal
|
|
open={taskModal}
|
|
onOpenChange={setTaskModal}
|
|
title={hasFileUpload ? 'Add Attachment' : 'Submit Response'}
|
|
description={task?.name ?? ''}
|
|
footer={
|
|
<>
|
|
<Button variant="outline" onClick={() => setTaskModal(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleSubmit}
|
|
disabled={
|
|
submitting ||
|
|
uploadState.isUploading ||
|
|
(hasFileUpload && uploadState.files.length === 0) ||
|
|
(!hasFileUpload && hasTextReq && !responseText.trim())
|
|
}
|
|
>
|
|
{submitting ? 'Submitting…' : uploadState.isUploading ? 'Uploading…' : 'Turn in'}
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
{hasTextReq && (
|
|
<div className="flex flex-col gap-1.5 mb-3">
|
|
<label className="text-sm font-medium">
|
|
{textReq?.prompt || 'Your response'}
|
|
</label>
|
|
<textarea
|
|
className="w-full rounded-md border bg-muted/50 px-3 py-2 text-sm resize-none focus:outline-none focus:ring-1 focus:ring-ring"
|
|
rows={5}
|
|
placeholder="Write your response…"
|
|
value={responseText}
|
|
onChange={(e) => setResponseText(e.target.value)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Optional note */}
|
|
<div className="flex flex-col gap-1.5 mb-3">
|
|
<label className="text-sm font-medium">Note (optional)</label>
|
|
<textarea
|
|
className="w-full rounded-md border bg-muted/50 px-3 py-2 text-sm resize-none focus:outline-none focus:ring-1 focus:ring-ring"
|
|
rows={2}
|
|
placeholder="Add a note for your submission…"
|
|
value={note}
|
|
onChange={(e) => setNote(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{hasFileUpload && (
|
|
<FileUpload
|
|
allowedFileTypes={allowedFileTypes}
|
|
maxFileCount={maxFileCount}
|
|
onChange={(state) => setUploadState(state)}
|
|
/>
|
|
)}
|
|
</ResponsiveModal>
|
|
)}
|
|
|
|
<div className="lg:container lg:mx-auto flex flex-col gap-6 py-6 xs:px-6 lg:px-0">
|
|
<AppBreadcrumb items={breadcrumbItems} />
|
|
|
|
<div className="grid lg:grid-cols-[1fr_350px] gap-6 items-start">
|
|
|
|
{/* ── Left ──────────────────────────────────────────────── */}
|
|
<div className="flex flex-col gap-6 min-w-0 w-full max-w-full">
|
|
|
|
{/* Task header card */}
|
|
<div className="border rounded-lg bg-card overflow-hidden">
|
|
<div className="p-6 border-b flex flex-col gap-2">
|
|
{isResolving ? (
|
|
<>
|
|
<Skeleton className="h-4 w-32" />
|
|
<Skeleton className="h-7 w-64 mt-1" />
|
|
<Skeleton className="h-4 w-48 mt-1" />
|
|
</>
|
|
) : (
|
|
<>
|
|
<p className="text-sm text-muted-foreground">
|
|
{taskList?.name} · {task?.createdAt ? formatDate(task.createdAt) : ''}
|
|
</p>
|
|
<h1 className="text-2xl font-bold">{task?.name}</h1>
|
|
<div className="flex items-center gap-4 text-sm [&_svg]:size-4">
|
|
{task?.deadline && (
|
|
<span className="flex items-center gap-1.5">
|
|
<Clock /> Due {formatDateTime(task.deadline)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="p-6">
|
|
{isResolving
|
|
? <Skeleton className="h-4 w-full" />
|
|
: <p className="text-md leading-relaxed">{task?.description ?? '—'}</p>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Requirements status panel — mobile only, right after the header */}
|
|
{!isResolving && requirements.length > 0 && (
|
|
<div className="lg:hidden">
|
|
<RequirementsStatusPanel
|
|
requirements={requirements}
|
|
latestCompletion={latestCompletion}
|
|
isVisited={isVisited}
|
|
isCompleted={isCompleted}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Requirements section */}
|
|
{!isResolving && requirements.length > 0 && (
|
|
<>
|
|
{/* visit_link */}
|
|
{visitLinkReqs.length > 0 && (
|
|
<VisitLink
|
|
links={visitLinkReqs.map((r) => ({
|
|
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 && (
|
|
<ReadCourse
|
|
courses={readCourseReqs.map((r) => ({
|
|
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 && (
|
|
<ReadUnit
|
|
units={readUnitReqs.map((r) => ({
|
|
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 && (
|
|
<ReadLesson
|
|
lessons={readLessonReqs.map((r) => ({
|
|
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 && (
|
|
<PassQuiz
|
|
quizzes={passQuizReqs.map((r) => ({
|
|
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 && (
|
|
<div className="lg:hidden">
|
|
<SubmissionPanel
|
|
latestCompletion={latestCompletion}
|
|
onAddAttachment={() => setTaskModal(true)}
|
|
submitting={submitting}
|
|
onFileClick={(file) => setPreviewFile(file)}
|
|
requiresReview={requiresReview}
|
|
hasTextRequirement={hasTextReq}
|
|
acceptsSubmissions={task?.accepts_submissions !== false}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Right (desktop sidebar only) ───────────────────────── */}
|
|
<div className="hidden lg:flex lg:flex-col gap-4 lg:sticky lg:top-24 lg:self-start select-none">
|
|
{hasSubmission && (
|
|
<SubmissionPanel
|
|
latestCompletion={latestCompletion}
|
|
onAddAttachment={() => setTaskModal(true)}
|
|
submitting={submitting}
|
|
onFileClick={(file) => setPreviewFile(file)}
|
|
requiresReview={requiresReview}
|
|
hasTextRequirement={hasTextReq}
|
|
acceptsSubmissions={task?.accepts_submissions !== false}
|
|
/>
|
|
)}
|
|
<RequirementsStatusPanel
|
|
requirements={requirements}
|
|
latestCompletion={latestCompletion}
|
|
isVisited={isVisited}
|
|
isCompleted={isCompleted}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── File preview dialog ──────────────────────────────────────── */}
|
|
<FilePreview
|
|
file={previewFile}
|
|
open={!!previewFile}
|
|
onOpenChange={(v) => !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
|
|
}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ViewTask; |