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 selected +

+
+
+ {overflow.map((item) => ( +
+ {item[fieldLabel]} + {fieldMeta && item[fieldMeta] && ( + + {item[fieldMeta]} + + )} + +
+ ))} +
+
+
+ ); +} + +// ─── 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 ── */} + + + + + {/* ── 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 */} + {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]} + )} + + + ))} + + {/* +N overflow → popover */} + {overflowBadges.length > 0 && ( + toggle(id)} + fieldId={fieldId} + fieldLabel={fieldLabel} + fieldMeta={fieldMeta} + /> + )} + + {/* Clear all */} + {selected.length > 1 && ( + + )} +
+ )} +
+ ); +} \ 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 */} + + + +
+ + {/* ── visit_link fields ── */} + {item.type === 'visit_link' && ( +
+
+ + updateItem(item._key, { link_url: e.target.value })} + className="h-8 text-sm" + /> +
+
+ + updateItem(item._key, { link_label: e.target.value })} + className="h-8 text-sm" + /> +
+
+ )} + + {/* ── upload_file fields ── */} + {item.type === 'upload_file' && ( +
+
+ +
+ {FILE_TYPE_OPTIONS.map((ft) => ( + toggleFileType(item._key, ft.value)} + > + {ft.label} + + ))} +
+
+
+ + 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) && ( +
+ + + {/* Reference selector */} + {item.type === 'read_course' && ( + + )} + + {item.type === 'read_unit' && ( + + )} + + {item.type === 'read_lesson' && ( + + )} +
+ )} +
+
+ ); + })} + + +
+ ); +} \ 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 ( +
+ + {children} + {error &&

{error}

} +
+ ); +} + +// ─── Password input with toggle ─────────────────────────────────────────────── +function PasswordInput({ visible, onToggle, ...props }) { + return ( +
+ + +
+ ); +} + +// ─── 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 */} +
+ + setShowNew((v) => !v)} + placeholder="Enter new password" + disabled={isSubmitting} + {...register('new_password')} + /> + + + + setShowConfirm((v) => !v)} + placeholder="Confirm new password" + disabled={isSubmitting} + {...register('confirm_password')} + /> + + + {/* Password rules hint */} +
    +
  • At least 8 characters
  • +
  • One uppercase & one lowercase letter
  • +
  • One number and one special character
  • +
+ + +
+ +
+
+ ); +} \ 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) => ( + + ))} +
+ )} + + {/* Loading */} + {loading && ( +
+ )} + + {/* Matrix table */} + {progress && !loading && ( +
+ + + + + {progress.data[0]?.tasks.map(({ task }) => ( + + ))} + + + + + {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 ( + + + + {tasks.map(({ task, completion }) => ( + + ))} + + + + ); + })} + +
+ Member + + {task.name} + + Progress +
+

{fullName}

+
+
+ {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 }) => ( +
+

{value}

+

{label}

+
+ ))} +
+

+ 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}`} + +
+ + {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 === "..." ? ( + … + ) : ( + + ) + )} + +
+
+ )} +
+ + ); +} \ 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" : ""} + + +
+
+
+
+ {pct}% +
+ + + {formatDate(tl.TaskListGroup?.assignedAt)} + + + + + + ); + }) + )} + +
+
+ + {/* 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 && ( + + )} + {onDelete && ( + + )} +
+ )} +
+ ); +} 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 */} + + + + {/* Main */} +
+ {/* Topbar */} +
+
+ {/* Breadcrumb rendered by each page via a portal or just title */} +
+ + +
+ + {/* 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 }) => ( + + +
+

{label}

+ +
+

+ {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 ( +
+ +

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 */} + + + {/* ── 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

+ +
+ + + + Quiz scores + Assessment scores + + + {/* ── Quiz scores ─────────────────────────────────────────────── */} + + + + {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 ────────────────────────────────────────── */} + + + + {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