implemented task more

This commit is contained in:
rgrgogu
2026-07-17 17:27:59 +08:00
parent 2e9ab5786c
commit 5eefbe0dc9
10 changed files with 120 additions and 31 deletions
@@ -115,6 +115,9 @@ export function resolveNotificationLink(type, data) {
: null; : null;
case "task": 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) return (data.groupId && data.taskListId)
? { label: "View task list", go: (navigate) => navigate(`/group/${data.groupId}/view/${data.taskListId}`) } ? { label: "View task list", go: (navigate) => navigate(`/group/${data.groupId}/view/${data.taskListId}`) }
: null; : null;
+12 -2
View File
@@ -17,8 +17,18 @@ export const CRON_PRESET_MAP = Object.fromEntries(
// job_name -> friendly copy (label/description come from the API too, but this // job_name -> friendly copy (label/description come from the API too, but this
// is used as a fallback and for the settings icon). // is used as a fallback and for the settings icon).
export const JOB_LABELS = { export const JOB_LABELS = {
taskOverdue: { label: "Task Overdue Alerts (Admin)", description: "Notifies admins when tasks flip to overdue." }, taskOverdue: { label: "Task Alerts (Admin)", description: "Automatically marks expired tasks as overdue or completed, and notifies admins." },
userNotifications: { label: "Task Overdue Alerts (Users)", description: "Notifies affected users when their tasks are marked overdue." }, 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." }, 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." }, 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])
);
+39 -1
View File
@@ -14,7 +14,7 @@ import { Switch } from "@/components/ui/switch";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; 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 }) { function SectionCard({ children }) {
return <div className="rounded-lg border bg-card p-4">{children}</div>; return <div className="rounded-lg border bg-card p-4">{children}</div>;
@@ -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 ( return (
<section className="bg-muted h-full"> <section className="bg-muted h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4">
@@ -151,6 +168,27 @@ export default function Jobs() {
</Select> </Select>
{isSaving && <Spinner className="size-3.5" />} {isSaving && <Spinner className="size-3.5" />}
</div> </div>
{s.target_status != null && (
<div className="mt-2 flex items-center gap-2">
<span className="text-xs text-muted-foreground">Marks tasks as:</span>
<Select
value={s.target_status}
disabled={isSaving}
onValueChange={(v) => handleTargetStatusChange(s.job_name, v)}
>
<SelectTrigger className="w-[180px] h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TARGET_STATUS_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
{isSaving && <Spinner className="size-3.5" />}
</div>
)}
</SectionCard> </SectionCard>
); );
})} })}
@@ -137,7 +137,7 @@ export default function ViewTaskList() {
const navigate = useNavigate(); const navigate = useNavigate();
const { taskListId } = useParams(); const { taskListId } = useParams();
const { fetchTaskList } = useAdminTask(); const { fetchTaskList } = useAdminTask();
const { fmtDate } = useDateFormat(); const { fmtDateTime } = useDateFormat();
const [taskList, setTaskList] = useState(null); const [taskList, setTaskList] = useState(null);
@@ -275,7 +275,7 @@ export default function ViewTaskList() {
<span className="text-sm"> <span className="text-sm">
Deadline:{' '} Deadline:{' '}
<span className="text-foreground font-medium"> <span className="text-foreground font-medium">
{fmtDate(task.deadline)} {fmtDateTime(task.deadline)}
</span> </span>
</span> </span>
</div> </div>
@@ -15,13 +15,15 @@ import { RestoreDialog } from '@/components/generic/Dialogs/RestoreDialog';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { House, NotebookPen, Users, Paperclip, Check, X } from 'lucide-react'; import { House, NotebookPen, Users, Paperclip, Check, X } from 'lucide-react';
import AppBreadcrumb from '@/components/generic/Breadcrumb/AppBreadcrumb'; 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 { getTimestamp } from '@/utils/timestamp.util';
import { buildDataColumns, columnPinning } from '@/modules/admin/config/task_list/task_completion/columns.config'; import { buildDataColumns, columnPinning } from '@/modules/admin/config/task_list/task_completion/columns.config';
@@ -51,13 +53,14 @@ export default function TaskCompletions() {
task, taskList, task, taskList,
completions, completionPagination, setCompletionPagination, completionLoading, completions, completionPagination, setCompletionPagination, completionLoading,
completionAttributes, completionAttributes,
fetchTask, fetchTaskList, fetchTask, fetchTaskList, updateTask,
fetchCompletions, reviewSubmission, fetchCompletions, reviewSubmission,
archiveCompletion, restoreCompletion, archiveCompletion, restoreCompletion,
bulkArchiveCompletions, bulkRestoreCompletions, bulkArchiveCompletions, bulkRestoreCompletions,
} = useAdminTask(); } = useAdminTask();
const [showArchived, setShowArchived] = useState(false); const [showArchived, setShowArchived] = useState(false);
const [togglingSubmissions, setTogglingSubmissions] = useState(false);
const [archiveTarget, setArchiveTarget] = useState(null); const [archiveTarget, setArchiveTarget] = useState(null);
const [restoreTarget, setRestoreTarget] = useState(null); const [restoreTarget, setRestoreTarget] = useState(null);
const [bulkArchiveIds, setBulkArchiveIds] = useState(null); const [bulkArchiveIds, setBulkArchiveIds] = useState(null);
@@ -78,6 +81,17 @@ export default function TaskCompletions() {
const handleRefsReady = (refs) => { tableRefsRef.current = refs; }; 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 ───────────────────────────────────────────────────────── // ── Fetch handler ─────────────────────────────────────────────────────────
const handleFetch = useCallback((params) => { const handleFetch = useCallback((params) => {
return fetchCompletions(taskListId, taskId, params); return fetchCompletions(taskListId, taskId, params);
@@ -186,18 +200,28 @@ export default function TaskCompletions() {
{/* ── Detail card ───────────────────────────────────────────────── */} {/* ── Detail card ───────────────────────────────────────────────── */}
<div className="bg-card border rounded-xl p-5 flex flex-col gap-4 w-full mb-6"> <div className="bg-card border rounded-xl p-5 flex flex-col gap-4 w-full mb-6">
<div className="flex flex-col gap-1 min-w-0"> <div className="flex items-start justify-between gap-4">
{task <div className="flex flex-col gap-1 min-w-0">
? <h1 className="text-lg font-medium leading-none">{task.name}</h1> {task
: <Skeleton className="h-5 w-48" /> ? <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"> {task
{taskList?.name ?? '—'} ? <p className="text-sm text-muted-foreground mt-1">
{task.deadline ? ` · Due ${formatDate(task.deadline)}` : ''} {taskList?.name ?? '—'}
</p> {task.deadline ? ` · Due ${formatDateTime(task.deadline)}` : ''}
: <Skeleton className="h-4 w-72 mt-1" /> </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>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
@@ -21,7 +21,7 @@ import {
FileVideo, FileAudio, File, ArrowLeft, FileVideo, FileAudio, File, ArrowLeft,
Clock, CalendarDays, ExternalLink, NotebookPen, Clock, CalendarDays, ExternalLink, NotebookPen,
} from 'lucide-react'; } from 'lucide-react';
import { formatDate } from '@/utils/table.util'; import { formatDate, formatDateTime } from '@/utils/table.util';
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
const getInitials = (name = '') => const getInitials = (name = '') =>
@@ -275,7 +275,7 @@ export default function ViewTaskCompletion() {
{task?.deadline && ( {task?.deadline && (
<span className="flex items-center gap-1.5 text-muted-foreground"> <span className="flex items-center gap-1.5 text-muted-foreground">
<CalendarDays className="size-3.5" /> <CalendarDays className="size-3.5" />
Due {formatDate(task.deadline)} Due {formatDateTime(task.deadline)}
</span> </span>
)} )}
<span className="flex items-center gap-1.5 text-muted-foreground"> <span className="flex items-center gap-1.5 text-muted-foreground">
@@ -28,7 +28,7 @@ import {
Search, RefreshCw, ChevronsLeft, ChevronLeft, ChevronRight, ChevronsRight, Search, RefreshCw, ChevronsLeft, ChevronLeft, ChevronRight, ChevronsRight,
ArrowRight, Check, AlertTriangle, Circle, ArrowRight, Check, AlertTriangle, Circle,
} from 'lucide-react'; } from 'lucide-react';
import { formatDate } from '@/utils/table.util'; import { formatDateTime } from '@/utils/table.util';
const PAGE_SIZES = [50, 100, 1000]; const PAGE_SIZES = [50, 100, 1000];
@@ -168,7 +168,7 @@ export default function ClientTaskListTable({
{tl.description || '—'} {tl.description || '—'}
</TableCell> </TableCell>
<TableCell className="text-sm whitespace-nowrap"> <TableCell className="text-sm whitespace-nowrap">
{task?.deadline ? formatDate(task.deadline) : '—'} {task?.deadline ? formatDateTime(task.deadline) : '—'}
</TableCell> </TableCell>
<TableCell> <TableCell>
<StatusBadge status={statusLabel} /> <StatusBadge status={statusLabel} />
+14 -6
View File
@@ -35,7 +35,7 @@ import { useTask } from '@/contexts/ClientTaskContext';
import { PageMeta } from '@/contexts/MetadataContext'; import { PageMeta } from '@/contexts/MetadataContext';
import { useTaskProgress } from '@/contexts/ClientTaskProgressContext'; import { useTaskProgress } from '@/contexts/ClientTaskProgressContext';
import { useGroup } from '@/contexts/ClientGroupContext'; import { useGroup } from '@/contexts/ClientGroupContext';
import { formatDate } from '@/utils/table.util'; import { formatDate, formatDateTime } from '@/utils/table.util';
import api from '@/utils/api.util'; import api from '@/utils/api.util';
// ─── Status badge ───────────────────────────────────────────────────────────── // ─── Status badge ─────────────────────────────────────────────────────────────
@@ -215,7 +215,7 @@ const FileRow = ({ file, onClick }) => {
}; };
// ─── Submission panel (Your Work) — upload_file and/or submit_text ──────────── // ─── 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 ?? []; const files = latestCompletion?.files ?? [];
return ( return (
@@ -264,9 +264,15 @@ const SubmissionPanel = ({ latestCompletion, onAddAttachment, submitting, onFile
) : null} ) : null}
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Button className="w-full" onClick={onAddAttachment} disabled={submitting}> {acceptsSubmissions ? (
<Plus /> {latestCompletion ? 'Resubmit' : 'Turn In'} <Button className="w-full" onClick={onAddAttachment} disabled={submitting}>
</Button> <Plus /> {latestCompletion ? 'Resubmit' : 'Turn In'}
</Button>
) : (
<p className="text-sm text-center text-muted-foreground py-2">
This task no longer accepts submissions
</p>
)}
</div> </div>
</div> </div>
); );
@@ -498,7 +504,7 @@ const ViewTask = () => {
<div className="flex items-center gap-4 text-sm [&_svg]:size-4"> <div className="flex items-center gap-4 text-sm [&_svg]:size-4">
{task?.deadline && ( {task?.deadline && (
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<Clock /> Due {formatDate(task.deadline)} <Clock /> Due {formatDateTime(task.deadline)}
</span> </span>
)} )}
</div> </div>
@@ -623,6 +629,7 @@ const ViewTask = () => {
onFileClick={(file) => setPreviewFile(file)} onFileClick={(file) => setPreviewFile(file)}
requiresReview={requiresReview} requiresReview={requiresReview}
hasTextRequirement={hasTextReq} hasTextRequirement={hasTextReq}
acceptsSubmissions={task?.accepts_submissions !== false}
/> />
</div> </div>
)} )}
@@ -638,6 +645,7 @@ const ViewTask = () => {
onFileClick={(file) => setPreviewFile(file)} onFileClick={(file) => setPreviewFile(file)}
requiresReview={requiresReview} requiresReview={requiresReview}
hasTextRequirement={hasTextReq} hasTextRequirement={hasTextReq}
acceptsSubmissions={task?.accepts_submissions !== false}
/> />
)} )}
<RequirementsStatusPanel <RequirementsStatusPanel
+2 -2
View File
@@ -26,7 +26,7 @@ import {
LaptopMinimal, Table, Lock, LaptopMinimal, Table, Lock,
} from 'lucide-react'; } from 'lucide-react';
import { Tabs, TabsList, TabsPanel, TabsTab } from '@/components/coss/tabs'; import { Tabs, TabsList, TabsPanel, TabsTab } from '@/components/coss/tabs';
import { formatDate } from '@/utils/table.util'; import { formatDateTime } from '@/utils/table.util';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import ClientTaskListTable from '../components/TaskListTable'; import ClientTaskListTable from '../components/TaskListTable';
@@ -109,7 +109,7 @@ const TaskCard = ({ task, onClick, locked, lockedBy, onLockedClick }) => {
</p> </p>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-auto pt-1 [&_svg]:size-3.5"> <div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-auto pt-1 [&_svg]:size-3.5">
<Calendar /> <Calendar />
{task.deadline ? `Due ${formatDate(task.deadline)}` : 'No due date'} {task.deadline ? `Due ${formatDateTime(task.deadline)}` : 'No due date'}
</div> </div>
{locked && ( {locked && (
<p className="text-xs text-muted-foreground">Tap to see what's required to unlock this task.</p> <p className="text-xs text-muted-foreground">Tap to see what's required to unlock this task.</p>
+6
View File
@@ -98,6 +98,12 @@ export function formatDate(value) {
catch { return 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]. // Human-readable labels for boolean-backed enum fields — [falseLabel, trueLabel].
// Shared by renderCell (table cells) and ColumnFilter (filter dropdown), so a // Shared by renderCell (table cells) and ColumnFilter (filter dropdown), so a
// "true"/"false" boolean column never surfaces its raw value to the admin. // "true"/"false" boolean column never surfaces its raw value to the admin.