diff --git a/src/components/generic/notificationDisplay.jsx b/src/components/generic/notificationDisplay.jsx
index aabaa00..25f8630 100644
--- a/src/components/generic/notificationDisplay.jsx
+++ b/src/components/generic/notificationDisplay.jsx
@@ -115,6 +115,9 @@ export function resolveNotificationLink(type, data) {
: null;
case "task":
+ if (data.groupId && data.taskListId && data.taskId) {
+ return { label: "View task", go: (navigate) => navigate(`/group/${data.groupId}/view/${data.taskListId}/task/${data.taskId}`) };
+ }
return (data.groupId && data.taskListId)
? { label: "View task list", go: (navigate) => navigate(`/group/${data.groupId}/view/${data.taskListId}`) }
: null;
diff --git a/src/data/cronPresets.data.js b/src/data/cronPresets.data.js
index 3911a5f..7a3e500 100644
--- a/src/data/cronPresets.data.js
+++ b/src/data/cronPresets.data.js
@@ -17,8 +17,18 @@ export const CRON_PRESET_MAP = Object.fromEntries(
// job_name -> friendly copy (label/description come from the API too, but this
// is used as a fallback and for the settings icon).
export const JOB_LABELS = {
- taskOverdue: { label: "Task Overdue Alerts (Admin)", description: "Notifies admins when tasks flip to overdue." },
- userNotifications: { label: "Task Overdue Alerts (Users)", description: "Notifies affected users when their tasks are marked overdue." },
+ taskOverdue: { label: "Task Alerts (Admin)", description: "Automatically marks expired tasks as overdue or completed, and notifies admins." },
+ userNotifications: { label: "Task Alerts (Users)", description: "Notifies affected users when their tasks are automatically marked overdue or completed." },
issueCertificates: { label: "Certificate Issued", description: "Notifies users when a course certificate is ready." },
expireUserTiers: { label: "Tier Expired", description: "Notifies users when their subscription tier expires." },
};
+
+// The only jobs that support a configurable target_status, and the values it accepts.
+export const TARGET_STATUS_OPTIONS = [
+ { value: "overdue", label: "Overdue" },
+ { value: "completed", label: "Completed" },
+];
+
+export const TARGET_STATUS_MAP = Object.fromEntries(
+ TARGET_STATUS_OPTIONS.map((s) => [s.value, s])
+);
diff --git a/src/modules/admin/pages/jobs/Jobs.jsx b/src/modules/admin/pages/jobs/Jobs.jsx
index 25671de..84bfcb4 100644
--- a/src/modules/admin/pages/jobs/Jobs.jsx
+++ b/src/modules/admin/pages/jobs/Jobs.jsx
@@ -14,7 +14,7 @@ import { Switch } from "@/components/ui/switch";
import { Spinner } from "@/components/ui/spinner";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
-import { CRON_PRESET_OPTIONS, JOB_LABELS } from "@/data/cronPresets.data";
+import { CRON_PRESET_OPTIONS, JOB_LABELS, TARGET_STATUS_OPTIONS } from "@/data/cronPresets.data";
function SectionCard({ children }) {
return
{children}
;
@@ -84,6 +84,23 @@ export default function Jobs() {
}
}
+ async function handleTargetStatusChange(jobName, target_status) {
+ setSavingJob(jobName);
+ try {
+ const { data } = await api.patch(`/admin/announcement-settings/${jobName}`, {
+ target_status,
+ updatedBy: user?.user_id ?? null,
+ });
+ const updated = data?.data?.data;
+ setSettings((prev) => prev.map((s) => (s.job_name === jobName ? { ...s, ...updated, target_status } : s)));
+ toast("Target status updated.");
+ } catch (err) {
+ toast(err?.response?.data?.message ?? "Failed to update target status.");
+ } finally {
+ setSavingJob(null);
+ }
+ }
+
return (
@@ -151,6 +168,27 @@ export default function Jobs() {
{isSaving && }
+
+ {s.target_status != null && (
+
+ Marks tasks as:
+
+ {isSaving && }
+
+ )}
);
})}
diff --git a/src/modules/admin/pages/task_list/ViewTaskList.jsx b/src/modules/admin/pages/task_list/ViewTaskList.jsx
index 1759f99..e31595d 100644
--- a/src/modules/admin/pages/task_list/ViewTaskList.jsx
+++ b/src/modules/admin/pages/task_list/ViewTaskList.jsx
@@ -137,7 +137,7 @@ export default function ViewTaskList() {
const navigate = useNavigate();
const { taskListId } = useParams();
const { fetchTaskList } = useAdminTask();
- const { fmtDate } = useDateFormat();
+ const { fmtDateTime } = useDateFormat();
const [taskList, setTaskList] = useState(null);
@@ -275,7 +275,7 @@ export default function ViewTaskList() {
Deadline:{' '}
- {fmtDate(task.deadline)}
+ {fmtDateTime(task.deadline)}
diff --git a/src/modules/admin/pages/task_list/task/TaskCompletion.jsx b/src/modules/admin/pages/task_list/task/TaskCompletion.jsx
index b654df7..9d27207 100644
--- a/src/modules/admin/pages/task_list/task/TaskCompletion.jsx
+++ b/src/modules/admin/pages/task_list/task/TaskCompletion.jsx
@@ -15,13 +15,15 @@ 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 } from '@/utils/table.util';
+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';
@@ -51,13 +53,14 @@ export default function TaskCompletions() {
task, taskList,
completions, completionPagination, setCompletionPagination, completionLoading,
completionAttributes,
- fetchTask, fetchTaskList,
+ 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);
@@ -78,6 +81,17 @@ export default function TaskCompletions() {
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);
@@ -186,18 +200,28 @@ export default function TaskCompletions() {
{/* ── Detail card ───────────────────────────────────────────────── */}
-
- {task
- ?
{task.name}
- :
- }
- {task
- ?
- {taskList?.name ?? '—'}
- {task.deadline ? ` · Due ${formatDate(task.deadline)}` : ''}
-
- :
- }
+
+
+ {task
+ ?
{task.name}
+ :
+ }
+ {task
+ ?
+ {taskList?.name ?? '—'}
+ {task.deadline ? ` · Due ${formatDateTime(task.deadline)}` : ''}
+
+ :
+ }
+
+
+
+
+
diff --git a/src/modules/admin/pages/task_list/task/ViewTaskCompletion.jsx b/src/modules/admin/pages/task_list/task/ViewTaskCompletion.jsx
index 2c72fb5..3a8852c 100644
--- a/src/modules/admin/pages/task_list/task/ViewTaskCompletion.jsx
+++ b/src/modules/admin/pages/task_list/task/ViewTaskCompletion.jsx
@@ -21,7 +21,7 @@ import {
FileVideo, FileAudio, File, ArrowLeft,
Clock, CalendarDays, ExternalLink, NotebookPen,
} from 'lucide-react';
-import { formatDate } from '@/utils/table.util';
+import { formatDate, formatDateTime } from '@/utils/table.util';
// ─── Helpers ──────────────────────────────────────────────────────────────────
const getInitials = (name = '') =>
@@ -275,7 +275,7 @@ export default function ViewTaskCompletion() {
{task?.deadline && (
- Due {formatDate(task.deadline)}
+ Due {formatDateTime(task.deadline)}
)}
diff --git a/src/modules/client/components/TaskListTable.jsx b/src/modules/client/components/TaskListTable.jsx
index 60cad87..817285c 100644
--- a/src/modules/client/components/TaskListTable.jsx
+++ b/src/modules/client/components/TaskListTable.jsx
@@ -28,7 +28,7 @@ import {
Search, RefreshCw, ChevronsLeft, ChevronLeft, ChevronRight, ChevronsRight,
ArrowRight, Check, AlertTriangle, Circle,
} from 'lucide-react';
-import { formatDate } from '@/utils/table.util';
+import { formatDateTime } from '@/utils/table.util';
const PAGE_SIZES = [50, 100, 1000];
@@ -168,7 +168,7 @@ export default function ClientTaskListTable({
{tl.description || '—'}
- {task?.deadline ? formatDate(task.deadline) : '—'}
+ {task?.deadline ? formatDateTime(task.deadline) : '—'}
diff --git a/src/modules/client/pages/ViewTask.jsx b/src/modules/client/pages/ViewTask.jsx
index a30701c..42b936f 100644
--- a/src/modules/client/pages/ViewTask.jsx
+++ b/src/modules/client/pages/ViewTask.jsx
@@ -35,7 +35,7 @@ import { useTask } from '@/contexts/ClientTaskContext';
import { PageMeta } from '@/contexts/MetadataContext';
import { useTaskProgress } from '@/contexts/ClientTaskProgressContext';
import { useGroup } from '@/contexts/ClientGroupContext';
-import { formatDate } from '@/utils/table.util';
+import { formatDate, formatDateTime } from '@/utils/table.util';
import api from '@/utils/api.util';
// ─── Status badge ─────────────────────────────────────────────────────────────
@@ -215,7 +215,7 @@ const FileRow = ({ file, onClick }) => {
};
// ─── Submission panel (Your Work) — upload_file and/or submit_text ────────────
-const SubmissionPanel = ({ latestCompletion, onAddAttachment, submitting, onFileClick, requiresReview, hasTextRequirement }) => {
+const SubmissionPanel = ({ latestCompletion, onAddAttachment, submitting, onFileClick, requiresReview, hasTextRequirement, acceptsSubmissions }) => {
const files = latestCompletion?.files ?? [];
return (
@@ -264,9 +264,15 @@ const SubmissionPanel = ({ latestCompletion, onAddAttachment, submitting, onFile
) : null}
-
+ {acceptsSubmissions ? (
+
+ ) : (
+
+ This task no longer accepts submissions
+
+ )}
);
@@ -498,7 +504,7 @@ const ViewTask = () => {
{task?.deadline && (
- Due {formatDate(task.deadline)}
+ Due {formatDateTime(task.deadline)}
)}
@@ -623,6 +629,7 @@ const ViewTask = () => {
onFileClick={(file) => setPreviewFile(file)}
requiresReview={requiresReview}
hasTextRequirement={hasTextReq}
+ acceptsSubmissions={task?.accepts_submissions !== false}
/>
)}
@@ -638,6 +645,7 @@ const ViewTask = () => {
onFileClick={(file) => setPreviewFile(file)}
requiresReview={requiresReview}
hasTextRequirement={hasTextReq}
+ acceptsSubmissions={task?.accepts_submissions !== false}
/>
)}
{
- {task.deadline ? `Due ${formatDate(task.deadline)}` : 'No due date'}
+ {task.deadline ? `Due ${formatDateTime(task.deadline)}` : 'No due date'}
{locked && (
Tap to see what's required to unlock this task.
diff --git a/src/utils/table.util.jsx b/src/utils/table.util.jsx
index 037ae10..e1d638c 100644
--- a/src/utils/table.util.jsx
+++ b/src/utils/table.util.jsx
@@ -98,6 +98,12 @@ export function formatDate(value) {
catch { return value; }
}
+export function formatDateTime(value) {
+ if (!value) return null;
+ try { return format(new Date(value), "MMM d, yyyy h:mm a"); }
+ catch { return value; }
+}
+
// Human-readable labels for boolean-backed enum fields — [falseLabel, trueLabel].
// Shared by renderCell (table cells) and ColumnFilter (filter dropdown), so a
// "true"/"false" boolean column never surfaces its raw value to the admin.