Files
starr-philproperties/src/modules/client/pages/ViewTask.jsx
T
kennethobsequio bac7168b1e push
pushy

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
2026-06-28 11:30:01 +08:00

565 lines
28 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,
} 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 { useTask } from '@/contexts/ClientTaskContext';
import { PageMeta } from '@/contexts/MetadataContext';
import { useTaskProgress } from '@/contexts/ClientTaskProgressContext';
import { useGroup } from '@/contexts/ClientGroupContext';
import { formatDate } from '@/utils/table.util';
import api from '@/utils/api.util';
// ─── Status badge ─────────────────────────────────────────────────────────────
const StatusBadge = ({ hasCompletion }) => {
if (hasCompletion) return <Badge variant="outline">Turned in</Badge>;
return <Badge variant="outline">Assigned</Badge>;
};
// ─── Requirements status panel ────────────────────────────────────────────────
const RequirementsStatusPanel = ({ requirements = [], latestCompletion, isVisited, isCompleted }) => {
const reqTypes = requirements.map((r) => r.type);
const items = [
{
key: 'upload_file',
label: 'File upload',
icon: <Paperclip className="size-4 shrink-0 text-muted-foreground" />,
getValue: () => {
const done = !!latestCompletion;
return { done: done ? 1 : 0, total: 1, binary: true };
},
},
{
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">
{items.map(({ key, label, icon, getValue }) => {
const isProvided = reqTypes.includes(key);
if (!isProvided) {
return (
<div key={key} className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
{icon}
<span className="text-sm text-muted-foreground">{label}</span>
</div>
<Badge variant="secondary" className="text-xs">Not provided</Badge>
</div>
);
}
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>
<span className="text-sm">{completedCount} / {provided.length} done</span>
</div>
<Progress value={overallPercent} className="h-1.5" />
</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>
);
};
// ─── File upload panel (Your Work) ────────────────────────────────────────────
const FileUploadPanel = ({ latestCompletion, onAddAttachment, submitting, onFileClick }) => {
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 hasCompletion={!!latestCompletion} />
{files.length >= 2 && (
<Badge>
<Paperclip className="size-3 mr-1" />
+{files.length} Attachments
</Badge>
)}
</div>
</div>
{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>
)
) : (
<p className="text-sm text-center py-6 text-muted-foreground">No work attached</p>
)}
<div className="flex flex-col gap-3">
<Button className="w-full" onClick={onAddAttachment} disabled={submitting}>
<Plus /> {latestCompletion ? 'Resubmit' : 'Add Attachment'}
</Button>
</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 [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');
// ── 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;
// ── 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 (!uploadState.files.length) 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 (!uploadedFiles.length) {
toast.error('No files were uploaded successfully.');
return;
}
// 2. Submit completion with uploaded file references
await completeTask(groupId, taskListId, taskId, {
note: note.trim() || null,
files: uploadedFiles,
});
setTaskModal(false);
setNote('');
setUploadState({ files: [], isUploading: false });
} catch (err) {
toast.error('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 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} />
{/* ── File upload modal ──────────────────────────────────────────── */}
{hasFileUpload && (
<ResponsiveModal
open={taskModal}
onOpenChange={setTaskModal}
title="Add Attachment"
description={task?.name ?? ''}
footer={
<>
<Button variant="outline" onClick={() => setTaskModal(false)}>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={
submitting ||
uploadState.isUploading ||
uploadState.files.length === 0
}
>
{submitting ? 'Submitting…' : uploadState.isUploading ? 'Uploading…' : 'Turn in'}
</Button>
</>
}
>
{/* 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>
<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 xs:order-2 lg:order-0 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 {formatDate(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 section */}
{!isResolving && requirements.length > 0 && (
<>
<div className="flex items-center gap-4 text-lg">
<h1>Requirements</h1>
</div>
{/* 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}
/>
)}
</>
)}
</div>
{/* ── Right ─────────────────────────────────────────────── */}
<div className="flex flex-col gap-4 xs:order-1 lg:order-0 lg:sticky lg:top-24 lg:self-start select-none">
{hasFileUpload && (
<FileUploadPanel
latestCompletion={latestCompletion}
onAddAttachment={() => setTaskModal(true)}
submitting={submitting}
onFileClick={(file) => setPreviewFile(file)}
/>
)}
<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;