This commit is contained in:
rgrgogu
2026-06-24 13:43:28 +08:00
parent 93c3c688ca
commit b03b204861
35 changed files with 4238 additions and 2 deletions
@@ -0,0 +1,135 @@
import { useEffect, useState } from "react";
import { Check, Minus } from "lucide-react";
import { useStaffScores } from "@/contexts/StaffScoreContext";
import { cn } from "@/lib/utils";
/**
* CompletionMatrix
* Shows a grid of members × tasks with check/minus icons per cell.
*
* @param {Array} taskLists - Task list objects from the group (each with task_list_id + name)
*/
export default function CompletionMatrix({ taskLists = [] }) {
const { progress, loading, fetchTaskListProgress, clearProgress } = useStaffScores();
const [selectedId, setSelectedId] = useState(taskLists[0]?.task_list_id ?? null);
useEffect(() => {
if (selectedId) fetchTaskListProgress(selectedId);
return () => clearProgress();
}, [selectedId]);
if (taskLists.length === 0) {
return (
<p className="text-sm text-muted-foreground">
No task lists assigned to this group.
</p>
);
}
return (
<div className="space-y-4">
{/* Task list selector — only shown when there are multiple */}
{taskLists.length > 1 && (
<div className="flex flex-wrap gap-2">
{taskLists.map((tl) => (
<button
key={tl.task_list_id}
onClick={() => setSelectedId(tl.task_list_id)}
className={cn(
"text-xs px-3 py-1.5 rounded-md border transition-colors",
selectedId === tl.task_list_id
? "bg-primary text-primary-foreground border-primary"
: "border-border hover:bg-muted"
)}
>
{tl.name}
</button>
))}
</div>
)}
{/* Loading */}
{loading && (
<div className="h-36 rounded-lg bg-muted/40 animate-pulse" />
)}
{/* Matrix table */}
{progress && !loading && (
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-muted/40">
<th className="text-left px-3 py-2.5 text-xs font-medium text-muted-foreground w-40 shrink-0">
Member
</th>
{progress.data[0]?.tasks.map(({ task }) => (
<th
key={task.task_id}
className="px-3 py-2.5 text-xs font-medium text-muted-foreground text-center max-w-28"
>
<span className="line-clamp-2 leading-tight">{task.name}</span>
</th>
))}
<th className="px-3 py-2.5 text-xs font-medium text-muted-foreground text-center">
Progress
</th>
</tr>
</thead>
<tbody className="divide-y">
{progress.data.map(({ user, tasks, completed_count, total_tasks }) => {
const fullName = user.personal_info?.name?.full_name ?? user.email ?? "";
const pct = total_tasks
? Math.round((completed_count / total_tasks) * 100)
: 0;
return (
<tr key={user.user_id} className="hover:bg-muted/20 transition-colors">
<td className="px-3 py-2.5">
<p className="text-xs font-medium truncate max-w-36">{fullName}</p>
</td>
{tasks.map(({ task, completion }) => (
<td key={task.task_id} className="px-3 py-2.5 text-center">
<div className="flex justify-center">
{completion.status === "completed" ? (
<div className="w-5 h-5 rounded-full bg-emerald-100 flex items-center justify-center">
<Check size={11} className="text-emerald-700" aria-label="Completed" />
</div>
) : (
<div className="w-5 h-5 rounded-full bg-muted flex items-center justify-center">
<Minus size={11} className="text-muted-foreground" aria-label="Not completed" />
</div>
)}
</div>
</td>
))}
<td className="px-3 py-2.5">
<div className="flex items-center gap-2 min-w-20">
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-emerald-500 transition-all duration-300"
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-xs text-muted-foreground shrink-0 w-10 text-right">
{completed_count}/{total_tasks}
</span>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{progress && !loading && progress.data.length === 0 && (
<p className="text-sm text-muted-foreground">
No members found for this task list.
</p>
)}
</div>
);
}
@@ -0,0 +1,95 @@
import { Users, CheckSquare, BarChart2 } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
const AVATAR_COLORS = [
"bg-emerald-100 text-emerald-800",
"bg-purple-100 text-purple-800",
"bg-amber-100 text-amber-800",
"bg-blue-100 text-blue-800",
"bg-pink-100 text-pink-800",
];
/**
* GroupTile
* @param {object} group - Group object from API
* @param {function} onClick - Click handler (navigate to group detail)
*/
export default function GroupTile({ group, onClick }) {
const members = group.members ?? [];
const taskLists = group.taskLists ?? [];
// Avg completion across all tasks in all task lists
const allTasks = taskLists.flatMap((tl) => tl.tasks ?? []);
const done = allTasks.filter((t) => t.status === "completed").length;
const pct = allTasks.length ? Math.round((done / allTasks.length) * 100) : 0;
// First 3 member initials for avatar stack
const avatarSlice = members.slice(0, 3).map((m) => {
const name = m.personal_info?.name?.full_name ?? m.email ?? "";
return name.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2);
});
const overflow = members.length - 3;
return (
<Card
className="cursor-pointer hover:border-border/80 transition-colors"
onClick={onClick}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && onClick?.()}
>
<CardContent className="p-4">
{/* Header */}
<div className="flex items-start justify-between gap-2 mb-3">
<div className="min-w-0">
<p className="text-sm font-medium truncate">{group.name}</p>
{group.description && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">
{group.description}
</p>
)}
</div>
{group.group_code && (
<Badge variant="secondary" className="text-[10px] shrink-0">
{group.group_code}
</Badge>
)}
</div>
{/* Avatar stack */}
<div className="flex -space-x-1.5 mb-3">
{avatarSlice.map((init, i) => (
<div
key={i}
className={`w-6 h-6 rounded-full border-2 border-background flex items-center justify-center text-[10px] font-medium ${AVATAR_COLORS[i % AVATAR_COLORS.length]}`}
>
{init}
</div>
))}
{overflow > 0 && (
<div className="w-6 h-6 rounded-full border-2 border-background bg-muted flex items-center justify-center text-[10px] text-muted-foreground">
+{overflow}
</div>
)}
{members.length === 0 && (
<p className="text-xs text-muted-foreground">No members yet</p>
)}
</div>
{/* Footer meta */}
<div className="flex items-center gap-4 text-xs text-muted-foreground border-t pt-3">
<span className="flex items-center gap-1">
<Users size={12} aria-hidden /> {members.length} member{members.length !== 1 ? "s" : ""}
</span>
<span className="flex items-center gap-1">
<CheckSquare size={12} aria-hidden /> {taskLists.length} list{taskLists.length !== 1 ? "s" : ""}
</span>
<span className="flex items-center gap-1">
<BarChart2 size={12} aria-hidden /> {pct}% done
</span>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,101 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
function formatDate(iso) {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("en-PH", {
year: "numeric",
month: "short",
day: "numeric",
});
}
function InfoRow({ label, value }) {
return (
<div className="flex gap-3 py-2 border-b last:border-0 text-sm">
<span className="text-muted-foreground w-32 shrink-0">{label}</span>
<span className="text-foreground break-all">{value || "—"}</span>
</div>
);
}
export default function MemberDetailTabs({ member }) {
const info = member.personal_info ?? {};
const name = info.name ?? {};
const phones = info.phone_number ?? [];
const addresses = info.addresses ?? [];
return (
<Tabs defaultValue="personal">
<TabsList className="w-full">
<TabsTrigger value="personal" className="flex-1">Personal details</TabsTrigger>
<TabsTrigger value="activity" className="flex-1">User activity</TabsTrigger>
</TabsList>
{/* Personal details */}
<TabsContent value="personal" className="mt-3 space-y-0">
<InfoRow label="Full name" value={name.full_name} />
<InfoRow label="Given name" value={name.given_name} />
<InfoRow label="Last name" value={name.last_name} />
<InfoRow label="Middle name" value={name.middle_name} />
<InfoRow label="Extension" value={name.extension_name} />
<InfoRow label="Date of birth" value={formatDate(info.date_of_birth)} />
<InfoRow label="Occupation" value={info.occupation} />
<InfoRow
label="Phone"
value={
phones.length
? phones.map((p) => `+${p.country_code} ${p.number} (${p.phone_type})`).join(", ")
: null
}
/>
<InfoRow label="Email" value={member.email} />
<InfoRow
label="Address"
value={
addresses.length
? addresses.map((a) => a.full_address).join("; ")
: null
}
/>
{/* Account info section */}
<div className="pt-3 mt-3 border-t space-y-0">
<div className="flex gap-3 py-2 border-b text-sm">
<span className="text-muted-foreground w-32 shrink-0">Account type</span>
<Badge variant={member.acc_type === "staff" ? "default" : "secondary"} className="text-xs">
{member.acc_type}
</Badge>
</div>
<div className="flex gap-3 py-2 border-b text-sm">
<span className="text-muted-foreground w-32 shrink-0">Status</span>
<span className={`inline-flex items-center gap-1.5 text-xs font-medium ${member.is_active ? "text-emerald-700" : "text-muted-foreground"}`}>
<span className={`w-1.5 h-1.5 rounded-full ${member.is_active ? "bg-emerald-500" : "bg-muted-foreground"}`} />
{member.is_active ? "Active" : "Inactive"}
</span>
</div>
<InfoRow label="Joined group" value={formatDate(member.UserGroupMember?.joined_at)} />
</div>
</TabsContent>
{/* Activity */}
<TabsContent value="activity" className="mt-3">
<div className="grid grid-cols-3 gap-3 mb-4">
{[
{ label: "Tasks assigned", value: "—" },
{ label: "Tasks completed", value: "—" },
{ label: "Last active", value: "—" },
].map(({ label, value }) => (
<div key={label} className="bg-muted/40 rounded-md p-3 text-center">
<p className="text-lg font-semibold">{value}</p>
<p className="text-xs text-muted-foreground mt-0.5">{label}</p>
</div>
))}
</div>
<p className="text-sm text-muted-foreground text-center py-6">
No activity recorded yet.
</p>
</TabsContent>
</Tabs>
);
}
@@ -0,0 +1,54 @@
import { cn } from "@/lib/utils";
const AVATAR_COLORS = [
"bg-emerald-100 text-emerald-800",
"bg-purple-100 text-purple-800",
"bg-amber-100 text-amber-800",
"bg-blue-100 text-blue-800",
"bg-pink-100 text-pink-800",
];
/**
* MemberRow
* @param {object} member - User object from API
* @param {number} colorIndex - Index to pick avatar color from palette
*/
export default function MemberRow({ member, colorIndex = 0 }) {
const fullName = member.personal_info?.name?.full_name ?? member.email ?? "";
const initials = fullName
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2) || "?";
const color = AVATAR_COLORS[colorIndex % AVATAR_COLORS.length];
return (
<div className="flex items-center gap-3 px-4 py-3">
{/* Avatar */}
<div className={cn(
"w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium shrink-0",
color
)}>
{initials}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{fullName}</p>
<p className="text-xs text-muted-foreground truncate">{member.email}</p>
</div>
{/* Active badge */}
<span className={cn(
"text-[11px] font-medium px-2 py-0.5 rounded shrink-0",
member.is_active
? "bg-emerald-50 text-emerald-800"
: "bg-muted text-muted-foreground"
)}>
{member.is_active ? "active" : "inactive"}
</span>
</div>
);
}
@@ -0,0 +1,31 @@
import { cn } from "@/lib/utils";
/**
* ScoreBadge
* Shows a score as a colored pill — green if passed, red if failed, gray if no attempt.
*
* @param {number|null} score - Score as a percentage (0–100), or null
* @param {number} passingScore - Minimum passing score (default 70)
*/
export default function ScoreBadge({ score, passingScore = 70 }) {
if (score === null || score === undefined) {
return (
<span className="text-xs font-medium px-2.5 py-0.5 rounded-full bg-muted text-muted-foreground">
—
</span>
);
}
const passed = parseFloat(score) >= passingScore;
return (
<span className={cn(
"text-xs font-medium px-2.5 py-0.5 rounded-full",
passed
? "bg-emerald-100 text-emerald-800"
: "bg-red-100 text-red-800"
)}>
{parseFloat(score).toFixed(1)}%
</span>
);
}
@@ -0,0 +1,256 @@
import { useState, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { StatGrid } from "@/components/generic/Dashboard/StatGrid";
import { PieBreakdown } from "@/components/generic/Dashboard/PieBreakdown";
import { BarBreakdown } from "@/components/generic/Dashboard/BarBreakdown";
const PAGE_SIZE = 10;
const STATUS_LABEL = {
completed: "Completed",
in_progress: "In progress",
pending: "Pending",
not_started: "Not started",
};
function formatDate(iso) {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("en-PH", {
year: "numeric",
month: "short",
day: "numeric",
});
}
function StatusBadge({ status }) {
const map = {
completed: "bg-emerald-100 text-emerald-800",
in_progress: "bg-blue-100 text-blue-800",
pending: "bg-amber-100 text-amber-800",
not_started: "bg-slate-100 text-slate-700",
};
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${map[status] ?? map.not_started}`}>
{STATUS_LABEL[status] ?? status ?? "Not started"}
</span>
);
}
export default function TaskListDetail({ taskList }) {
const tasks = taskList.tasks ?? [];
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
// ── Derived stats ───────────────────────────────────────────────────────
const counts = useMemo(() => {
const total = tasks.length;
const completed = tasks.filter((t) => t.status === "completed").length;
const inProgress = tasks.filter((t) => t.status === "in_progress").length;
const pending = tasks.filter((t) => t.status === "pending").length;
const notStarted = tasks.filter((t) => !t.status || t.status === "not_started").length;
const pct = total ? Math.round((completed / total) * 100) : 0;
return { total, completed, inProgress, pending, notStarted, pct };
}, [tasks]);
// StatGrid data
const summaryStats = [
{ key: "total", label: "Total tasks", value: counts.total },
{ key: "completed", label: "Completed", value: counts.completed },
{ key: "in_progress", label: "In progress", value: counts.inProgress },
{ key: "pending", label: "Pending", value: counts.pending },
{ key: "not_started", label: "Not started", value: counts.notStarted },
];
// PieBreakdown: status distribution
const pieData = useMemo(() => [
{ label: "Completed", value: counts.completed },
{ label: "In progress", value: counts.inProgress },
{ label: "Pending", value: counts.pending },
{ label: "Not started", value: counts.notStarted },
].filter((d) => d.value > 0), [counts]);
// BarBreakdown: per-task completion (first 20)
const barData = useMemo(() =>
tasks.slice(0, 20).map((t, i) => ({
label: t.name
? (t.name.length > 14 ? t.name.slice(0, 12) + "…" : t.name)
: `Task ${i + 1}`,
value: t.status === "completed" ? 1 : 0,
})),
[tasks]
);
// ── Tasks DataTable ─────────────────────────────────────────────────────
const filtered = useMemo(() => {
const q = search.toLowerCase();
if (!q) return tasks;
return tasks.filter((t) =>
(t.name ?? "").toLowerCase().includes(q) ||
(t.status ?? "").toLowerCase().includes(q)
);
}, [tasks, search]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
const slice = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
return (
<Tabs defaultValue="summary">
<TabsList className="w-full">
<TabsTrigger value="summary" className="flex-1">Summary</TabsTrigger>
<TabsTrigger value="tasks" className="flex-1">Tasks ({tasks.length})</TabsTrigger>
</TabsList>
{/* ── Summary ─────────────────────────────────────────────────────── */}
<TabsContent value="summary" className="mt-4 space-y-4">
{/* Stat cards via StatGrid */}
<StatGrid stats={summaryStats} />
{/* Overall progress bar */}
<div>
<div className="flex justify-between text-xs text-muted-foreground mb-1">
<span>Overall completion</span>
<span>{counts.pct}%</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-emerald-500 transition-all"
style={{ width: `${counts.pct}%` }}
/>
</div>
</div>
{tasks.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No tasks in this list yet.
</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Status breakdown pie */}
<PieBreakdown
label="Status breakdown"
data={pieData}
height={200}
/>
{/* Per-task bar (horizontal) */}
<BarBreakdown
label={`Task completion${tasks.length > 20 ? " (first 20)" : ""}`}
data={barData}
height={Math.max(160, barData.length * 32 + 40)}
yAxisWidth={100}
/>
</div>
)}
{/* Meta */}
<div className="text-xs text-muted-foreground space-y-1 pt-2 border-t">
<p>Created: <span className="text-foreground">{formatDate(taskList.createdAt)}</span></p>
<p>Assigned: <span className="text-foreground">{formatDate(taskList.TaskListGroup?.assignedAt)}</span></p>
</div>
</TabsContent>
{/* ── Tasks DataTable ──────────────────────────────────────────────── */}
<TabsContent value="tasks" className="mt-4 space-y-3">
<Input
placeholder="Search tasks…"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
className="h-8 text-sm w-56"
/>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">#</TableHead>
<TableHead>Task name</TableHead>
<TableHead>Status</TableHead>
<TableHead>Due date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{slice.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-sm text-muted-foreground py-8">
{tasks.length === 0 ? "No tasks in this list." : "No tasks match your search."}
</TableCell>
</TableRow>
) : (
slice.map((task, i) => (
<TableRow key={task.task_id ?? i}>
<TableCell className="text-muted-foreground text-xs">
{(safePage - 1) * PAGE_SIZE + i + 1}
</TableCell>
<TableCell className="font-medium text-sm">
{task.name ?? `Task ${i + 1}`}
</TableCell>
<TableCell>
<StatusBadge status={task.status} />
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDate(task.due_date)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{filtered.length > 0 && (
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{`${(safePage - 1) * PAGE_SIZE + 1}–${Math.min(safePage * PAGE_SIZE, filtered.length)} of ${filtered.length}`}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline" size="sm" className="h-7 px-2"
disabled={safePage === 1}
onClick={() => setPage(safePage - 1)}
>
Previous
</Button>
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter((p) => p === 1 || p === totalPages || Math.abs(p - safePage) <= 1)
.reduce((acc, p, i, arr) => {
if (i > 0 && p - arr[i - 1] > 1) acc.push("...");
acc.push(p);
return acc;
}, [])
.map((p, idx) =>
p === "..." ? (
<span key={`e-${idx}`} className="px-1">…</span>
) : (
<Button
key={p}
variant={p === safePage ? "default" : "outline"}
size="sm"
className="h-7 w-7 p-0"
onClick={() => setPage(p)}
>
{p}
</Button>
)
)}
<Button
variant="outline" size="sm" className="h-7 px-2"
disabled={safePage === totalPages}
onClick={() => setPage(safePage + 1)}
>
Next
</Button>
</div>
</div>
)}
</TabsContent>
</Tabs>
);
}
@@ -0,0 +1,202 @@
/***********************************************************************************************************************************************************************
* File Name: TaskListTable.jsx (staff)
* Type of Program: Component
* Description: DataTable-based task list table for staff, scoped to their groups.
* Mirrors the admin TaskListTable pattern exactly.
***********************************************************************************************************************************************************************/
import { useMemo, useRef, useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useStaffTasks } from "@/contexts/StaffTaskContext";
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 { buildDataColumns, columnPinning } from "../config/task_list/columns.config";
import { buildToolbarActions } from "../config/task_list/toolbar.config";
import { buildSelectionActions } from "../config/task_list/selection.config";
import { buildRowActions } from "../config/task_list/rowActions.config";
import { getTimestamp } from "@/utils/timestamp.util";
export default function TaskListTable() {
const navigate = useNavigate();
const {
taskLists, attributes, pagination, setPagination, loading,
fetchTaskLists, fetchArchivedTaskLists, fetchTaskListFieldValues,
archiveTaskList, restoreTaskList,
bulkArchiveTaskLists, bulkRestoreTaskLists,
} = useStaffTasks();
const [showArchived, setShowArchived] = useState(false);
const [archiveTarget, setArchiveTarget] = useState(null);
const [restoreTarget, setRestoreTarget] = useState(null);
const [archiveIds, setArchiveIds] = useState(null);
const [restoreIds, setRestoreIds] = useState(null);
const tableRefsRef = useRef({
getFilters: () => [],
getSort: () => [],
resetSelection: () => {},
tableInstance: null,
});
const handleRefsReady = (refs) => {
tableRefsRef.current = refs;
};
// ── Toggle archived view ──────────────────────────────────────────────────
const handleToggleArchived = useCallback(() => {
const next = !showArchived;
setShowArchived(next);
fetchTaskLists({
page: 1,
limit: pagination?.limit ?? 10,
filters: tableRefsRef.current.getFilters(),
sort: tableRefsRef.current.getSort(),
archived: next,
});
}, [showArchived, pagination, fetchTaskLists]);
// ── Refetch after any archive/restore action ──────────────────────────────
const handleSuccess = () => {
setArchiveTarget(null);
setRestoreTarget(null);
setArchiveIds(null);
setRestoreIds(null);
tableRefsRef.current.resetSelection?.();
const fetcher = showArchived ? fetchArchivedTaskLists : fetchTaskLists;
fetcher({
page: 1,
limit: pagination?.limit ?? 10,
filters: tableRefsRef.current.getFilters(),
sort: tableRefsRef.current.getSort(),
});
};
// ── Export config ─────────────────────────────────────────────────────────
const exportConfig = useMemo(() => ({
allData: taskLists,
attributes,
filename: `${getTimestamp()}_TaskLists`,
sheetName: "Task Lists",
}), [taskLists, attributes]);
// ── Row actions ───────────────────────────────────────────────────────────
const rowActions = buildRowActions({
navigate,
onArchive: (row) => setArchiveTarget(row),
onRestore: (row) => setRestoreTarget(row),
showArchived,
});
// ── Toolbar ───────────────────────────────────────────────────────────────
const toolbarActions = buildToolbarActions({
fetchTaskLists,
fetchArchivedTaskLists,
pagination,
navigate,
exportConfig,
showArchived,
onToggleArchived: handleToggleArchived,
getFilters: () => tableRefsRef.current.getFilters(),
getSort: () => tableRefsRef.current.getSort(),
getTableInstance: () => tableRefsRef.current.tableInstance,
});
// ── Selection ─────────────────────────────────────────────────────────────
const selectionActions = buildSelectionActions({
exportConfig,
showArchived,
onBulkArchive: (ids) => setArchiveIds(ids),
onBulkRestore: (ids) => setRestoreIds(ids),
getTableInstance: () => tableRefsRef.current.tableInstance,
});
// ── Columns ───────────────────────────────────────────────────────────────
const columns = useMemo(
() => buildDataColumns(attributes, rowActions),
[attributes, rowActions]
);
return (
<>
<DataTable
title="Task Lists"
data={taskLists}
columns={columns}
attributes={attributes}
pagination={pagination}
setPagination={setPagination}
loading={loading}
onFetch={fetchTaskLists}
onFetchFilterData={fetchTaskListFieldValues}
onRefsReady={handleRefsReady}
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
<FilterSheet
open={open}
onOpenChange={onOpenChange}
column={column}
attr={attr}
data={data}
loading={loading}
/>
)}
columnPinning={columnPinning}
toolbarActions={toolbarActions}
selectionActions={selectionActions}
recordLabel="task list"
emptyMessage="No task lists found."
/>
{/* Single archive */}
<ArchiveDialog
open={!!archiveTarget}
onOpenChange={(v) => !v && setArchiveTarget(null)}
entity={archiveTarget}
entityLabel="Task List"
getName={(r) => r?.name}
onArchive={(entity) => archiveTaskList(entity?.task_list_id)}
loading={loading}
onSuccess={handleSuccess}
/>
{/* Single restore */}
<RestoreDialog
open={!!restoreTarget}
onOpenChange={(v) => !v && setRestoreTarget(null)}
entity={restoreTarget}
entityLabel="Task List"
getName={(r) => r?.name}
onRestore={(entity) => restoreTaskList(entity?.task_list_id)}
loading={loading}
onSuccess={handleSuccess}
/>
{/* Bulk archive */}
<ArchiveDialog
open={!!archiveIds}
onOpenChange={(v) => !v && setArchiveIds(null)}
ids={archiveIds ?? []}
entityLabel="Task List"
onArchive={({ ids }) => bulkArchiveTaskLists(ids)}
loading={loading}
onSuccess={handleSuccess}
/>
{/* Bulk restore */}
<RestoreDialog
open={!!restoreIds}
onOpenChange={(v) => !v && setRestoreIds(null)}
ids={restoreIds ?? []}
entityLabel="Task List"
onRestore={({ ids }) => bulkRestoreTaskLists(ids)}
loading={loading}
onSuccess={handleSuccess}
/>
</>
);
}
@@ -0,0 +1,164 @@
import { useState, useMemo } from "react";
import { Eye } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { PieBreakdown } from "@/components/generic/Dashboard/PieBreakdown";
import { BarBreakdown } from "@/components/generic/Dashboard/BarBreakdown";
import TaskListDetail from "./TaskListDetail";
function formatDate(iso) {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("en-PH", {
year: "numeric",
month: "short",
day: "numeric",
});
}
export default function TaskListsTab({ taskLists = [] }) {
const [selected, setSelected] = useState(null);
// PieBreakdown: overall task completion status across all lists
const completionPieData = useMemo(() => {
let completed = 0, inProgress = 0, pending = 0, notStarted = 0;
taskLists.forEach((tl) => {
(tl.tasks ?? []).forEach((t) => {
if (t.status === "completed") completed++;
else if (t.status === "in_progress") inProgress++;
else if (t.status === "pending") pending++;
else notStarted++;
});
});
return [
{ label: "Completed", value: completed },
{ label: "In progress", value: inProgress },
{ label: "Pending", value: pending },
{ label: "Not started", value: notStarted },
].filter((d) => d.value > 0);
}, [taskLists]);
// BarBreakdown: total tasks per list
const tasksPerListData = useMemo(() =>
taskLists.map((tl) => ({
label: tl.name.length > 20 ? tl.name.slice(0, 18) + "…" : tl.name,
value: tl.tasks?.length ?? 0,
})),
[taskLists]
);
const hasAnyTasks = taskLists.some((tl) => (tl.tasks?.length ?? 0) > 0);
return (
<div className="space-y-4">
{/* Charts — only shown when there's actual task data */}
{taskLists.length > 0 && hasAnyTasks && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<PieBreakdown
label="Task completion status"
data={completionPieData}
height={220}
/>
<BarBreakdown
label="Tasks per list"
data={tasksPerListData}
height={Math.max(160, tasksPerListData.length * 40 + 40)}
yAxisWidth={120}
/>
</div>
)}
{/* Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Tasks</TableHead>
<TableHead className="w-48">Progress</TableHead>
<TableHead>Assigned</TableHead>
<TableHead className="w-10"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{taskLists.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-sm text-muted-foreground py-8">
No task lists assigned to this group.
</TableCell>
</TableRow>
) : (
taskLists.map((tl) => {
const total = tl.tasks?.length ?? 0;
const done = tl.tasks?.filter((t) => t.status === "completed").length ?? 0;
const pct = total ? Math.round((done / total) * 100) : 0;
return (
<TableRow
key={tl.task_list_id}
className="cursor-pointer"
onClick={() => setSelected(tl)}
>
<TableCell className="font-medium">{tl.name}</TableCell>
<TableCell className="text-muted-foreground text-sm">
{total} task{total !== 1 ? "s" : ""}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-emerald-500 transition-all"
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-xs text-muted-foreground w-8 text-right">{pct}%</span>
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDate(tl.TaskListGroup?.assignedAt)}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => { e.stopPropagation(); setSelected(tl); }}
>
<Eye size={14} />
</Button>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
{/* Task list detail dialog */}
<Dialog open={!!selected} onOpenChange={(open) => !open && setSelected(null)}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{selected?.name}</DialogTitle>
{selected?.description && (
<p className="text-sm text-muted-foreground">{selected.description}</p>
)}
</DialogHeader>
{selected && <TaskListDetail taskList={selected} />}
</DialogContent>
</Dialog>
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
import { Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { format } from "date-fns";
const REQ_TYPE_STYLES = {
visit_link: "bg-blue-50 text-blue-800",
upload_file: "bg-purple-50 text-purple-800",
read_course: "bg-emerald-50 text-emerald-800",
read_unit: "bg-amber-50 text-amber-800",
read_lesson: "bg-pink-50 text-pink-800",
};
const REQ_TYPE_LABELS = {
visit_link: "link",
upload_file: "upload",
read_course: "course",
read_unit: "unit",
read_lesson: "lesson",
};
const STATUS_STYLES = {
pending: "bg-muted text-muted-foreground",
in_progress: "bg-blue-50 text-blue-800",
completed: "bg-emerald-50 text-emerald-800",
overdue: "bg-red-50 text-red-800",
};
/**
* TaskRow
* @param {object} task - Task object from API
* @param {function} onEdit - Optional edit handler (shows edit button)
* @param {function} onDelete - Optional delete handler (shows delete button)
*/
export default function TaskRow({ task, onEdit, onDelete }) {
const reqType = task.requirements?.[0]?.type;
return (
<div className="flex items-center gap-3 py-2.5">
{/* Name */}
<p className="flex-1 text-sm truncate min-w-0">{task.name}</p>
{/* Requirement type badge */}
{reqType && (
<span className={cn(
"text-[11px] font-medium px-2 py-0.5 rounded shrink-0",
REQ_TYPE_STYLES[reqType]
)}>
{REQ_TYPE_LABELS[reqType]}
</span>
)}
{/* Status badge */}
{task.status && (
<span className={cn(
"text-[11px] font-medium px-2 py-0.5 rounded shrink-0",
STATUS_STYLES[task.status]
)}>
{task.status.replace("_", " ")}
</span>
)}
{/* Deadline */}
{task.deadline && (
<span className="text-xs text-muted-foreground w-14 text-right shrink-0">
{format(new Date(task.deadline), "MMM d")}
</span>
)}
{/* Edit / Delete */}
{(onEdit || onDelete) && (
<div className="flex items-center gap-0.5 shrink-0">
{onEdit && (
<Button
variant="ghost" size="icon" className="h-6 w-6"
onClick={(e) => { e.stopPropagation(); onEdit(); }}
title="Edit task"
>
<Pencil size={11} />
</Button>
)}
{onDelete && (
<Button
variant="ghost" size="icon"
className="h-6 w-6 text-destructive hover:text-destructive"
onClick={(e) => { e.stopPropagation(); onDelete(); }}
title="Delete task"
>
<Trash2 size={11} />
</Button>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,175 @@
import { useMemo, useRef, useState, useCallback } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useStaffGroups } from "@/contexts/StaffGroupContext";
import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
import DataTable from "@/components/generic/Table/DataTable";
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
import { buildDataColumns, columnPinning } from "../../config/members/columns.config";
import { buildToolbarActions } from "../../config/members/toolbar.config";
import { buildSelectionActions } from "../../config/members/selection.config";
import { buildRowActions } from "../../config/members/rowActions.config";
import MemberDetailTabs from "../MemberDetailTabs";
import { getTimestamp } from "@/utils/timestamp.util";
// ── Helpers ───────────────────────────────────────────────────────────────────
function getInitials(member) {
const first = member.personal_info?.name?.given_name?.[0] ?? "";
const last = member.personal_info?.name?.last_name?.[0] ?? "";
return (first + last).toUpperCase() || "??";
}
function getFullName(member) {
const n = member.personal_info?.name;
if (!n) return member.email;
return `${n.given_name ?? ""} ${n.last_name ?? ""}`.trim();
}
const AVATAR_COLORS = [
"bg-blue-100 text-blue-800",
"bg-emerald-100 text-emerald-800",
"bg-violet-100 text-violet-800",
"bg-amber-100 text-amber-800",
"bg-rose-100 text-rose-800",
"bg-cyan-100 text-cyan-800",
];
export default function MembersTable() {
const navigate = useNavigate();
const { groupId } = useParams();
const {
members,
memberAttributes,
memberPagination,
setMemberPagination,
membersLoading,
fetchGroupMembers,
fetchGroupMemberFieldValues,
} = useStaffGroups();
const [selected, setSelected] = useState(null);
const tableRefsRef = useRef({
getFilters: () => [],
getSort: () => [],
resetSelection: () => { },
tableInstance: null,
});
const handleRefsReady = (refs) => {
tableRefsRef.current = refs;
};
const handleFetch = useCallback(
(params) => fetchGroupMembers(groupId, params),
[groupId, fetchGroupMembers]
);
const handleFetchFilterData = useCallback(
(col, params) => fetchGroupMemberFieldValues(groupId, col, params),
[groupId, fetchGroupMemberFieldValues]
);
// ── Export config ───────────────────────────────────────────────────────────
const exportConfig = useMemo(() => ({
allData: members,
attributes: memberAttributes,
filename: `${getTimestamp()}_GroupMembers`,
sheetName: "Members",
}), [members, memberAttributes]);
// ── Row actions ─────────────────────────────────────────────────────────────
const rowActions = useMemo(() => buildRowActions({
onView: (member) => setSelected(member),
}), []);
// ── Toolbar ─────────────────────────────────────────────────────────────────
const toolbarActions = useMemo(() => buildToolbarActions({
fetchMembers: handleFetch,
memberPagination,
exportConfig,
getFilters: () => tableRefsRef.current.getFilters(),
getSort: () => tableRefsRef.current.getSort(),
getTableInstance: () => tableRefsRef.current.tableInstance,
}), [handleFetch, memberPagination, exportConfig]);
// ── Selection ───────────────────────────────────────────────────────────────
const selectionActions = buildSelectionActions({
exportConfig,
getTableInstance: () => tableRefsRef.current.tableInstance,
});
// ── Columns ─────────────────────────────────────────────────────────────────
const columns = useMemo(
() => buildDataColumns(memberAttributes, rowActions),
[memberAttributes, rowActions]
);
return (
<>
<DataTable
title="Members"
data={members}
columns={columns}
attributes={memberAttributes}
pagination={memberPagination}
setPagination={setMemberPagination}
loading={membersLoading}
onFetch={handleFetch}
onFetchFilterData={handleFetchFilterData}
onRefsReady={handleRefsReady}
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
<FilterSheet
open={open}
onOpenChange={onOpenChange}
column={column}
attr={attr}
data={data}
loading={loading}
/>
)}
columnPinning={columnPinning}
toolbarActions={toolbarActions}
selectionActions={selectionActions}
recordLabel="member"
emptyMessage="No members found."
onRowClick={(row) => setSelected(row.original)}
/>
{/* Member detail dialog */}
<Dialog open={!!selected} onOpenChange={(open) => !open && setSelected(null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<div className="flex items-center gap-3">
{selected && (
<div
className={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold ${AVATAR_COLORS[
members.findIndex((m) => m.user_id === selected.user_id) % AVATAR_COLORS.length
]
}`}
>
{getInitials(selected)}
</div>
)}
<div>
<DialogTitle className="text-base">
{selected && getFullName(selected)}
</DialogTitle>
<p className="text-xs text-muted-foreground mt-0.5">
{selected?.personal_info?.occupation} · {selected?.acc_type}
</p>
</div>
</div>
</DialogHeader>
{selected && <MemberDetailTabs member={selected} />}
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,43 @@
// config/task_list/columns.config.jsx
// Column definitions and pinning config for the Staff Task List table.
import { buildColumns } from "@/utils/table.util";
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
import { OverflowBadges } from "@/components/generic/OverflowBadges";
export const columnPinning = {
right: ["actions"],
left: [],
};
const cellOverrides = {
// Example: render assigned groups as badges
groups: (info) => (
<OverflowBadges
items={info.getValue() ?? []}
keyKey="group_id"
labelKey="group_code"
dialogTitleKey="name"
dialogTitle="All groups"
badgeClassName="text-xs font-mono"
/>
),
};
/**
* Builds the full column array for the Staff Task List table.
*
* @param {Array} attributes Field definitions from the server (drives data columns)
* @param {Array} rowActions Row-level kebab action definitions
* @returns {Array} TanStack column definitions
*/
export function buildDataColumns(attributes, rowActions) {
const visibleAttributes = attributes.filter((a) => !a.hidden);
return [
buildSelectionColumn(),
...buildColumns(visibleAttributes, { cellOverrides }),
buildRowActionsColumn(rowActions, { dropdownLabel: "Task List Actions" }),
];
}
@@ -0,0 +1,56 @@
// config/task_list/rowActions.config.jsx
// Row-level kebab menu actions for the Staff Task List table.
// Staff can view, edit, view tasks, archive, and restore — scoped to their groups.
import { Eye, Pencil, NotebookPen, Archive, RotateCcw } from "lucide-react";
/**
* @param {Object} deps
* @param {Function} deps.navigate react-router navigate fn
* @param {Function} deps.onArchive called with the row when Archive is clicked
* @param {Function} deps.onRestore called with the row when Restore is clicked
* @param {boolean} deps.showArchived toggles archive vs restore action visibility
*/
export function buildRowActions({ navigate, onArchive, onRestore, showArchived }) {
return [
{
key: "view",
label: "View Info",
icon: <Eye className="size-4" />,
onClick: (row) => navigate(`${row.task_list_id}/view`),
},
{
key: "edit",
label: "Edit Info",
icon: <Pencil className="size-4" />,
onClick: (row) => navigate(`${row.task_list_id}/edit`),
hidden: () => showArchived,
},
{
key: "tasks",
label: "View Tasks",
icon: <NotebookPen className="size-4" />,
onClick: (row) => navigate(`${row.task_list_id}/tasks`),
separator: true,
className: "text-sky-800",
},
{
key: "archive",
label: "Archive",
icon: <Archive className="size-4" />,
className: "text-destructive focus:text-destructive",
onClick: (row) => onArchive(row),
hidden: () => showArchived,
separator: true,
},
{
key: "restore",
label: "Restore",
icon: <RotateCcw className="size-4" />,
className: "text-emerald-600 focus:text-emerald-600",
onClick: (row) => onRestore(row),
hidden: () => !showArchived,
separator: true,
},
];
}
@@ -0,0 +1,51 @@
// config/task_list/selection.config.jsx
// Bulk selection actions for the Staff Task List table.
import { Download, Archive, RotateCcw } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util";
/**
* @param {Object} deps
* @param {Object} deps.exportConfig passed straight to exportTableToExcel
* @param {boolean} deps.showArchived drives archive vs restore label/icon
* @param {Function} deps.onBulkArchive called with selected task_list_id[]
* @param {Function} deps.onBulkRestore called with selected task_list_id[]
* @param {Function} deps.getTableInstance returns the TanStack table instance
*/
export function buildSelectionActions({
exportConfig,
showArchived,
onBulkArchive,
onBulkRestore,
getTableInstance,
}) {
return [
{
key: "export-selected",
label: "Export",
icon: <Download className="h-3.5 w-3.5" />,
onClick: (rows, table) =>
exportTableToExcel({
...exportConfig,
selectedRows: rows,
tableInstance: table ?? getTableInstance?.(),
}),
},
{
key: showArchived ? "restore-selected" : "archive-selected",
label: showArchived ? "Restore" : "Archive",
icon: showArchived ? (
<RotateCcw className="h-3.5 w-3.5" />
) : (
<Archive className="h-3.5 w-3.5" />
),
className:
"text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive",
onClick: (rows) => {
const ids = rows.map((r) => r.task_list_id).filter(Boolean);
if (!ids.length) return;
showArchived ? onBulkRestore?.(ids) : onBulkArchive?.(ids);
},
},
];
}
@@ -0,0 +1,78 @@
// config/task_list/toolbar.config.jsx
// Toolbar actions for the Staff Task List table.
import { RefreshCw, Download, Plus, Archive } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util";
/**
* @param {Object} deps
* @param {Function} deps.fetchTaskLists fetches active task lists
* @param {Function} deps.fetchArchivedTaskLists fetches archived task lists
* @param {Object} deps.pagination current pagination state
* @param {Object} deps.exportConfig passed straight to exportTableToExcel
* @param {Function} deps.navigate react-router navigate fn
* @param {boolean} deps.showArchived drives label and toggle behaviour
* @param {Function} deps.onToggleArchived flips showArchived in the page
* @param {Function} deps.getFilters returns active filter array from table
* @param {Function} deps.getSort returns active sort array from table
* @param {Function} deps.getTableInstance returns the TanStack table instance
*/
export function buildToolbarActions({
fetchTaskLists,
fetchArchivedTaskLists,
pagination,
exportConfig,
navigate,
showArchived,
onToggleArchived,
getFilters,
getSort,
getTableInstance,
}) {
return [
{
key: "refresh",
type: "button",
icon: <RefreshCw className="size-4" />,
label: "Refresh",
onClick: () => {
const fetcher = showArchived ? fetchArchivedTaskLists : fetchTaskLists;
fetcher({
page: 1,
limit: pagination?.limit ?? 10,
filters: getFilters?.() ?? [],
sort: getSort?.() ?? [],
});
},
},
{
key: "export",
type: "button",
icon: <Download className="size-4" />,
label: "Export",
onClick: (table) =>
exportTableToExcel({
...exportConfig,
tableInstance: table ?? getTableInstance?.(),
}),
},
{
key: "add-task-list",
type: "button",
icon: <Plus className="size-4" />,
label: "Create Task List",
variant: "default",
className: "text-primary-foreground",
onClick: () => navigate(`/staff/task-lists/create`),
},
{
key: "toggle-archived",
type: "button",
icon: <Archive className="size-4" />,
label: showArchived ? "Active Task Lists" : "Archived Task Lists",
variant: "secondary",
className: "border border-border",
onClick: onToggleArchived,
},
];
}
@@ -0,0 +1,43 @@
// config/task_list/columns.config.jsx
// Column definitions and pinning config for the Staff Task List table.
import { buildColumns } from "@/utils/table.util";
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
import { OverflowBadges } from "@/components/generic/OverflowBadges";
export const columnPinning = {
right: ["actions"],
left: [],
};
const cellOverrides = {
// Example: render assigned groups as badges
groups: (info) => (
<OverflowBadges
items={info.getValue() ?? []}
keyKey="group_id"
labelKey="group_code"
dialogTitleKey="name"
dialogTitle="All groups"
badgeClassName="text-xs font-mono"
/>
),
};
/**
* Builds the full column array for the Staff Task List table.
*
* @param {Array} attributes Field definitions from the server (drives data columns)
* @param {Array} rowActions Row-level kebab action definitions
* @returns {Array} TanStack column definitions
*/
export function buildDataColumns(attributes, rowActions) {
const visibleAttributes = attributes.filter((a) => !a.hidden);
return [
buildSelectionColumn(),
...buildColumns(visibleAttributes, { cellOverrides }),
buildRowActionsColumn(rowActions, { dropdownLabel: "Task List Actions" }),
];
}
@@ -0,0 +1,56 @@
// config/task_list/rowActions.config.jsx
// Row-level kebab menu actions for the Staff Task List table.
// Staff can view, edit, view tasks, archive, and restore — scoped to their groups.
import { Eye, Pencil, NotebookPen, Archive, RotateCcw } from "lucide-react";
/**
* @param {Object} deps
* @param {Function} deps.navigate react-router navigate fn
* @param {Function} deps.onArchive called with the row when Archive is clicked
* @param {Function} deps.onRestore called with the row when Restore is clicked
* @param {boolean} deps.showArchived toggles archive vs restore action visibility
*/
export function buildRowActions({ navigate, onArchive, onRestore, showArchived }) {
return [
{
key: "view",
label: "View Info",
icon: <Eye className="size-4" />,
onClick: (row) => navigate(`${row.task_list_id}/view`),
},
{
key: "edit",
label: "Edit Info",
icon: <Pencil className="size-4" />,
onClick: (row) => navigate(`${row.task_list_id}/edit`),
hidden: () => showArchived,
},
{
key: "tasks",
label: "View Tasks",
icon: <NotebookPen className="size-4" />,
onClick: (row) => navigate(`${row.task_list_id}/tasks`),
separator: true,
className: "text-sky-800",
},
{
key: "archive",
label: "Archive",
icon: <Archive className="size-4" />,
className: "text-destructive focus:text-destructive",
onClick: (row) => onArchive(row),
hidden: () => showArchived,
separator: true,
},
{
key: "restore",
label: "Restore",
icon: <RotateCcw className="size-4" />,
className: "text-emerald-600 focus:text-emerald-600",
onClick: (row) => onRestore(row),
hidden: () => !showArchived,
separator: true,
},
];
}
@@ -0,0 +1,51 @@
// config/task_list/selection.config.jsx
// Bulk selection actions for the Staff Task List table.
import { Download, Archive, RotateCcw } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util";
/**
* @param {Object} deps
* @param {Object} deps.exportConfig passed straight to exportTableToExcel
* @param {boolean} deps.showArchived drives archive vs restore label/icon
* @param {Function} deps.onBulkArchive called with selected task_list_id[]
* @param {Function} deps.onBulkRestore called with selected task_list_id[]
* @param {Function} deps.getTableInstance returns the TanStack table instance
*/
export function buildSelectionActions({
exportConfig,
showArchived,
onBulkArchive,
onBulkRestore,
getTableInstance,
}) {
return [
{
key: "export-selected",
label: "Export",
icon: <Download className="h-3.5 w-3.5" />,
onClick: (rows, table) =>
exportTableToExcel({
...exportConfig,
selectedRows: rows,
tableInstance: table ?? getTableInstance?.(),
}),
},
{
key: showArchived ? "restore-selected" : "archive-selected",
label: showArchived ? "Restore" : "Archive",
icon: showArchived ? (
<RotateCcw className="h-3.5 w-3.5" />
) : (
<Archive className="h-3.5 w-3.5" />
),
className:
"text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive",
onClick: (rows) => {
const ids = rows.map((r) => r.task_list_id).filter(Boolean);
if (!ids.length) return;
showArchived ? onBulkRestore?.(ids) : onBulkArchive?.(ids);
},
},
];
}
@@ -0,0 +1,78 @@
// config/task_list/toolbar.config.jsx
// Toolbar actions for the Staff Task List table.
import { RefreshCw, Download, Plus, Archive } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util";
/**
* @param {Object} deps
* @param {Function} deps.fetchTaskLists fetches active task lists
* @param {Function} deps.fetchArchivedTaskLists fetches archived task lists
* @param {Object} deps.pagination current pagination state
* @param {Object} deps.exportConfig passed straight to exportTableToExcel
* @param {Function} deps.navigate react-router navigate fn
* @param {boolean} deps.showArchived drives label and toggle behaviour
* @param {Function} deps.onToggleArchived flips showArchived in the page
* @param {Function} deps.getFilters returns active filter array from table
* @param {Function} deps.getSort returns active sort array from table
* @param {Function} deps.getTableInstance returns the TanStack table instance
*/
export function buildToolbarActions({
fetchTaskLists,
fetchArchivedTaskLists,
pagination,
exportConfig,
navigate,
showArchived,
onToggleArchived,
getFilters,
getSort,
getTableInstance,
}) {
return [
{
key: "refresh",
type: "button",
icon: <RefreshCw className="size-4" />,
label: "Refresh",
onClick: () => {
const fetcher = showArchived ? fetchArchivedTaskLists : fetchTaskLists;
fetcher({
page: 1,
limit: pagination?.limit ?? 10,
filters: getFilters?.() ?? [],
sort: getSort?.() ?? [],
});
},
},
{
key: "export",
type: "button",
icon: <Download className="size-4" />,
label: "Export",
onClick: (table) =>
exportTableToExcel({
...exportConfig,
tableInstance: table ?? getTableInstance?.(),
}),
},
{
key: "add-task-list",
type: "button",
icon: <Plus className="size-4" />,
label: "Create Task List",
variant: "default",
className: "text-primary-foreground",
onClick: () => navigate(`/staff/task-lists/create`),
},
{
key: "toggle-archived",
type: "button",
icon: <Archive className="size-4" />,
label: showArchived ? "Active Task Lists" : "Archived Task Lists",
variant: "secondary",
className: "border border-border",
onClick: onToggleArchived,
},
];
}
+84
View File
@@ -0,0 +1,84 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { LayoutDashboard, Users, CheckSquare, BarChart2, Settings, LogOut, ChevronRight, } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { useAuth } from '@/contexts/AuthContext';
import { cn } from '@/lib/utils';
import { StaffProviders } from '@/contexts/provider/StaffProvider';
import UserMenu from '@/components/generic/UserMenu';
const navItems = [
{ to: '/staff', label: 'Dashboard', icon: LayoutDashboard, end: true },
{ to: '/staff/groups', label: 'My groups', icon: Users },
{ to: '/staff/task-lists', label: 'Task lists', icon: CheckSquare },
{ to: '/staff/scores', label: 'Scores', icon: BarChart2 },
];
export default function StaffLayout() {
const { user, logout } = useAuth();
const navigate = useNavigate();
return (
<div className="flex h-screen bg-background">
{/* Sidebar */}
<aside className="w-56 shrink-0 flex flex-col border-r bg-card">
<div className="px-4 py-4 border-b">
<p className="text-sm font-medium">Staff portal</p>
<p className="text-xs text-muted-foreground mt-0.5 truncate">AA</p>
</div>
<nav className="flex-1 py-2 space-y-0.5 px-2">
{navItems.map(({ to, label, icon: Icon, end }) => (
<NavLink
key={to}
to={to}
end={end}
className={({ isActive }) =>
cn(
'flex items-center gap-2.5 px-3 py-2 rounded-md text-sm transition-colors',
isActive
? 'bg-secondary text-foreground font-medium'
: 'text-muted-foreground hover:bg-secondary hover:text-foreground'
)
}
>
<Icon size={16} />
{label}
</NavLink>
))}
</nav>
<Separator />
{/* <div className="px-2 py-2 space-y-0.5">
<NavLink
to="/staff/settings"
className="flex items-center gap-2.5 px-3 py-2 rounded-md text-sm text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
>
<Settings size={16} /> Settings
</NavLink>
</div> */}
</aside>
{/* Main */}
<div className="flex flex-col flex-1 min-w-0">
{/* Topbar */}
<header className="h-14 border-b bg-card flex items-center justify-between px-6 shrink-0">
<div className="flex items-center gap-1 text-sm text-muted-foreground">
{/* Breadcrumb rendered by each page via a portal or just title */}
</div>
<UserMenu />
</header>
{/* Page content */}
<main className="flex-1 overflow-y-auto p-6">
<StaffProviders>
<Outlet />
</StaffProviders>
</main>
</div>
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Layers, Users, CheckSquare, BarChart2 } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { useStaffGroups } from "@/contexts/StaffGroupContext";
import GroupTile from "../components/GroupTile";
import TaskRow from "../components/TaskRow";
export default function DashboardPage() {
const navigate = useNavigate();
const { fetchMyGroups } = useStaffGroups();
const [groups, setGroups] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchMyGroups()
.then((res) => setGroups(res?.data ?? []))
.catch(console.error)
.finally(() => setLoading(false));
}, []);
// ── Derived stats ─────────────────────────────────────────────────────────
const totalMembers = groups.reduce((sum, g) => sum + (g.members?.length ?? 0), 0);
const totalTaskLists = groups.reduce((sum, g) => sum + (g.taskLists?.length ?? 0), 0);
const allTasks = groups.flatMap((g) => g.taskLists ?? []).flatMap((tl) => tl.tasks ?? []);
const completedCount = allTasks.filter((t) => t.status === "completed").length;
const avgCompletion = allTasks.length
? Math.round((completedCount / allTasks.length) * 100)
: 0;
const STATS = [
{ label: "My groups", value: groups.length, icon: Layers, sub: "You are a member of" },
{ label: "Total members", value: totalMembers, icon: Users, sub: "Across all groups" },
{ label: "Active task lists", value: totalTaskLists, icon: CheckSquare, sub: "Assigned to your groups" },
{ label: "Avg. completion", value: `${avgCompletion}%`, icon: BarChart2, sub: "Across all tasks" },
];
const recentTasks = allTasks.slice(0, 5);
return (
<div className="space-y-6">
<h1 className="text-lg font-medium">Dashboard</h1>
{/* ── Stat tiles ──────────────────────────────────────────────────── */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{STATS.map(({ label, value, icon: Icon, sub }) => (
<Card key={label} className="bg-muted/40 border-0 shadow-none">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-2">
<p className="text-xs text-muted-foreground">{label}</p>
<Icon size={14} className="text-muted-foreground" aria-hidden />
</div>
<p className="text-2xl font-medium">
{loading ? "—" : value}
</p>
<p className="text-xs text-muted-foreground mt-1">{sub}</p>
</CardContent>
</Card>
))}
</div>
{/* ── Group tiles ──────────────────────────────────────────────────── */}
<div>
<h2 className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">
My groups
</h2>
{loading ? (
<div className="grid grid-cols-2 gap-3">
{[0, 1].map((i) => (
<Card key={i} className="h-36 animate-pulse bg-muted/40 border-0" />
))}
</div>
) : groups.length === 0 ? (
<p className="text-sm text-muted-foreground">
You are not assigned to any groups yet.
</p>
) : (
<div className="grid grid-cols-2 gap-3">
{groups.map((group) => (
<GroupTile
key={group.group_id}
group={group}
onClick={() => navigate(`/staff/groups/${group.group_id}`)}
/>
))}
</div>
)}
</div>
{/* ── Recent tasks ─────────────────────────────────────────────────── */}
<div>
<h2 className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">
Recent task activity
</h2>
<Card>
<CardContent className="p-0 divide-y">
{recentTasks.length === 0 && !loading && (
<p className="text-sm text-muted-foreground p-4">No tasks yet.</p>
)}
{recentTasks.map((task) => (
<TaskRow key={task.task_id} task={task} />
))}
</CardContent>
</Card>
</div>
</div>
);
}
+189
View File
@@ -0,0 +1,189 @@
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useStaffGroups } from "@/contexts/StaffGroupContext";
import MembersTable from "../components/members/MembersTable";
import TaskListsTab from "../components/TaskListsTab";
function formatDate(iso) {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("en-PH", {
year: "numeric",
month: "short",
day: "numeric",
});
}
function getInitials(name = "") {
const parts = name.trim().split(/\s+/);
return parts.length >= 2
? (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
: name.slice(0, 2).toUpperCase();
}
export default function GroupDetailPage() {
const { groupId } = useParams();
const navigate = useNavigate();
const {
fetchGroupById,
// members pagination — provided by your context after the backend split
members,
memberAttributes,
memberPagination,
setMemberPagination,
membersLoading,
fetchGroupMembers,
fetchGroupMemberFieldValues,
} = useStaffGroups();
const [group, setGroup] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchGroupById(groupId)
.then((res) => setGroup(res?.data ?? null))
.catch(console.error)
.finally(() => setLoading(false));
// Initial members fetch
// fetchGroupMembers(groupId, { page: 1, limit: 10 });
return () => setGroup(null);
}, [groupId]);
// ── Loading skeleton ──────────────────────────────────────────────────────
if (loading) {
return (
<div className="space-y-4">
<div className="h-5 w-32 rounded bg-muted/40 animate-pulse" />
<div className="h-64 rounded-lg bg-muted/40 animate-pulse" />
</div>
);
}
if (!group) {
return (
<div className="space-y-4">
<button
onClick={() => navigate("/staff/groups")}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft size={14} /> Back to groups
</button>
<p className="text-sm text-muted-foreground">Group not found.</p>
</div>
);
}
const listCount = group.taskLists?.length ?? 0;
const totalTasks = group.taskLists?.reduce((acc, tl) => acc + (tl.tasks?.length ?? 0), 0) ?? 0;
const completedTasks = group.taskLists?.reduce(
(acc, tl) => acc + (tl.tasks?.filter((t) => t.status === "completed").length ?? 0),
0
) ?? 0;
return (
<div className="space-y-5">
{/* Back */}
<button
onClick={() => navigate("/staff/groups")}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft size={14} /> Back to groups
</button>
{/* ── Group header card ───────────────────────────────────────────────── */}
<Card>
<CardContent className="p-5">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-primary/10 text-primary flex items-center justify-center text-sm font-semibold shrink-0">
{getInitials(group.name)}
</div>
<div>
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-base font-semibold">{group.name}</h1>
{group.group_code && (
<Badge variant="secondary" className="text-xs">
{group.group_code}
</Badge>
)}
<Badge
variant={group.is_active ? "default" : "outline"}
className="text-xs"
>
{group.is_active ? "Active" : "Inactive"}
</Badge>
</div>
{group.description && (
<p className="text-sm text-muted-foreground mt-0.5">
{group.description}
</p>
)}
</div>
</div>
<div className="text-xs text-muted-foreground text-right space-y-0.5">
<p>Created <span className="text-foreground">{formatDate(group.createdAt)}</span></p>
<p>Updated <span className="text-foreground">{formatDate(group.updatedAt)}</span></p>
</div>
</div>
{/* Summary stats */}
<div className="grid grid-cols-3 gap-3 mt-4 pt-4 border-t">
<div className="text-center">
<p className="text-xl font-semibold">{memberPagination?.total ?? "—"}</p>
<p className="text-xs text-muted-foreground mt-0.5">Members</p>
</div>
<div className="text-center">
<p className="text-xl font-semibold">{listCount}</p>
<p className="text-xs text-muted-foreground mt-0.5">Task lists</p>
</div>
<div className="text-center">
<p className="text-xl font-semibold">
{totalTasks > 0
? `${Math.round((completedTasks / totalTasks) * 100)}%`
: "—"}
</p>
<p className="text-xs text-muted-foreground mt-0.5">Completion</p>
</div>
</div>
</CardContent>
</Card>
{/* ── Tabs ───────────────────────────────────────────────────────────── */}
<Tabs defaultValue="members" className="flex flex-col">
<TabsList>
<TabsTrigger value="members">
Members ({memberPagination?.total ?? 0})
</TabsTrigger>
<TabsTrigger value="tasklists">
Task lists ({listCount})
</TabsTrigger>
</TabsList>
<TabsContent value="members" className="mt-4">
<MembersTable
members={members}
attributes={memberAttributes}
pagination={memberPagination}
setPagination={setMemberPagination}
loading={membersLoading}
fetchMembers={(params) => fetchGroupMembers(groupId, params)}
fetchMemberFieldValues={(col, params) =>
fetchGroupMemberFieldValues(groupId, col, params)
}
/>
</TabsContent>
<TabsContent value="tasklists" className="mt-4">
<TaskListsTab taskLists={group.taskLists ?? []} />
</TabsContent>
</Tabs>
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useStaffGroups } from "@/contexts/StaffGroupContext";
import GroupTile from "../components/GroupTile";
export default function GroupsPage() {
const navigate = useNavigate();
const { fetchMyGroups } = useStaffGroups();
const [groups, setGroups] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchMyGroups()
.then((res) => setGroups(res?.data ?? []))
.catch(console.error)
.finally(() => setLoading(false));
}, []);
return (
<div className="space-y-4">
<h1 className="text-lg font-medium">My groups</h1>
{loading ? (
<div className="grid grid-cols-2 gap-3">
{[0, 1, 2].map((i) => (
<div key={i} className="h-36 rounded-lg bg-muted/40 animate-pulse" />
))}
</div>
) : groups.length === 0 ? (
<p className="text-sm text-muted-foreground">
You are not assigned to any groups yet.
</p>
) : (
<div className="grid grid-cols-2 gap-3">
{groups.map((group) => (
<GroupTile
key={group.group_id}
group={group}
onClick={() => navigate(`/staff/groups/${group.group_id}`)}
/>
))}
</div>
)}
</div>
);
}
+262
View File
@@ -0,0 +1,262 @@
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Select, SelectContent, SelectItem,
SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { useStaffGroups } from "@/contexts/StaffGroupContext";
import { useStaffScores } from "@/contexts/StaffScoreContext";
import { cn } from "@/lib/utils";
import ScoreBadge from "../components/ScoreBadge";
export default function ScoresPage() {
const { fetchMyGroups } = useStaffGroups();
const {
quizScores, assessScores, loading,
fetchQuizScores, fetchAssessmentScores,
clearQuizScores, clearAssessScores,
} = useStaffScores();
const [groups, setGroups] = useState([]);
const [selectedGroup, setSelectedGroup] = useState(null);
const [quizId, setQuizId] = useState("");
const [assessmentId, setAssessmentId] = useState("");
useEffect(() => {
fetchMyGroups()
.then((res) => {
const data = res?.data ?? [];
setGroups(data);
if (data.length) setSelectedGroup(data[0].group_id);
})
.catch(console.error);
return () => {
clearQuizScores();
clearAssessScores();
};
}, []);
// ── Derive quiz + assessment options from the selected group's task lists ──
const selectedGroupData = groups.find((g) => g.group_id === selectedGroup);
const allTasks = selectedGroupData?.taskLists?.flatMap((tl) => tl.tasks ?? []) ?? [];
const quizTasks = allTasks.filter((t) =>
t.requirements?.some((r) => r.type === "read_unit")
);
const assessmentTasks = allTasks.filter((t) =>
t.requirements?.some((r) => r.type === "read_course")
);
const handleGroupChange = (value) => {
setSelectedGroup(parseInt(value));
setQuizId("");
setAssessmentId("");
clearQuizScores();
clearAssessScores();
};
const handleQuizChange = (id) => {
setQuizId(id);
fetchQuizScores(id);
};
const handleAssessmentChange = (id) => {
setAssessmentId(id);
fetchAssessmentScores(id);
};
return (
<div className="space-y-5">
{/* ── Header ──────────────────────────────────────────────────────── */}
<div className="flex items-center justify-between">
<h1 className="text-lg font-medium">Scores &amp; progress</h1>
<Select
value={selectedGroup?.toString() ?? ""}
onValueChange={handleGroupChange}
>
<SelectTrigger className="w-48 h-8 text-sm">
<SelectValue placeholder="Select group..." />
</SelectTrigger>
<SelectContent>
{groups.map((g) => (
<SelectItem key={g.group_id} value={g.group_id.toString()}>
{g.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Tabs defaultValue="quiz">
<TabsList>
<TabsTrigger value="quiz">Quiz scores</TabsTrigger>
<TabsTrigger value="assessment">Assessment scores</TabsTrigger>
</TabsList>
{/* ── Quiz scores ─────────────────────────────────────────────── */}
<TabsContent value="quiz" className="mt-4 space-y-4">
<Select value={quizId} onValueChange={handleQuizChange}>
<SelectTrigger className="w-64 h-8 text-sm">
<SelectValue placeholder="Select a unit quiz..." />
</SelectTrigger>
<SelectContent>
{quizTasks.length === 0 ? (
<div className="px-3 py-2 text-xs text-muted-foreground">
No unit quizzes in this group.
</div>
) : (
quizTasks.map((t) => (
<SelectItem
key={t.requirements[0].reference_id}
value={t.requirements[0].reference_id}
>
{t.requirements[0].reference_label ?? t.name}
</SelectItem>
))
)}
</SelectContent>
</Select>
{loading && quizId && (
<div className="h-32 rounded-lg bg-muted/40 animate-pulse" />
)}
{quizScores && !loading && (
<>
<div className="flex items-center gap-3 flex-wrap">
<p className="text-sm font-medium">
{quizScores.quiz?.title ?? "Unit quiz"}
</p>
<Badge variant="outline" className="text-xs">
Passing: {quizScores.quiz?.passing_score ?? 70}%
</Badge>
{quizScores.quiz?.unit?.title && (
<Badge variant="secondary" className="text-xs">
{quizScores.quiz.unit.title}
</Badge>
)}
</div>
<ScoreTable
data={quizScores.data}
passingScore={quizScores.quiz?.passing_score ?? 70}
/>
</>
)}
{!quizId && !loading && (
<p className="text-sm text-muted-foreground">
Select a unit quiz above to view scores.
</p>
)}
</TabsContent>
{/* ── Assessment scores ────────────────────────────────────────── */}
<TabsContent value="assessment" className="mt-4 space-y-4">
<Select value={assessmentId} onValueChange={handleAssessmentChange}>
<SelectTrigger className="w-64 h-8 text-sm">
<SelectValue placeholder="Select a course assessment..." />
</SelectTrigger>
<SelectContent>
{assessmentTasks.length === 0 ? (
<div className="px-3 py-2 text-xs text-muted-foreground">
No course assessments in this group.
</div>
) : (
assessmentTasks.map((t) => (
<SelectItem
key={t.requirements[0].reference_id}
value={t.requirements[0].reference_id}
>
{t.requirements[0].reference_label ?? t.name}
</SelectItem>
))
)}
</SelectContent>
</Select>
{loading && assessmentId && (
<div className="h-32 rounded-lg bg-muted/40 animate-pulse" />
)}
{assessScores && !loading && (
<>
<div className="flex items-center gap-3 flex-wrap">
<p className="text-sm font-medium">
{assessScores.assessment?.title ?? "Course assessment"}
</p>
<Badge variant="outline" className="text-xs">
Passing: {assessScores.assessment?.passing_score ?? 75}%
</Badge>
{assessScores.assessment?.course?.title && (
<Badge variant="secondary" className="text-xs">
{assessScores.assessment.course.title}
</Badge>
)}
</div>
<ScoreTable
data={assessScores.data}
passingScore={assessScores.assessment?.passing_score ?? 75}
/>
</>
)}
{!assessmentId && !loading && (
<p className="text-sm text-muted-foreground">
Select a course assessment above to view scores.
</p>
)}
</TabsContent>
</Tabs>
</div>
);
}
// ── ScoreTable ────────────────────────────────────────────────────────────────
function ScoreTable({ data = [], passingScore }) {
if (!data.length) {
return <p className="text-sm text-muted-foreground py-4">No data yet.</p>;
}
const COLORS = [
"bg-emerald-100 text-emerald-800",
"bg-purple-100 text-purple-800",
"bg-amber-100 text-amber-800",
"bg-blue-100 text-blue-800",
"bg-pink-100 text-pink-800",
];
return (
<Card>
<CardContent className="p-0 divide-y">
{data.map(({ user, best_score, attempts, latest }, i) => {
const fullName = user.personal_info?.name?.full_name ?? user.email ?? "";
const initials = fullName
.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2) || "?";
return (
<div key={user.user_id} className="flex items-center gap-3 px-4 py-3">
<div className={cn(
"w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium shrink-0",
COLORS[i % COLORS.length]
)}>
{initials}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{fullName}</p>
<p className="text-xs text-muted-foreground">
{attempts.length === 0
? "No attempt yet"
: `${attempts.length} attempt${attempts.length !== 1 ? "s" : ""} · best score`}
</p>
</div>
<ScoreBadge score={best_score} passingScore={passingScore} />
</div>
);
})}
</CardContent>
</Card>
);
}
+28
View File
@@ -0,0 +1,28 @@
/***********************************************************************************************************************************************************************
* File Name: TaskList.jsx (staff)
* Type of Program: Page
* Description: Staff task list page — breadcrumb + DataTable via TaskListTable.
***********************************************************************************************************************************************************************/
import { House } from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import TaskListTable from "../components/TaskListTable";
export default function TaskList() {
const items = [
{ label: "Home", icon: <House className="size-4" />, to: "/staff" },
{ label: "Task Lists" },
];
return (
<section className="h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 mb-6">
<AppBreadcrumb items={items} />
</div>
<div className="w-full">
<TaskListTable />
</div>
</div>
</section>
);
}