add more things

This commit is contained in:
rgrgogu
2026-07-20 09:25:27 +08:00
parent d021725c1b
commit 793b24c019
11 changed files with 448 additions and 491 deletions
+10
View File
@@ -167,6 +167,15 @@ export function CoursesProvider({ children }) {
[request], [request],
); );
const reorderCourses = useCallback(
(courseIds) =>
request(async () => {
await api.put(`${BASE}/order`, { course_ids: courseIds });
return true;
}),
[request],
);
const archiveCourse = useCallback( const archiveCourse = useCallback(
(courseId) => (courseId) =>
request(async () => { request(async () => {
@@ -1284,6 +1293,7 @@ export function CoursesProvider({ children }) {
createCourse, createCourse,
createCourseFull, createCourseFull,
updateCourse, updateCourse,
reorderCourses,
archiveCourse, archiveCourse,
archiveCourses, archiveCourses,
@@ -29,7 +29,7 @@ export default function CoursesTable() {
const navigate = useNavigate(); const navigate = useNavigate();
const { courses, attributes, pagination, setPagination, loading, fetchCourses, archiveCourse, archiveCourses, fetchCourseFieldValues } = useCourses(); const { courses, attributes, pagination, setPagination, loading, fetchCourses, reorderCourses, archiveCourse, archiveCourses, fetchCourseFieldValues } = useCourses();
const handleRefsReady = (refs) => { const handleRefsReady = (refs) => {
tableRefsRef.current = refs; tableRefsRef.current = refs;
@@ -42,14 +42,31 @@ export default function CoursesTable() {
sheetName: "Courses", sheetName: "Courses",
}; };
const rowActions = useMemo(() => buildRowActions({ // Reorder — swaps this row with its neighbor in the currently displayed
// (order_index-sorted) list, then persists the new order_index set.
const handleMove = async (row, direction) => {
const idx = courses.findIndex((c) => c.course_id === row.course_id);
const swapIdx = idx + direction;
if (idx < 0 || swapIdx < 0 || swapIdx >= courses.length) return;
const reordered = [...courses];
[reordered[idx], reordered[swapIdx]] = [reordered[swapIdx], reordered[idx]];
const ok = await reorderCourses(reordered.map((c) => c.course_id));
if (ok) fetchCourses({ page: 1, limit: pagination?.limit ?? 10 });
};
const rowActions = buildRowActions({
onViewAssessment: (row) => navigate(`/admin/courses/${row.course_id}/assessment/view`), onViewAssessment: (row) => navigate(`/admin/courses/${row.course_id}/assessment/view`),
onAssessment: (row) => navigate(`/admin/courses/${row.course_id}/assessment`), onAssessment: (row) => navigate(`/admin/courses/${row.course_id}/assessment`),
onViewUnits: (row) => navigate(`/admin/courses/${row.course_id}/units`), onViewUnits: (row) => navigate(`/admin/courses/${row.course_id}/units`),
onView: (row) => navigate(`/admin/courses/${row.course_id}/view`), onView: (row) => navigate(`/admin/courses/${row.course_id}/view`),
onEdit: (row) => navigate(`/admin/courses/${row.course_id}/edit`), onEdit: (row) => navigate(`/admin/courses/${row.course_id}/edit`),
onArchive: (row) => setArchiveTarget(row), onArchive: (row) => setArchiveTarget(row),
}), []); onMoveUp: (row) => handleMove(row, -1),
onMoveDown: (row) => handleMove(row, 1),
courses,
});
const toolbarActions = buildToolbarActions({ const toolbarActions = buildToolbarActions({
fetchCourses, fetchCourses,
@@ -70,7 +87,7 @@ export default function CoursesTable() {
const columns = useMemo( const columns = useMemo(
() => buildDataColumns(attributes, rowActions), () => buildDataColumns(attributes, rowActions),
[attributes] [attributes, rowActions]
); );
const handleArchiveSuccess = () => { const handleArchiveSuccess = () => {
@@ -1,6 +1,6 @@
import { Eye, Archive, ShelvingUnit, NotebookPen, ClipboardList, PlusCircle } from "lucide-react"; import { Eye, Archive, ShelvingUnit, NotebookPen, ClipboardList, PlusCircle, ArrowUp, ArrowDown } from "lucide-react";
export function buildRowActions({ onViewUnits, onView, onEdit, onArchive, onAssessment, onViewAssessment }) { export function buildRowActions({ onViewUnits, onView, onEdit, onArchive, onAssessment, onViewAssessment, onMoveUp, onMoveDown, courses = [] }) {
return [ return [
{ {
key: "view", key: "view",
@@ -8,6 +8,21 @@ export function buildRowActions({ onViewUnits, onView, onEdit, onArchive, onAsse
icon: <Eye className="h-3.5 w-3.5" />, icon: <Eye className="h-3.5 w-3.5" />,
onClick: (row) => onView(row), onClick: (row) => onView(row),
}, },
{
key: "move-up",
label: "Move Up",
icon: <ArrowUp className="h-3.5 w-3.5" />,
onClick: (row) => onMoveUp(row),
separator: true,
disabled: (row) => courses.findIndex((c) => c.course_id === row.course_id) <= 0,
},
{
key: "move-down",
label: "Move Down",
icon: <ArrowDown className="h-3.5 w-3.5" />,
onClick: (row) => onMoveDown(row),
disabled: (row) => courses.findIndex((c) => c.course_id === row.course_id) >= courses.length - 1,
},
{ {
key: "view_units", key: "view_units",
label: "View Units", label: "View Units",
@@ -8,14 +8,6 @@ export function buildRowActions({ navigate, onArchive, onRestore, onMoveUp, onMo
icon: <Eye className="size-4" />, icon: <Eye className="size-4" />,
onClick: (row) => navigate(`${row.task_id}/view`), onClick: (row) => navigate(`${row.task_id}/view`),
}, },
{
key: "completions",
label: "Completions",
icon: <NotebookPen className="size-4" />,
onClick: (row) => navigate(`${row.task_id}/completions`),
separator: true,
className: "text-sky-600",
},
{ {
key: "move-up", key: "move-up",
label: "Move Up", label: "Move Up",
@@ -1,13 +1,13 @@
// config/task_completion/rowActions.config.jsx // config/task_completion/rowActions.config.jsx
import { Eye, Archive, RotateCcw, ShieldCheck } from "lucide-react"; import { Eye, Archive, RotateCcw, ShieldCheck } from "lucide-react";
export function buildRowActions({ navigate, onArchive, onRestore, onReview, showArchived }) { export function buildRowActions({ navigate, taskListId, taskId, onArchive, onRestore, onReview, showArchived }) {
return [ return [
{ {
key: "view", key: "view",
label: "View", label: "View",
icon: <Eye className="size-4" />, icon: <Eye className="size-4" />,
onClick: (row) => navigate(`${row.completion_id}/view`), onClick: (row) => navigate(`/admin/taskList/${taskListId}/tasks/${taskId}/completions/${row.completion_id}/view`),
}, },
{ {
key: "review", key: "review",
+6 -21
View File
@@ -35,7 +35,6 @@ const schema = z.object({
title: z.string().min(1, "Title is required."), title: z.string().min(1, "Title is required."),
description: z.string().optional(), description: z.string().optional(),
course_code: z.string().min(1, "Course Code is required."), course_code: z.string().min(1, "Course Code is required."),
order_index: z.coerce.number().min(0).default(0),
level: z.enum(["beginner", "intermediate", "advanced"]).optional(), level: z.enum(["beginner", "intermediate", "advanced"]).optional(),
subscription: z.string().min(1, "Subscription is required.").default("free"), subscription: z.string().min(1, "Subscription is required.").default("free"),
status: z.enum(["draft", "published", "unpublished"]).default("draft"), status: z.enum(["draft", "published", "unpublished"]).default("draft"),
@@ -173,7 +172,6 @@ export default function AddCourse() {
title: "", title: "",
description: "", description: "",
course_code: "", course_code: "",
order_index: 0,
level: "beginner", level: "beginner",
subscription: "free", subscription: "free",
status: "draft", status: "draft",
@@ -192,7 +190,6 @@ export default function AddCourse() {
const watchedTitle = useWatch({ control, name: "title" }); const watchedTitle = useWatch({ control, name: "title" });
const watchedDescription = useWatch({ control, name: "description" }); const watchedDescription = useWatch({ control, name: "description" });
const watchedCourseCode = useWatch({ control, name: "course_code" }); const watchedCourseCode = useWatch({ control, name: "course_code" });
const watchedOrderIndex = useWatch({ control, name: "order_index" });
const watchedLevel = useWatch({ control, name: "level" }); const watchedLevel = useWatch({ control, name: "level" });
const watchedSubscr = useWatch({ control, name: "subscription" }); const watchedSubscr = useWatch({ control, name: "subscription" });
const watchedStatus = useWatch({ control, name: "status" }); const watchedStatus = useWatch({ control, name: "status" });
@@ -252,7 +249,6 @@ export default function AddCourse() {
title: values.title, title: values.title,
description: values.description, description: values.description,
course_code: values.course_code || null, course_code: values.course_code || null,
order_index: values.order_index,
level: values.level || null, level: values.level || null,
subscription: values.subscription, subscription: values.subscription,
status: values.status, status: values.status,
@@ -338,19 +334,12 @@ export default function AddCourse() {
<Textarea id="description" placeholder="Optional course description" rows={3} {...register("description")} /> <Textarea id="description" placeholder="Optional course description" rows={3} {...register("description")} />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="space-y-1.5">
<div className="space-y-1.5"> <Label htmlFor="course_code">
<Label htmlFor="course_code"> Course Code <span className="text-destructive">*</span>
Course Code <span className="text-destructive">*</span> </Label>
</Label> <Input id="course_code" placeholder="e.g. RE-101" {...register("course_code")} />
<Input id="course_code" placeholder="e.g. RE-101" {...register("course_code")} /> <FieldError message={errors.course_code?.message} />
<FieldError message={errors.course_code?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="order_index">Order</Label>
<Input id="order_index" type="number" min={0} {...register("order_index")} />
<FieldError message={errors.order_index?.message} />
</div>
</div> </div>
</SectionCard> </SectionCard>
@@ -640,10 +629,6 @@ export default function AddCourse() {
<p className="text-xs text-muted-foreground">Level</p> <p className="text-xs text-muted-foreground">Level</p>
<p className="font-medium capitalize">{watchedLevel || "—"}</p> <p className="font-medium capitalize">{watchedLevel || "—"}</p>
</div> </div>
<div>
<p className="text-xs text-muted-foreground">Order</p>
<p className="font-medium">{watchedOrderIndex ?? 0}</p>
</div>
<div> <div>
<p className="text-xs text-muted-foreground">Subscription</p> <p className="text-xs text-muted-foreground">Subscription</p>
<p className="font-medium capitalize">{watchedSubscr || "—"}</p> <p className="font-medium capitalize">{watchedSubscr || "—"}</p>
+9 -23
View File
@@ -46,7 +46,6 @@ const schema = z.object({
title: z.string().min(1, "Title is required."), title: z.string().min(1, "Title is required."),
description: z.string().optional(), description: z.string().optional(),
course_code: z.string().optional(), course_code: z.string().optional(),
order_index: z.coerce.number().min(0).default(0),
level: z.enum(["beginner", "intermediate", "advanced"]).optional(), level: z.enum(["beginner", "intermediate", "advanced"]).optional(),
subscription: z.string().min(1, "Subscription is required.").default("free"), subscription: z.string().min(1, "Subscription is required.").default("free"),
status: z.enum(["draft", "published", "unpublished"]).default("draft"), status: z.enum(["draft", "published", "unpublished"]).default("draft"),
@@ -236,7 +235,7 @@ export default function EditCourse() {
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
title: "", description: "", course_code: "", title: "", description: "", course_code: "",
order_index: 0, level: undefined, subscription: "free", status: "draft", objectives: [], roles: [], level: undefined, subscription: "free", status: "draft", objectives: [], roles: [],
}, },
}); });
@@ -276,7 +275,6 @@ export default function EditCourse() {
title: c.title ?? "", title: c.title ?? "",
description: c.description ?? "", description: c.description ?? "",
course_code: c.course_code ?? "", course_code: c.course_code ?? "",
order_index: c.order_index ?? 0,
level: c.level ?? undefined, level: c.level ?? undefined,
subscription: c.subscription ?? "free", subscription: c.subscription ?? "free",
status: c.status ?? "draft", status: c.status ?? "draft",
@@ -595,26 +593,14 @@ export default function EditCourse() {
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="space-y-1.5">
<div className="space-y-1.5"> <Label htmlFor="course_code">Course Code</Label>
<Label htmlFor="course_code">Course Code</Label> <Input
<Input id="course_code"
id="course_code" placeholder="e.g. RE-101"
placeholder="e.g. RE-101" {...register("course_code")}
{...register("course_code")} />
/> <FieldError message={errors.course_code?.message} />
<FieldError message={errors.course_code?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="order_index">Order</Label>
<Input
id="order_index"
type="number"
min={0}
{...register("order_index")}
/>
<FieldError message={errors.order_index?.message} />
</div>
</div> </div>
</SectionCard> </SectionCard>
@@ -1,349 +0,0 @@
/***********************************************************************************************************************************************************************
* File Name : TaskCompletions.jsx
* Type : Page
* Description : Admin page — lists all completions for a specific task.
* Route: /admin/taskList/:taskListId/tasks/:taskId/completions
***********************************************************************************************************************************************************************/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useAdminTask } from '@/contexts/AdminTaskContext';
import DataTable from '@/components/generic/Table/DataTable';
import { FilterSheet } from '@/components/generic/Sheet/FilterSheet';
import { ArchiveDialog } from '@/components/generic/Dialogs/ArchiveDialog';
import { RestoreDialog } from '@/components/generic/Dialogs/RestoreDialog';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { House, NotebookPen, Users, Paperclip, Check, X } from 'lucide-react';
import AppBreadcrumb from '@/components/generic/Breadcrumb/AppBreadcrumb';
import { formatDate, formatDateTime } from '@/utils/table.util';
import { getTimestamp } from '@/utils/timestamp.util';
import { buildDataColumns, columnPinning } from '@/modules/admin/config/task_list/task_completion/columns.config';
import { buildToolbarActions } from '@/modules/admin/config/task_list/task_completion/toolbar.config';
import { buildSelectionActions } from '@/modules/admin/config/task_list/task_completion/selection.config';
import { buildRowActions } from '@/modules/admin/config/task_list/task_completion/rowActions.config';
// ─── Stat card ────────────────────────────────────────────────────────────────
const StatCard = ({ label, value, icon: Icon, loading }) => (
<div className="bg-muted rounded-lg px-3 py-2">
<span className="block text-[11px] uppercase tracking-wide text-muted-foreground mb-1">
{label}
</span>
<span className="text-sm font-medium flex items-center gap-1.5">
{Icon && <Icon className="size-3.5 text-muted-foreground" />}
{loading ? <Skeleton className="h-4 w-10 inline-block" /> : value}
</span>
</div>
);
// ─── Main page ────────────────────────────────────────────────────────────────
export default function TaskCompletions() {
const navigate = useNavigate();
const { taskListId, taskId } = useParams();
const {
task, taskList,
completions, completionPagination, setCompletionPagination, completionLoading,
completionAttributes,
fetchTask, fetchTaskList, updateTask,
fetchCompletions, reviewSubmission,
archiveCompletion, restoreCompletion,
bulkArchiveCompletions, bulkRestoreCompletions,
} = useAdminTask();
const [showArchived, setShowArchived] = useState(false);
const [togglingSubmissions, setTogglingSubmissions] = useState(false);
const [archiveTarget, setArchiveTarget] = useState(null);
const [restoreTarget, setRestoreTarget] = useState(null);
const [bulkArchiveIds, setBulkArchiveIds] = useState(null);
const [bulkRestoreIds, setBulkRestoreIds] = useState(null);
const [reviewTarget, setReviewTarget] = useState(null);
const [reviewNote, setReviewNote] = useState('');
const tableRefsRef = useRef({
getFilters: () => [], getSort: () => [], resetSelection: () => {}, tableInstance: null,
});
// ── Initial fetch ─────────────────────────────────────────────────────────
useEffect(() => {
fetchTaskList(taskListId);
fetchTask(taskListId, taskId);
fetchCompletions(taskListId, taskId, { page: 1, limit: 10 });
}, [taskListId, taskId]);
const handleRefsReady = (refs) => { tableRefsRef.current = refs; };
// ── Toggle accepting submissions ─────────────────────────────────────────
async function handleToggleAcceptingSubmissions(v) {
setTogglingSubmissions(true);
try {
await updateTask(taskListId, taskId, { accepts_submissions: v });
await fetchTask(taskListId, taskId);
} finally {
setTogglingSubmissions(false);
}
}
// ── Fetch handler ─────────────────────────────────────────────────────────
const handleFetch = useCallback((params) => {
return fetchCompletions(taskListId, taskId, params);
}, [fetchCompletions, taskListId, taskId]);
// ── Toggle archived ───────────────────────────────────────────────────────
const handleToggleArchived = () => {
const next = !showArchived;
setShowArchived(next);
fetchCompletions(taskListId, taskId, {
page: 1,
limit: completionPagination?.limit ?? 10,
filters: tableRefsRef.current.getFilters(),
sort: tableRefsRef.current.getSort(),
});
};
// ── After mutation ────────────────────────────────────────────────────────
const afterMutation = () => {
tableRefsRef.current.resetSelection?.();
fetchCompletions(taskListId, taskId, {
page: 1,
limit: completionPagination?.limit ?? 10,
filters: tableRefsRef.current.getFilters(),
sort: tableRefsRef.current.getSort(),
});
};
// ── Derived stats ─────────────────────────────────────────────────────────
const totalRecords = completionPagination?.totalRecords ?? 0;
const totalFiles = completions.reduce((acc, c) => acc + (c.files?.length ?? 0), 0);
const uniqueUsers = new Set(completions.map((c) => c.user_id)).size;
const latestDate = completions[0]?.submitted_at
? formatDate(completions[0].submitted_at)
: '—';
// ── Config ────────────────────────────────────────────────────────────────
const exportConfig = useMemo(() => ({
allData: completions,
attributes: completionAttributes,
filename: `${getTimestamp()}_Completions`,
sheetName: 'Completions',
}), [completions, completionAttributes]);
const rowActions = buildRowActions({
navigate,
onArchive: (row) => setArchiveTarget(row),
onRestore: (row) => setRestoreTarget(row),
onReview: (row) => { setReviewTarget(row); setReviewNote(''); },
showArchived,
});
const handleReview = async (status) => {
if (!reviewTarget) return;
const result = await reviewSubmission(taskListId, taskId, reviewTarget.completion_id, {
status, review_note: reviewNote || null,
});
if (result) {
setReviewTarget(null);
afterMutation();
}
};
const toolbarActions = buildToolbarActions({
fetchCompletions,
taskListId,
taskId,
pagination: completionPagination,
exportConfig,
showArchived,
onToggleArchived: handleToggleArchived,
getFilters: () => tableRefsRef.current.getFilters(),
getSort: () => tableRefsRef.current.getSort(),
getTableInstance: () => tableRefsRef.current.tableInstance,
});
const selectionActions = buildSelectionActions({
exportConfig,
showArchived,
onBulkArchive: (ids) => setBulkArchiveIds(ids),
onBulkRestore: (ids) => setBulkRestoreIds(ids),
getTableInstance: () => tableRefsRef.current.tableInstance,
});
const columns = useMemo(
() => buildDataColumns(completionAttributes, rowActions),
[completionAttributes, rowActions]
);
// ── Breadcrumbs ───────────────────────────────────────────────────────────
const breadcrumbs = [
{ label: 'Home', icon: <House className="size-4" />, to: '/admin' },
{ label: 'Task Lists', to: '/admin/taskList' },
{ label: taskList?.name ?? '…', to: `/admin/taskList/${taskListId}/tasks` },
{ label: task?.name ?? '…', to: `/admin/taskList/${taskListId}/tasks/${taskId}/view` },
{ label: 'Completions' },
];
return (
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
{/* ── Breadcrumb ────────────────────────────────────────────────── */}
<div className="flex flex-col gap-2 my-6">
<AppBreadcrumb items={breadcrumbs} />
</div>
{/* ── Detail card ───────────────────────────────────────────────── */}
<div className="bg-card border rounded-xl p-5 flex flex-col gap-4 w-full mb-6">
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-1 min-w-0">
{task
? <h1 className="text-lg font-medium leading-none">{task.name}</h1>
: <Skeleton className="h-5 w-48" />
}
{task
? <p className="text-sm text-muted-foreground mt-1">
{taskList?.name ?? '—'}
{task.deadline ? ` · Due ${formatDateTime(task.deadline)}` : ''}
</p>
: <Skeleton className="h-4 w-72 mt-1" />
}
</div>
<div className="flex items-center gap-2 shrink-0">
<Label className="text-sm text-muted-foreground">Accepting submissions</Label>
<Switch
checked={task?.accepts_submissions !== false}
disabled={!task || togglingSubmissions}
onCheckedChange={handleToggleAcceptingSubmissions}
/>
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<StatCard label="Completions" value={totalRecords} icon={NotebookPen} loading={completionLoading} />
<StatCard label="Unique users" value={uniqueUsers} icon={Users} loading={completionLoading} />
<StatCard label="Files" value={totalFiles} icon={Paperclip} loading={completionLoading} />
<StatCard label="Latest" value={latestDate} loading={completionLoading} />
</div>
</div>
{/* ── DataTable ─────────────────────────────────────────────────── */}
<DataTable
columns={columns}
data={completions}
attributes={completionAttributes}
pagination={completionPagination}
setPagination={setCompletionPagination}
loading={completionLoading}
onFetch={handleFetch}
onFetchFilterData={() => Promise.resolve([])}
toolbarActions={toolbarActions}
selectionActions={selectionActions}
columnPinning={columnPinning}
onRefsReady={handleRefsReady}
recordLabel="completion"
emptyMessage="No completions yet."
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
<FilterSheet
open={open}
onOpenChange={onOpenChange}
column={column}
attr={attr}
data={data}
loading={loading}
/>
)}
/>
{/* ── Single archive ────────────────────────────────────────────── */}
<ArchiveDialog
open={!!archiveTarget}
onOpenChange={(v) => !v && setArchiveTarget(null)}
entity={archiveTarget}
entityLabel="Completion"
getName={(r) => r?.user?.name ?? 'this completion'}
onArchive={(entity) => archiveCompletion(taskListId, taskId, entity?.completion_id)}
loading={completionLoading}
onSuccess={afterMutation}
/>
{/* ── Single restore ────────────────────────────────────────────── */}
<RestoreDialog
open={!!restoreTarget}
onOpenChange={(v) => !v && setRestoreTarget(null)}
entity={restoreTarget}
entityLabel="Completion"
getName={(r) => r?.user?.name ?? 'this completion'}
onRestore={(entity) => restoreCompletion(taskListId, taskId, entity?.completion_id)}
loading={completionLoading}
onSuccess={afterMutation}
/>
{/* ── Bulk archive ──────────────────────────────────────────────── */}
<ArchiveDialog
open={!!bulkArchiveIds}
onOpenChange={(v) => !v && setBulkArchiveIds(null)}
ids={bulkArchiveIds ?? []}
entityLabel="Completion"
onArchive={({ ids }) => bulkArchiveCompletions(taskListId, taskId, ids)}
loading={completionLoading}
onSuccess={afterMutation}
/>
{/* ── Bulk restore ──────────────────────────────────────────────── */}
<RestoreDialog
open={!!bulkRestoreIds}
onOpenChange={(v) => !v && setBulkRestoreIds(null)}
ids={bulkRestoreIds ?? []}
entityLabel="Completion"
onRestore={({ ids }) => bulkRestoreCompletions(taskListId, taskId, ids)}
loading={completionLoading}
onSuccess={afterMutation}
/>
{/* ── Review submission ────────────────────────────────────────────── */}
<AlertDialog open={!!reviewTarget} onOpenChange={(v) => !v && setReviewTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Review Submission</AlertDialogTitle>
<AlertDialogDescription>
{reviewTarget?.user?.name ?? 'This learner'}'s submission requires review before it counts as complete.
</AlertDialogDescription>
</AlertDialogHeader>
{reviewTarget?.note && (
<p className="text-sm text-muted-foreground border rounded-md p-3 bg-muted/40">{reviewTarget.note}</p>
)}
{reviewTarget?.response_text && (
<p className="text-sm border rounded-md p-3 whitespace-pre-wrap">{reviewTarget.response_text}</p>
)}
<Textarea
placeholder="Optional note for the learner..."
value={reviewNote}
onChange={(e) => setReviewNote(e.target.value)}
rows={3}
/>
<AlertDialogFooter>
<AlertDialogCancel disabled={completionLoading}>Cancel</AlertDialogCancel>
<Button
type="button" variant="outline"
className="text-destructive border-destructive/50 hover:bg-destructive/5"
disabled={completionLoading}
onClick={() => handleReview('rejected')}
>
<X className="size-4 mr-1.5" /> Reject
</Button>
<AlertDialogAction disabled={completionLoading} onClick={() => handleReview('approved')}>
<Check className="size-4 mr-1.5" /> Approve
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useAdminTask } from '@/contexts/AdminTaskContext'; import { useAdminTask } from '@/contexts/AdminTaskContext';
import { useDateFormat } from '@/hooks/useDateFormat'; import { useDateFormat } from '@/hooks/useDateFormat';
@@ -8,13 +8,33 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import DataTable from '@/components/generic/Table/DataTable';
import { FilterSheet } from '@/components/generic/Sheet/FilterSheet';
import { ArchiveDialog } from '@/components/generic/Dialogs/ArchiveDialog';
import { RestoreDialog } from '@/components/generic/Dialogs/RestoreDialog';
import { import {
ArrowLeft, Pencil, FileText, CalendarClock, ArrowLeft, Pencil, FileText, CalendarClock,
Link2, Upload, BookOpen, BookMarked, FileCheck2, Link2, Upload, BookOpen, BookMarked, FileCheck2,
Info, GitPullRequest, Tag, Globe, Copy, File, Bookmark, Info, GitPullRequest, Tag, Globe, Copy, File, Bookmark,
ClipboardCheck, ClipboardCheck, NotebookPen, Users, Paperclip, Check, X,
} from 'lucide-react'; } from 'lucide-react';
import { formatDate } from '@/utils/table.util';
import { getTimestamp } from '@/utils/timestamp.util';
import { buildDataColumns, columnPinning } from '@/modules/admin/config/task_list/task_completion/columns.config';
import { buildToolbarActions } from '@/modules/admin/config/task_list/task_completion/toolbar.config';
import { buildSelectionActions } from '@/modules/admin/config/task_list/task_completion/selection.config';
import { buildRowActions as buildCompletionRowActions } from '@/modules/admin/config/task_list/task_completion/rowActions.config';
// ─── Same config as ViewTaskList — label, icon only, all styling via shadcn tokens // ─── Same config as ViewTaskList — label, icon only, all styling via shadcn tokens
const REQUIREMENT_CONFIG = { const REQUIREMENT_CONFIG = {
visit_link: { label: 'Visit Link', badgeLabel: 'Link', Icon: Link2 }, visit_link: { label: 'Visit Link', badgeLabel: 'Link', Icon: Link2 },
@@ -125,23 +145,166 @@ function TaskRequirementsSection({ requirements = [] }) {
); );
} }
// ─── Stat card ────────────────────────────────────────────────────────────────
const StatCard = ({ label, value, icon: Icon, loading }) => (
<div className="bg-muted rounded-lg px-3 py-2">
<span className="block text-[11px] uppercase tracking-wide text-muted-foreground mb-1">
{label}
</span>
<span className="text-sm font-medium flex items-center gap-1.5">
{Icon && <Icon className="size-3.5 text-muted-foreground" />}
{loading ? <Skeleton className="h-4 w-10 inline-block" /> : value}
</span>
</div>
);
// ─── Tabs config ──────────────────────────────────────────────────────────────
const TABS = [
{ key: 'overview', label: 'Overview', icon: Info },
{ key: 'completions', label: 'Completions', icon: NotebookPen },
];
// ─── Page ───────────────────────────────────────────────────────────────────── // ─── Page ─────────────────────────────────────────────────────────────────────
export default function ViewTask() { export default function ViewTask() {
const navigate = useNavigate(); const navigate = useNavigate();
const { taskListId, taskId } = useParams(); const { taskListId, taskId } = useParams();
const { fetchTask, fetchCompletions, completionPagination, completionLoading } = useAdminTask(); const [searchParams] = useSearchParams();
const {
task,
completions, completionPagination, setCompletionPagination, completionLoading, completionAttributes,
fetchTask, updateTask,
fetchCompletions, reviewSubmission,
archiveCompletion, restoreCompletion,
bulkArchiveCompletions, bulkRestoreCompletions,
} = useAdminTask();
const { fmtDateTime } = useDateFormat(); const { fmtDateTime } = useDateFormat();
const [task, setTask] = useState(null); const [activeTab, setActiveTab] = useState(
searchParams.get('tab') === 'completions' ? 'completions' : 'overview'
);
const [togglingSubmissions, setTogglingSubmissions] = useState(false);
const [showArchived, setShowArchived] = useState(false);
const [archiveTarget, setArchiveTarget] = useState(null);
const [restoreTarget, setRestoreTarget] = useState(null);
const [bulkArchiveIds, setBulkArchiveIds] = useState(null);
const [bulkRestoreIds, setBulkRestoreIds] = useState(null);
const [reviewTarget, setReviewTarget] = useState(null);
const [reviewNote, setReviewNote] = useState('');
const tableRefsRef = useRef({
getFilters: () => [], getSort: () => [], resetSelection: () => {}, tableInstance: null,
});
useEffect(() => { useEffect(() => {
fetchTask(taskListId, taskId).then((data) => { fetchTask(taskListId, taskId);
if (!data) return;
setTask(data);
});
fetchCompletions(taskListId, taskId, { page: 1, limit: 1 });
}, [taskListId, taskId]); }, [taskListId, taskId]);
const handleRefsReady = (refs) => { tableRefsRef.current = refs; };
// ── Toggle accepting submissions ─────────────────────────────────────────
async function handleToggleAcceptingSubmissions(v) {
setTogglingSubmissions(true);
try {
await updateTask(taskListId, taskId, { accepts_submissions: v });
await fetchTask(taskListId, taskId);
} finally {
setTogglingSubmissions(false);
}
}
// ── Fetch handler ─────────────────────────────────────────────────────────
const handleFetch = useCallback((params) => {
return fetchCompletions(taskListId, taskId, params);
}, [fetchCompletions, taskListId, taskId]);
// ── Toggle archived ───────────────────────────────────────────────────────
const handleToggleArchived = () => {
const next = !showArchived;
setShowArchived(next);
fetchCompletions(taskListId, taskId, {
page: 1,
limit: completionPagination?.limit ?? 10,
filters: tableRefsRef.current.getFilters(),
sort: tableRefsRef.current.getSort(),
});
};
// ── After mutation ────────────────────────────────────────────────────────
const afterMutation = () => {
tableRefsRef.current.resetSelection?.();
fetchCompletions(taskListId, taskId, {
page: 1,
limit: completionPagination?.limit ?? 10,
filters: tableRefsRef.current.getFilters(),
sort: tableRefsRef.current.getSort(),
});
};
// ── Derived stats ─────────────────────────────────────────────────────────
const totalRecords = completionPagination?.totalRecords ?? 0;
const totalFiles = completions.reduce((acc, c) => acc + (c.files?.length ?? 0), 0);
const uniqueUsers = new Set(completions.map((c) => c.user_id)).size;
const latestDate = completions[0]?.submitted_at
? formatDate(completions[0].submitted_at)
: '—';
// ── Config ────────────────────────────────────────────────────────────────
const exportConfig = useMemo(() => ({
allData: completions,
attributes: completionAttributes,
filename: `${getTimestamp()}_Completions`,
sheetName: 'Completions',
}), [completions, completionAttributes]);
const rowActions = buildCompletionRowActions({
navigate,
taskListId,
taskId,
onArchive: (row) => setArchiveTarget(row),
onRestore: (row) => setRestoreTarget(row),
onReview: (row) => { setReviewTarget(row); setReviewNote(''); },
showArchived,
});
const handleReview = async (status) => {
if (!reviewTarget) return;
const result = await reviewSubmission(taskListId, taskId, reviewTarget.completion_id, {
status, review_note: reviewNote || null,
});
if (result) {
setReviewTarget(null);
afterMutation();
}
};
const toolbarActions = buildToolbarActions({
fetchCompletions,
taskListId,
taskId,
pagination: completionPagination,
exportConfig,
showArchived,
onToggleArchived: handleToggleArchived,
getFilters: () => tableRefsRef.current.getFilters(),
getSort: () => tableRefsRef.current.getSort(),
getTableInstance: () => tableRefsRef.current.tableInstance,
});
const selectionActions = buildSelectionActions({
exportConfig,
showArchived,
onBulkArchive: (ids) => setBulkArchiveIds(ids),
onBulkRestore: (ids) => setBulkRestoreIds(ids),
getTableInstance: () => tableRefsRef.current.tableInstance,
});
const columns = useMemo(
() => buildDataColumns(completionAttributes, rowActions),
[completionAttributes, rowActions]
);
if (!task) return ( if (!task) return (
<div className="max-w-xl mx-auto py-8 px-4 space-y-4"> <div className="max-w-xl mx-auto py-8 px-4 space-y-4">
<Skeleton className="h-8 w-32" /> <Skeleton className="h-8 w-32" />
@@ -154,8 +317,8 @@ export default function ViewTask() {
const requirements = task.requirements ?? []; const requirements = task.requirements ?? [];
return ( return (
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start py-4 w-full">
<div className="mx-auto p-4"> <div className="w-full p-4">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
@@ -181,86 +344,226 @@ export default function ViewTask() {
</Button> </Button>
</div> </div>
{/* Main content — plain div avoids Card overflow:hidden clipping */} {/* Underline tabs */}
<div className="lg:w-2xl rounded-lg border border-border bg-card text-card-foreground shadow-sm"> <div className="border-b border-border mb-4">
<div className="p-4 space-y-2"> <div className="flex gap-1 -mb-px">
{TABS.map(({ key, label, icon: Icon }) => (
<button
key={key}
type="button"
onClick={() => setActiveTab(key)}
className={[
"flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors",
activeTab === key
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground",
].join(" ")}
>
<Icon className="h-3.5 w-3.5" />
{label}
</button>
))}
</div>
</div>
{/* Description */} {/* ── Overview tab ──────────────────────────────────────────── */}
{task.description ? ( {activeTab === 'overview' && (
<div className="flex gap-2 text-muted-foreground"> // plain div avoids Card overflow:hidden clipping
<FileText className="h-4 w-4 mt-0.5 shrink-0" /> <div className="lg:w-xl mx-auto rounded-lg border border-border bg-card text-card-foreground shadow-sm">
<p className="text-sm leading-relaxed">{task.description}</p> <div className="p-4 space-y-2">
</div>
) : (
<p className="text-sm text-muted-foreground italic">No description provided.</p>
)}
{/* Deadline */} {/* Description */}
{task.deadline && ( {task.description ? (
<> <div className="flex gap-2 text-muted-foreground">
<Separator /> <FileText className="h-4 w-4 mt-0.5 shrink-0" />
<div className="flex items-center gap-2 text-muted-foreground"> <p className="text-sm leading-relaxed">{task.description}</p>
<CalendarClock className="size-4 shrink-0" /> </div>
<span className="text-sm"> ) : (
Deadline:{' '} <p className="text-sm text-muted-foreground italic">No description provided.</p>
<span className="text-foreground font-medium"> )}
{fmtDateTime(task.deadline)}
</span>
</span>
</div>
</>
)}
{/* Status */} {/* Deadline */}
{task.status && ( {task.deadline && (
<> <>
<Separator />
<div className="flex items-center gap-2 text-muted-foreground">
<CalendarClock className="size-4 shrink-0" />
<span className="text-sm">
Deadline:{' '}
<span className="text-foreground font-medium">
{fmtDateTime(task.deadline)}
</span>
</span>
</div>
</>
)}
{/* Status */}
{task.status && (
<>
<Separator />
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Info className="size-4" /> Status
</div>
<Badge variant="outline" className="capitalize">
{task.status}
</Badge>
</div>
</>
)}
{/* Accepting submissions */}
<Separator /> <Separator />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<Info className="size-4" /> Status <ClipboardCheck className="size-4" /> Accepting submissions
</div> </div>
<Badge variant="outline" className="capitalize"> <Switch
{task.status} checked={task.accepts_submissions !== false}
</Badge> disabled={togglingSubmissions}
onCheckedChange={handleToggleAcceptingSubmissions}
/>
</div>
<Separator />
{/* Requirements */}
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-medium">
<GitPullRequest className="size-4" /> Requirements
</div>
<TaskRequirementsSection requirements={requirements} />
</div> </div>
</>
)}
{/* Completions */}
<Separator />
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<ClipboardCheck className="size-4" /> Completions
</div>
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-foreground">
{completionLoading
? '…'
: `${completionPagination.totalRecords} submission${completionPagination.totalRecords === 1 ? '' : 's'}`}
</span>
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/admin/taskList/${taskListId}/tasks/${taskId}/completions`)}
>
View
</Button>
</div> </div>
</div> </div>
)}
<Separator /> {/* ── Completions tab ───────────────────────────────────────── */}
{activeTab === 'completions' && (
{/* Requirements */} <div className="flex flex-col gap-4 w-full mx-auto">
<div className="space-y-3"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="flex items-center gap-2 text-sm font-medium"> <StatCard label="Completions" value={totalRecords} icon={NotebookPen} loading={completionLoading} />
<GitPullRequest className="size-4" /> Requirements <StatCard label="Unique users" value={uniqueUsers} icon={Users} loading={completionLoading} />
<StatCard label="Files" value={totalFiles} icon={Paperclip} loading={completionLoading} />
<StatCard label="Latest" value={latestDate} loading={completionLoading} />
</div> </div>
<TaskRequirementsSection requirements={requirements} />
</div>
</div> <DataTable
</div> columns={columns}
data={completions}
attributes={completionAttributes}
pagination={completionPagination}
setPagination={setCompletionPagination}
loading={completionLoading}
onFetch={handleFetch}
onFetchFilterData={() => Promise.resolve([])}
toolbarActions={toolbarActions}
selectionActions={selectionActions}
columnPinning={columnPinning}
onRefsReady={handleRefsReady}
recordLabel="completion"
emptyMessage="No completions yet."
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
<FilterSheet
open={open}
onOpenChange={onOpenChange}
column={column}
attr={attr}
data={data}
loading={loading}
/>
)}
/>
</div>
)}
</div> </div>
{/* ── Single archive ────────────────────────────────────────────── */}
<ArchiveDialog
open={!!archiveTarget}
onOpenChange={(v) => !v && setArchiveTarget(null)}
entity={archiveTarget}
entityLabel="Completion"
getName={(r) => r?.user?.name ?? 'this completion'}
onArchive={(entity) => archiveCompletion(taskListId, taskId, entity?.completion_id)}
loading={completionLoading}
onSuccess={afterMutation}
/>
{/* ── Single restore ────────────────────────────────────────────── */}
<RestoreDialog
open={!!restoreTarget}
onOpenChange={(v) => !v && setRestoreTarget(null)}
entity={restoreTarget}
entityLabel="Completion"
getName={(r) => r?.user?.name ?? 'this completion'}
onRestore={(entity) => restoreCompletion(taskListId, taskId, entity?.completion_id)}
loading={completionLoading}
onSuccess={afterMutation}
/>
{/* ── Bulk archive ──────────────────────────────────────────────── */}
<ArchiveDialog
open={!!bulkArchiveIds}
onOpenChange={(v) => !v && setBulkArchiveIds(null)}
ids={bulkArchiveIds ?? []}
entityLabel="Completion"
onArchive={({ ids }) => bulkArchiveCompletions(taskListId, taskId, ids)}
loading={completionLoading}
onSuccess={afterMutation}
/>
{/* ── Bulk restore ──────────────────────────────────────────────── */}
<RestoreDialog
open={!!bulkRestoreIds}
onOpenChange={(v) => !v && setBulkRestoreIds(null)}
ids={bulkRestoreIds ?? []}
entityLabel="Completion"
onRestore={({ ids }) => bulkRestoreCompletions(taskListId, taskId, ids)}
loading={completionLoading}
onSuccess={afterMutation}
/>
{/* ── Review submission ────────────────────────────────────────────── */}
<AlertDialog open={!!reviewTarget} onOpenChange={(v) => !v && setReviewTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Review Submission</AlertDialogTitle>
<AlertDialogDescription>
{reviewTarget?.user?.name ?? 'This learner'}'s submission requires review before it counts as complete.
</AlertDialogDescription>
</AlertDialogHeader>
{reviewTarget?.note && (
<p className="text-sm text-muted-foreground border rounded-md p-3 bg-muted/40">{reviewTarget.note}</p>
)}
{reviewTarget?.response_text && (
<p className="text-sm border rounded-md p-3 whitespace-pre-wrap">{reviewTarget.response_text}</p>
)}
<Textarea
placeholder="Optional note for the learner..."
value={reviewNote}
onChange={(e) => setReviewNote(e.target.value)}
rows={3}
/>
<AlertDialogFooter>
<AlertDialogCancel disabled={completionLoading}>Cancel</AlertDialogCancel>
<Button
type="button" variant="outline"
className="text-destructive border-destructive/50 hover:bg-destructive/5"
disabled={completionLoading}
onClick={() => handleReview('rejected')}
>
<X className="size-4 mr-1.5" /> Reject
</Button>
<AlertDialogAction disabled={completionLoading} onClick={() => handleReview('approved')}>
<Check className="size-4 mr-1.5" /> Approve
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
} }
@@ -102,7 +102,7 @@ export default function ViewTaskCompletion() {
{ label: 'Task Lists', to: '/admin/taskList' }, { label: 'Task Lists', to: '/admin/taskList' },
{ label: taskList?.name ?? '…', to: `/admin/taskList/${taskListId}/tasks` }, { label: taskList?.name ?? '…', to: `/admin/taskList/${taskListId}/tasks` },
{ label: task?.name ?? '…', to: `/admin/taskList/${taskListId}/tasks/${taskId}/view` }, { label: task?.name ?? '…', to: `/admin/taskList/${taskListId}/tasks/${taskId}/view` },
{ label: 'Completions', to: `/admin/taskList/${taskListId}/tasks/${taskId}/completions` }, { label: 'Completions', to: `/admin/taskList/${taskListId}/tasks/${taskId}/view?tab=completions` },
{ label: 'View' }, { label: 'View' },
]; ];
@@ -115,7 +115,7 @@ export default function ViewTaskCompletion() {
<div className="flex items-center gap-3 my-6 w-full"> <div className="flex items-center gap-3 my-6 w-full">
<Button <Button
variant="ghost" size="icon" variant="ghost" size="icon"
onClick={() => navigate(`/admin/taskList/${taskListId}/tasks/${taskId}/completions`)} onClick={() => navigate(`/admin/taskList/${taskListId}/tasks/${taskId}/view?tab=completions`)}
className="shrink-0" className="shrink-0"
> >
<ArrowLeft className="size-4" /> <ArrowLeft className="size-4" />
-2
View File
@@ -108,7 +108,6 @@ import TierCategories from '../pages/tiers/TierCategories';
import ArchivedPlanList from '../pages/tiers/ArchivedPlanList'; import ArchivedPlanList from '../pages/tiers/ArchivedPlanList';
import PaymentPolicy from '../pages/tiers/PaymentPolicy'; import PaymentPolicy from '../pages/tiers/PaymentPolicy';
import { AddTierCategory, EditTierCategory } from '../pages/tiers/EditTierCategory'; import { AddTierCategory, EditTierCategory } from '../pages/tiers/EditTierCategory';
import TaskSubmissions from '../pages/task_list/task/TaskCompletion'
import ViewTaskCompletion from '../pages/task_list/task/ViewTaskCompletion' import ViewTaskCompletion from '../pages/task_list/task/ViewTaskCompletion'
// Advertisements // Advertisements
@@ -294,7 +293,6 @@ export const AdminRoutes = {
{ path: 'archived', element: <ArchivedTask /> }, { path: 'archived', element: <ArchivedTask /> },
{ path: ':taskId/view', element: <ViewTask /> }, { path: ':taskId/view', element: <ViewTask /> },
{ path: ':taskId/edit', element: <EditTask /> }, { path: ':taskId/edit', element: <EditTask /> },
{ path: ':taskId/completions', element: <TaskSubmissions /> },
{ path: ':taskId/completions/:completionId/view', element: <ViewTaskCompletion /> }, { path: ':taskId/completions/:completionId/view', element: <ViewTaskCompletion /> },
] ]
} }