diff --git a/src/components/generic/ComboBoxCommand.jsx b/src/components/generic/ComboBoxCommand.jsx
new file mode 100644
index 0000000..2705385
--- /dev/null
+++ b/src/components/generic/ComboBoxCommand.jsx
@@ -0,0 +1,463 @@
+// ComboBoxCommand.jsx
+// ─────────────────────────────────────────────────────────────────────────────
+// Generic multi-select combobox with server-search, select-all, embossed
+// checkboxes, and +N overflow badge popover.
+//
+// Props:
+// value {any[]} — array of selected IDs (controlled)
+// onChange {fn} — called with new array of IDs
+// items {object[]} — flat list of option objects
+// loading {boolean} — shows spinner while fetching
+// onSearch {fn} — called with search term when Search is clicked
+//
+// // Field mapping — tell the component which keys to read from each item:
+// fieldId {string} — unique identifier key default: "id"
+// fieldLabel {string} — primary display label key default: "name"
+// fieldMeta {string} — secondary mono badge key default: null (hidden)
+//
+// // Copy overrides (all optional):
+// placeholder {string} — trigger placeholder default: "Select items…"
+// searchPlaceholder {string} default: "Search…"
+// selectAllLabel {string} default: "Select all"
+// deselectAllLabel {string} default: "Deselect all"
+// emptyLabel {string} default: "No items available."
+// unit {string} — singular noun for counts default: "item"
+//
+// maxVisible {number} — badges before +N collapse default: 3
+//
+// ── Usage examples ────────────────────────────────────────────────────────────
+//
+// // Groups (your existing use-case)
+//
+//
+// // Users
+//
+//
+// // Tags (no meta badge)
+//
+// ─────────────────────────────────────────────────────────────────────────────
+
+import { useState, useRef } from "react";
+import { Check, ChevronsUpDown, X, Search, Loader2 } from "lucide-react";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandItem,
+ CommandList,
+ CommandSeparator,
+} from "@/components/ui/command";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { cn } from "@/lib/utils";
+
+// ─── Overflow popover ─────────────────────────────────────────────────────────
+function OverflowPopover({ overflow, onRemove, fieldId, fieldLabel, fieldMeta }) {
+ const [open, setOpen] = useState(false);
+
+ return (
+
+
+
+ +{overflow.length} more
+
+
+
+
+
+
+ {overflow.length} more selected
+
+
+
+ {overflow.map((item) => (
+
+ {item[fieldLabel]}
+ {fieldMeta && item[fieldMeta] && (
+
+ {item[fieldMeta]}
+
+ )}
+ onRemove(item[fieldId])}
+ className={cn(
+ "shrink-0 rounded-sm p-0.5 opacity-0 group-hover:opacity-60",
+ "hover:!opacity-100 hover:bg-destructive/20 transition-all"
+ )}
+ >
+
+
+
+ ))}
+
+
+
+ );
+}
+
+// ─── Main component ───────────────────────────────────────────────────────────
+export function ComboBoxCommand({
+ // Data
+ value = [],
+ onChange,
+ items = [],
+ loading = false,
+ onSearch,
+
+ // Field mapping
+ fieldId = "id",
+ fieldLabel = "name",
+ fieldMeta = null, // set to a key string to show the mono badge
+
+ // Copy
+ placeholder = "Select items…",
+ searchPlaceholder = "Search…",
+ selectAllLabel = "Select all",
+ deselectAllLabel = "Deselect all",
+ emptyLabel = "No items available.",
+ unit = "item",
+
+ // Layout
+ maxVisible = 3,
+}) {
+ const [open, setOpen] = useState(false);
+ const [inputValue, setInputValue] = useState("");
+ const inputRef = useRef(null);
+
+ const safeItems = Array.isArray(items) ? items : [];
+
+ // ── Client-side filter ─────────────────────────────────────────────────────
+ const filtered = safeItems.filter((item) => {
+ if (!inputValue.trim()) return true;
+ const q = inputValue.toLowerCase();
+ const matchLabel = String(item[fieldLabel] ?? "").toLowerCase().includes(q);
+ const matchMeta = fieldMeta
+ ? String(item[fieldMeta] ?? "").toLowerCase().includes(q)
+ : false;
+ return matchLabel || matchMeta;
+ });
+
+ // ── Selection state ────────────────────────────────────────────────────────
+ const selected = safeItems.filter((item) => value.includes(item[fieldId]));
+ const allSelected = filtered.length > 0 && filtered.every((item) => value.includes(item[fieldId]));
+ const someSelected = !allSelected && filtered.some((item) => value.includes(item[fieldId]));
+
+ const visibleBadges = selected.slice(0, maxVisible);
+ const overflowBadges = selected.slice(maxVisible);
+
+ // ── Handlers ───────────────────────────────────────────────────────────────
+ const toggle = (id) => {
+ const key = String(id);
+ onChange(
+ value.map(String).includes(key)
+ ? value.filter((v) => String(v) !== key)
+ : [...value, key]
+ );
+ };
+
+ const toggleAll = () => {
+ if (allSelected) {
+ const filteredIds = new Set(filtered.map((item) => item[fieldId]));
+ onChange(value.filter((id) => !filteredIds.has(id)));
+ } else {
+ const merged = Array.from(new Set([...value, ...filtered.map((item) => item[fieldId])]));
+ onChange(merged);
+ }
+ };
+
+ const handleSearch = () => {
+ if (onSearch && inputValue.trim()) onSearch(inputValue.trim());
+ };
+
+ const handleKeyDown = (e) => {
+ if (e.key === "Enter" && inputValue.trim()) {
+ e.preventDefault();
+ handleSearch();
+ }
+ };
+
+ // ── Pluralise helper ───────────────────────────────────────────────────────
+ const plural = (n) => `${n} ${unit}${n !== 1 ? "s" : ""}`;
+
+ return (
+
+
+
+ {/* ── Trigger ── */}
+
+
+
+ {selected.length > 0
+ ? `${plural(selected.length)} selected`
+ : placeholder}
+
+
+
+
+
+ {/* ── Dropdown ── */}
+
+
+
+ {/* Search bar */}
+
+
+ setInputValue(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder={searchPlaceholder}
+ className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground py-1"
+ />
+
+ {loading
+ ?
+ :
+ }
+ Search
+
+
+
+
+
+ {/* Loading */}
+ {loading && (
+
+
+ Loading…
+
+ )}
+
+ {/* Empty */}
+ {!loading && filtered.length === 0 && (
+
+ {inputValue ? `No results for "${inputValue}".` : emptyLabel}
+
+ )}
+
+ {!loading && filtered.length > 0 && (
+ <>
+ {/* Select All row */}
+
+ {/* Embossed checkbox */}
+
+ {allSelected && }
+ {someSelected && }
+
+
+ {allSelected ? deselectAllLabel : selectAllLabel}
+
+
+ {plural(filtered.length)}
+
+
+
+ {/* Items */}
+
+ {filtered.map((item) => {
+ const id = item[fieldId];
+ const label = item[fieldLabel];
+ const meta = fieldMeta ? item[fieldMeta] : null;
+ const isSelected = value.includes(id);
+
+ return (
+ toggle(id)}
+ className={cn(
+ "flex items-center gap-3 px-3 py-2.5 cursor-pointer transition-colors",
+ "aria-selected:bg-transparent data-selected:bg-transparent",
+ isSelected
+ ? "bg-primary/[0.06] hover:bg-primary/[0.10]"
+ : "hover:bg-muted/50"
+ )}
+ >
+ {/* Embossed checkbox */}
+
+ {isSelected && (
+
+ )}
+
+
+ {/* Label */}
+
+ {label}
+
+
+ {/* Meta pill */}
+ {meta && (
+
+ {meta}
+
+ )}
+
+ );
+ })}
+
+ >
+ )}
+
+
+ {/* Footer */}
+ {!loading && safeItems.length > 0 && (
+ <>
+
+
+
+ {value.length > 0 ? `${plural(value.length)} selected` : "None selected"}
+
+
+ {filtered.length} / {safeItems.length} shown
+
+
+ >
+ )}
+
+
+
+
+ {/* ── Selected badges with +N overflow ── */}
+ {selected.length > 0 && (
+
+
+ {/* First maxVisible badges */}
+ {visibleBadges.map((item) => (
+
+ {item[fieldLabel]}
+ {fieldMeta && item[fieldMeta] && (
+ {item[fieldMeta]}
+ )}
+ toggle(item[fieldId])}
+ className="rounded-sm opacity-60 hover:opacity-100 hover:bg-destructive/20 p-0.5 ml-0.5"
+ >
+
+
+
+ ))}
+
+ {/* +N overflow → popover */}
+ {overflowBadges.length > 0 && (
+ toggle(id)}
+ fieldId={fieldId}
+ fieldLabel={fieldLabel}
+ fieldMeta={fieldMeta}
+ />
+ )}
+
+ {/* Clear all */}
+ {selected.length > 1 && (
+ onChange([])}
+ className="text-[11px] text-muted-foreground hover:text-destructive transition-colors underline underline-offset-2 ml-0.5"
+ >
+ Clear all
+
+ )}
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/generic/RequirementBuilder.jsx b/src/components/generic/RequirementBuilder.jsx
new file mode 100644
index 0000000..71693b3
--- /dev/null
+++ b/src/components/generic/RequirementBuilder.jsx
@@ -0,0 +1,282 @@
+import { useState } from 'react';
+import { Plus, Trash2, GripVertical, Link, Upload, BookOpen, Layers, FileText } from 'lucide-react';
+
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Card, CardContent } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+
+// ─── Requirement type config ──────────────────────────────────────────────────
+const REQUIREMENT_TYPES = [
+ { value: 'visit_link', label: 'Visit a Link', icon: Link, category: 'Action' },
+ { value: 'upload_file', label: 'Upload a File', icon: Upload, category: 'Action' },
+ { value: 'read_course', label: 'Read a Course', icon: BookOpen, category: 'Content' },
+ { value: 'read_unit', label: 'Read a Unit', icon: Layers, category: 'Content' },
+ { value: 'read_lesson', label: 'Read a Lesson', icon: FileText, category: 'Content' },
+];
+
+const TYPE_MAP = Object.fromEntries(REQUIREMENT_TYPES.map((t) => [t.value, t]));
+
+const FILE_TYPE_OPTIONS = [
+ { value: 'pdf', label: 'PDF' },
+ { value: 'docx', label: 'DOCX' },
+ { value: 'xlsx', label: 'XLSX' },
+ { value: 'png', label: 'PNG' },
+ { value: 'jpg', label: 'JPG' },
+ { value: 'mp4', label: 'MP4' },
+ { value: 'zip', label: 'ZIP' },
+];
+
+// ─── Empty requirement factory ────────────────────────────────────────────────
+function createRequirement(type = 'visit_link') {
+ return {
+ _key: crypto.randomUUID(),
+ type,
+ // visit_link
+ link_url: '',
+ link_label: '',
+ // upload_file
+ allowed_file_types: [],
+ max_file_count: 1,
+ // read_*
+ reference_id: '',
+ reference_label: '',
+ };
+}
+
+// ─── RequirementBuilder ───────────────────────────────────────────────────────
+export default function RequirementBuilder({ value = [], onChange, courses = [], units = [], lessons = [] }) {
+ const [items, setItems] = useState(
+ value.length > 0
+ ? value.map((r) => ({ _key: crypto.randomUUID(), ...r }))
+ : []
+ );
+
+ const emit = (next) => {
+ setItems(next);
+ // strip _key before calling onChange
+ onChange?.(next.map(({ _key, ...r }) => r));
+ };
+
+ const addItem = () => emit([...items, createRequirement('visit_link')]);
+
+ const removeItem = (key) => emit(items.filter((i) => i._key !== key));
+
+ const updateItem = (key, patch) =>
+ emit(items.map((i) => (i._key === key ? { ...i, ...patch } : i)));
+
+ const toggleFileType = (key, ft) => {
+ const item = items.find((i) => i._key === key);
+ if (!item) return;
+ const current = item.allowed_file_types ?? [];
+ const next = current.includes(ft)
+ ? current.filter((t) => t !== ft)
+ : [...current, ft];
+ updateItem(key, { allowed_file_types: next });
+ };
+
+ return (
+
+ {items.length === 0 && (
+
+ No requirements added. Click "Add Requirement" to start.
+
+ )}
+
+ {items.map((item, idx) => {
+ const typeDef = TYPE_MAP[item.type];
+ const Icon = typeDef?.icon ?? Link;
+
+ return (
+
+
+ {/* Header row */}
+
+
+
+
+ {idx + 1}
+
+
+ {/* Type selector */}
+ updateItem(item._key, { type: v })}
+ >
+
+
+
+
+ {REQUIREMENT_TYPES.map((t) => (
+
+
+
+ {t.label}
+
+
+ ))}
+
+
+
+ removeItem(item._key)}
+ >
+
+
+
+
+ {/* ── visit_link fields ── */}
+ {item.type === 'visit_link' && (
+
+ )}
+
+ {/* ── upload_file fields ── */}
+ {item.type === 'upload_file' && (
+
+
+
Allowed File Types
+
+ {FILE_TYPE_OPTIONS.map((ft) => (
+ toggleFileType(item._key, ft.value)}
+ >
+ {ft.label}
+
+ ))}
+
+
+
+ Max Files
+ updateItem(item._key, { max_file_count: parseInt(e.target.value) || 1 })}
+ className="h-8 text-sm"
+ />
+
+
+ )}
+
+ {/* ── read_course / read_unit / read_lesson fields ── */}
+ {['read_course', 'read_unit', 'read_lesson'].includes(item.type) && (
+
+
+ {item.type === 'read_course' ? 'Course' : item.type === 'read_unit' ? 'Unit' : 'Lesson'}
+
+
+ {/* Reference selector */}
+ {item.type === 'read_course' && (
+ {
+ const course = courses.find((c) => c.course_id === v);
+ updateItem(item._key, {
+ reference_id: v,
+ reference_label: course?.title ?? '',
+ });
+ }}
+ >
+
+
+
+
+ {courses.map((c) => (
+
+ {c.title}
+
+ ))}
+
+
+ )}
+
+ {item.type === 'read_unit' && (
+ {
+ const unit = units.find((u) => u.unit_id === v);
+ updateItem(item._key, {
+ reference_id: v,
+ reference_label: unit?.title ?? '',
+ });
+ }}
+ >
+
+
+
+
+ {units.map((u) => (
+
+ {u.title}
+
+ ))}
+
+
+ )}
+
+ {item.type === 'read_lesson' && (
+ {
+ const lesson = lessons.find((l) => l.lesson_id === v);
+ updateItem(item._key, {
+ reference_id: v,
+ reference_label: lesson?.title ?? '',
+ });
+ }}
+ >
+
+
+
+
+ {lessons.map((l) => (
+
+ {l.title}
+
+ ))}
+
+
+ )}
+
+ )}
+
+
+ );
+ })}
+
+
+
+ Add Requirement
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/contexts/AdminTaskContext.jsx b/src/contexts/AdminTaskContext.jsx
index 70b4894..b77c67d 100644
--- a/src/contexts/AdminTaskContext.jsx
+++ b/src/contexts/AdminTaskContext.jsx
@@ -144,7 +144,7 @@ export function AdminTaskProvider({ children }) {
request(async () => {
const res = await api.patch(`${BASE}/${taskListId}`, payload);
toast.success('Task list updated.');
- return res.data?.data?.data ?? null;
+ return res.data?.data ?? null;
}),
[request]
);
diff --git a/src/contexts/StaffGroupContext.jsx b/src/contexts/StaffGroupContext.jsx
new file mode 100644
index 0000000..8e414b8
--- /dev/null
+++ b/src/contexts/StaffGroupContext.jsx
@@ -0,0 +1,181 @@
+import { createContext, useContext, useState, useCallback } from "react";
+import api from "@/utils/api.util";
+import { toast } from "sonner";
+
+const StaffGroupContext = createContext(null);
+
+export const useStaffGroups = () => {
+ const ctx = useContext(StaffGroupContext);
+ if (!ctx) throw new Error("useStaffGroups must be used inside StaffGroupProvider");
+ return ctx;
+};
+
+const BASE = "/staff";
+
+const PAGINATION_INIT = {
+ page: 1,
+ limit: 10,
+ totalRecords: 0,
+ totalPages: 0,
+ hasPrevPage: false,
+ hasNextPage: false,
+};
+
+export const StaffGroupProvider = ({ children }) => {
+ const [groups, setGroups] = useState([]);
+ const [group, setGroup] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ // ── Members pagination state ───────────────────────────────────────────────
+ const [members, setMembers] = useState([]);
+ const [memberAttributes, setMemberAttributes] = useState([]);
+ const [memberPagination, setMemberPagination] = useState(PAGINATION_INIT);
+ const [membersLoading, setMembersLoading] = useState(false);
+
+ const request = useCallback(async (fn) => {
+ setLoading(true);
+ setError(null);
+ try {
+ return await fn();
+ } catch (err) {
+ const message = err?.response?.data?.message || err.message || "Something went wrong.";
+ setError(message);
+ toast.error(message);
+ return null;
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ // Lightweight wrapper for members-specific loading flag
+ // so it doesn't block the whole page when paginating/filtering
+ const membersRequest = useCallback(async (fn) => {
+ setMembersLoading(true);
+ try {
+ return await fn();
+ } catch (err) {
+ const message = err?.response?.data?.message || err.message || "Something went wrong.";
+ toast.error(message);
+ return null;
+ } finally {
+ setMembersLoading(false);
+ }
+ }, []);
+
+ // ─── GET /api/staff/groups ────────────────────────────────────────────────
+ const fetchMyGroups = useCallback(
+ () =>
+ request(async () => {
+ const res = await api.get(`${BASE}/groups`);
+ setGroups(res.data?.data ?? []);
+ return res.data;
+ }),
+ [request]
+ );
+
+ // ─── GET /api/staff/groups/:group_id ─────────────────────────────────────
+ const fetchGroupById = useCallback(
+ (groupId) =>
+ request(async () => {
+ const res = await api.get(`${BASE}/groups/${groupId}`);
+ setGroup(res.data?.data ?? null);
+ return res.data;
+ }),
+ [request]
+ );
+
+ // ─── GET /api/staff/groups/by-code/:group_code ───────────────────────────
+ const fetchGroupByCode = useCallback(
+ (groupCode) =>
+ request(async () => {
+ const res = await api.get(`${BASE}/groups/by-code/${groupCode}`);
+ setGroup(res.data?.data ?? null);
+ return res.data;
+ }),
+ [request]
+ );
+
+ // ─── GET /api/staff/groups/:group_id/members ─────────────────────────────
+ // Paginated, searchable, filterable — mirrors the paginate() pattern.
+ // params: { page, limit, search, filters, sort }
+ const fetchGroupMembers = useCallback(
+ (groupId, params = {}) =>
+ membersRequest(async () => {
+ const res = await api.get(`${BASE}/groups/${groupId}/members`, {
+ params: {
+ page: params.page ?? 1,
+ limit: params.limit ?? 10,
+ search: params.search ?? undefined,
+ filters: params.filters?.length
+ ? JSON.stringify(params.filters)
+ : undefined,
+ sort: params.sort?.length
+ ? JSON.stringify(params.sort)
+ : undefined,
+ },
+ });
+
+ const payload = res.data?.data;
+
+ setMembers(payload.data ?? []);
+ setMemberAttributes(payload?.attributes ?? []);
+ setMemberPagination({
+ page: payload?.page ?? 1,
+ limit: payload?.limit ?? 10,
+ total: payload?.total ?? 0,
+ totalPages: payload?.totalPages ?? 1,
+ });
+
+ return res.data;
+ }),
+ [membersRequest]
+ );
+
+ // ─── GET /api/staff/groups/:group_id/members/field-values ────────────────
+ // Fetches distinct values for a given column — used by FilterSheet.
+ // column: the column id (e.g. "acc_type", "is_active")
+ // params: forwarded query params (search, page, limit, etc.)
+ const fetchGroupMemberFieldValues = useCallback(
+ (groupId, column, params = {}) =>
+ membersRequest(async () => {
+ const res = await api.get(
+ `${BASE}/groups/${groupId}/members/field-values`,
+ {
+ params: {
+ column,
+ page: params.page ?? 1,
+ limit: params.limit ?? 20,
+ search: params.search ?? undefined,
+ },
+ }
+ );
+ return res.data;
+ }),
+ [membersRequest]
+ );
+
+ return (
+
+ {children}
+
+ );
+};
\ No newline at end of file
diff --git a/src/contexts/StaffScoreContext.jsx b/src/contexts/StaffScoreContext.jsx
new file mode 100644
index 0000000..3d88f0b
--- /dev/null
+++ b/src/contexts/StaffScoreContext.jsx
@@ -0,0 +1,93 @@
+import { createContext, useContext, useState, useCallback } from "react";
+import api from "@/utils/api.util";
+import { toast } from "sonner";
+
+const StaffScoreContext = createContext(null);
+
+export const useStaffScores = () => {
+ const ctx = useContext(StaffScoreContext);
+ if (!ctx) throw new Error("useStaffScores must be used inside StaffScoreProvider");
+ return ctx;
+};
+
+const BASE = "/staff";
+
+export const StaffScoreProvider = ({ children }) => {
+ const [progress, setProgress] = useState(null); // task list completion matrix
+ const [quizScores, setQuizScores] = useState(null); // quiz attempt scores per user
+ const [assessScores, setAssessScores] = useState(null); // assessment attempt scores per user
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const request = useCallback(async (fn) => {
+ setLoading(true);
+ setError(null);
+ try {
+ return await fn();
+ } catch (err) {
+ const message = err?.response?.data?.message || err.message || "Something went wrong.";
+ setError(message);
+ toast.error(message);
+ return null;
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ // ─── GET /api/staff/progress/task-list/:task_list_id ─────────────────────
+ // Completion matrix: all members × all tasks in the task list.
+ // Response shape: { data: [{ user, tasks: [{ task, completion }], completed_count, total_tasks }] }
+ const fetchTaskListProgress = useCallback(
+ (taskListId) =>
+ request(async () => {
+ const res = await api.get(`${BASE}/progress/task-list/${taskListId}`);
+ setProgress(res.data ?? null);
+ return res.data;
+ }),
+ [request]
+ );
+
+ // ─── GET /api/staff/scores/quiz/:quiz_id ─────────────────────────────────
+ // Unit quiz scores for all members in the staff's groups.
+ // Response shape: { quiz, data: [{ user, attempts, best_score, latest }] }
+ const fetchQuizScores = useCallback(
+ (quizId) =>
+ request(async () => {
+ const res = await api.get(`${BASE}/scores/quiz/${quizId}`);
+ setQuizScores(res.data ?? null);
+ return res.data;
+ }),
+ [request]
+ );
+
+ // ─── GET /api/staff/scores/assessment/:assessment_id ─────────────────────
+ // Course assessment scores for all members in the staff's groups.
+ // Response shape: { assessment, data: [{ user, attempts, best_score, latest }] }
+ const fetchAssessmentScores = useCallback(
+ (assessmentId) =>
+ request(async () => {
+ const res = await api.get(`${BASE}/scores/assessment/${assessmentId}`);
+ setAssessScores(res.data ?? null);
+ return res.data;
+ }),
+ [request]
+ );
+
+ // ─── Clear helpers ────────────────────────────────────────────────────────
+ // Useful when navigating away from a scores page to avoid stale data.
+ const clearProgress = useCallback(() => setProgress(null), []);
+ const clearQuizScores = useCallback(() => setQuizScores(null), []);
+ const clearAssessScores = useCallback(() => setAssessScores(null), []);
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/contexts/StaffTaskContext.jsx b/src/contexts/StaffTaskContext.jsx
new file mode 100644
index 0000000..7f7653f
--- /dev/null
+++ b/src/contexts/StaffTaskContext.jsx
@@ -0,0 +1,418 @@
+/***********************************************************************************************************************************************************************
+ * File Name: StaffTaskContext.jsx
+ * Type of Program: Context
+ * Description: Staff task management context — aligned to the updated tasks.ctrl.js.
+ * Supports pagination, archive/restore, bulk ops, and filter field values
+ * to match the DataTable-based TaskListsPage.
+ ***********************************************************************************************************************************************************************/
+import { createContext, useContext, useState, useCallback } from "react";
+import api from "@/utils/api.util";
+import { toast } from "sonner";
+
+const StaffTaskContext = createContext(null);
+
+export const useStaffTasks = () => {
+ const ctx = useContext(StaffTaskContext);
+ if (!ctx) throw new Error("useStaffTasks must be used inside StaffTaskProvider");
+ return ctx;
+};
+
+const BASE = "/staff";
+
+const PAGINATION_INIT = {
+ page: 1,
+ limit: 10,
+ totalRecords: 0,
+ totalPages: 0,
+ hasPrevPage: false,
+ hasNextPage: false,
+};
+
+export const StaffTaskProvider = ({ children }) => {
+ // ── Task Lists ──────────────────────────────────────────────────────────────
+ const [taskLists, setTaskLists] = useState([]);
+ const [taskList, setTaskList] = useState(null);
+ const [attributes, setAttributes] = useState([]);
+ const [pagination, setPagination] = useState(PAGINATION_INIT);
+
+ // ── Tasks ───────────────────────────────────────────────────────────────────
+ const [tasks, setTasks] = useState([]);
+ const [task, setTask] = useState(null);
+ const [taskAttrs, setTaskAttrs] = useState([]);
+ const [taskPagination, setTaskPagination] = useState(PAGINATION_INIT);
+
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ // ── Internal request wrapper ────────────────────────────────────────────────
+ const request = useCallback(async (fn) => {
+ setLoading(true);
+ setError(null);
+ try {
+ return await fn();
+ } catch (err) {
+ const message = err?.response?.data?.message || err.message || "Something went wrong.";
+ setError(message);
+ toast.error(message);
+ return null;
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ // ── Build query params from DataTable fetch args ────────────────────────────
+ const buildParams = ({ page, limit, filters, sort, archived } = {}) => ({
+ ...(page && { page }),
+ ...(limit && { limit }),
+ ...(archived !== undefined && { archived }),
+ ...(filters?.length && { filters: JSON.stringify(filters) }),
+ ...(sort?.length && { sort: JSON.stringify(sort) }),
+ });
+
+ // ════════════════════════════════════════════════════════════════════════════
+ // TASK LISTS
+ // ════════════════════════════════════════════════════════════════════════════
+
+ // ─── GET /staff/task-lists ───────────────────────────────────────────────
+ const fetchTaskLists = useCallback(
+ (args = {}) =>
+ request(async () => {
+ const res = await api.get(`${BASE}/task-lists`, { params: buildParams(args) });
+ const { data, attributes, pagination } = res.data?.data;
+
+ setTaskLists(data ?? []);
+ setAttributes(attributes ?? []);
+ setPagination(pagination ?? null);
+
+ return res.data;
+ }),
+ [request]
+ );
+
+ // ─── GET /staff/task-lists/archived ──────────────────────────────────────
+ const fetchArchivedTaskLists = useCallback(
+ (args = {}) =>
+ request(async () => {
+ const res = await api.get(`${BASE}/task-lists/archived`, { params: buildParams(args) });
+ const payload = res.data?.data;
+ setTaskLists(payload?.rows ?? payload ?? []);
+ setAttributes(payload?.attributes ?? []);
+ setPagination(payload?.pagination ?? null);
+ return res.data;
+ }),
+ [request]
+ );
+
+ // ─── GET /staff/task-lists/field-values ──────────────────────────────────
+ const fetchTaskListFieldValues = useCallback(
+ (field, args = {}) =>
+ request(async () => {
+ const res = await api.get(`${BASE}/task-lists/field-values`, {
+ params: { field, ...buildParams(args) },
+ });
+ return res.data;
+ }),
+ [request]
+ );
+
+ // ─── GET /staff/task-lists/:taskListId ───────────────────────────────────
+ const fetchTaskList = useCallback(
+ (taskListId) =>
+ request(async () => {
+ const res = await api.get(`${BASE}/task-lists/${taskListId}`);
+ setTaskList(res.data?.data ?? null);
+ return res.data;
+ }),
+ [request]
+ );
+
+ // ─── POST /staff/task-lists ──────────────────────────────────────────────
+ // body: { name, description, group_ids: ["27", "28"] }
+ const addTaskList = useCallback(
+ (payload) =>
+ request(async () => {
+ const res = await api.post(`${BASE}/task-lists`, payload);
+ const created = res.data?.data;
+ if (created) setTaskLists((prev) => [created, ...prev]);
+ toast.success("Task list created.");
+ return res.data;
+ }),
+ [request]
+ );
+
+ // ─── PUT /staff/task-lists/:taskListId ───────────────────────────────────
+ const updateTaskList = useCallback(
+ (taskListId, payload) =>
+ request(async () => {
+ const res = await api.put(`${BASE}/task-lists/${taskListId}`, payload);
+ const updated = res.data?.data;
+ if (updated) {
+ setTaskLists((prev) =>
+ prev.map((tl) => (tl.task_list_id === taskListId ? { ...tl, ...updated } : tl))
+ );
+ if (taskList?.task_list_id === taskListId)
+ setTaskList((prev) => ({ ...prev, ...updated }));
+ }
+ toast.success("Task list updated.");
+ return res.data;
+ }),
+ [request, taskList]
+ );
+
+ // ─── POST /staff/task-lists/:taskListId/archive ──────────────────────────
+ const archiveTaskList = useCallback(
+ (taskListId) =>
+ request(async () => {
+ const res = await api.post(`${BASE}/task-lists/${taskListId}/archive`);
+ setTaskLists((prev) => prev.filter((tl) => tl.task_list_id !== taskListId));
+ if (taskList?.task_list_id === taskListId) setTaskList(null);
+ toast.success("Task list archived.");
+ return res.data;
+ }),
+ [request, taskList]
+ );
+
+ // ─── POST /staff/task-lists/:taskListId/restore ──────────────────────────
+ const restoreTaskList = useCallback(
+ (taskListId) =>
+ request(async () => {
+ const res = await api.post(`${BASE}/task-lists/${taskListId}/restore`);
+ setTaskLists((prev) => prev.filter((tl) => tl.task_list_id !== taskListId));
+ toast.success("Task list restored.");
+ return res.data;
+ }),
+ [request]
+ );
+
+ // ─── POST /staff/task-lists/bulk-archive ─────────────────────────────────
+ const bulkArchiveTaskLists = useCallback(
+ (ids) =>
+ request(async () => {
+ const res = await api.post(`${BASE}/task-lists/bulk-archive`, { ids });
+ const { archived_ids = [] } = res.data?.data ?? {};
+ setTaskLists((prev) =>
+ prev.filter((tl) => !archived_ids.includes(tl.task_list_id))
+ );
+ toast.success(`${archived_ids.length} task list(s) archived.`);
+ return res.data;
+ }),
+ [request]
+ );
+
+ // ─── POST /staff/task-lists/bulk-restore ─────────────────────────────────
+ const bulkRestoreTaskLists = useCallback(
+ (ids) =>
+ request(async () => {
+ const res = await api.post(`${BASE}/task-lists/bulk-restore`, { ids });
+ const { restored_ids = [] } = res.data?.data ?? {};
+ setTaskLists((prev) =>
+ prev.filter((tl) => !restored_ids.includes(tl.task_list_id))
+ );
+ toast.success(`${restored_ids.length} task list(s) restored.`);
+ return res.data;
+ }),
+ [request]
+ );
+
+ // ════════════════════════════════════════════════════════════════════════════
+ // TASKS
+ // ════════════════════════════════════════════════════════════════════════════
+
+ // ─── GET /staff/task-lists/:taskListId/tasks ─────────────────────────────
+ const fetchTasks = useCallback(
+ (taskListId, args = {}) =>
+ request(async () => {
+ const res = await api.get(`${BASE}/task-lists/${taskListId}/tasks`, {
+ params: buildParams(args),
+ });
+ const payload = res.data?.data;
+ setTasks(payload?.rows ?? payload ?? []);
+ setTaskAttrs(payload?.attributes ?? []);
+ setTaskPagination(payload?.pagination ?? null);
+ return res.data;
+ }),
+ [request]
+ );
+
+ // ─── GET /staff/task-lists/:taskListId/tasks/archived ────────────────────
+ const fetchArchivedTasks = useCallback(
+ (taskListId, args = {}) =>
+ request(async () => {
+ const res = await api.get(`${BASE}/task-lists/${taskListId}/tasks/archived`, {
+ params: buildParams(args),
+ });
+ const payload = res.data?.data;
+ setTasks(payload?.rows ?? payload ?? []);
+ setTaskAttrs(payload?.attributes ?? []);
+ setTaskPagination(payload?.pagination ?? null);
+ return res.data;
+ }),
+ [request]
+ );
+
+ // ─── GET /staff/task-lists/:taskListId/tasks/field-values ────────────────
+ const fetchTaskFieldValues = useCallback(
+ (taskListId, field, args = {}) =>
+ request(async () => {
+ const res = await api.get(`${BASE}/task-lists/${taskListId}/tasks/field-values`, {
+ params: { field, ...buildParams(args) },
+ });
+ return res.data;
+ }),
+ [request]
+ );
+
+ // ─── GET /staff/task-lists/:taskListId/tasks/:taskId ─────────────────────
+ const fetchTask = useCallback(
+ (taskListId, taskId) =>
+ request(async () => {
+ const res = await api.get(`${BASE}/task-lists/${taskListId}/tasks/${taskId}`);
+ setTask(res.data?.data ?? null);
+ return res.data;
+ }),
+ [request]
+ );
+
+ // ─── POST /staff/task-lists/:taskListId/tasks ────────────────────────────
+ // body: { name, description, deadline, requirements: [{ type, ...fields }] }
+ const addTask = useCallback(
+ (taskListId, payload) =>
+ request(async () => {
+ const res = await api.post(`${BASE}/task-lists/${taskListId}/tasks`, payload);
+ const created = res.data?.data;
+ if (created) {
+ setTasks((prev) => [...prev, created]);
+ setTaskLists((prev) =>
+ prev.map((tl) =>
+ tl.task_list_id === taskListId
+ ? { ...tl, tasks: [...(tl.tasks ?? []), created] }
+ : tl
+ )
+ );
+ }
+ toast.success("Task created.");
+ return res.data;
+ }),
+ [request]
+ );
+
+ // ─── PUT /staff/task-lists/:taskListId/tasks/:taskId ─────────────────────
+ const updateTask = useCallback(
+ (taskListId, taskId, payload) =>
+ request(async () => {
+ const res = await api.put(`${BASE}/task-lists/${taskListId}/tasks/${taskId}`, payload);
+ const updated = res.data?.data;
+ if (updated) {
+ setTasks((prev) =>
+ prev.map((t) => (t.task_id === taskId ? { ...t, ...updated } : t))
+ );
+ setTaskLists((prev) =>
+ prev.map((tl) =>
+ tl.task_list_id === taskListId
+ ? {
+ ...tl,
+ tasks: tl.tasks?.map((t) =>
+ t.task_id === taskId ? { ...t, ...updated } : t
+ ),
+ }
+ : tl
+ )
+ );
+ if (task?.task_id === taskId) setTask((prev) => ({ ...prev, ...updated }));
+ }
+ toast.success("Task updated.");
+ return res.data;
+ }),
+ [request, task]
+ );
+
+ // ─── POST /staff/task-lists/:taskListId/tasks/:taskId/archive ────────────
+ const archiveTask = useCallback(
+ (taskListId, taskId) =>
+ request(async () => {
+ const res = await api.post(`${BASE}/task-lists/${taskListId}/tasks/${taskId}/archive`);
+ setTasks((prev) => prev.filter((t) => t.task_id !== taskId));
+ setTaskLists((prev) =>
+ prev.map((tl) =>
+ tl.task_list_id === taskListId
+ ? { ...tl, tasks: tl.tasks?.filter((t) => t.task_id !== taskId) }
+ : tl
+ )
+ );
+ toast.success("Task archived.");
+ return res.data;
+ }),
+ [request]
+ );
+
+ // ─── POST /staff/task-lists/:taskListId/tasks/:taskId/restore ────────────
+ const restoreTask = useCallback(
+ (taskListId, taskId) =>
+ request(async () => {
+ const res = await api.post(`${BASE}/task-lists/${taskListId}/tasks/${taskId}/restore`);
+ setTasks((prev) => prev.filter((t) => t.task_id !== taskId));
+ toast.success("Task restored.");
+ return res.data;
+ }),
+ [request]
+ );
+
+ // ─── POST /staff/task-lists/:taskListId/tasks/bulk-archive ───────────────
+ const bulkArchiveTasks = useCallback(
+ (taskListId, ids) =>
+ request(async () => {
+ const res = await api.post(`${BASE}/task-lists/${taskListId}/tasks/bulk-archive`, { ids });
+ const { archived_ids = [] } = res.data?.data ?? {};
+ setTasks((prev) => prev.filter((t) => !archived_ids.includes(t.task_id)));
+ toast.success(`${archived_ids.length} task(s) archived.`);
+ return res.data;
+ }),
+ [request]
+ );
+
+ // ─── POST /staff/task-lists/:taskListId/tasks/bulk-restore ───────────────
+ const bulkRestoreTasks = useCallback(
+ (taskListId, ids) =>
+ request(async () => {
+ const res = await api.post(`${BASE}/task-lists/${taskListId}/tasks/bulk-restore`, { ids });
+ const { restored_ids = [] } = res.data?.data ?? {};
+ setTasks((prev) => prev.filter((t) => !restored_ids.includes(t.task_id)));
+ toast.success(`${restored_ids.length} task(s) restored.`);
+ return res.data;
+ }),
+ [request]
+ );
+
+ return (
+
+ {children}
+
+ );
+};
\ No newline at end of file
diff --git a/src/contexts/StaffUserContext.jsx b/src/contexts/StaffUserContext.jsx
new file mode 100644
index 0000000..7621841
--- /dev/null
+++ b/src/contexts/StaffUserContext.jsx
@@ -0,0 +1,92 @@
+import { createContext, useContext, useState, useCallback } from "react";
+import api from "@/utils/api.util";
+import { toast } from "sonner";
+
+const StaffUserContext = createContext(null);
+
+export const useStaffUsers = () => {
+ const ctx = useContext(StaffUserContext);
+ if (!ctx) throw new Error("useStaffUsers must be used inside StaffUserProvider");
+ return ctx;
+};
+
+const PAGINATION_INIT = {
+ page: 1,
+ limit: 10,
+ totalRecords: 0,
+ totalPages: 0,
+ hasPrevPage: false,
+ hasNextPage: false,
+};
+
+const BASE = "/staff";
+
+export const StaffUserProvider = ({ children }) => {
+ const [users, setUsers] = useState([]);
+ const [user, setUser] = useState(null);
+ const [pagination, setPagination] = useState(PAGINATION_INIT);
+ const [attributes, setAttributes] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const request = useCallback(async (fn) => {
+ setLoading(true);
+ setError(null);
+ try {
+ return await fn();
+ } catch (err) {
+ const message = err?.response?.data?.message || err.message || "Something went wrong.";
+ setError(message);
+ toast.error(message);
+ return null;
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ // ─── GET /api/staff/users ─────────────────────────────────────────────────
+ // Returns users scoped to the logged-in staff member's groups.
+ // Optional query: group_id or group_code to filter to one group.
+ const fetchUsers = useCallback(
+ ({ page = 1, limit = 10, group_id, group_code, filters = [], sort = [] } = {}) =>
+ request(async () => {
+ const { data } = await api.get(`${BASE}/users`, {
+ params: {
+ page, limit,
+ ...(group_id && { group_id }),
+ ...(group_code && { group_code }),
+ filters: filters.length ? JSON.stringify(filters) : undefined,
+ sort: sort.length ? JSON.stringify(sort) : undefined,
+ },
+ });
+ const final_data = data?.data;
+ setUsers(final_data?.data ?? []);
+ setPagination(final_data?.pagination ?? PAGINATION_INIT);
+ setAttributes(final_data?.attributes ?? []);
+ return data?.data;
+ }),
+ [request]
+ );
+
+ // ─── GET /api/staff/users/:user_id ───────────────────────────────────────
+ // Returns a single user profile — only if they share a group with the staff member.
+ const fetchUser = useCallback(
+ (userId) =>
+ request(async () => {
+ const res = await api.get(`${BASE}/users/${userId}`);
+ setUser(res.data?.data ?? null);
+ return res.data;
+ }),
+ [request]
+ );
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/contexts/provider/StaffProvider.jsx b/src/contexts/provider/StaffProvider.jsx
new file mode 100644
index 0000000..130f49b
--- /dev/null
+++ b/src/contexts/provider/StaffProvider.jsx
@@ -0,0 +1,25 @@
+// ─── StaffProviders.jsx ───────────────────────────────────────────────────────
+// Wrap all staff contexts together so you only need one import in your router.
+//
+// Usage in App.jsx / layout:
+// import { StaffProviders } from "@/contexts/staff";
+//
+
+import { StaffUserProvider } from "../StaffUserContext";
+import { StaffGroupProvider } from "../StaffGroupContext";
+import { StaffTaskProvider } from "../StaffTaskContext";
+import { StaffScoreProvider } from "../StaffScoreContext";
+
+export function StaffProviders({ children }) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/modules/admin/pages/task_list/EditTaskList.jsx b/src/modules/admin/pages/task_list/EditTaskList.jsx
index b8a26a1..2360c1a 100644
--- a/src/modules/admin/pages/task_list/EditTaskList.jsx
+++ b/src/modules/admin/pages/task_list/EditTaskList.jsx
@@ -86,7 +86,7 @@ export default function EditTaskList() {
toUnassign.length ? unassignGroups(taskListId, toUnassign) : Promise.resolve(),
]);
- navigate(`/admin/taskList/${taskListId}`);
+ navigate(`/admin/taskList`);
};
// ── Loading skeleton ──────────────────────────────────────────────────────
diff --git a/src/modules/auth/pages/ChangePassword.jsx b/src/modules/auth/pages/ChangePassword.jsx
new file mode 100644
index 0000000..bc4b28b
--- /dev/null
+++ b/src/modules/auth/pages/ChangePassword.jsx
@@ -0,0 +1,173 @@
+/***********************************************************************************************************************************************************************
+ * File Name: ChangePasswordPage.jsx
+ * Type of Program: Frontend Page
+ * Description: Forced password change page shown after first login when
+ * must_change_password is true. Redirects to the user's dashboard
+ * on success and clears the flag via the backend.
+ * Author: rgrgogu
+ * Date Created: May 23, 2026
+ ***********************************************************************************************************************************************************************/
+import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useForm } from 'react-hook-form';
+import { z } from 'zod';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useAuth } from '@/contexts/AuthContext';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Eye, EyeOff, LoaderCircle, ShieldCheck } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import api from '@/utils/api.util';
+import { toast } from 'sonner';
+
+// ─── Schema ───────────────────────────────────────────────────────────────────
+const schema = z
+ .object({
+ new_password: z
+ .string()
+ .min(8, 'Password must be at least 8 characters.')
+ .regex(/[A-Z]/, 'Must contain at least one uppercase letter.')
+ .regex(/[a-z]/, 'Must contain at least one lowercase letter.')
+ .regex(/[0-9]/, 'Must contain at least one number.')
+ .regex(/[^A-Za-z0-9]/, 'Must contain at least one special character.'),
+ confirm_password: z.string().min(1, 'Please confirm your password.'),
+ })
+ .refine((d) => d.new_password === d.confirm_password, {
+ path: ['confirm_password'],
+ message: 'Passwords do not match.',
+ });
+
+// ─── Field wrapper ─────────────────────────────────────────────────────────────
+function Field({ label, error, children }) {
+ return (
+
+
{label}
+ {children}
+ {error &&
{error}
}
+
+ );
+}
+
+// ─── Password input with toggle ───────────────────────────────────────────────
+function PasswordInput({ visible, onToggle, ...props }) {
+ return (
+
+
+
+ {visible ? : }
+
+
+ );
+}
+
+// ─── Page ─────────────────────────────────────────────────────────────────────
+export default function ChangePassword() {
+ const navigate = useNavigate();
+ const { user, setUser } = useAuth();
+
+ const [showNew, setShowNew] = useState(false);
+ const [showConfirm, setShowConfirm] = useState(false);
+
+ const {
+ register,
+ handleSubmit,
+ formState: { errors, isSubmitting },
+ } = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: { new_password: '', confirm_password: '' },
+ });
+
+ const onSubmit = async ({ new_password }) => {
+ try {
+ await api.post('/auth/change-password', { new_password });
+
+ // Update local user state so must_change_password is cleared
+ setUser((prev) => ({ ...prev, must_change_password: false }));
+
+ toast.success('Password changed successfully. Welcome!');
+
+ // Redirect to the correct dashboard
+ switch (user?.acc_type) {
+ case 'admin': navigate('/admin'); break;
+ case 'staff': navigate('/staff'); break;
+ case 'client': navigate('/client'); break;
+ default: navigate('/');
+ }
+ } catch (err) {
+ toast.error(err?.response?.data?.message || 'Could not change password.');
+ }
+ };
+
+ return (
+
+
+
+ {/* Header */}
+
+
+
+
+
Set your password
+
+ Your account requires a new password before you can continue.
+
+
+
+ {/* Form */}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/modules/staff/components/CompletionMatrix.jsx b/src/modules/staff/components/CompletionMatrix.jsx
new file mode 100644
index 0000000..184122f
--- /dev/null
+++ b/src/modules/staff/components/CompletionMatrix.jsx
@@ -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 (
+
+ No task lists assigned to this group.
+
+ );
+ }
+
+ return (
+
+ {/* Task list selector — only shown when there are multiple */}
+ {taskLists.length > 1 && (
+
+ {taskLists.map((tl) => (
+ 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}
+
+ ))}
+
+ )}
+
+ {/* Loading */}
+ {loading && (
+
+ )}
+
+ {/* Matrix table */}
+ {progress && !loading && (
+
+
+
+
+
+ Member
+
+ {progress.data[0]?.tasks.map(({ task }) => (
+
+ {task.name}
+
+ ))}
+
+ Progress
+
+
+
+
+ {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 (
+
+
+ {fullName}
+
+
+ {tasks.map(({ task, completion }) => (
+
+
+ {completion.status === "completed" ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+ ))}
+
+
+
+
+
+ {completed_count}/{total_tasks}
+
+
+
+
+ );
+ })}
+
+
+
+ )}
+
+ {progress && !loading && progress.data.length === 0 && (
+
+ No members found for this task list.
+
+ )}
+
+ );
+}
diff --git a/src/modules/staff/components/GroupTile.jsx b/src/modules/staff/components/GroupTile.jsx
new file mode 100644
index 0000000..cc2183e
--- /dev/null
+++ b/src/modules/staff/components/GroupTile.jsx
@@ -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 (
+ e.key === "Enter" && onClick?.()}
+ >
+
+ {/* Header */}
+
+
+
{group.name}
+ {group.description && (
+
+ {group.description}
+
+ )}
+
+ {group.group_code && (
+
+ {group.group_code}
+
+ )}
+
+
+ {/* Avatar stack */}
+
+ {avatarSlice.map((init, i) => (
+
+ {init}
+
+ ))}
+ {overflow > 0 && (
+
+ +{overflow}
+
+ )}
+ {members.length === 0 && (
+
No members yet
+ )}
+
+
+ {/* Footer meta */}
+
+
+ {members.length} member{members.length !== 1 ? "s" : ""}
+
+
+ {taskLists.length} list{taskLists.length !== 1 ? "s" : ""}
+
+
+ {pct}% done
+
+
+
+
+ );
+}
diff --git a/src/modules/staff/components/MemberDetailTabs.jsx b/src/modules/staff/components/MemberDetailTabs.jsx
new file mode 100644
index 0000000..aaab631
--- /dev/null
+++ b/src/modules/staff/components/MemberDetailTabs.jsx
@@ -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 (
+
+ {label}
+ {value || "—"}
+
+ );
+}
+
+export default function MemberDetailTabs({ member }) {
+ const info = member.personal_info ?? {};
+ const name = info.name ?? {};
+ const phones = info.phone_number ?? [];
+ const addresses = info.addresses ?? [];
+
+ return (
+
+
+ Personal details
+ User activity
+
+
+ {/* Personal details */}
+
+
+
+
+
+
+
+
+ `+${p.country_code} ${p.number} (${p.phone_type})`).join(", ")
+ : null
+ }
+ />
+
+ a.full_address).join("; ")
+ : null
+ }
+ />
+
+ {/* Account info section */}
+
+
+ Account type
+
+ {member.acc_type}
+
+
+
+ Status
+
+
+ {member.is_active ? "Active" : "Inactive"}
+
+
+
+
+
+
+ {/* Activity */}
+
+
+ {[
+ { label: "Tasks assigned", value: "—" },
+ { label: "Tasks completed", value: "—" },
+ { label: "Last active", value: "—" },
+ ].map(({ label, value }) => (
+
+ ))}
+
+
+ No activity recorded yet.
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/modules/staff/components/MemberRow.jsx b/src/modules/staff/components/MemberRow.jsx
new file mode 100644
index 0000000..c62fdc0
--- /dev/null
+++ b/src/modules/staff/components/MemberRow.jsx
@@ -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 (
+
+ {/* Avatar */}
+
+ {initials}
+
+
+ {/* Info */}
+
+
{fullName}
+
{member.email}
+
+
+ {/* Active badge */}
+
+ {member.is_active ? "active" : "inactive"}
+
+
+ );
+}
diff --git a/src/modules/staff/components/ScoreBadge.jsx b/src/modules/staff/components/ScoreBadge.jsx
new file mode 100644
index 0000000..0719d8c
--- /dev/null
+++ b/src/modules/staff/components/ScoreBadge.jsx
@@ -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 (
+
+ —
+
+ );
+ }
+
+ const passed = parseFloat(score) >= passingScore;
+
+ return (
+
+ {parseFloat(score).toFixed(1)}%
+
+ );
+}
diff --git a/src/modules/staff/components/TaskListDetail.jsx b/src/modules/staff/components/TaskListDetail.jsx
new file mode 100644
index 0000000..943a6a1
--- /dev/null
+++ b/src/modules/staff/components/TaskListDetail.jsx
@@ -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 (
+
+ {STATUS_LABEL[status] ?? status ?? "Not started"}
+
+ );
+}
+
+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 (
+
+
+ Summary
+ Tasks ({tasks.length})
+
+
+ {/* ── Summary ─────────────────────────────────────────────────────── */}
+
+
+ {/* Stat cards via StatGrid */}
+
+
+ {/* Overall progress bar */}
+
+
+ Overall completion
+ {counts.pct}%
+
+
+
+
+ {tasks.length === 0 ? (
+
+ No tasks in this list yet.
+
+ ) : (
+
+ {/* Status breakdown pie */}
+
+
+ {/* Per-task bar (horizontal) */}
+
20 ? " (first 20)" : ""}`}
+ data={barData}
+ height={Math.max(160, barData.length * 32 + 40)}
+ yAxisWidth={100}
+ />
+
+ )}
+
+ {/* Meta */}
+
+
Created: {formatDate(taskList.createdAt)}
+
Assigned: {formatDate(taskList.TaskListGroup?.assignedAt)}
+
+
+
+ {/* ── Tasks DataTable ──────────────────────────────────────────────── */}
+
+ { setSearch(e.target.value); setPage(1); }}
+ className="h-8 text-sm w-56"
+ />
+
+
+
+
+
+ #
+ Task name
+ Status
+ Due date
+
+
+
+ {slice.length === 0 ? (
+
+
+ {tasks.length === 0 ? "No tasks in this list." : "No tasks match your search."}
+
+
+ ) : (
+ slice.map((task, i) => (
+
+
+ {(safePage - 1) * PAGE_SIZE + i + 1}
+
+
+ {task.name ?? `Task ${i + 1}`}
+
+
+
+
+
+ {formatDate(task.due_date)}
+
+
+ ))
+ )}
+
+
+
+
+ {/* Pagination */}
+ {filtered.length > 0 && (
+
+
+ {`${(safePage - 1) * PAGE_SIZE + 1}–${Math.min(safePage * PAGE_SIZE, filtered.length)} of ${filtered.length}`}
+
+
+ setPage(safePage - 1)}
+ >
+ Previous
+
+ {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 === "..." ? (
+ …
+ ) : (
+ setPage(p)}
+ >
+ {p}
+
+ )
+ )}
+ setPage(safePage + 1)}
+ >
+ Next
+
+
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/modules/staff/components/TaskListTable.jsx b/src/modules/staff/components/TaskListTable.jsx
new file mode 100644
index 0000000..d275596
--- /dev/null
+++ b/src/modules/staff/components/TaskListTable.jsx
@@ -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 (
+ <>
+ (
+
+ )}
+ columnPinning={columnPinning}
+ toolbarActions={toolbarActions}
+ selectionActions={selectionActions}
+ recordLabel="task list"
+ emptyMessage="No task lists found."
+ />
+
+ {/* Single archive */}
+ !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 */}
+ !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 */}
+ !v && setArchiveIds(null)}
+ ids={archiveIds ?? []}
+ entityLabel="Task List"
+ onArchive={({ ids }) => bulkArchiveTaskLists(ids)}
+ loading={loading}
+ onSuccess={handleSuccess}
+ />
+
+ {/* Bulk restore */}
+ !v && setRestoreIds(null)}
+ ids={restoreIds ?? []}
+ entityLabel="Task List"
+ onRestore={({ ids }) => bulkRestoreTaskLists(ids)}
+ loading={loading}
+ onSuccess={handleSuccess}
+ />
+ >
+ );
+}
\ No newline at end of file
diff --git a/src/modules/staff/components/TaskListsTab.jsx b/src/modules/staff/components/TaskListsTab.jsx
new file mode 100644
index 0000000..39e9686
--- /dev/null
+++ b/src/modules/staff/components/TaskListsTab.jsx
@@ -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 (
+
+ {/* Charts — only shown when there's actual task data */}
+ {taskLists.length > 0 && hasAnyTasks && (
+
+ )}
+
+ {/* Table */}
+
+
+
+
+ Name
+ Tasks
+ Progress
+ Assigned
+
+
+
+
+ {taskLists.length === 0 ? (
+
+
+ No task lists assigned to this group.
+
+
+ ) : (
+ 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 (
+ setSelected(tl)}
+ >
+ {tl.name}
+
+ {total} task{total !== 1 ? "s" : ""}
+
+
+
+
+
+ {formatDate(tl.TaskListGroup?.assignedAt)}
+
+
+ { e.stopPropagation(); setSelected(tl); }}
+ >
+
+
+
+
+ );
+ })
+ )}
+
+
+
+
+ {/* Task list detail dialog */}
+
!open && setSelected(null)}>
+
+
+ {selected?.name}
+ {selected?.description && (
+ {selected.description}
+ )}
+
+ {selected && }
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/modules/staff/components/TaskRow.jsx b/src/modules/staff/components/TaskRow.jsx
new file mode 100644
index 0000000..1ba0800
--- /dev/null
+++ b/src/modules/staff/components/TaskRow.jsx
@@ -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 (
+
+ {/* Name */}
+
{task.name}
+
+ {/* Requirement type badge */}
+ {reqType && (
+
+ {REQ_TYPE_LABELS[reqType]}
+
+ )}
+
+ {/* Status badge */}
+ {task.status && (
+
+ {task.status.replace("_", " ")}
+
+ )}
+
+ {/* Deadline */}
+ {task.deadline && (
+
+ {format(new Date(task.deadline), "MMM d")}
+
+ )}
+
+ {/* Edit / Delete */}
+ {(onEdit || onDelete) && (
+
+ {onEdit && (
+
{ e.stopPropagation(); onEdit(); }}
+ title="Edit task"
+ >
+
+
+ )}
+ {onDelete && (
+
{ e.stopPropagation(); onDelete(); }}
+ title="Delete task"
+ >
+
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/src/modules/staff/components/members/MembersTable.jsx b/src/modules/staff/components/members/MembersTable.jsx
new file mode 100644
index 0000000..377dacb
--- /dev/null
+++ b/src/modules/staff/components/members/MembersTable.jsx
@@ -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 (
+ <>
+ (
+
+ )}
+ columnPinning={columnPinning}
+ toolbarActions={toolbarActions}
+ selectionActions={selectionActions}
+ recordLabel="member"
+ emptyMessage="No members found."
+ onRowClick={(row) => setSelected(row.original)}
+ />
+
+ {/* Member detail dialog */}
+ !open && setSelected(null)}>
+
+
+
+ {selected && (
+
m.user_id === selected.user_id) % AVATAR_COLORS.length
+ ]
+ }`}
+ >
+ {getInitials(selected)}
+
+ )}
+
+
+ {selected && getFullName(selected)}
+
+
+ {selected?.personal_info?.occupation} · {selected?.acc_type}
+
+
+
+
+ {selected && }
+
+
+ >
+ );
+}
\ No newline at end of file
diff --git a/src/modules/staff/config/members/columns.config.jsx b/src/modules/staff/config/members/columns.config.jsx
new file mode 100644
index 0000000..e300134
--- /dev/null
+++ b/src/modules/staff/config/members/columns.config.jsx
@@ -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) => (
+
+ ),
+};
+
+/**
+ * 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" }),
+ ];
+}
\ No newline at end of file
diff --git a/src/modules/staff/config/members/rowActions.config.jsx b/src/modules/staff/config/members/rowActions.config.jsx
new file mode 100644
index 0000000..b51059e
--- /dev/null
+++ b/src/modules/staff/config/members/rowActions.config.jsx
@@ -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: ,
+ onClick: (row) => navigate(`${row.task_list_id}/view`),
+ },
+ {
+ key: "edit",
+ label: "Edit Info",
+ icon: ,
+ onClick: (row) => navigate(`${row.task_list_id}/edit`),
+ hidden: () => showArchived,
+ },
+ {
+ key: "tasks",
+ label: "View Tasks",
+ icon: ,
+ onClick: (row) => navigate(`${row.task_list_id}/tasks`),
+ separator: true,
+ className: "text-sky-800",
+ },
+ {
+ key: "archive",
+ label: "Archive",
+ icon: ,
+ className: "text-destructive focus:text-destructive",
+ onClick: (row) => onArchive(row),
+ hidden: () => showArchived,
+ separator: true,
+ },
+ {
+ key: "restore",
+ label: "Restore",
+ icon: ,
+ className: "text-emerald-600 focus:text-emerald-600",
+ onClick: (row) => onRestore(row),
+ hidden: () => !showArchived,
+ separator: true,
+ },
+ ];
+}
\ No newline at end of file
diff --git a/src/modules/staff/config/members/selection.config.jsx b/src/modules/staff/config/members/selection.config.jsx
new file mode 100644
index 0000000..4dd64c0
--- /dev/null
+++ b/src/modules/staff/config/members/selection.config.jsx
@@ -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: ,
+ onClick: (rows, table) =>
+ exportTableToExcel({
+ ...exportConfig,
+ selectedRows: rows,
+ tableInstance: table ?? getTableInstance?.(),
+ }),
+ },
+ {
+ key: showArchived ? "restore-selected" : "archive-selected",
+ label: showArchived ? "Restore" : "Archive",
+ icon: showArchived ? (
+
+ ) : (
+
+ ),
+ 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);
+ },
+ },
+ ];
+}
\ No newline at end of file
diff --git a/src/modules/staff/config/members/toolbar.config.jsx b/src/modules/staff/config/members/toolbar.config.jsx
new file mode 100644
index 0000000..e492ef5
--- /dev/null
+++ b/src/modules/staff/config/members/toolbar.config.jsx
@@ -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: ,
+ label: "Refresh",
+ onClick: () => {
+ const fetcher = showArchived ? fetchArchivedTaskLists : fetchTaskLists;
+ fetcher({
+ page: 1,
+ limit: pagination?.limit ?? 10,
+ filters: getFilters?.() ?? [],
+ sort: getSort?.() ?? [],
+ });
+ },
+ },
+ {
+ key: "export",
+ type: "button",
+ icon: ,
+ label: "Export",
+ onClick: (table) =>
+ exportTableToExcel({
+ ...exportConfig,
+ tableInstance: table ?? getTableInstance?.(),
+ }),
+ },
+ {
+ key: "add-task-list",
+ type: "button",
+ icon: ,
+ label: "Create Task List",
+ variant: "default",
+ className: "text-primary-foreground",
+ onClick: () => navigate(`/staff/task-lists/create`),
+ },
+ {
+ key: "toggle-archived",
+ type: "button",
+ icon: ,
+ label: showArchived ? "Active Task Lists" : "Archived Task Lists",
+ variant: "secondary",
+ className: "border border-border",
+ onClick: onToggleArchived,
+ },
+ ];
+}
\ No newline at end of file
diff --git a/src/modules/staff/config/task_list/columns.config.jsx b/src/modules/staff/config/task_list/columns.config.jsx
new file mode 100644
index 0000000..e300134
--- /dev/null
+++ b/src/modules/staff/config/task_list/columns.config.jsx
@@ -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) => (
+
+ ),
+};
+
+/**
+ * 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" }),
+ ];
+}
\ No newline at end of file
diff --git a/src/modules/staff/config/task_list/rowActions.config.jsx b/src/modules/staff/config/task_list/rowActions.config.jsx
new file mode 100644
index 0000000..b51059e
--- /dev/null
+++ b/src/modules/staff/config/task_list/rowActions.config.jsx
@@ -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: ,
+ onClick: (row) => navigate(`${row.task_list_id}/view`),
+ },
+ {
+ key: "edit",
+ label: "Edit Info",
+ icon: ,
+ onClick: (row) => navigate(`${row.task_list_id}/edit`),
+ hidden: () => showArchived,
+ },
+ {
+ key: "tasks",
+ label: "View Tasks",
+ icon: ,
+ onClick: (row) => navigate(`${row.task_list_id}/tasks`),
+ separator: true,
+ className: "text-sky-800",
+ },
+ {
+ key: "archive",
+ label: "Archive",
+ icon: ,
+ className: "text-destructive focus:text-destructive",
+ onClick: (row) => onArchive(row),
+ hidden: () => showArchived,
+ separator: true,
+ },
+ {
+ key: "restore",
+ label: "Restore",
+ icon: ,
+ className: "text-emerald-600 focus:text-emerald-600",
+ onClick: (row) => onRestore(row),
+ hidden: () => !showArchived,
+ separator: true,
+ },
+ ];
+}
\ No newline at end of file
diff --git a/src/modules/staff/config/task_list/selection.config.jsx b/src/modules/staff/config/task_list/selection.config.jsx
new file mode 100644
index 0000000..4dd64c0
--- /dev/null
+++ b/src/modules/staff/config/task_list/selection.config.jsx
@@ -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: ,
+ onClick: (rows, table) =>
+ exportTableToExcel({
+ ...exportConfig,
+ selectedRows: rows,
+ tableInstance: table ?? getTableInstance?.(),
+ }),
+ },
+ {
+ key: showArchived ? "restore-selected" : "archive-selected",
+ label: showArchived ? "Restore" : "Archive",
+ icon: showArchived ? (
+
+ ) : (
+
+ ),
+ 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);
+ },
+ },
+ ];
+}
\ No newline at end of file
diff --git a/src/modules/staff/config/task_list/toolbar.config.jsx b/src/modules/staff/config/task_list/toolbar.config.jsx
new file mode 100644
index 0000000..e492ef5
--- /dev/null
+++ b/src/modules/staff/config/task_list/toolbar.config.jsx
@@ -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: ,
+ label: "Refresh",
+ onClick: () => {
+ const fetcher = showArchived ? fetchArchivedTaskLists : fetchTaskLists;
+ fetcher({
+ page: 1,
+ limit: pagination?.limit ?? 10,
+ filters: getFilters?.() ?? [],
+ sort: getSort?.() ?? [],
+ });
+ },
+ },
+ {
+ key: "export",
+ type: "button",
+ icon: ,
+ label: "Export",
+ onClick: (table) =>
+ exportTableToExcel({
+ ...exportConfig,
+ tableInstance: table ?? getTableInstance?.(),
+ }),
+ },
+ {
+ key: "add-task-list",
+ type: "button",
+ icon: ,
+ label: "Create Task List",
+ variant: "default",
+ className: "text-primary-foreground",
+ onClick: () => navigate(`/staff/task-lists/create`),
+ },
+ {
+ key: "toggle-archived",
+ type: "button",
+ icon: ,
+ label: showArchived ? "Active Task Lists" : "Archived Task Lists",
+ variant: "secondary",
+ className: "border border-border",
+ onClick: onToggleArchived,
+ },
+ ];
+}
\ No newline at end of file
diff --git a/src/modules/staff/layouts/StaffLayout.jsx b/src/modules/staff/layouts/StaffLayout.jsx
new file mode 100644
index 0000000..359f321
--- /dev/null
+++ b/src/modules/staff/layouts/StaffLayout.jsx
@@ -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 (
+
+ {/* Sidebar */}
+
+
+
+
+
+ {navItems.map(({ to, label, icon: Icon, end }) => (
+
+ 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'
+ )
+ }
+ >
+
+ {label}
+
+ ))}
+
+
+
+
+ {/*
+
+ Settings
+
+
*/}
+
+
+ {/* Main */}
+
+ {/* Topbar */}
+
+
+ {/* Page content */}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/modules/staff/pages/DashboardPage.jsx b/src/modules/staff/pages/DashboardPage.jsx
new file mode 100644
index 0000000..a53af93
--- /dev/null
+++ b/src/modules/staff/pages/DashboardPage.jsx
@@ -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 (
+
+
Dashboard
+
+ {/* ── Stat tiles ──────────────────────────────────────────────────── */}
+
+ {STATS.map(({ label, value, icon: Icon, sub }) => (
+
+
+
+
+ {loading ? "—" : value}
+
+ {sub}
+
+
+ ))}
+
+
+ {/* ── Group tiles ──────────────────────────────────────────────────── */}
+
+
+ My groups
+
+ {loading ? (
+
+ {[0, 1].map((i) => (
+
+ ))}
+
+ ) : groups.length === 0 ? (
+
+ You are not assigned to any groups yet.
+
+ ) : (
+
+ {groups.map((group) => (
+ navigate(`/staff/groups/${group.group_id}`)}
+ />
+ ))}
+
+ )}
+
+
+ {/* ── Recent tasks ─────────────────────────────────────────────────── */}
+
+
+ Recent task activity
+
+
+
+ {recentTasks.length === 0 && !loading && (
+ No tasks yet.
+ )}
+ {recentTasks.map((task) => (
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/modules/staff/pages/GroupDetailPage.jsx b/src/modules/staff/pages/GroupDetailPage.jsx
new file mode 100644
index 0000000..fb0fec0
--- /dev/null
+++ b/src/modules/staff/pages/GroupDetailPage.jsx
@@ -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 (
+
+ );
+ }
+
+ if (!group) {
+ return (
+
+
navigate("/staff/groups")}
+ className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
+ >
+ Back to groups
+
+
Group not found.
+
+ );
+ }
+
+ 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 (
+
+ {/* Back */}
+
navigate("/staff/groups")}
+ className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
+ >
+ Back to groups
+
+
+ {/* ── Group header card ───────────────────────────────────────────────── */}
+
+
+
+
+
+ {getInitials(group.name)}
+
+
+
+
{group.name}
+ {group.group_code && (
+
+ {group.group_code}
+
+ )}
+
+ {group.is_active ? "Active" : "Inactive"}
+
+
+ {group.description && (
+
+ {group.description}
+
+ )}
+
+
+
+
+
Created {formatDate(group.createdAt)}
+
Updated {formatDate(group.updatedAt)}
+
+
+
+ {/* Summary stats */}
+
+
+
{memberPagination?.total ?? "—"}
+
Members
+
+
+
{listCount}
+
Task lists
+
+
+
+ {totalTasks > 0
+ ? `${Math.round((completedTasks / totalTasks) * 100)}%`
+ : "—"}
+
+
Completion
+
+
+
+
+
+ {/* ── Tabs ───────────────────────────────────────────────────────────── */}
+
+
+
+ Members ({memberPagination?.total ?? 0})
+
+
+ Task lists ({listCount})
+
+
+
+
+ fetchGroupMembers(groupId, params)}
+ fetchMemberFieldValues={(col, params) =>
+ fetchGroupMemberFieldValues(groupId, col, params)
+ }
+ />
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/modules/staff/pages/GroupsPage.jsx b/src/modules/staff/pages/GroupsPage.jsx
new file mode 100644
index 0000000..c0cf8dc
--- /dev/null
+++ b/src/modules/staff/pages/GroupsPage.jsx
@@ -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 (
+
+
My groups
+
+ {loading ? (
+
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
+ ) : groups.length === 0 ? (
+
+ You are not assigned to any groups yet.
+
+ ) : (
+
+ {groups.map((group) => (
+ navigate(`/staff/groups/${group.group_id}`)}
+ />
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/modules/staff/pages/ScoresPage.jsx b/src/modules/staff/pages/ScoresPage.jsx
new file mode 100644
index 0000000..9bacf02
--- /dev/null
+++ b/src/modules/staff/pages/ScoresPage.jsx
@@ -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 (
+
+ {/* ── Header ──────────────────────────────────────────────────────── */}
+
+
Scores & progress
+
+
+
+
+
+ {groups.map((g) => (
+
+ {g.name}
+
+ ))}
+
+
+
+
+
+
+ Quiz scores
+ Assessment scores
+
+
+ {/* ── Quiz scores ─────────────────────────────────────────────── */}
+
+
+
+
+
+
+ {quizTasks.length === 0 ? (
+
+ No unit quizzes in this group.
+
+ ) : (
+ quizTasks.map((t) => (
+
+ {t.requirements[0].reference_label ?? t.name}
+
+ ))
+ )}
+
+
+
+ {loading && quizId && (
+
+ )}
+
+ {quizScores && !loading && (
+ <>
+
+
+ {quizScores.quiz?.title ?? "Unit quiz"}
+
+
+ Passing: {quizScores.quiz?.passing_score ?? 70}%
+
+ {quizScores.quiz?.unit?.title && (
+
+ {quizScores.quiz.unit.title}
+
+ )}
+
+
+ >
+ )}
+
+ {!quizId && !loading && (
+
+ Select a unit quiz above to view scores.
+
+ )}
+
+
+ {/* ── Assessment scores ────────────────────────────────────────── */}
+
+
+
+
+
+
+ {assessmentTasks.length === 0 ? (
+
+ No course assessments in this group.
+
+ ) : (
+ assessmentTasks.map((t) => (
+
+ {t.requirements[0].reference_label ?? t.name}
+
+ ))
+ )}
+
+
+
+ {loading && assessmentId && (
+
+ )}
+
+ {assessScores && !loading && (
+ <>
+
+
+ {assessScores.assessment?.title ?? "Course assessment"}
+
+
+ Passing: {assessScores.assessment?.passing_score ?? 75}%
+
+ {assessScores.assessment?.course?.title && (
+
+ {assessScores.assessment.course.title}
+
+ )}
+
+
+ >
+ )}
+
+ {!assessmentId && !loading && (
+
+ Select a course assessment above to view scores.
+
+ )}
+
+
+
+ );
+}
+
+// ── ScoreTable ────────────────────────────────────────────────────────────────
+function ScoreTable({ data = [], passingScore }) {
+ if (!data.length) {
+ return No data yet.
;
+ }
+
+ 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 (
+
+
+ {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 (
+
+
+ {initials}
+
+
+
{fullName}
+
+ {attempts.length === 0
+ ? "No attempt yet"
+ : `${attempts.length} attempt${attempts.length !== 1 ? "s" : ""} · best score`}
+
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/modules/staff/pages/TaskListsPage.jsx b/src/modules/staff/pages/TaskListsPage.jsx
new file mode 100644
index 0000000..991974e
--- /dev/null
+++ b/src/modules/staff/pages/TaskListsPage.jsx
@@ -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: , to: "/staff" },
+ { label: "Task Lists" },
+ ];
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/routes/RequiredPasswordChange.jsx b/src/routes/RequiredPasswordChange.jsx
new file mode 100644
index 0000000..8561458
--- /dev/null
+++ b/src/routes/RequiredPasswordChange.jsx
@@ -0,0 +1,25 @@
+// RequirePasswordChange.jsx
+import { Navigate, Outlet } from 'react-router-dom'
+import { useAuth } from '../contexts/AuthContext'
+
+export default function RequirePasswordChange() {
+ const { user, loading } = useAuth()
+
+ if (loading) return null
+
+ // No user at all → send to login
+ if (!user) return
+
+ // User is logged in but doesn't need to change password → send to dashboard
+ if (!user.must_change_password) {
+ switch (user.acc_type) {
+ case 'admin': return
+ case 'staff': return
+ case 'client': return
+ default: return
+ }
+ }
+
+ // User is logged in AND must change password → allow through
+ return
+}
\ No newline at end of file