mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
add more things
This commit is contained in:
@@ -167,6 +167,15 @@ export function CoursesProvider({ children }) {
|
||||
[request],
|
||||
);
|
||||
|
||||
const reorderCourses = useCallback(
|
||||
(courseIds) =>
|
||||
request(async () => {
|
||||
await api.put(`${BASE}/order`, { course_ids: courseIds });
|
||||
return true;
|
||||
}),
|
||||
[request],
|
||||
);
|
||||
|
||||
const archiveCourse = useCallback(
|
||||
(courseId) =>
|
||||
request(async () => {
|
||||
@@ -1284,6 +1293,7 @@ export function CoursesProvider({ children }) {
|
||||
createCourse,
|
||||
createCourseFull,
|
||||
updateCourse,
|
||||
reorderCourses,
|
||||
archiveCourse,
|
||||
archiveCourses,
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function CoursesTable() {
|
||||
|
||||
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) => {
|
||||
tableRefsRef.current = refs;
|
||||
@@ -42,14 +42,31 @@ export default function CoursesTable() {
|
||||
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`),
|
||||
onAssessment: (row) => navigate(`/admin/courses/${row.course_id}/assessment`),
|
||||
onViewUnits: (row) => navigate(`/admin/courses/${row.course_id}/units`),
|
||||
onView: (row) => navigate(`/admin/courses/${row.course_id}/view`),
|
||||
onEdit: (row) => navigate(`/admin/courses/${row.course_id}/edit`),
|
||||
onArchive: (row) => setArchiveTarget(row),
|
||||
}), []);
|
||||
onMoveUp: (row) => handleMove(row, -1),
|
||||
onMoveDown: (row) => handleMove(row, 1),
|
||||
courses,
|
||||
});
|
||||
|
||||
const toolbarActions = buildToolbarActions({
|
||||
fetchCourses,
|
||||
@@ -70,7 +87,7 @@ export default function CoursesTable() {
|
||||
|
||||
const columns = useMemo(
|
||||
() => buildDataColumns(attributes, rowActions),
|
||||
[attributes]
|
||||
[attributes, rowActions]
|
||||
);
|
||||
|
||||
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 [
|
||||
{
|
||||
key: "view",
|
||||
@@ -8,6 +8,21 @@ export function buildRowActions({ onViewUnits, onView, onEdit, onArchive, onAsse
|
||||
icon: <Eye className="h-3.5 w-3.5" />,
|
||||
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",
|
||||
label: "View Units",
|
||||
|
||||
@@ -8,14 +8,6 @@ export function buildRowActions({ navigate, onArchive, onRestore, onMoveUp, onMo
|
||||
icon: <Eye className="size-4" />,
|
||||
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",
|
||||
label: "Move Up",
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// config/task_completion/rowActions.config.jsx
|
||||
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 [
|
||||
{
|
||||
key: "view",
|
||||
label: "View",
|
||||
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",
|
||||
|
||||
@@ -35,7 +35,6 @@ const schema = z.object({
|
||||
title: z.string().min(1, "Title is required."),
|
||||
description: z.string().optional(),
|
||||
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(),
|
||||
subscription: z.string().min(1, "Subscription is required.").default("free"),
|
||||
status: z.enum(["draft", "published", "unpublished"]).default("draft"),
|
||||
@@ -173,7 +172,6 @@ export default function AddCourse() {
|
||||
title: "",
|
||||
description: "",
|
||||
course_code: "",
|
||||
order_index: 0,
|
||||
level: "beginner",
|
||||
subscription: "free",
|
||||
status: "draft",
|
||||
@@ -192,7 +190,6 @@ export default function AddCourse() {
|
||||
const watchedTitle = useWatch({ control, name: "title" });
|
||||
const watchedDescription = useWatch({ control, name: "description" });
|
||||
const watchedCourseCode = useWatch({ control, name: "course_code" });
|
||||
const watchedOrderIndex = useWatch({ control, name: "order_index" });
|
||||
const watchedLevel = useWatch({ control, name: "level" });
|
||||
const watchedSubscr = useWatch({ control, name: "subscription" });
|
||||
const watchedStatus = useWatch({ control, name: "status" });
|
||||
@@ -252,7 +249,6 @@ export default function AddCourse() {
|
||||
title: values.title,
|
||||
description: values.description,
|
||||
course_code: values.course_code || null,
|
||||
order_index: values.order_index,
|
||||
level: values.level || null,
|
||||
subscription: values.subscription,
|
||||
status: values.status,
|
||||
@@ -338,19 +334,12 @@ export default function AddCourse() {
|
||||
<Textarea id="description" placeholder="Optional course description" rows={3} {...register("description")} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="course_code">
|
||||
Course Code <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input id="course_code" placeholder="e.g. RE-101" {...register("course_code")} />
|
||||
<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 className="space-y-1.5">
|
||||
<Label htmlFor="course_code">
|
||||
Course Code <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input id="course_code" placeholder="e.g. RE-101" {...register("course_code")} />
|
||||
<FieldError message={errors.course_code?.message} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
@@ -640,10 +629,6 @@ export default function AddCourse() {
|
||||
<p className="text-xs text-muted-foreground">Level</p>
|
||||
<p className="font-medium capitalize">{watchedLevel || "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Order</p>
|
||||
<p className="font-medium">{watchedOrderIndex ?? 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Subscription</p>
|
||||
<p className="font-medium capitalize">{watchedSubscr || "—"}</p>
|
||||
|
||||
@@ -46,7 +46,6 @@ const schema = z.object({
|
||||
title: z.string().min(1, "Title is required."),
|
||||
description: z.string().optional(),
|
||||
course_code: z.string().optional(),
|
||||
order_index: z.coerce.number().min(0).default(0),
|
||||
level: z.enum(["beginner", "intermediate", "advanced"]).optional(),
|
||||
subscription: z.string().min(1, "Subscription is required.").default("free"),
|
||||
status: z.enum(["draft", "published", "unpublished"]).default("draft"),
|
||||
@@ -236,7 +235,7 @@ export default function EditCourse() {
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
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 ?? "",
|
||||
description: c.description ?? "",
|
||||
course_code: c.course_code ?? "",
|
||||
order_index: c.order_index ?? 0,
|
||||
level: c.level ?? undefined,
|
||||
subscription: c.subscription ?? "free",
|
||||
status: c.status ?? "draft",
|
||||
@@ -595,26 +593,14 @@ export default function EditCourse() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="course_code">Course Code</Label>
|
||||
<Input
|
||||
id="course_code"
|
||||
placeholder="e.g. RE-101"
|
||||
{...register("course_code")}
|
||||
/>
|
||||
<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 className="space-y-1.5">
|
||||
<Label htmlFor="course_code">Course Code</Label>
|
||||
<Input
|
||||
id="course_code"
|
||||
placeholder="e.g. RE-101"
|
||||
{...register("course_code")}
|
||||
/>
|
||||
<FieldError message={errors.course_code?.message} />
|
||||
</div>
|
||||
</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 { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { useAdminTask } from '@/contexts/AdminTaskContext';
|
||||
import { useDateFormat } from '@/hooks/useDateFormat';
|
||||
@@ -8,13 +8,33 @@ import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
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 {
|
||||
ArrowLeft, Pencil, FileText, CalendarClock,
|
||||
Link2, Upload, BookOpen, BookMarked, FileCheck2,
|
||||
Info, GitPullRequest, Tag, Globe, Copy, File, Bookmark,
|
||||
ClipboardCheck,
|
||||
ClipboardCheck, NotebookPen, Users, Paperclip, Check, X,
|
||||
} 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
|
||||
const REQUIREMENT_CONFIG = {
|
||||
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 ─────────────────────────────────────────────────────────────────────
|
||||
export default function ViewTask() {
|
||||
const navigate = useNavigate();
|
||||
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 [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(() => {
|
||||
fetchTask(taskListId, taskId).then((data) => {
|
||||
if (!data) return;
|
||||
setTask(data);
|
||||
});
|
||||
fetchCompletions(taskListId, taskId, { page: 1, limit: 1 });
|
||||
fetchTask(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 (
|
||||
<div className="max-w-xl mx-auto py-8 px-4 space-y-4">
|
||||
<Skeleton className="h-8 w-32" />
|
||||
@@ -154,8 +317,8 @@ export default function ViewTask() {
|
||||
const requirements = task.requirements ?? [];
|
||||
|
||||
return (
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
|
||||
<div className="mx-auto p-4">
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start py-4 w-full">
|
||||
<div className="w-full p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex gap-2 items-center">
|
||||
@@ -181,86 +344,226 @@ export default function ViewTask() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Main content — plain div avoids Card overflow:hidden clipping */}
|
||||
<div className="lg:w-2xl rounded-lg border border-border bg-card text-card-foreground shadow-sm">
|
||||
<div className="p-4 space-y-2">
|
||||
{/* Underline tabs */}
|
||||
<div className="border-b border-border mb-4">
|
||||
<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 */}
|
||||
{task.description ? (
|
||||
<div className="flex gap-2 text-muted-foreground">
|
||||
<FileText className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<p className="text-sm leading-relaxed">{task.description}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">No description provided.</p>
|
||||
)}
|
||||
{/* ── Overview tab ──────────────────────────────────────────── */}
|
||||
{activeTab === 'overview' && (
|
||||
// plain div avoids Card overflow:hidden clipping
|
||||
<div className="lg:w-xl mx-auto rounded-lg border border-border bg-card text-card-foreground shadow-sm">
|
||||
<div className="p-4 space-y-2">
|
||||
|
||||
{/* Deadline */}
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
{/* Description */}
|
||||
{task.description ? (
|
||||
<div className="flex gap-2 text-muted-foreground">
|
||||
<FileText className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<p className="text-sm leading-relaxed">{task.description}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">No description provided.</p>
|
||||
)}
|
||||
|
||||
{/* Status */}
|
||||
{task.status && (
|
||||
<>
|
||||
{/* Deadline */}
|
||||
{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 />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Info className="size-4" /> Status
|
||||
<ClipboardCheck className="size-4" /> Accepting submissions
|
||||
</div>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{task.status}
|
||||
</Badge>
|
||||
<Switch
|
||||
checked={task.accepts_submissions !== false}
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Requirements */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<GitPullRequest className="size-4" /> Requirements
|
||||
{/* ── Completions tab ───────────────────────────────────────── */}
|
||||
{activeTab === 'completions' && (
|
||||
<div className="flex flex-col gap-4 w-full mx-auto">
|
||||
<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>
|
||||
<TaskRequirementsSection requirements={requirements} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ export default function ViewTaskCompletion() {
|
||||
{ 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', to: `/admin/taskList/${taskListId}/tasks/${taskId}/completions` },
|
||||
{ label: 'Completions', to: `/admin/taskList/${taskListId}/tasks/${taskId}/view?tab=completions` },
|
||||
{ label: 'View' },
|
||||
];
|
||||
|
||||
@@ -115,7 +115,7 @@ export default function ViewTaskCompletion() {
|
||||
<div className="flex items-center gap-3 my-6 w-full">
|
||||
<Button
|
||||
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"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
|
||||
@@ -108,7 +108,6 @@ import TierCategories from '../pages/tiers/TierCategories';
|
||||
import ArchivedPlanList from '../pages/tiers/ArchivedPlanList';
|
||||
import PaymentPolicy from '../pages/tiers/PaymentPolicy';
|
||||
import { AddTierCategory, EditTierCategory } from '../pages/tiers/EditTierCategory';
|
||||
import TaskSubmissions from '../pages/task_list/task/TaskCompletion'
|
||||
import ViewTaskCompletion from '../pages/task_list/task/ViewTaskCompletion'
|
||||
|
||||
// Advertisements
|
||||
@@ -294,7 +293,6 @@ export const AdminRoutes = {
|
||||
{ path: 'archived', element: <ArchivedTask /> },
|
||||
{ path: ':taskId/view', element: <ViewTask /> },
|
||||
{ path: ':taskId/edit', element: <EditTask /> },
|
||||
{ path: ':taskId/completions', element: <TaskSubmissions /> },
|
||||
{ path: ':taskId/completions/:completionId/view', element: <ViewTaskCompletion /> },
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user