mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
adjusted
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 ──────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 />
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user