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
+6 -21
View File
@@ -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>
+9 -23
View File
@@ -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" />