mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
adjusted
This commit is contained in:
@@ -86,7 +86,7 @@ export default function EditTaskList() {
|
||||
toUnassign.length ? unassignGroups(taskListId, toUnassign) : Promise.resolve(),
|
||||
]);
|
||||
|
||||
navigate(`/admin/taskList/${taskListId}`);
|
||||
navigate(`/admin/taskList`);
|
||||
};
|
||||
|
||||
// ── 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user