This commit is contained in:
rgrgogu
2026-06-24 13:43:28 +08:00
parent 93c3c688ca
commit b03b204861
35 changed files with 4238 additions and 2 deletions
+463
View File
@@ -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)
// <ComboBoxCommand
// value={field.value}
// onChange={field.onChange}
// items={groups}
// loading={groupsLoading}
// onSearch={onSearchGroups}
// fieldId="group_id"
// fieldLabel="name"
// fieldMeta="group_code"
// placeholder="Select groups…"
// unit="group"
// />
//
// // Users
// <ComboBoxCommand
// value={field.value}
// onChange={field.onChange}
// items={users}
// fieldId="user_id"
// fieldLabel="full_name"
// fieldMeta="email"
// placeholder="Assign users…"
// unit="user"
// />
//
// // Tags (no meta badge)
// <ComboBoxCommand
// value={field.value}
// onChange={field.onChange}
// items={tags}
// fieldId="tag_id"
// fieldLabel="label"
// placeholder="Select tags…"
// unit="tag"
// />
// ─────────────────────────────────────────────────────────────────────────────
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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"inline-flex items-center h-6 px-2 rounded-full text-xs font-medium transition-colors",
"bg-primary/10 text-primary border border-primary/20",
"hover:bg-primary/20 hover:border-primary/40",
open && "bg-primary/20 border-primary/40"
)}
>
+{overflow.length} more
</button>
</PopoverTrigger>
<PopoverContent className="p-0 w-64" align="start" sideOffset={4}>
<div className="px-3 py-2 border-b border-border">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
{overflow.length} more selected
</p>
</div>
<div className="p-2 space-y-0.5 max-h-52 overflow-y-auto">
{overflow.map((item) => (
<div
key={item[fieldId]}
className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-muted/50 group transition-colors"
>
<span className="flex-1 text-sm truncate">{item[fieldLabel]}</span>
{fieldMeta && item[fieldMeta] && (
<span className="font-mono text-[10px] text-muted-foreground bg-muted px-1 py-0.5 rounded shrink-0">
{item[fieldMeta]}
</span>
)}
<button
type="button"
onClick={() => onRemove(item[fieldId])}
className={cn(
"shrink-0 rounded-sm p-0.5 opacity-0 group-hover:opacity-60",
"hover:!opacity-100 hover:bg-destructive/20 transition-all"
)}
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
</PopoverContent>
</Popover>
);
}
// ─── 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 (
<div className="space-y-2">
<Popover open={open} onOpenChange={setOpen}>
{/* ── Trigger ── */}
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between font-normal h-9"
>
<span className="truncate">
{selected.length > 0
? `${plural(selected.length)} selected`
: placeholder}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
{/* ── Dropdown ── */}
<PopoverContent className="p-0 w-[480px]" align="start" sideOffset={4}>
<Command shouldFilter={false}>
{/* Search bar */}
<div className="flex items-center gap-1.5 border-b border-border px-2 py-1.5">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
<input
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={searchPlaceholder}
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground py-1"
/>
<button
type="button"
onClick={handleSearch}
disabled={!inputValue.trim() || loading}
title="Search on server"
className={cn(
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
"border border-border bg-muted hover:bg-muted/70",
"disabled:opacity-40 disabled:cursor-not-allowed",
inputValue.trim() && !loading &&
"border-primary/40 bg-primary/10 text-primary hover:bg-primary/15"
)}
>
{loading
? <Loader2 className="h-3 w-3 animate-spin" />
: <Search className="h-3 w-3" />
}
Search
</button>
</div>
<CommandList className="max-h-[300px]">
{/* Loading */}
{loading && (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading…
</div>
)}
{/* Empty */}
{!loading && filtered.length === 0 && (
<CommandEmpty className="py-6 text-sm text-muted-foreground text-center">
{inputValue ? `No results for "${inputValue}".` : emptyLabel}
</CommandEmpty>
)}
{!loading && filtered.length > 0 && (
<>
{/* Select All row */}
<div
role="button"
onClick={toggleAll}
className={cn(
"flex items-center gap-3 px-3 py-2.5 cursor-pointer select-none",
"border-b border-border transition-colors hover:bg-muted/50",
(allSelected || someSelected) && "bg-primary/5"
)}
>
{/* Embossed checkbox */}
<span className={cn(
"h-4 w-4 shrink-0 rounded border-2 flex items-center justify-center transition-all",
"shadow-[inset_0_2px_4px_rgba(0,0,0,0.12),inset_0_1px_2px_rgba(0,0,0,0.08)]",
allSelected
? "bg-primary border-primary shadow-none"
: someSelected
? "bg-primary/15 border-primary"
: "bg-background border-border"
)}>
{allSelected && <Check className="h-2.5 w-2.5 text-primary-foreground stroke-[3.5]" />}
{someSelected && <span className="h-1.5 w-1.5 rounded-[2px] bg-primary block" />}
</span>
<span className="text-sm font-semibold text-foreground">
{allSelected ? deselectAllLabel : selectAllLabel}
</span>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
{plural(filtered.length)}
</span>
</div>
{/* Items */}
<CommandGroup>
{filtered.map((item) => {
const id = item[fieldId];
const label = item[fieldLabel];
const meta = fieldMeta ? item[fieldMeta] : null;
const isSelected = value.includes(id);
return (
<CommandItem
key={id}
value={String(id)}
onSelect={() => 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 */}
<span className={cn(
"h-4 w-4 shrink-0 rounded border-2 flex items-center justify-center transition-all",
isSelected
? "bg-primary border-primary shadow-none ring-2 ring-primary/25 ring-offset-1"
: [
"bg-background border-border",
"shadow-[inset_0_2px_4px_rgba(0,0,0,0.10),inset_0_1px_2px_rgba(0,0,0,0.06)]",
"hover:border-primary/60",
]
)}>
{isSelected && (
<Check className="h-2.5 w-2.5 text-primary-foreground stroke-[3.5]" />
)}
</span>
{/* Label */}
<span className={cn(
"flex-1 truncate text-sm",
isSelected ? "font-medium text-foreground" : "text-foreground/90"
)}>
{label}
</span>
{/* Meta pill */}
{meta && (
<span className={cn(
"font-mono text-[11px] px-1.5 py-0.5 rounded shrink-0",
isSelected
? "bg-primary/15 text-primary"
: "bg-muted text-muted-foreground"
)}>
{meta}
</span>
)}
</CommandItem>
);
})}
</CommandGroup>
</>
)}
</CommandList>
{/* Footer */}
{!loading && safeItems.length > 0 && (
<>
<CommandSeparator />
<div className="flex items-center justify-between px-3 py-2 text-[11px] text-muted-foreground">
<span>
{value.length > 0 ? `${plural(value.length)} selected` : "None selected"}
</span>
<span className="tabular-nums">
{filtered.length} / {safeItems.length} shown
</span>
</div>
</>
)}
</Command>
</PopoverContent>
</Popover>
{/* ── Selected badges with +N overflow ── */}
{selected.length > 0 && (
<div className="flex flex-wrap gap-1.5 items-center">
{/* First maxVisible badges */}
{visibleBadges.map((item) => (
<Badge
key={item[fieldId]}
variant="secondary"
className="gap-1 pr-1 text-xs h-6"
>
{item[fieldLabel]}
{fieldMeta && item[fieldMeta] && (
<span className="font-mono opacity-50 text-[10px]">{item[fieldMeta]}</span>
)}
<button
type="button"
onClick={() => toggle(item[fieldId])}
className="rounded-sm opacity-60 hover:opacity-100 hover:bg-destructive/20 p-0.5 ml-0.5"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
{/* +N overflow → popover */}
{overflowBadges.length > 0 && (
<OverflowPopover
overflow={overflowBadges}
onRemove={(id) => toggle(id)}
fieldId={fieldId}
fieldLabel={fieldLabel}
fieldMeta={fieldMeta}
/>
)}
{/* Clear all */}
{selected.length > 1 && (
<button
type="button"
onClick={() => onChange([])}
className="text-[11px] text-muted-foreground hover:text-destructive transition-colors underline underline-offset-2 ml-0.5"
>
Clear all
</button>
)}
</div>
)}
</div>
);
}
@@ -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 (
<div className="space-y-3">
{items.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-6 border border-dashed rounded-lg">
No requirements added. Click "Add Requirement" to start.
</p>
)}
{items.map((item, idx) => {
const typeDef = TYPE_MAP[item.type];
const Icon = typeDef?.icon ?? Link;
return (
<Card key={item._key} className="relative">
<CardContent className="pt-4 pb-4 space-y-3">
{/* Header row */}
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-muted-foreground shrink-0" />
<Badge variant="outline" className="text-xs gap-1 shrink-0">
<Icon className="h-3 w-3" />
{idx + 1}
</Badge>
{/* Type selector */}
<Select
value={item.type}
onValueChange={(v) => updateItem(item._key, { type: v })}
>
<SelectTrigger className="h-8 text-sm flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{REQUIREMENT_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>
<span className="flex items-center gap-2">
<t.icon className="h-3.5 w-3.5" />
{t.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-destructive hover:text-destructive"
onClick={() => removeItem(item._key)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* ── visit_link fields ── */}
{item.type === 'visit_link' && (
<div className="grid grid-cols-2 gap-3 pl-7">
<div className="space-y-1">
<Label className="text-xs">URL *</Label>
<Input
placeholder="https://example.com"
value={item.link_url}
onChange={(e) => updateItem(item._key, { link_url: e.target.value })}
className="h-8 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Label (optional)</Label>
<Input
placeholder="Link description"
value={item.link_label}
onChange={(e) => updateItem(item._key, { link_label: e.target.value })}
className="h-8 text-sm"
/>
</div>
</div>
)}
{/* ── upload_file fields ── */}
{item.type === 'upload_file' && (
<div className="pl-7 space-y-3">
<div className="space-y-1">
<Label className="text-xs">Allowed File Types</Label>
<div className="flex flex-wrap gap-2">
{FILE_TYPE_OPTIONS.map((ft) => (
<Badge
key={ft.value}
variant={(item.allowed_file_types ?? []).includes(ft.value) ? 'default' : 'outline'}
className="cursor-pointer select-none text-xs"
onClick={() => toggleFileType(item._key, ft.value)}
>
{ft.label}
</Badge>
))}
</div>
</div>
<div className="space-y-1 w-32">
<Label className="text-xs">Max Files</Label>
<Input
type="number"
min={1}
max={20}
value={item.max_file_count}
onChange={(e) => updateItem(item._key, { max_file_count: parseInt(e.target.value) || 1 })}
className="h-8 text-sm"
/>
</div>
</div>
)}
{/* ── read_course / read_unit / read_lesson fields ── */}
{['read_course', 'read_unit', 'read_lesson'].includes(item.type) && (
<div className="pl-7 space-y-1">
<Label className="text-xs">
{item.type === 'read_course' ? 'Course' : item.type === 'read_unit' ? 'Unit' : 'Lesson'}
</Label>
{/* Reference selector */}
{item.type === 'read_course' && (
<Select
value={item.reference_id}
onValueChange={(v) => {
const course = courses.find((c) => c.course_id === v);
updateItem(item._key, {
reference_id: v,
reference_label: course?.title ?? '',
});
}}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="Select a course" />
</SelectTrigger>
<SelectContent>
{courses.map((c) => (
<SelectItem key={c.course_id} value={c.course_id}>
{c.title}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{item.type === 'read_unit' && (
<Select
value={item.reference_id}
onValueChange={(v) => {
const unit = units.find((u) => u.unit_id === v);
updateItem(item._key, {
reference_id: v,
reference_label: unit?.title ?? '',
});
}}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="Select a unit" />
</SelectTrigger>
<SelectContent>
{units.map((u) => (
<SelectItem key={u.unit_id} value={u.unit_id}>
{u.title}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{item.type === 'read_lesson' && (
<Select
value={item.reference_id}
onValueChange={(v) => {
const lesson = lessons.find((l) => l.lesson_id === v);
updateItem(item._key, {
reference_id: v,
reference_label: lesson?.title ?? '',
});
}}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="Select a lesson" />
</SelectTrigger>
<SelectContent>
{lessons.map((l) => (
<SelectItem key={l.lesson_id} value={l.lesson_id}>
{l.title}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)}
</CardContent>
</Card>
);
})}
<Button type="button" variant="outline" size="sm" className="w-full gap-2" onClick={addItem}>
<Plus className="h-4 w-4" />
Add Requirement
</Button>
</div>
);
}
+1 -1
View File
@@ -144,7 +144,7 @@ export function AdminTaskProvider({ children }) {
request(async () => { request(async () => {
const res = await api.patch(`${BASE}/${taskListId}`, payload); const res = await api.patch(`${BASE}/${taskListId}`, payload);
toast.success('Task list updated.'); toast.success('Task list updated.');
return res.data?.data?.data ?? null; return res.data?.data ?? null;
}), }),
[request] [request]
); );
+181
View File
@@ -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 (
<StaffGroupContext.Provider
value={{
// Groups
groups, group, loading, error,
setGroup,
fetchMyGroups,
fetchGroupById,
fetchGroupByCode,
// Members (paginated)
members,
memberAttributes,
memberPagination,
setMemberPagination,
membersLoading,
fetchGroupMembers,
fetchGroupMemberFieldValues,
}}
>
{children}
</StaffGroupContext.Provider>
);
};
+93
View File
@@ -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 (
<StaffScoreContext.Provider value={{
progress, quizScores, assessScores, loading, error,
fetchTaskListProgress,
fetchQuizScores,
fetchAssessmentScores,
clearProgress, clearQuizScores, clearAssessScores,
}}>
{children}
</StaffScoreContext.Provider>
);
};
+418
View File
@@ -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 (
<StaffTaskContext.Provider
value={{
// ── Task List state
taskLists, taskList, attributes, pagination,
setPagination, setTaskList,
// ── Task List actions
fetchTaskLists, fetchArchivedTaskLists, fetchTaskListFieldValues,
fetchTaskList,
addTaskList, updateTaskList,
archiveTaskList, restoreTaskList,
bulkArchiveTaskLists, bulkRestoreTaskLists,
// ── Task state
tasks, task, taskAttrs, taskPagination,
setTask,
// ── Task actions
fetchTasks, fetchArchivedTasks, fetchTaskFieldValues,
fetchTask,
addTask, updateTask,
archiveTask, restoreTask,
bulkArchiveTasks, bulkRestoreTasks,
// ── Shared
loading, error,
}}
>
{children}
</StaffTaskContext.Provider>
);
};
+92
View File
@@ -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 (
<StaffUserContext.Provider value={{
users, user, pagination, attributes, loading, error,
setPagination,
fetchUsers, fetchUser,
}}>
{children}
</StaffUserContext.Provider>
);
};
+25
View File
@@ -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";
// <StaffProviders><StaffLayout /></StaffProviders>
import { StaffUserProvider } from "../StaffUserContext";
import { StaffGroupProvider } from "../StaffGroupContext";
import { StaffTaskProvider } from "../StaffTaskContext";
import { StaffScoreProvider } from "../StaffScoreContext";
export function StaffProviders({ children }) {
return (
<StaffUserProvider>
<StaffGroupProvider>
<StaffTaskProvider>
<StaffScoreProvider>
{children}
</StaffScoreProvider>
</StaffTaskProvider>
</StaffGroupProvider>
</StaffUserProvider>
);
}
@@ -86,7 +86,7 @@ export default function EditTaskList() {
toUnassign.length ? unassignGroups(taskListId, toUnassign) : Promise.resolve(), toUnassign.length ? unassignGroups(taskListId, toUnassign) : Promise.resolve(),
]); ]);
navigate(`/admin/taskList/${taskListId}`); navigate(`/admin/taskList`);
}; };
// ── Loading skeleton ────────────────────────────────────────────────────── // ── Loading skeleton ──────────────────────────────────────────────────────
+173
View File
@@ -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 (
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">{label}</label>
{children}
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
);
}
// ─── Password input with toggle ───────────────────────────────────────────────
function PasswordInput({ visible, onToggle, ...props }) {
return (
<div className="relative">
<Input
type={visible ? 'text' : 'password'}
className="pr-10"
{...props}
/>
<Button
type="button"
size="sm"
variant="ghost"
onClick={onToggle}
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{visible ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</Button>
</div>
);
}
// ─── 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 (
<div className="min-h-screen flex items-center justify-center bg-background px-4">
<div className="w-full max-w-md space-y-6">
{/* Header */}
<div className="flex flex-col items-center gap-2 text-center">
<div className="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
<ShieldCheck className="h-6 w-6 text-primary" />
</div>
<h1 className="text-2xl font-bold tracking-tight">Set your password</h1>
<p className="text-sm text-muted-foreground">
Your account requires a new password before you can continue.
</p>
</div>
{/* Form */}
<form
onSubmit={handleSubmit(onSubmit)}
className="border border-border rounded-xl p-6 bg-card space-y-4"
>
<Field label="New Password" error={errors.new_password?.message}>
<PasswordInput
visible={showNew}
onToggle={() => setShowNew((v) => !v)}
placeholder="Enter new password"
disabled={isSubmitting}
{...register('new_password')}
/>
</Field>
<Field label="Confirm Password" error={errors.confirm_password?.message}>
<PasswordInput
visible={showConfirm}
onToggle={() => setShowConfirm((v) => !v)}
placeholder="Confirm new password"
disabled={isSubmitting}
{...register('confirm_password')}
/>
</Field>
{/* Password rules hint */}
<ul className="text-xs text-muted-foreground space-y-0.5 pl-4 list-disc">
<li>At least 8 characters</li>
<li>One uppercase &amp; one lowercase letter</li>
<li>One number and one special character</li>
</ul>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? (
<span className="flex items-center gap-2">
<LoaderCircle className="size-4 animate-spin" />
Saving…
</span>
) : (
'Set Password & Continue'
)}
</Button>
</form>
</div>
</div>
);
}
@@ -0,0 +1,135 @@
import { useEffect, useState } from "react";
import { Check, Minus } from "lucide-react";
import { useStaffScores } from "@/contexts/StaffScoreContext";
import { cn } from "@/lib/utils";
/**
* CompletionMatrix
* Shows a grid of members × tasks with check/minus icons per cell.
*
* @param {Array} taskLists - Task list objects from the group (each with task_list_id + name)
*/
export default function CompletionMatrix({ taskLists = [] }) {
const { progress, loading, fetchTaskListProgress, clearProgress } = useStaffScores();
const [selectedId, setSelectedId] = useState(taskLists[0]?.task_list_id ?? null);
useEffect(() => {
if (selectedId) fetchTaskListProgress(selectedId);
return () => clearProgress();
}, [selectedId]);
if (taskLists.length === 0) {
return (
<p className="text-sm text-muted-foreground">
No task lists assigned to this group.
</p>
);
}
return (
<div className="space-y-4">
{/* Task list selector — only shown when there are multiple */}
{taskLists.length > 1 && (
<div className="flex flex-wrap gap-2">
{taskLists.map((tl) => (
<button
key={tl.task_list_id}
onClick={() => setSelectedId(tl.task_list_id)}
className={cn(
"text-xs px-3 py-1.5 rounded-md border transition-colors",
selectedId === tl.task_list_id
? "bg-primary text-primary-foreground border-primary"
: "border-border hover:bg-muted"
)}
>
{tl.name}
</button>
))}
</div>
)}
{/* Loading */}
{loading && (
<div className="h-36 rounded-lg bg-muted/40 animate-pulse" />
)}
{/* Matrix table */}
{progress && !loading && (
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-muted/40">
<th className="text-left px-3 py-2.5 text-xs font-medium text-muted-foreground w-40 shrink-0">
Member
</th>
{progress.data[0]?.tasks.map(({ task }) => (
<th
key={task.task_id}
className="px-3 py-2.5 text-xs font-medium text-muted-foreground text-center max-w-28"
>
<span className="line-clamp-2 leading-tight">{task.name}</span>
</th>
))}
<th className="px-3 py-2.5 text-xs font-medium text-muted-foreground text-center">
Progress
</th>
</tr>
</thead>
<tbody className="divide-y">
{progress.data.map(({ user, tasks, completed_count, total_tasks }) => {
const fullName = user.personal_info?.name?.full_name ?? user.email ?? "";
const pct = total_tasks
? Math.round((completed_count / total_tasks) * 100)
: 0;
return (
<tr key={user.user_id} className="hover:bg-muted/20 transition-colors">
<td className="px-3 py-2.5">
<p className="text-xs font-medium truncate max-w-36">{fullName}</p>
</td>
{tasks.map(({ task, completion }) => (
<td key={task.task_id} className="px-3 py-2.5 text-center">
<div className="flex justify-center">
{completion.status === "completed" ? (
<div className="w-5 h-5 rounded-full bg-emerald-100 flex items-center justify-center">
<Check size={11} className="text-emerald-700" aria-label="Completed" />
</div>
) : (
<div className="w-5 h-5 rounded-full bg-muted flex items-center justify-center">
<Minus size={11} className="text-muted-foreground" aria-label="Not completed" />
</div>
)}
</div>
</td>
))}
<td className="px-3 py-2.5">
<div className="flex items-center gap-2 min-w-20">
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-emerald-500 transition-all duration-300"
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-xs text-muted-foreground shrink-0 w-10 text-right">
{completed_count}/{total_tasks}
</span>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{progress && !loading && progress.data.length === 0 && (
<p className="text-sm text-muted-foreground">
No members found for this task list.
</p>
)}
</div>
);
}
@@ -0,0 +1,95 @@
import { Users, CheckSquare, BarChart2 } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
const AVATAR_COLORS = [
"bg-emerald-100 text-emerald-800",
"bg-purple-100 text-purple-800",
"bg-amber-100 text-amber-800",
"bg-blue-100 text-blue-800",
"bg-pink-100 text-pink-800",
];
/**
* GroupTile
* @param {object} group - Group object from API
* @param {function} onClick - Click handler (navigate to group detail)
*/
export default function GroupTile({ group, onClick }) {
const members = group.members ?? [];
const taskLists = group.taskLists ?? [];
// Avg completion across all tasks in all task lists
const allTasks = taskLists.flatMap((tl) => tl.tasks ?? []);
const done = allTasks.filter((t) => t.status === "completed").length;
const pct = allTasks.length ? Math.round((done / allTasks.length) * 100) : 0;
// First 3 member initials for avatar stack
const avatarSlice = members.slice(0, 3).map((m) => {
const name = m.personal_info?.name?.full_name ?? m.email ?? "";
return name.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2);
});
const overflow = members.length - 3;
return (
<Card
className="cursor-pointer hover:border-border/80 transition-colors"
onClick={onClick}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && onClick?.()}
>
<CardContent className="p-4">
{/* Header */}
<div className="flex items-start justify-between gap-2 mb-3">
<div className="min-w-0">
<p className="text-sm font-medium truncate">{group.name}</p>
{group.description && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">
{group.description}
</p>
)}
</div>
{group.group_code && (
<Badge variant="secondary" className="text-[10px] shrink-0">
{group.group_code}
</Badge>
)}
</div>
{/* Avatar stack */}
<div className="flex -space-x-1.5 mb-3">
{avatarSlice.map((init, i) => (
<div
key={i}
className={`w-6 h-6 rounded-full border-2 border-background flex items-center justify-center text-[10px] font-medium ${AVATAR_COLORS[i % AVATAR_COLORS.length]}`}
>
{init}
</div>
))}
{overflow > 0 && (
<div className="w-6 h-6 rounded-full border-2 border-background bg-muted flex items-center justify-center text-[10px] text-muted-foreground">
+{overflow}
</div>
)}
{members.length === 0 && (
<p className="text-xs text-muted-foreground">No members yet</p>
)}
</div>
{/* Footer meta */}
<div className="flex items-center gap-4 text-xs text-muted-foreground border-t pt-3">
<span className="flex items-center gap-1">
<Users size={12} aria-hidden /> {members.length} member{members.length !== 1 ? "s" : ""}
</span>
<span className="flex items-center gap-1">
<CheckSquare size={12} aria-hidden /> {taskLists.length} list{taskLists.length !== 1 ? "s" : ""}
</span>
<span className="flex items-center gap-1">
<BarChart2 size={12} aria-hidden /> {pct}% done
</span>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,101 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
function formatDate(iso) {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("en-PH", {
year: "numeric",
month: "short",
day: "numeric",
});
}
function InfoRow({ label, value }) {
return (
<div className="flex gap-3 py-2 border-b last:border-0 text-sm">
<span className="text-muted-foreground w-32 shrink-0">{label}</span>
<span className="text-foreground break-all">{value || "—"}</span>
</div>
);
}
export default function MemberDetailTabs({ member }) {
const info = member.personal_info ?? {};
const name = info.name ?? {};
const phones = info.phone_number ?? [];
const addresses = info.addresses ?? [];
return (
<Tabs defaultValue="personal">
<TabsList className="w-full">
<TabsTrigger value="personal" className="flex-1">Personal details</TabsTrigger>
<TabsTrigger value="activity" className="flex-1">User activity</TabsTrigger>
</TabsList>
{/* Personal details */}
<TabsContent value="personal" className="mt-3 space-y-0">
<InfoRow label="Full name" value={name.full_name} />
<InfoRow label="Given name" value={name.given_name} />
<InfoRow label="Last name" value={name.last_name} />
<InfoRow label="Middle name" value={name.middle_name} />
<InfoRow label="Extension" value={name.extension_name} />
<InfoRow label="Date of birth" value={formatDate(info.date_of_birth)} />
<InfoRow label="Occupation" value={info.occupation} />
<InfoRow
label="Phone"
value={
phones.length
? phones.map((p) => `+${p.country_code} ${p.number} (${p.phone_type})`).join(", ")
: null
}
/>
<InfoRow label="Email" value={member.email} />
<InfoRow
label="Address"
value={
addresses.length
? addresses.map((a) => a.full_address).join("; ")
: null
}
/>
{/* Account info section */}
<div className="pt-3 mt-3 border-t space-y-0">
<div className="flex gap-3 py-2 border-b text-sm">
<span className="text-muted-foreground w-32 shrink-0">Account type</span>
<Badge variant={member.acc_type === "staff" ? "default" : "secondary"} className="text-xs">
{member.acc_type}
</Badge>
</div>
<div className="flex gap-3 py-2 border-b text-sm">
<span className="text-muted-foreground w-32 shrink-0">Status</span>
<span className={`inline-flex items-center gap-1.5 text-xs font-medium ${member.is_active ? "text-emerald-700" : "text-muted-foreground"}`}>
<span className={`w-1.5 h-1.5 rounded-full ${member.is_active ? "bg-emerald-500" : "bg-muted-foreground"}`} />
{member.is_active ? "Active" : "Inactive"}
</span>
</div>
<InfoRow label="Joined group" value={formatDate(member.UserGroupMember?.joined_at)} />
</div>
</TabsContent>
{/* Activity */}
<TabsContent value="activity" className="mt-3">
<div className="grid grid-cols-3 gap-3 mb-4">
{[
{ label: "Tasks assigned", value: "—" },
{ label: "Tasks completed", value: "—" },
{ label: "Last active", value: "—" },
].map(({ label, value }) => (
<div key={label} className="bg-muted/40 rounded-md p-3 text-center">
<p className="text-lg font-semibold">{value}</p>
<p className="text-xs text-muted-foreground mt-0.5">{label}</p>
</div>
))}
</div>
<p className="text-sm text-muted-foreground text-center py-6">
No activity recorded yet.
</p>
</TabsContent>
</Tabs>
);
}
@@ -0,0 +1,54 @@
import { cn } from "@/lib/utils";
const AVATAR_COLORS = [
"bg-emerald-100 text-emerald-800",
"bg-purple-100 text-purple-800",
"bg-amber-100 text-amber-800",
"bg-blue-100 text-blue-800",
"bg-pink-100 text-pink-800",
];
/**
* MemberRow
* @param {object} member - User object from API
* @param {number} colorIndex - Index to pick avatar color from palette
*/
export default function MemberRow({ member, colorIndex = 0 }) {
const fullName = member.personal_info?.name?.full_name ?? member.email ?? "";
const initials = fullName
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2) || "?";
const color = AVATAR_COLORS[colorIndex % AVATAR_COLORS.length];
return (
<div className="flex items-center gap-3 px-4 py-3">
{/* Avatar */}
<div className={cn(
"w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium shrink-0",
color
)}>
{initials}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{fullName}</p>
<p className="text-xs text-muted-foreground truncate">{member.email}</p>
</div>
{/* Active badge */}
<span className={cn(
"text-[11px] font-medium px-2 py-0.5 rounded shrink-0",
member.is_active
? "bg-emerald-50 text-emerald-800"
: "bg-muted text-muted-foreground"
)}>
{member.is_active ? "active" : "inactive"}
</span>
</div>
);
}
@@ -0,0 +1,31 @@
import { cn } from "@/lib/utils";
/**
* ScoreBadge
* Shows a score as a colored pill — green if passed, red if failed, gray if no attempt.
*
* @param {number|null} score - Score as a percentage (0–100), or null
* @param {number} passingScore - Minimum passing score (default 70)
*/
export default function ScoreBadge({ score, passingScore = 70 }) {
if (score === null || score === undefined) {
return (
<span className="text-xs font-medium px-2.5 py-0.5 rounded-full bg-muted text-muted-foreground">
—
</span>
);
}
const passed = parseFloat(score) >= passingScore;
return (
<span className={cn(
"text-xs font-medium px-2.5 py-0.5 rounded-full",
passed
? "bg-emerald-100 text-emerald-800"
: "bg-red-100 text-red-800"
)}>
{parseFloat(score).toFixed(1)}%
</span>
);
}
@@ -0,0 +1,256 @@
import { useState, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { StatGrid } from "@/components/generic/Dashboard/StatGrid";
import { PieBreakdown } from "@/components/generic/Dashboard/PieBreakdown";
import { BarBreakdown } from "@/components/generic/Dashboard/BarBreakdown";
const PAGE_SIZE = 10;
const STATUS_LABEL = {
completed: "Completed",
in_progress: "In progress",
pending: "Pending",
not_started: "Not started",
};
function formatDate(iso) {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("en-PH", {
year: "numeric",
month: "short",
day: "numeric",
});
}
function StatusBadge({ status }) {
const map = {
completed: "bg-emerald-100 text-emerald-800",
in_progress: "bg-blue-100 text-blue-800",
pending: "bg-amber-100 text-amber-800",
not_started: "bg-slate-100 text-slate-700",
};
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${map[status] ?? map.not_started}`}>
{STATUS_LABEL[status] ?? status ?? "Not started"}
</span>
);
}
export default function TaskListDetail({ taskList }) {
const tasks = taskList.tasks ?? [];
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
// ── Derived stats ───────────────────────────────────────────────────────
const counts = useMemo(() => {
const total = tasks.length;
const completed = tasks.filter((t) => t.status === "completed").length;
const inProgress = tasks.filter((t) => t.status === "in_progress").length;
const pending = tasks.filter((t) => t.status === "pending").length;
const notStarted = tasks.filter((t) => !t.status || t.status === "not_started").length;
const pct = total ? Math.round((completed / total) * 100) : 0;
return { total, completed, inProgress, pending, notStarted, pct };
}, [tasks]);
// StatGrid data
const summaryStats = [
{ key: "total", label: "Total tasks", value: counts.total },
{ key: "completed", label: "Completed", value: counts.completed },
{ key: "in_progress", label: "In progress", value: counts.inProgress },
{ key: "pending", label: "Pending", value: counts.pending },
{ key: "not_started", label: "Not started", value: counts.notStarted },
];
// PieBreakdown: status distribution
const pieData = useMemo(() => [
{ label: "Completed", value: counts.completed },
{ label: "In progress", value: counts.inProgress },
{ label: "Pending", value: counts.pending },
{ label: "Not started", value: counts.notStarted },
].filter((d) => d.value > 0), [counts]);
// BarBreakdown: per-task completion (first 20)
const barData = useMemo(() =>
tasks.slice(0, 20).map((t, i) => ({
label: t.name
? (t.name.length > 14 ? t.name.slice(0, 12) + "…" : t.name)
: `Task ${i + 1}`,
value: t.status === "completed" ? 1 : 0,
})),
[tasks]
);
// ── Tasks DataTable ─────────────────────────────────────────────────────
const filtered = useMemo(() => {
const q = search.toLowerCase();
if (!q) return tasks;
return tasks.filter((t) =>
(t.name ?? "").toLowerCase().includes(q) ||
(t.status ?? "").toLowerCase().includes(q)
);
}, [tasks, search]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
const slice = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
return (
<Tabs defaultValue="summary">
<TabsList className="w-full">
<TabsTrigger value="summary" className="flex-1">Summary</TabsTrigger>
<TabsTrigger value="tasks" className="flex-1">Tasks ({tasks.length})</TabsTrigger>
</TabsList>
{/* ── Summary ─────────────────────────────────────────────────────── */}
<TabsContent value="summary" className="mt-4 space-y-4">
{/* Stat cards via StatGrid */}
<StatGrid stats={summaryStats} />
{/* Overall progress bar */}
<div>
<div className="flex justify-between text-xs text-muted-foreground mb-1">
<span>Overall completion</span>
<span>{counts.pct}%</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-emerald-500 transition-all"
style={{ width: `${counts.pct}%` }}
/>
</div>
</div>
{tasks.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No tasks in this list yet.
</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Status breakdown pie */}
<PieBreakdown
label="Status breakdown"
data={pieData}
height={200}
/>
{/* Per-task bar (horizontal) */}
<BarBreakdown
label={`Task completion${tasks.length > 20 ? " (first 20)" : ""}`}
data={barData}
height={Math.max(160, barData.length * 32 + 40)}
yAxisWidth={100}
/>
</div>
)}
{/* Meta */}
<div className="text-xs text-muted-foreground space-y-1 pt-2 border-t">
<p>Created: <span className="text-foreground">{formatDate(taskList.createdAt)}</span></p>
<p>Assigned: <span className="text-foreground">{formatDate(taskList.TaskListGroup?.assignedAt)}</span></p>
</div>
</TabsContent>
{/* ── Tasks DataTable ──────────────────────────────────────────────── */}
<TabsContent value="tasks" className="mt-4 space-y-3">
<Input
placeholder="Search tasks…"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
className="h-8 text-sm w-56"
/>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">#</TableHead>
<TableHead>Task name</TableHead>
<TableHead>Status</TableHead>
<TableHead>Due date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{slice.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-sm text-muted-foreground py-8">
{tasks.length === 0 ? "No tasks in this list." : "No tasks match your search."}
</TableCell>
</TableRow>
) : (
slice.map((task, i) => (
<TableRow key={task.task_id ?? i}>
<TableCell className="text-muted-foreground text-xs">
{(safePage - 1) * PAGE_SIZE + i + 1}
</TableCell>
<TableCell className="font-medium text-sm">
{task.name ?? `Task ${i + 1}`}
</TableCell>
<TableCell>
<StatusBadge status={task.status} />
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDate(task.due_date)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{filtered.length > 0 && (
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{`${(safePage - 1) * PAGE_SIZE + 1}–${Math.min(safePage * PAGE_SIZE, filtered.length)} of ${filtered.length}`}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline" size="sm" className="h-7 px-2"
disabled={safePage === 1}
onClick={() => setPage(safePage - 1)}
>
Previous
</Button>
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter((p) => p === 1 || p === totalPages || Math.abs(p - safePage) <= 1)
.reduce((acc, p, i, arr) => {
if (i > 0 && p - arr[i - 1] > 1) acc.push("...");
acc.push(p);
return acc;
}, [])
.map((p, idx) =>
p === "..." ? (
<span key={`e-${idx}`} className="px-1">…</span>
) : (
<Button
key={p}
variant={p === safePage ? "default" : "outline"}
size="sm"
className="h-7 w-7 p-0"
onClick={() => setPage(p)}
>
{p}
</Button>
)
)}
<Button
variant="outline" size="sm" className="h-7 px-2"
disabled={safePage === totalPages}
onClick={() => setPage(safePage + 1)}
>
Next
</Button>
</div>
</div>
)}
</TabsContent>
</Tabs>
);
}
@@ -0,0 +1,202 @@
/***********************************************************************************************************************************************************************
* File Name: TaskListTable.jsx (staff)
* Type of Program: Component
* Description: DataTable-based task list table for staff, scoped to their groups.
* Mirrors the admin TaskListTable pattern exactly.
***********************************************************************************************************************************************************************/
import { useMemo, useRef, useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useStaffTasks } from "@/contexts/StaffTaskContext";
import DataTable from "@/components/generic/Table/DataTable";
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog";
import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog";
import { buildDataColumns, columnPinning } from "../config/task_list/columns.config";
import { buildToolbarActions } from "../config/task_list/toolbar.config";
import { buildSelectionActions } from "../config/task_list/selection.config";
import { buildRowActions } from "../config/task_list/rowActions.config";
import { getTimestamp } from "@/utils/timestamp.util";
export default function TaskListTable() {
const navigate = useNavigate();
const {
taskLists, attributes, pagination, setPagination, loading,
fetchTaskLists, fetchArchivedTaskLists, fetchTaskListFieldValues,
archiveTaskList, restoreTaskList,
bulkArchiveTaskLists, bulkRestoreTaskLists,
} = useStaffTasks();
const [showArchived, setShowArchived] = useState(false);
const [archiveTarget, setArchiveTarget] = useState(null);
const [restoreTarget, setRestoreTarget] = useState(null);
const [archiveIds, setArchiveIds] = useState(null);
const [restoreIds, setRestoreIds] = useState(null);
const tableRefsRef = useRef({
getFilters: () => [],
getSort: () => [],
resetSelection: () => {},
tableInstance: null,
});
const handleRefsReady = (refs) => {
tableRefsRef.current = refs;
};
// ── Toggle archived view ──────────────────────────────────────────────────
const handleToggleArchived = useCallback(() => {
const next = !showArchived;
setShowArchived(next);
fetchTaskLists({
page: 1,
limit: pagination?.limit ?? 10,
filters: tableRefsRef.current.getFilters(),
sort: tableRefsRef.current.getSort(),
archived: next,
});
}, [showArchived, pagination, fetchTaskLists]);
// ── Refetch after any archive/restore action ──────────────────────────────
const handleSuccess = () => {
setArchiveTarget(null);
setRestoreTarget(null);
setArchiveIds(null);
setRestoreIds(null);
tableRefsRef.current.resetSelection?.();
const fetcher = showArchived ? fetchArchivedTaskLists : fetchTaskLists;
fetcher({
page: 1,
limit: pagination?.limit ?? 10,
filters: tableRefsRef.current.getFilters(),
sort: tableRefsRef.current.getSort(),
});
};
// ── Export config ─────────────────────────────────────────────────────────
const exportConfig = useMemo(() => ({
allData: taskLists,
attributes,
filename: `${getTimestamp()}_TaskLists`,
sheetName: "Task Lists",
}), [taskLists, attributes]);
// ── Row actions ───────────────────────────────────────────────────────────
const rowActions = buildRowActions({
navigate,
onArchive: (row) => setArchiveTarget(row),
onRestore: (row) => setRestoreTarget(row),
showArchived,
});
// ── Toolbar ───────────────────────────────────────────────────────────────
const toolbarActions = buildToolbarActions({
fetchTaskLists,
fetchArchivedTaskLists,
pagination,
navigate,
exportConfig,
showArchived,
onToggleArchived: handleToggleArchived,
getFilters: () => tableRefsRef.current.getFilters(),
getSort: () => tableRefsRef.current.getSort(),
getTableInstance: () => tableRefsRef.current.tableInstance,
});
// ── Selection ─────────────────────────────────────────────────────────────
const selectionActions = buildSelectionActions({
exportConfig,
showArchived,
onBulkArchive: (ids) => setArchiveIds(ids),
onBulkRestore: (ids) => setRestoreIds(ids),
getTableInstance: () => tableRefsRef.current.tableInstance,
});
// ── Columns ───────────────────────────────────────────────────────────────
const columns = useMemo(
() => buildDataColumns(attributes, rowActions),
[attributes, rowActions]
);
return (
<>
<DataTable
title="Task Lists"
data={taskLists}
columns={columns}
attributes={attributes}
pagination={pagination}
setPagination={setPagination}
loading={loading}
onFetch={fetchTaskLists}
onFetchFilterData={fetchTaskListFieldValues}
onRefsReady={handleRefsReady}
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
<FilterSheet
open={open}
onOpenChange={onOpenChange}
column={column}
attr={attr}
data={data}
loading={loading}
/>
)}
columnPinning={columnPinning}
toolbarActions={toolbarActions}
selectionActions={selectionActions}
recordLabel="task list"
emptyMessage="No task lists found."
/>
{/* Single archive */}
<ArchiveDialog
open={!!archiveTarget}
onOpenChange={(v) => !v && setArchiveTarget(null)}
entity={archiveTarget}
entityLabel="Task List"
getName={(r) => r?.name}
onArchive={(entity) => archiveTaskList(entity?.task_list_id)}
loading={loading}
onSuccess={handleSuccess}
/>
{/* Single restore */}
<RestoreDialog
open={!!restoreTarget}
onOpenChange={(v) => !v && setRestoreTarget(null)}
entity={restoreTarget}
entityLabel="Task List"
getName={(r) => r?.name}
onRestore={(entity) => restoreTaskList(entity?.task_list_id)}
loading={loading}
onSuccess={handleSuccess}
/>
{/* Bulk archive */}
<ArchiveDialog
open={!!archiveIds}
onOpenChange={(v) => !v && setArchiveIds(null)}
ids={archiveIds ?? []}
entityLabel="Task List"
onArchive={({ ids }) => bulkArchiveTaskLists(ids)}
loading={loading}
onSuccess={handleSuccess}
/>
{/* Bulk restore */}
<RestoreDialog
open={!!restoreIds}
onOpenChange={(v) => !v && setRestoreIds(null)}
ids={restoreIds ?? []}
entityLabel="Task List"
onRestore={({ ids }) => bulkRestoreTaskLists(ids)}
loading={loading}
onSuccess={handleSuccess}
/>
</>
);
}
@@ -0,0 +1,164 @@
import { useState, useMemo } from "react";
import { Eye } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { PieBreakdown } from "@/components/generic/Dashboard/PieBreakdown";
import { BarBreakdown } from "@/components/generic/Dashboard/BarBreakdown";
import TaskListDetail from "./TaskListDetail";
function formatDate(iso) {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("en-PH", {
year: "numeric",
month: "short",
day: "numeric",
});
}
export default function TaskListsTab({ taskLists = [] }) {
const [selected, setSelected] = useState(null);
// PieBreakdown: overall task completion status across all lists
const completionPieData = useMemo(() => {
let completed = 0, inProgress = 0, pending = 0, notStarted = 0;
taskLists.forEach((tl) => {
(tl.tasks ?? []).forEach((t) => {
if (t.status === "completed") completed++;
else if (t.status === "in_progress") inProgress++;
else if (t.status === "pending") pending++;
else notStarted++;
});
});
return [
{ label: "Completed", value: completed },
{ label: "In progress", value: inProgress },
{ label: "Pending", value: pending },
{ label: "Not started", value: notStarted },
].filter((d) => d.value > 0);
}, [taskLists]);
// BarBreakdown: total tasks per list
const tasksPerListData = useMemo(() =>
taskLists.map((tl) => ({
label: tl.name.length > 20 ? tl.name.slice(0, 18) + "…" : tl.name,
value: tl.tasks?.length ?? 0,
})),
[taskLists]
);
const hasAnyTasks = taskLists.some((tl) => (tl.tasks?.length ?? 0) > 0);
return (
<div className="space-y-4">
{/* Charts — only shown when there's actual task data */}
{taskLists.length > 0 && hasAnyTasks && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<PieBreakdown
label="Task completion status"
data={completionPieData}
height={220}
/>
<BarBreakdown
label="Tasks per list"
data={tasksPerListData}
height={Math.max(160, tasksPerListData.length * 40 + 40)}
yAxisWidth={120}
/>
</div>
)}
{/* Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Tasks</TableHead>
<TableHead className="w-48">Progress</TableHead>
<TableHead>Assigned</TableHead>
<TableHead className="w-10"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{taskLists.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-sm text-muted-foreground py-8">
No task lists assigned to this group.
</TableCell>
</TableRow>
) : (
taskLists.map((tl) => {
const total = tl.tasks?.length ?? 0;
const done = tl.tasks?.filter((t) => t.status === "completed").length ?? 0;
const pct = total ? Math.round((done / total) * 100) : 0;
return (
<TableRow
key={tl.task_list_id}
className="cursor-pointer"
onClick={() => setSelected(tl)}
>
<TableCell className="font-medium">{tl.name}</TableCell>
<TableCell className="text-muted-foreground text-sm">
{total} task{total !== 1 ? "s" : ""}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-emerald-500 transition-all"
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-xs text-muted-foreground w-8 text-right">{pct}%</span>
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDate(tl.TaskListGroup?.assignedAt)}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => { e.stopPropagation(); setSelected(tl); }}
>
<Eye size={14} />
</Button>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
{/* Task list detail dialog */}
<Dialog open={!!selected} onOpenChange={(open) => !open && setSelected(null)}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{selected?.name}</DialogTitle>
{selected?.description && (
<p className="text-sm text-muted-foreground">{selected.description}</p>
)}
</DialogHeader>
{selected && <TaskListDetail taskList={selected} />}
</DialogContent>
</Dialog>
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
import { Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { format } from "date-fns";
const REQ_TYPE_STYLES = {
visit_link: "bg-blue-50 text-blue-800",
upload_file: "bg-purple-50 text-purple-800",
read_course: "bg-emerald-50 text-emerald-800",
read_unit: "bg-amber-50 text-amber-800",
read_lesson: "bg-pink-50 text-pink-800",
};
const REQ_TYPE_LABELS = {
visit_link: "link",
upload_file: "upload",
read_course: "course",
read_unit: "unit",
read_lesson: "lesson",
};
const STATUS_STYLES = {
pending: "bg-muted text-muted-foreground",
in_progress: "bg-blue-50 text-blue-800",
completed: "bg-emerald-50 text-emerald-800",
overdue: "bg-red-50 text-red-800",
};
/**
* TaskRow
* @param {object} task - Task object from API
* @param {function} onEdit - Optional edit handler (shows edit button)
* @param {function} onDelete - Optional delete handler (shows delete button)
*/
export default function TaskRow({ task, onEdit, onDelete }) {
const reqType = task.requirements?.[0]?.type;
return (
<div className="flex items-center gap-3 py-2.5">
{/* Name */}
<p className="flex-1 text-sm truncate min-w-0">{task.name}</p>
{/* Requirement type badge */}
{reqType && (
<span className={cn(
"text-[11px] font-medium px-2 py-0.5 rounded shrink-0",
REQ_TYPE_STYLES[reqType]
)}>
{REQ_TYPE_LABELS[reqType]}
</span>
)}
{/* Status badge */}
{task.status && (
<span className={cn(
"text-[11px] font-medium px-2 py-0.5 rounded shrink-0",
STATUS_STYLES[task.status]
)}>
{task.status.replace("_", " ")}
</span>
)}
{/* Deadline */}
{task.deadline && (
<span className="text-xs text-muted-foreground w-14 text-right shrink-0">
{format(new Date(task.deadline), "MMM d")}
</span>
)}
{/* Edit / Delete */}
{(onEdit || onDelete) && (
<div className="flex items-center gap-0.5 shrink-0">
{onEdit && (
<Button
variant="ghost" size="icon" className="h-6 w-6"
onClick={(e) => { e.stopPropagation(); onEdit(); }}
title="Edit task"
>
<Pencil size={11} />
</Button>
)}
{onDelete && (
<Button
variant="ghost" size="icon"
className="h-6 w-6 text-destructive hover:text-destructive"
onClick={(e) => { e.stopPropagation(); onDelete(); }}
title="Delete task"
>
<Trash2 size={11} />
</Button>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,175 @@
import { useMemo, useRef, useState, useCallback } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useStaffGroups } from "@/contexts/StaffGroupContext";
import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
import DataTable from "@/components/generic/Table/DataTable";
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
import { buildDataColumns, columnPinning } from "../../config/members/columns.config";
import { buildToolbarActions } from "../../config/members/toolbar.config";
import { buildSelectionActions } from "../../config/members/selection.config";
import { buildRowActions } from "../../config/members/rowActions.config";
import MemberDetailTabs from "../MemberDetailTabs";
import { getTimestamp } from "@/utils/timestamp.util";
// ── Helpers ───────────────────────────────────────────────────────────────────
function getInitials(member) {
const first = member.personal_info?.name?.given_name?.[0] ?? "";
const last = member.personal_info?.name?.last_name?.[0] ?? "";
return (first + last).toUpperCase() || "??";
}
function getFullName(member) {
const n = member.personal_info?.name;
if (!n) return member.email;
return `${n.given_name ?? ""} ${n.last_name ?? ""}`.trim();
}
const AVATAR_COLORS = [
"bg-blue-100 text-blue-800",
"bg-emerald-100 text-emerald-800",
"bg-violet-100 text-violet-800",
"bg-amber-100 text-amber-800",
"bg-rose-100 text-rose-800",
"bg-cyan-100 text-cyan-800",
];
export default function MembersTable() {
const navigate = useNavigate();
const { groupId } = useParams();
const {
members,
memberAttributes,
memberPagination,
setMemberPagination,
membersLoading,
fetchGroupMembers,
fetchGroupMemberFieldValues,
} = useStaffGroups();
const [selected, setSelected] = useState(null);
const tableRefsRef = useRef({
getFilters: () => [],
getSort: () => [],
resetSelection: () => { },
tableInstance: null,
});
const handleRefsReady = (refs) => {
tableRefsRef.current = refs;
};
const handleFetch = useCallback(
(params) => fetchGroupMembers(groupId, params),
[groupId, fetchGroupMembers]
);
const handleFetchFilterData = useCallback(
(col, params) => fetchGroupMemberFieldValues(groupId, col, params),
[groupId, fetchGroupMemberFieldValues]
);
// ── Export config ───────────────────────────────────────────────────────────
const exportConfig = useMemo(() => ({
allData: members,
attributes: memberAttributes,
filename: `${getTimestamp()}_GroupMembers`,
sheetName: "Members",
}), [members, memberAttributes]);
// ── Row actions ─────────────────────────────────────────────────────────────
const rowActions = useMemo(() => buildRowActions({
onView: (member) => setSelected(member),
}), []);
// ── Toolbar ─────────────────────────────────────────────────────────────────
const toolbarActions = useMemo(() => buildToolbarActions({
fetchMembers: handleFetch,
memberPagination,
exportConfig,
getFilters: () => tableRefsRef.current.getFilters(),
getSort: () => tableRefsRef.current.getSort(),
getTableInstance: () => tableRefsRef.current.tableInstance,
}), [handleFetch, memberPagination, exportConfig]);
// ── Selection ───────────────────────────────────────────────────────────────
const selectionActions = buildSelectionActions({
exportConfig,
getTableInstance: () => tableRefsRef.current.tableInstance,
});
// ── Columns ─────────────────────────────────────────────────────────────────
const columns = useMemo(
() => buildDataColumns(memberAttributes, rowActions),
[memberAttributes, rowActions]
);
return (
<>
<DataTable
title="Members"
data={members}
columns={columns}
attributes={memberAttributes}
pagination={memberPagination}
setPagination={setMemberPagination}
loading={membersLoading}
onFetch={handleFetch}
onFetchFilterData={handleFetchFilterData}
onRefsReady={handleRefsReady}
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
<FilterSheet
open={open}
onOpenChange={onOpenChange}
column={column}
attr={attr}
data={data}
loading={loading}
/>
)}
columnPinning={columnPinning}
toolbarActions={toolbarActions}
selectionActions={selectionActions}
recordLabel="member"
emptyMessage="No members found."
onRowClick={(row) => setSelected(row.original)}
/>
{/* Member detail dialog */}
<Dialog open={!!selected} onOpenChange={(open) => !open && setSelected(null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<div className="flex items-center gap-3">
{selected && (
<div
className={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold ${AVATAR_COLORS[
members.findIndex((m) => m.user_id === selected.user_id) % AVATAR_COLORS.length
]
}`}
>
{getInitials(selected)}
</div>
)}
<div>
<DialogTitle className="text-base">
{selected && getFullName(selected)}
</DialogTitle>
<p className="text-xs text-muted-foreground mt-0.5">
{selected?.personal_info?.occupation} · {selected?.acc_type}
</p>
</div>
</div>
</DialogHeader>
{selected && <MemberDetailTabs member={selected} />}
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,43 @@
// config/task_list/columns.config.jsx
// Column definitions and pinning config for the Staff Task List table.
import { buildColumns } from "@/utils/table.util";
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
import { OverflowBadges } from "@/components/generic/OverflowBadges";
export const columnPinning = {
right: ["actions"],
left: [],
};
const cellOverrides = {
// Example: render assigned groups as badges
groups: (info) => (
<OverflowBadges
items={info.getValue() ?? []}
keyKey="group_id"
labelKey="group_code"
dialogTitleKey="name"
dialogTitle="All groups"
badgeClassName="text-xs font-mono"
/>
),
};
/**
* Builds the full column array for the Staff Task List table.
*
* @param {Array} attributes Field definitions from the server (drives data columns)
* @param {Array} rowActions Row-level kebab action definitions
* @returns {Array} TanStack column definitions
*/
export function buildDataColumns(attributes, rowActions) {
const visibleAttributes = attributes.filter((a) => !a.hidden);
return [
buildSelectionColumn(),
...buildColumns(visibleAttributes, { cellOverrides }),
buildRowActionsColumn(rowActions, { dropdownLabel: "Task List Actions" }),
];
}
@@ -0,0 +1,56 @@
// config/task_list/rowActions.config.jsx
// Row-level kebab menu actions for the Staff Task List table.
// Staff can view, edit, view tasks, archive, and restore — scoped to their groups.
import { Eye, Pencil, NotebookPen, Archive, RotateCcw } from "lucide-react";
/**
* @param {Object} deps
* @param {Function} deps.navigate react-router navigate fn
* @param {Function} deps.onArchive called with the row when Archive is clicked
* @param {Function} deps.onRestore called with the row when Restore is clicked
* @param {boolean} deps.showArchived toggles archive vs restore action visibility
*/
export function buildRowActions({ navigate, onArchive, onRestore, showArchived }) {
return [
{
key: "view",
label: "View Info",
icon: <Eye className="size-4" />,
onClick: (row) => navigate(`${row.task_list_id}/view`),
},
{
key: "edit",
label: "Edit Info",
icon: <Pencil className="size-4" />,
onClick: (row) => navigate(`${row.task_list_id}/edit`),
hidden: () => showArchived,
},
{
key: "tasks",
label: "View Tasks",
icon: <NotebookPen className="size-4" />,
onClick: (row) => navigate(`${row.task_list_id}/tasks`),
separator: true,
className: "text-sky-800",
},
{
key: "archive",
label: "Archive",
icon: <Archive className="size-4" />,
className: "text-destructive focus:text-destructive",
onClick: (row) => onArchive(row),
hidden: () => showArchived,
separator: true,
},
{
key: "restore",
label: "Restore",
icon: <RotateCcw className="size-4" />,
className: "text-emerald-600 focus:text-emerald-600",
onClick: (row) => onRestore(row),
hidden: () => !showArchived,
separator: true,
},
];
}
@@ -0,0 +1,51 @@
// config/task_list/selection.config.jsx
// Bulk selection actions for the Staff Task List table.
import { Download, Archive, RotateCcw } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util";
/**
* @param {Object} deps
* @param {Object} deps.exportConfig passed straight to exportTableToExcel
* @param {boolean} deps.showArchived drives archive vs restore label/icon
* @param {Function} deps.onBulkArchive called with selected task_list_id[]
* @param {Function} deps.onBulkRestore called with selected task_list_id[]
* @param {Function} deps.getTableInstance returns the TanStack table instance
*/
export function buildSelectionActions({
exportConfig,
showArchived,
onBulkArchive,
onBulkRestore,
getTableInstance,
}) {
return [
{
key: "export-selected",
label: "Export",
icon: <Download className="h-3.5 w-3.5" />,
onClick: (rows, table) =>
exportTableToExcel({
...exportConfig,
selectedRows: rows,
tableInstance: table ?? getTableInstance?.(),
}),
},
{
key: showArchived ? "restore-selected" : "archive-selected",
label: showArchived ? "Restore" : "Archive",
icon: showArchived ? (
<RotateCcw className="h-3.5 w-3.5" />
) : (
<Archive className="h-3.5 w-3.5" />
),
className:
"text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive",
onClick: (rows) => {
const ids = rows.map((r) => r.task_list_id).filter(Boolean);
if (!ids.length) return;
showArchived ? onBulkRestore?.(ids) : onBulkArchive?.(ids);
},
},
];
}
@@ -0,0 +1,78 @@
// config/task_list/toolbar.config.jsx
// Toolbar actions for the Staff Task List table.
import { RefreshCw, Download, Plus, Archive } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util";
/**
* @param {Object} deps
* @param {Function} deps.fetchTaskLists fetches active task lists
* @param {Function} deps.fetchArchivedTaskLists fetches archived task lists
* @param {Object} deps.pagination current pagination state
* @param {Object} deps.exportConfig passed straight to exportTableToExcel
* @param {Function} deps.navigate react-router navigate fn
* @param {boolean} deps.showArchived drives label and toggle behaviour
* @param {Function} deps.onToggleArchived flips showArchived in the page
* @param {Function} deps.getFilters returns active filter array from table
* @param {Function} deps.getSort returns active sort array from table
* @param {Function} deps.getTableInstance returns the TanStack table instance
*/
export function buildToolbarActions({
fetchTaskLists,
fetchArchivedTaskLists,
pagination,
exportConfig,
navigate,
showArchived,
onToggleArchived,
getFilters,
getSort,
getTableInstance,
}) {
return [
{
key: "refresh",
type: "button",
icon: <RefreshCw className="size-4" />,
label: "Refresh",
onClick: () => {
const fetcher = showArchived ? fetchArchivedTaskLists : fetchTaskLists;
fetcher({
page: 1,
limit: pagination?.limit ?? 10,
filters: getFilters?.() ?? [],
sort: getSort?.() ?? [],
});
},
},
{
key: "export",
type: "button",
icon: <Download className="size-4" />,
label: "Export",
onClick: (table) =>
exportTableToExcel({
...exportConfig,
tableInstance: table ?? getTableInstance?.(),
}),
},
{
key: "add-task-list",
type: "button",
icon: <Plus className="size-4" />,
label: "Create Task List",
variant: "default",
className: "text-primary-foreground",
onClick: () => navigate(`/staff/task-lists/create`),
},
{
key: "toggle-archived",
type: "button",
icon: <Archive className="size-4" />,
label: showArchived ? "Active Task Lists" : "Archived Task Lists",
variant: "secondary",
className: "border border-border",
onClick: onToggleArchived,
},
];
}
@@ -0,0 +1,43 @@
// config/task_list/columns.config.jsx
// Column definitions and pinning config for the Staff Task List table.
import { buildColumns } from "@/utils/table.util";
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
import { OverflowBadges } from "@/components/generic/OverflowBadges";
export const columnPinning = {
right: ["actions"],
left: [],
};
const cellOverrides = {
// Example: render assigned groups as badges
groups: (info) => (
<OverflowBadges
items={info.getValue() ?? []}
keyKey="group_id"
labelKey="group_code"
dialogTitleKey="name"
dialogTitle="All groups"
badgeClassName="text-xs font-mono"
/>
),
};
/**
* Builds the full column array for the Staff Task List table.
*
* @param {Array} attributes Field definitions from the server (drives data columns)
* @param {Array} rowActions Row-level kebab action definitions
* @returns {Array} TanStack column definitions
*/
export function buildDataColumns(attributes, rowActions) {
const visibleAttributes = attributes.filter((a) => !a.hidden);
return [
buildSelectionColumn(),
...buildColumns(visibleAttributes, { cellOverrides }),
buildRowActionsColumn(rowActions, { dropdownLabel: "Task List Actions" }),
];
}
@@ -0,0 +1,56 @@
// config/task_list/rowActions.config.jsx
// Row-level kebab menu actions for the Staff Task List table.
// Staff can view, edit, view tasks, archive, and restore — scoped to their groups.
import { Eye, Pencil, NotebookPen, Archive, RotateCcw } from "lucide-react";
/**
* @param {Object} deps
* @param {Function} deps.navigate react-router navigate fn
* @param {Function} deps.onArchive called with the row when Archive is clicked
* @param {Function} deps.onRestore called with the row when Restore is clicked
* @param {boolean} deps.showArchived toggles archive vs restore action visibility
*/
export function buildRowActions({ navigate, onArchive, onRestore, showArchived }) {
return [
{
key: "view",
label: "View Info",
icon: <Eye className="size-4" />,
onClick: (row) => navigate(`${row.task_list_id}/view`),
},
{
key: "edit",
label: "Edit Info",
icon: <Pencil className="size-4" />,
onClick: (row) => navigate(`${row.task_list_id}/edit`),
hidden: () => showArchived,
},
{
key: "tasks",
label: "View Tasks",
icon: <NotebookPen className="size-4" />,
onClick: (row) => navigate(`${row.task_list_id}/tasks`),
separator: true,
className: "text-sky-800",
},
{
key: "archive",
label: "Archive",
icon: <Archive className="size-4" />,
className: "text-destructive focus:text-destructive",
onClick: (row) => onArchive(row),
hidden: () => showArchived,
separator: true,
},
{
key: "restore",
label: "Restore",
icon: <RotateCcw className="size-4" />,
className: "text-emerald-600 focus:text-emerald-600",
onClick: (row) => onRestore(row),
hidden: () => !showArchived,
separator: true,
},
];
}
@@ -0,0 +1,51 @@
// config/task_list/selection.config.jsx
// Bulk selection actions for the Staff Task List table.
import { Download, Archive, RotateCcw } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util";
/**
* @param {Object} deps
* @param {Object} deps.exportConfig passed straight to exportTableToExcel
* @param {boolean} deps.showArchived drives archive vs restore label/icon
* @param {Function} deps.onBulkArchive called with selected task_list_id[]
* @param {Function} deps.onBulkRestore called with selected task_list_id[]
* @param {Function} deps.getTableInstance returns the TanStack table instance
*/
export function buildSelectionActions({
exportConfig,
showArchived,
onBulkArchive,
onBulkRestore,
getTableInstance,
}) {
return [
{
key: "export-selected",
label: "Export",
icon: <Download className="h-3.5 w-3.5" />,
onClick: (rows, table) =>
exportTableToExcel({
...exportConfig,
selectedRows: rows,
tableInstance: table ?? getTableInstance?.(),
}),
},
{
key: showArchived ? "restore-selected" : "archive-selected",
label: showArchived ? "Restore" : "Archive",
icon: showArchived ? (
<RotateCcw className="h-3.5 w-3.5" />
) : (
<Archive className="h-3.5 w-3.5" />
),
className:
"text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive",
onClick: (rows) => {
const ids = rows.map((r) => r.task_list_id).filter(Boolean);
if (!ids.length) return;
showArchived ? onBulkRestore?.(ids) : onBulkArchive?.(ids);
},
},
];
}
@@ -0,0 +1,78 @@
// config/task_list/toolbar.config.jsx
// Toolbar actions for the Staff Task List table.
import { RefreshCw, Download, Plus, Archive } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util";
/**
* @param {Object} deps
* @param {Function} deps.fetchTaskLists fetches active task lists
* @param {Function} deps.fetchArchivedTaskLists fetches archived task lists
* @param {Object} deps.pagination current pagination state
* @param {Object} deps.exportConfig passed straight to exportTableToExcel
* @param {Function} deps.navigate react-router navigate fn
* @param {boolean} deps.showArchived drives label and toggle behaviour
* @param {Function} deps.onToggleArchived flips showArchived in the page
* @param {Function} deps.getFilters returns active filter array from table
* @param {Function} deps.getSort returns active sort array from table
* @param {Function} deps.getTableInstance returns the TanStack table instance
*/
export function buildToolbarActions({
fetchTaskLists,
fetchArchivedTaskLists,
pagination,
exportConfig,
navigate,
showArchived,
onToggleArchived,
getFilters,
getSort,
getTableInstance,
}) {
return [
{
key: "refresh",
type: "button",
icon: <RefreshCw className="size-4" />,
label: "Refresh",
onClick: () => {
const fetcher = showArchived ? fetchArchivedTaskLists : fetchTaskLists;
fetcher({
page: 1,
limit: pagination?.limit ?? 10,
filters: getFilters?.() ?? [],
sort: getSort?.() ?? [],
});
},
},
{
key: "export",
type: "button",
icon: <Download className="size-4" />,
label: "Export",
onClick: (table) =>
exportTableToExcel({
...exportConfig,
tableInstance: table ?? getTableInstance?.(),
}),
},
{
key: "add-task-list",
type: "button",
icon: <Plus className="size-4" />,
label: "Create Task List",
variant: "default",
className: "text-primary-foreground",
onClick: () => navigate(`/staff/task-lists/create`),
},
{
key: "toggle-archived",
type: "button",
icon: <Archive className="size-4" />,
label: showArchived ? "Active Task Lists" : "Archived Task Lists",
variant: "secondary",
className: "border border-border",
onClick: onToggleArchived,
},
];
}
+84
View File
@@ -0,0 +1,84 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { LayoutDashboard, Users, CheckSquare, BarChart2, Settings, LogOut, ChevronRight, } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { useAuth } from '@/contexts/AuthContext';
import { cn } from '@/lib/utils';
import { StaffProviders } from '@/contexts/provider/StaffProvider';
import UserMenu from '@/components/generic/UserMenu';
const navItems = [
{ to: '/staff', label: 'Dashboard', icon: LayoutDashboard, end: true },
{ to: '/staff/groups', label: 'My groups', icon: Users },
{ to: '/staff/task-lists', label: 'Task lists', icon: CheckSquare },
{ to: '/staff/scores', label: 'Scores', icon: BarChart2 },
];
export default function StaffLayout() {
const { user, logout } = useAuth();
const navigate = useNavigate();
return (
<div className="flex h-screen bg-background">
{/* Sidebar */}
<aside className="w-56 shrink-0 flex flex-col border-r bg-card">
<div className="px-4 py-4 border-b">
<p className="text-sm font-medium">Staff portal</p>
<p className="text-xs text-muted-foreground mt-0.5 truncate">AA</p>
</div>
<nav className="flex-1 py-2 space-y-0.5 px-2">
{navItems.map(({ to, label, icon: Icon, end }) => (
<NavLink
key={to}
to={to}
end={end}
className={({ isActive }) =>
cn(
'flex items-center gap-2.5 px-3 py-2 rounded-md text-sm transition-colors',
isActive
? 'bg-secondary text-foreground font-medium'
: 'text-muted-foreground hover:bg-secondary hover:text-foreground'
)
}
>
<Icon size={16} />
{label}
</NavLink>
))}
</nav>
<Separator />
{/* <div className="px-2 py-2 space-y-0.5">
<NavLink
to="/staff/settings"
className="flex items-center gap-2.5 px-3 py-2 rounded-md text-sm text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
>
<Settings size={16} /> Settings
</NavLink>
</div> */}
</aside>
{/* Main */}
<div className="flex flex-col flex-1 min-w-0">
{/* Topbar */}
<header className="h-14 border-b bg-card flex items-center justify-between px-6 shrink-0">
<div className="flex items-center gap-1 text-sm text-muted-foreground">
{/* Breadcrumb rendered by each page via a portal or just title */}
</div>
<UserMenu />
</header>
{/* Page content */}
<main className="flex-1 overflow-y-auto p-6">
<StaffProviders>
<Outlet />
</StaffProviders>
</main>
</div>
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Layers, Users, CheckSquare, BarChart2 } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { useStaffGroups } from "@/contexts/StaffGroupContext";
import GroupTile from "../components/GroupTile";
import TaskRow from "../components/TaskRow";
export default function DashboardPage() {
const navigate = useNavigate();
const { fetchMyGroups } = useStaffGroups();
const [groups, setGroups] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchMyGroups()
.then((res) => setGroups(res?.data ?? []))
.catch(console.error)
.finally(() => setLoading(false));
}, []);
// ── Derived stats ─────────────────────────────────────────────────────────
const totalMembers = groups.reduce((sum, g) => sum + (g.members?.length ?? 0), 0);
const totalTaskLists = groups.reduce((sum, g) => sum + (g.taskLists?.length ?? 0), 0);
const allTasks = groups.flatMap((g) => g.taskLists ?? []).flatMap((tl) => tl.tasks ?? []);
const completedCount = allTasks.filter((t) => t.status === "completed").length;
const avgCompletion = allTasks.length
? Math.round((completedCount / allTasks.length) * 100)
: 0;
const STATS = [
{ label: "My groups", value: groups.length, icon: Layers, sub: "You are a member of" },
{ label: "Total members", value: totalMembers, icon: Users, sub: "Across all groups" },
{ label: "Active task lists", value: totalTaskLists, icon: CheckSquare, sub: "Assigned to your groups" },
{ label: "Avg. completion", value: `${avgCompletion}%`, icon: BarChart2, sub: "Across all tasks" },
];
const recentTasks = allTasks.slice(0, 5);
return (
<div className="space-y-6">
<h1 className="text-lg font-medium">Dashboard</h1>
{/* ── Stat tiles ──────────────────────────────────────────────────── */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{STATS.map(({ label, value, icon: Icon, sub }) => (
<Card key={label} className="bg-muted/40 border-0 shadow-none">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-2">
<p className="text-xs text-muted-foreground">{label}</p>
<Icon size={14} className="text-muted-foreground" aria-hidden />
</div>
<p className="text-2xl font-medium">
{loading ? "—" : value}
</p>
<p className="text-xs text-muted-foreground mt-1">{sub}</p>
</CardContent>
</Card>
))}
</div>
{/* ── Group tiles ──────────────────────────────────────────────────── */}
<div>
<h2 className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">
My groups
</h2>
{loading ? (
<div className="grid grid-cols-2 gap-3">
{[0, 1].map((i) => (
<Card key={i} className="h-36 animate-pulse bg-muted/40 border-0" />
))}
</div>
) : groups.length === 0 ? (
<p className="text-sm text-muted-foreground">
You are not assigned to any groups yet.
</p>
) : (
<div className="grid grid-cols-2 gap-3">
{groups.map((group) => (
<GroupTile
key={group.group_id}
group={group}
onClick={() => navigate(`/staff/groups/${group.group_id}`)}
/>
))}
</div>
)}
</div>
{/* ── Recent tasks ─────────────────────────────────────────────────── */}
<div>
<h2 className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">
Recent task activity
</h2>
<Card>
<CardContent className="p-0 divide-y">
{recentTasks.length === 0 && !loading && (
<p className="text-sm text-muted-foreground p-4">No tasks yet.</p>
)}
{recentTasks.map((task) => (
<TaskRow key={task.task_id} task={task} />
))}
</CardContent>
</Card>
</div>
</div>
);
}
+189
View File
@@ -0,0 +1,189 @@
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useStaffGroups } from "@/contexts/StaffGroupContext";
import MembersTable from "../components/members/MembersTable";
import TaskListsTab from "../components/TaskListsTab";
function formatDate(iso) {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("en-PH", {
year: "numeric",
month: "short",
day: "numeric",
});
}
function getInitials(name = "") {
const parts = name.trim().split(/\s+/);
return parts.length >= 2
? (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
: name.slice(0, 2).toUpperCase();
}
export default function GroupDetailPage() {
const { groupId } = useParams();
const navigate = useNavigate();
const {
fetchGroupById,
// members pagination — provided by your context after the backend split
members,
memberAttributes,
memberPagination,
setMemberPagination,
membersLoading,
fetchGroupMembers,
fetchGroupMemberFieldValues,
} = useStaffGroups();
const [group, setGroup] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchGroupById(groupId)
.then((res) => setGroup(res?.data ?? null))
.catch(console.error)
.finally(() => setLoading(false));
// Initial members fetch
// fetchGroupMembers(groupId, { page: 1, limit: 10 });
return () => setGroup(null);
}, [groupId]);
// ── Loading skeleton ──────────────────────────────────────────────────────
if (loading) {
return (
<div className="space-y-4">
<div className="h-5 w-32 rounded bg-muted/40 animate-pulse" />
<div className="h-64 rounded-lg bg-muted/40 animate-pulse" />
</div>
);
}
if (!group) {
return (
<div className="space-y-4">
<button
onClick={() => navigate("/staff/groups")}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft size={14} /> Back to groups
</button>
<p className="text-sm text-muted-foreground">Group not found.</p>
</div>
);
}
const listCount = group.taskLists?.length ?? 0;
const totalTasks = group.taskLists?.reduce((acc, tl) => acc + (tl.tasks?.length ?? 0), 0) ?? 0;
const completedTasks = group.taskLists?.reduce(
(acc, tl) => acc + (tl.tasks?.filter((t) => t.status === "completed").length ?? 0),
0
) ?? 0;
return (
<div className="space-y-5">
{/* Back */}
<button
onClick={() => navigate("/staff/groups")}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft size={14} /> Back to groups
</button>
{/* ── Group header card ───────────────────────────────────────────────── */}
<Card>
<CardContent className="p-5">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-primary/10 text-primary flex items-center justify-center text-sm font-semibold shrink-0">
{getInitials(group.name)}
</div>
<div>
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-base font-semibold">{group.name}</h1>
{group.group_code && (
<Badge variant="secondary" className="text-xs">
{group.group_code}
</Badge>
)}
<Badge
variant={group.is_active ? "default" : "outline"}
className="text-xs"
>
{group.is_active ? "Active" : "Inactive"}
</Badge>
</div>
{group.description && (
<p className="text-sm text-muted-foreground mt-0.5">
{group.description}
</p>
)}
</div>
</div>
<div className="text-xs text-muted-foreground text-right space-y-0.5">
<p>Created <span className="text-foreground">{formatDate(group.createdAt)}</span></p>
<p>Updated <span className="text-foreground">{formatDate(group.updatedAt)}</span></p>
</div>
</div>
{/* Summary stats */}
<div className="grid grid-cols-3 gap-3 mt-4 pt-4 border-t">
<div className="text-center">
<p className="text-xl font-semibold">{memberPagination?.total ?? "—"}</p>
<p className="text-xs text-muted-foreground mt-0.5">Members</p>
</div>
<div className="text-center">
<p className="text-xl font-semibold">{listCount}</p>
<p className="text-xs text-muted-foreground mt-0.5">Task lists</p>
</div>
<div className="text-center">
<p className="text-xl font-semibold">
{totalTasks > 0
? `${Math.round((completedTasks / totalTasks) * 100)}%`
: "—"}
</p>
<p className="text-xs text-muted-foreground mt-0.5">Completion</p>
</div>
</div>
</CardContent>
</Card>
{/* ── Tabs ───────────────────────────────────────────────────────────── */}
<Tabs defaultValue="members" className="flex flex-col">
<TabsList>
<TabsTrigger value="members">
Members ({memberPagination?.total ?? 0})
</TabsTrigger>
<TabsTrigger value="tasklists">
Task lists ({listCount})
</TabsTrigger>
</TabsList>
<TabsContent value="members" className="mt-4">
<MembersTable
members={members}
attributes={memberAttributes}
pagination={memberPagination}
setPagination={setMemberPagination}
loading={membersLoading}
fetchMembers={(params) => fetchGroupMembers(groupId, params)}
fetchMemberFieldValues={(col, params) =>
fetchGroupMemberFieldValues(groupId, col, params)
}
/>
</TabsContent>
<TabsContent value="tasklists" className="mt-4">
<TaskListsTab taskLists={group.taskLists ?? []} />
</TabsContent>
</Tabs>
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useStaffGroups } from "@/contexts/StaffGroupContext";
import GroupTile from "../components/GroupTile";
export default function GroupsPage() {
const navigate = useNavigate();
const { fetchMyGroups } = useStaffGroups();
const [groups, setGroups] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchMyGroups()
.then((res) => setGroups(res?.data ?? []))
.catch(console.error)
.finally(() => setLoading(false));
}, []);
return (
<div className="space-y-4">
<h1 className="text-lg font-medium">My groups</h1>
{loading ? (
<div className="grid grid-cols-2 gap-3">
{[0, 1, 2].map((i) => (
<div key={i} className="h-36 rounded-lg bg-muted/40 animate-pulse" />
))}
</div>
) : groups.length === 0 ? (
<p className="text-sm text-muted-foreground">
You are not assigned to any groups yet.
</p>
) : (
<div className="grid grid-cols-2 gap-3">
{groups.map((group) => (
<GroupTile
key={group.group_id}
group={group}
onClick={() => navigate(`/staff/groups/${group.group_id}`)}
/>
))}
</div>
)}
</div>
);
}
+262
View File
@@ -0,0 +1,262 @@
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Select, SelectContent, SelectItem,
SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { useStaffGroups } from "@/contexts/StaffGroupContext";
import { useStaffScores } from "@/contexts/StaffScoreContext";
import { cn } from "@/lib/utils";
import ScoreBadge from "../components/ScoreBadge";
export default function ScoresPage() {
const { fetchMyGroups } = useStaffGroups();
const {
quizScores, assessScores, loading,
fetchQuizScores, fetchAssessmentScores,
clearQuizScores, clearAssessScores,
} = useStaffScores();
const [groups, setGroups] = useState([]);
const [selectedGroup, setSelectedGroup] = useState(null);
const [quizId, setQuizId] = useState("");
const [assessmentId, setAssessmentId] = useState("");
useEffect(() => {
fetchMyGroups()
.then((res) => {
const data = res?.data ?? [];
setGroups(data);
if (data.length) setSelectedGroup(data[0].group_id);
})
.catch(console.error);
return () => {
clearQuizScores();
clearAssessScores();
};
}, []);
// ── Derive quiz + assessment options from the selected group's task lists ──
const selectedGroupData = groups.find((g) => g.group_id === selectedGroup);
const allTasks = selectedGroupData?.taskLists?.flatMap((tl) => tl.tasks ?? []) ?? [];
const quizTasks = allTasks.filter((t) =>
t.requirements?.some((r) => r.type === "read_unit")
);
const assessmentTasks = allTasks.filter((t) =>
t.requirements?.some((r) => r.type === "read_course")
);
const handleGroupChange = (value) => {
setSelectedGroup(parseInt(value));
setQuizId("");
setAssessmentId("");
clearQuizScores();
clearAssessScores();
};
const handleQuizChange = (id) => {
setQuizId(id);
fetchQuizScores(id);
};
const handleAssessmentChange = (id) => {
setAssessmentId(id);
fetchAssessmentScores(id);
};
return (
<div className="space-y-5">
{/* ── Header ──────────────────────────────────────────────────────── */}
<div className="flex items-center justify-between">
<h1 className="text-lg font-medium">Scores &amp; progress</h1>
<Select
value={selectedGroup?.toString() ?? ""}
onValueChange={handleGroupChange}
>
<SelectTrigger className="w-48 h-8 text-sm">
<SelectValue placeholder="Select group..." />
</SelectTrigger>
<SelectContent>
{groups.map((g) => (
<SelectItem key={g.group_id} value={g.group_id.toString()}>
{g.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Tabs defaultValue="quiz">
<TabsList>
<TabsTrigger value="quiz">Quiz scores</TabsTrigger>
<TabsTrigger value="assessment">Assessment scores</TabsTrigger>
</TabsList>
{/* ── Quiz scores ─────────────────────────────────────────────── */}
<TabsContent value="quiz" className="mt-4 space-y-4">
<Select value={quizId} onValueChange={handleQuizChange}>
<SelectTrigger className="w-64 h-8 text-sm">
<SelectValue placeholder="Select a unit quiz..." />
</SelectTrigger>
<SelectContent>
{quizTasks.length === 0 ? (
<div className="px-3 py-2 text-xs text-muted-foreground">
No unit quizzes in this group.
</div>
) : (
quizTasks.map((t) => (
<SelectItem
key={t.requirements[0].reference_id}
value={t.requirements[0].reference_id}
>
{t.requirements[0].reference_label ?? t.name}
</SelectItem>
))
)}
</SelectContent>
</Select>
{loading && quizId && (
<div className="h-32 rounded-lg bg-muted/40 animate-pulse" />
)}
{quizScores && !loading && (
<>
<div className="flex items-center gap-3 flex-wrap">
<p className="text-sm font-medium">
{quizScores.quiz?.title ?? "Unit quiz"}
</p>
<Badge variant="outline" className="text-xs">
Passing: {quizScores.quiz?.passing_score ?? 70}%
</Badge>
{quizScores.quiz?.unit?.title && (
<Badge variant="secondary" className="text-xs">
{quizScores.quiz.unit.title}
</Badge>
)}
</div>
<ScoreTable
data={quizScores.data}
passingScore={quizScores.quiz?.passing_score ?? 70}
/>
</>
)}
{!quizId && !loading && (
<p className="text-sm text-muted-foreground">
Select a unit quiz above to view scores.
</p>
)}
</TabsContent>
{/* ── Assessment scores ────────────────────────────────────────── */}
<TabsContent value="assessment" className="mt-4 space-y-4">
<Select value={assessmentId} onValueChange={handleAssessmentChange}>
<SelectTrigger className="w-64 h-8 text-sm">
<SelectValue placeholder="Select a course assessment..." />
</SelectTrigger>
<SelectContent>
{assessmentTasks.length === 0 ? (
<div className="px-3 py-2 text-xs text-muted-foreground">
No course assessments in this group.
</div>
) : (
assessmentTasks.map((t) => (
<SelectItem
key={t.requirements[0].reference_id}
value={t.requirements[0].reference_id}
>
{t.requirements[0].reference_label ?? t.name}
</SelectItem>
))
)}
</SelectContent>
</Select>
{loading && assessmentId && (
<div className="h-32 rounded-lg bg-muted/40 animate-pulse" />
)}
{assessScores && !loading && (
<>
<div className="flex items-center gap-3 flex-wrap">
<p className="text-sm font-medium">
{assessScores.assessment?.title ?? "Course assessment"}
</p>
<Badge variant="outline" className="text-xs">
Passing: {assessScores.assessment?.passing_score ?? 75}%
</Badge>
{assessScores.assessment?.course?.title && (
<Badge variant="secondary" className="text-xs">
{assessScores.assessment.course.title}
</Badge>
)}
</div>
<ScoreTable
data={assessScores.data}
passingScore={assessScores.assessment?.passing_score ?? 75}
/>
</>
)}
{!assessmentId && !loading && (
<p className="text-sm text-muted-foreground">
Select a course assessment above to view scores.
</p>
)}
</TabsContent>
</Tabs>
</div>
);
}
// ── ScoreTable ────────────────────────────────────────────────────────────────
function ScoreTable({ data = [], passingScore }) {
if (!data.length) {
return <p className="text-sm text-muted-foreground py-4">No data yet.</p>;
}
const COLORS = [
"bg-emerald-100 text-emerald-800",
"bg-purple-100 text-purple-800",
"bg-amber-100 text-amber-800",
"bg-blue-100 text-blue-800",
"bg-pink-100 text-pink-800",
];
return (
<Card>
<CardContent className="p-0 divide-y">
{data.map(({ user, best_score, attempts, latest }, i) => {
const fullName = user.personal_info?.name?.full_name ?? user.email ?? "";
const initials = fullName
.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2) || "?";
return (
<div key={user.user_id} className="flex items-center gap-3 px-4 py-3">
<div className={cn(
"w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium shrink-0",
COLORS[i % COLORS.length]
)}>
{initials}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{fullName}</p>
<p className="text-xs text-muted-foreground">
{attempts.length === 0
? "No attempt yet"
: `${attempts.length} attempt${attempts.length !== 1 ? "s" : ""} · best score`}
</p>
</div>
<ScoreBadge score={best_score} passingScore={passingScore} />
</div>
);
})}
</CardContent>
</Card>
);
}
+28
View File
@@ -0,0 +1,28 @@
/***********************************************************************************************************************************************************************
* File Name: TaskList.jsx (staff)
* Type of Program: Page
* Description: Staff task list page — breadcrumb + DataTable via TaskListTable.
***********************************************************************************************************************************************************************/
import { House } from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import TaskListTable from "../components/TaskListTable";
export default function TaskList() {
const items = [
{ label: "Home", icon: <House className="size-4" />, to: "/staff" },
{ label: "Task Lists" },
];
return (
<section className="h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 mb-6">
<AppBreadcrumb items={items} />
</div>
<div className="w-full">
<TaskListTable />
</div>
</div>
</section>
);
}
+25
View File
@@ -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 <Navigate to="/login" replace />
// 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 <Navigate to="/admin" replace />
case 'staff': return <Navigate to="/staff" replace />
case 'client': return <Navigate to="/client" replace />
default: return <Navigate to="/" replace />
}
}
// User is logged in AND must change password → allow through
return <Outlet />
}