change design and queries to api

This commit is contained in:
rgrgogu
2026-07-20 11:28:51 +08:00
parent 793b24c019
commit f23b022a6f
6 changed files with 107 additions and 59 deletions
+48 -5
View File
@@ -20,7 +20,9 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Check, ChevronsUpDown, X, Users } from 'lucide-react'; import { Check, ChevronsUpDown, X, Users, ChevronLeft, ChevronRight } from 'lucide-react';
const PAGE_SIZE = 5;
export default function GroupMultiSelect({ export default function GroupMultiSelect({
value = [], value = [],
@@ -31,6 +33,7 @@ export default function GroupMultiSelect({
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [allGroups, setAllGroups] = useState(groupsProp ?? []); const [allGroups, setAllGroups] = useState(groupsProp ?? []);
const [loadingGroups, setLoadingGroups] = useState(!groupsProp); const [loadingGroups, setLoadingGroups] = useState(!groupsProp);
const [dropdownStyle, setDropdownStyle] = useState({}); const [dropdownStyle, setDropdownStyle] = useState({});
@@ -109,6 +112,17 @@ export default function GroupMultiSelect({
const filteredIds = filtered.map((g) => g.group_id); const filteredIds = filtered.map((g) => g.group_id);
const allFilteredSelected = filteredIds.length > 0 && filteredIds.every((id) => value.includes(id)); const allFilteredSelected = filteredIds.length > 0 && filteredIds.every((id) => value.includes(id));
// ── Pagination ────────────────────────────────────────────────────────────
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
const paginated = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
// Reset to page 1 whenever the search term changes
const handleSearchChange = (e) => {
setSearch(e.target.value);
setPage(1);
};
const toggle = (groupId) => const toggle = (groupId) =>
onChange(value.includes(groupId) onChange(value.includes(groupId)
? value.filter((id) => id !== groupId) ? value.filter((id) => id !== groupId)
@@ -141,7 +155,7 @@ export default function GroupMultiSelect({
<Input <Input
autoFocus autoFocus
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={handleSearchChange}
placeholder="Search groups…" placeholder="Search groups…"
className="h-8 text-sm" className="h-8 text-sm"
/> />
@@ -173,7 +187,7 @@ export default function GroupMultiSelect({
)} )}
{/* Options */} {/* Options */}
<ul className="max-h-52 overflow-y-auto py-1"> <ul className="py-1">
{loadingGroups ? ( {loadingGroups ? (
<li className="px-3 py-6 text-center text-xs text-muted-foreground"> <li className="px-3 py-6 text-center text-xs text-muted-foreground">
Loading groups… Loading groups…
@@ -183,7 +197,7 @@ export default function GroupMultiSelect({
No groups found. No groups found.
</li> </li>
) : ( ) : (
filtered.map((g) => { paginated.map((g) => {
const selected = value.includes(g.group_id); const selected = value.includes(g.group_id);
return ( return (
<li <li
@@ -211,6 +225,35 @@ export default function GroupMultiSelect({
}) })
)} )}
</ul> </ul>
{/* Pagination */}
{!loadingGroups && filtered.length > PAGE_SIZE && (
<div className="flex items-center justify-between px-3 py-1.5 border-t border-border bg-muted/40">
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={safePage <= 1}
className="flex items-center gap-0.5 text-xs text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:pointer-events-none"
>
<ChevronLeft className="h-3.5 w-3.5" />
Prev
</button>
<span className="text-xs text-muted-foreground tabular-nums">
{safePage} / {totalPages}
</span>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={safePage >= totalPages}
className="flex items-center gap-0.5 text-xs text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:pointer-events-none"
>
Next
<ChevronRight className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>, </div>,
document.body document.body
); );
@@ -222,7 +265,7 @@ export default function GroupMultiSelect({
ref={triggerRef} ref={triggerRef}
type="button" type="button"
disabled={disabled} disabled={disabled}
onClick={() => { setOpen((o) => !o); setSearch(''); }} onClick={() => { setOpen((o) => !o); setSearch(''); setPage(1); }}
className={cn( className={cn(
'w-full min-h-9 px-3 py-1.5 rounded-md border border-input bg-background text-sm', 'w-full min-h-9 px-3 py-1.5 rounded-md border border-input bg-background text-sm',
'flex items-center gap-1.5 text-left', 'flex items-center gap-1.5 text-left',
+48 -5
View File
@@ -20,7 +20,9 @@ import { createPortal } from 'react-dom';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Check, ChevronsUpDown, X, ListChecks } from 'lucide-react'; import { Check, ChevronsUpDown, X, ListChecks, ChevronLeft, ChevronRight } from 'lucide-react';
const PAGE_SIZE = 5;
export default function TaskMultiSelect({ export default function TaskMultiSelect({
value = [], value = [],
@@ -31,6 +33,7 @@ export default function TaskMultiSelect({
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [dropdownStyle, setDropdownStyle] = useState({}); const [dropdownStyle, setDropdownStyle] = useState({});
const triggerRef = useRef(null); const triggerRef = useRef(null);
@@ -86,6 +89,17 @@ export default function TaskMultiSelect({
const filteredIds = filtered.map((t) => t.task_id); const filteredIds = filtered.map((t) => t.task_id);
const allFilteredSelected = filteredIds.length > 0 && filteredIds.every((id) => value.includes(id)); const allFilteredSelected = filteredIds.length > 0 && filteredIds.every((id) => value.includes(id));
// ── Pagination ────────────────────────────────────────────────────────────
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
const paginated = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
// Reset to page 1 whenever the search term changes
const handleSearchChange = (e) => {
setSearch(e.target.value);
setPage(1);
};
const toggle = (taskId) => const toggle = (taskId) =>
onChange(value.includes(taskId) onChange(value.includes(taskId)
? value.filter((id) => id !== taskId) ? value.filter((id) => id !== taskId)
@@ -118,7 +132,7 @@ export default function TaskMultiSelect({
<Input <Input
autoFocus autoFocus
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={handleSearchChange}
placeholder="Search tasks…" placeholder="Search tasks…"
className="h-8 text-sm" className="h-8 text-sm"
/> />
@@ -150,13 +164,13 @@ export default function TaskMultiSelect({
)} )}
{/* Options */} {/* Options */}
<ul className="max-h-52 overflow-y-auto py-1"> <ul className="py-1">
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<li className="px-3 py-6 text-center text-xs text-muted-foreground"> <li className="px-3 py-6 text-center text-xs text-muted-foreground">
No tasks found. No tasks found.
</li> </li>
) : ( ) : (
filtered.map((t) => { paginated.map((t) => {
const selected = value.includes(t.task_id); const selected = value.includes(t.task_id);
return ( return (
<li <li
@@ -184,6 +198,35 @@ export default function TaskMultiSelect({
}) })
)} )}
</ul> </ul>
{/* Pagination */}
{filtered.length > PAGE_SIZE && (
<div className="flex items-center justify-between px-3 py-1.5 border-t border-border bg-muted/40">
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={safePage <= 1}
className="flex items-center gap-0.5 text-xs text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:pointer-events-none"
>
<ChevronLeft className="h-3.5 w-3.5" />
Prev
</button>
<span className="text-xs text-muted-foreground tabular-nums">
{safePage} / {totalPages}
</span>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={safePage >= totalPages}
className="flex items-center gap-0.5 text-xs text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:pointer-events-none"
>
Next
<ChevronRight className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>, </div>,
document.body document.body
); );
@@ -195,7 +238,7 @@ export default function TaskMultiSelect({
ref={triggerRef} ref={triggerRef}
type="button" type="button"
disabled={disabled} disabled={disabled}
onClick={() => { setOpen((o) => !o); setSearch(''); }} onClick={() => { setOpen((o) => !o); setSearch(''); setPage(1); }}
className={cn( className={cn(
'w-full min-h-9 px-3 py-1.5 rounded-md border border-input bg-background text-sm', 'w-full min-h-9 px-3 py-1.5 rounded-md border border-input bg-background text-sm',
'flex items-center gap-1.5 text-left', 'flex items-center gap-1.5 text-left',
@@ -21,13 +21,6 @@ export function buildRowActions({ navigate, onArchive, onBan, onUnban }) {
icon: <Eye className="h-3.5 w-3.5" />, icon: <Eye className="h-3.5 w-3.5" />,
onClick: (row) => navigate(`view/${row.user_id}`), onClick: (row) => navigate(`view/${row.user_id}`),
}, },
{
key: "edit",
label: "Edit Info",
icon: <Pencil className="h-3.5 w-3.5" />,
onClick: (row) => navigate(`edit/${row.user_id}`),
disabled: (row) => row.role === "super_admin",
},
{ {
key: "ban", key: "ban",
label: "Ban User", label: "Ban User",
@@ -15,7 +15,6 @@ import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { ArrowLeft, ChevronRight, ChevronLeft, Check, FileText, ListChecks, ClipboardList, Link as LinkIcon, Clock } from 'lucide-react'; import { ArrowLeft, ChevronRight, ChevronLeft, Check, FileText, ListChecks, ClipboardList, Link as LinkIcon, Clock } from 'lucide-react';
import DeadlinePicker from '@/components/generic/DeadlinePicker'; import DeadlinePicker from '@/components/generic/DeadlinePicker';
@@ -47,7 +46,6 @@ export default function CreateTask() {
name: '', name: '',
description: '', description: '',
deadline: '', deadline: '',
is_required: true,
requirements: [], requirements: [],
prerequisite_task_ids: [], prerequisite_task_ids: [],
}); });
@@ -109,7 +107,6 @@ export default function CreateTask() {
name: form.name.trim(), name: form.name.trim(),
description: form.description.trim() || null, description: form.description.trim() || null,
deadline: form.deadline || null, deadline: form.deadline || null,
is_required: form.is_required,
// use result.data — it carries the schema's transforms (e.g. link_url scheme defaulting) // use result.data — it carries the schema's transforms (e.g. link_url scheme defaulting)
// strip duration_seconds — it's only used for local validation // strip duration_seconds — it's only used for local validation
requirements: result.data.requirements.map((r) => { requirements: result.data.requirements.map((r) => {
@@ -214,19 +211,6 @@ export default function CreateTask() {
/> />
</div> </div>
<div className="flex items-center justify-between rounded-md border px-3 py-2.5">
<div>
<Label>Required</Label>
<p className="text-xs text-muted-foreground">
Later tasks in this list stay locked until this one is complete. Turn off for optional tasks.
</p>
</div>
<Switch
checked={form.is_required}
onCheckedChange={(v) => setForm({ ...form, is_required: v })}
/>
</div>
<div className="space-y-1"> <div className="space-y-1">
<Label> <Label>
Prerequisite Tasks Prerequisite Tasks
@@ -242,7 +226,7 @@ export default function CreateTask() {
placeholder="Select tasks that must be completed first…" placeholder="Select tasks that must be completed first…"
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
This task stays locked until every selected task is complete. Leave empty to use the default order-based lock instead. This task stays locked until every selected task is complete. Leave empty for no lock — the task is available immediately.
</p> </p>
</div> </div>
</CardContent> </CardContent>
@@ -13,7 +13,6 @@ import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import DeadlinePicker from '@/components/generic/DeadlinePicker'; import DeadlinePicker from '@/components/generic/DeadlinePicker';
import { import {
@@ -56,7 +55,6 @@ export default function EditTask() {
description: data.description ?? '', description: data.description ?? '',
deadline: data.deadline ?? '', deadline: data.deadline ?? '',
status: data.status ?? 'pending', status: data.status ?? 'pending',
is_required: data.is_required ?? true,
requirements: reqs, requirements: reqs,
prerequisite_task_ids: (data.prerequisites ?? []).map((p) => p.task_id), prerequisite_task_ids: (data.prerequisites ?? []).map((p) => p.task_id),
}); });
@@ -102,7 +100,6 @@ export default function EditTask() {
description: form.description.trim() || null, description: form.description.trim() || null,
deadline: form.deadline || null, deadline: form.deadline || null,
status: form.status, status: form.status,
is_required: form.is_required,
requirements, requirements,
prerequisite_task_ids: form.prerequisite_task_ids ?? [], prerequisite_task_ids: form.prerequisite_task_ids ?? [],
}); });
@@ -215,19 +212,6 @@ export default function EditTask() {
</Select> </Select>
</div> </div>
<div className="flex items-center justify-between rounded-md border px-3 py-2.5">
<div>
<Label>Required</Label>
<p className="text-xs text-muted-foreground">
Later tasks in this list stay locked until this one is complete. Turn off for optional tasks.
</p>
</div>
<Switch
checked={!!form.is_required}
onCheckedChange={(v) => setForm({ ...form, is_required: v })}
/>
</div>
<div className="space-y-1"> <div className="space-y-1">
<Label> <Label>
Prerequisite Tasks Prerequisite Tasks
@@ -243,7 +227,7 @@ export default function EditTask() {
placeholder="Select tasks that must be completed first…" placeholder="Select tasks that must be completed first…"
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
This task stays locked until every selected task is complete. Leave empty to use the default order-based lock instead. This task stays locked until every selected task is complete. Leave empty for no lock — the task is available immediately.
</p> </p>
</div> </div>
</CardContent> </CardContent>
@@ -75,7 +75,6 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [starting, setStarting] = useState(false); const [starting, setStarting] = useState(false);
const [result, setResult] = useState(null); const [result, setResult] = useState(null);
const [reviewAttempted, setReviewAttempted] = useState(false);
const [submitError, setSubmitError] = useState(null); const [submitError, setSubmitError] = useState(null);
// ── Timer state ─────────────────────────────────────────────────────────── // ── Timer state ───────────────────────────────────────────────────────────
@@ -107,7 +106,6 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
setResult(null); setResult(null);
setRemainingSeconds(null); setRemainingSeconds(null);
setTimeExpired(false); setTimeExpired(false);
setReviewAttempted(false);
sessionRef.current = { sessionId: null, expiresAt: null }; sessionRef.current = { sessionId: null, expiresAt: null };
timerFiredRef.current = false; timerFiredRef.current = false;
const draft = quiz?.active_session?.draft_answers ?? {}; const draft = quiz?.active_session?.draft_answers ?? {};
@@ -222,7 +220,6 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
setResult(null); setResult(null);
setRemainingSeconds(null); setRemainingSeconds(null);
setTimeExpired(false); setTimeExpired(false);
setReviewAttempted(false);
sessionRef.current = { sessionId: null, expiresAt: null }; sessionRef.current = { sessionId: null, expiresAt: null };
timerFiredRef.current = false; timerFiredRef.current = false;
setStage("intro"); setStage("intro");
@@ -266,7 +263,6 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
}; };
const handleGoToReview = () => { const handleGoToReview = () => {
setReviewAttempted(true);
setStage("review"); setStage("review");
}; };
@@ -910,11 +906,11 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
<Button <Button
className="w-full" className="w-full"
onClick={handleGoToReview} onClick={handleGoToReview}
disabled={submitting} disabled={submitting || unansweredCount > 0}
> >
Review &amp; submit Review &amp; submit
</Button> </Button>
{reviewAttempted && unansweredCount > 0 && ( {unansweredCount > 0 && (
<p className="text-xs text-red-600 dark:text-red-400 text-center"> <p className="text-xs text-red-600 dark:text-red-400 text-center">
Answer all {total} questions first — {unansweredCount} remaining. Answer all {total} questions first — {unansweredCount} remaining.
</p> </p>
@@ -968,7 +964,7 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
<ChevronLeft className="size-4" /> <ChevronLeft className="size-4" />
Previous Previous
</Button> </Button>
<Button onClick={handleNext} disabled={submitting}> <Button onClick={handleNext} disabled={submitting || (isLast && unansweredCount > 0)}>
{submitting ? "Submitting…" : isLast ? "Review & submit" : "Next"} {submitting ? "Submitting…" : isLast ? "Review & submit" : "Next"}
{!submitting && <ChevronRight className="size-4" />} {!submitting && <ChevronRight className="size-4" />}
</Button> </Button>
@@ -1079,11 +1075,16 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
<ChevronLeft className="size-4" /> <ChevronLeft className="size-4" />
Previous Previous
</Button> </Button>
<Button onClick={handleNext} disabled={submitting}> <Button onClick={handleNext} disabled={submitting || (isLast && unansweredCount > 0)}>
{submitting ? "Submitting…" : isLast ? "Review answers" : "Next"} {submitting ? "Submitting…" : isLast ? "Review answers" : "Next"}
{!submitting && <ChevronRight className="size-4" />} {!submitting && <ChevronRight className="size-4" />}
</Button> </Button>
</div> </div>
{isLast && unansweredCount > 0 && (
<p className="text-xs text-red-600 dark:text-red-400 text-center">
Answer all {total} questions first — {unansweredCount} remaining.
</p>
)}
</div> </div>
</div> </div>
); );