Files
starr-philproperties/src/modules/client/pages/ViewTask.jsx
T
2026-07-17 17:27:59 +08:00

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} &nbsp;·&nbsp; {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;