Files
starr-philproperties/src/modules/admin/components/courses/CoursePrerequisiteBuilder.jsx
T
2026-07-17 13:04:40 +08:00

218 lines
11 KiB
React

import { useState } from 'react';
import { Plus, Trash2, GripVertical, ChevronsUpDown, BookOpen, Layers, FileText, Link2, Unlink } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from '@/components/ui/command';
// ─── Prerequisite type config ──────────────────────────────────────────────────
// idKey is the numeric FK field each flat list carries (course_id/unit_id/
// lesson_id) — CoursePrerequisite.ref_id is a BIGINT FK, not a uuid, so the
// picker must select on the numeric id even though the list is keyed/searched
// by uuid for React purposes.
const REF_TYPES = [
{ value: 'course', label: 'Course', icon: BookOpen, idKey: 'course_id' },
{ value: 'unit', label: 'Unit', icon: Layers, idKey: 'unit_id' },
{ value: 'lesson', label: 'Lesson', icon: FileText, idKey: 'lesson_id' },
];
const TYPE_MAP = Object.fromEntries(REF_TYPES.map((t) => [t.value, t]));
// ─── Course binding indicator (units/lessons may sit under 0..N courses) ──────
function BindingLine({ courses = [] }) {
if (!courses.length) {
return (
<span className="flex items-center gap-1 text-xs italic text-muted-foreground truncate">
<Unlink className="size-3 shrink-0" />
Standalone
</span>
);
}
return (
<span className="flex items-center gap-1 text-xs text-muted-foreground truncate">
<Link2 className="size-3 shrink-0" />
<span className="truncate">{courses.map((c) => c.title).join(', ')}</span>
</span>
);
}
// ─── Custom content picker — own Dialog rather than an anchored Popover ───────
function ContentPicker({ value, options, idKey, placeholder = 'Select…', dialogTitle, onSelect, renderTrigger, renderItem }) {
const [open, setOpen] = useState(false);
const selected = options.find((o) => String(o[idKey]) === String(value));
const searchValue = (o) => {
const courseNames = (o.courses ?? []).map((c) => c.title).join(' ');
return `${o.title ?? ''} ${courseNames}`.trim() || String(o[idKey]);
};
return (
<>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
className="h-auto min-h-8 w-full justify-between text-sm font-normal py-1.5 px-3"
onClick={() => setOpen(true)}
>
{selected
? renderTrigger(selected)
: <span className="text-muted-foreground">{placeholder}</span>}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="top-[15%] translate-y-0 flex flex-col max-h-[70vh] overflow-hidden rounded-xl! p-0 gap-0 sm:max-w-md">
<DialogHeader className="px-4 pt-4 pb-3 border-b pr-10">
<DialogTitle className="text-sm">{dialogTitle ?? placeholder}</DialogTitle>
</DialogHeader>
<Command className="flex-1 min-h-0 rounded-none! bg-transparent p-0" loop>
<CommandInput placeholder="Search…" />
<CommandList className="max-h-[50vh]">
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{options.map((o) => (
<CommandItem
key={o[idKey]}
value={searchValue(o)}
onSelect={() => { onSelect(o); setOpen(false); }}
className="p-0"
>
{renderItem(o)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</DialogContent>
</Dialog>
</>
);
}
function createPrereq(type = 'course') {
return { _key: crypto.randomUUID(), ref_type: type, ref_id: '', title: '' };
}
// ─── CoursePrerequisiteBuilder ─────────────────────────────────────────────────
// value: [{ prereq_id?, ref_type, ref_id, title? }]
export default function CoursePrerequisiteBuilder({ value = [], onChange, courses = [], units = [], lessons = [], excludeCourseId }) {
const [items, setItems] = useState(
value.length > 0
? value.map((v) => ({ _key: crypto.randomUUID(), ...v }))
: []
);
const filteredCourses = courses.filter((c) => String(c.course_id) !== String(excludeCourseId));
const optionsFor = { course: filteredCourses, unit: units, lesson: lessons };
const emit = (next) => {
setItems(next);
onChange?.(next.map(({ _key, ...r }) => r));
};
const addItem = () => emit([...items, createPrereq('course')]);
const removeItem = (key) => emit(items.filter((i) => i._key !== key));
const updateItem = (key, patch) =>
emit(items.map((i) => (i._key === key ? { ...i, ...patch } : i)));
return (
<div className="space-y-3">
{items.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-6 border border-dashed rounded-lg">
No prerequisites added. Learners can start this course freely.
</p>
)}
{items.map((item, idx) => {
const typeDef = TYPE_MAP[item.ref_type];
const Icon = typeDef?.icon ?? BookOpen;
const options = optionsFor[item.ref_type] ?? [];
return (
<Card key={item._key}>
<CardContent className="pt-4 pb-4 space-y-3">
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-muted-foreground shrink-0" />
<Badge variant="outline" className="text-xs gap-1 shrink-0">
<Icon className="h-3 w-3" />
{idx + 1}
</Badge>
<Select
value={item.ref_type}
onValueChange={(v) => updateItem(item._key, { ref_type: v, ref_id: '', title: '' })}
>
<SelectTrigger className="h-8 text-sm w-32 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{REF_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>
<span className="flex items-center gap-2">
<t.icon className="h-3.5 w-3.5" />
{t.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex-1 min-w-0">
<ContentPicker
value={item.ref_id}
options={options}
idKey={typeDef.idKey}
placeholder={`Select a ${typeDef.label.toLowerCase()}`}
dialogTitle={`Select a ${typeDef.label.toLowerCase()}`}
onSelect={(o) => updateItem(item._key, { ref_id: o[typeDef.idKey], title: o.title })}
renderTrigger={(o) => (
<div className="flex items-center gap-2 min-w-0 flex-1">
{item.ref_type !== 'course' && (
<div className="flex flex-col items-start flex-1 min-w-0">
<BindingLine courses={o.courses} />
<span className="text-sm leading-tight truncate">{o.title}</span>
</div>
)}
{item.ref_type === 'course' && (
<span className="flex-1 truncate text-sm">{o.title}</span>
)}
</div>
)}
renderItem={(o) => (
<div className="flex items-center gap-2 px-3 py-2 w-full">
<div className="flex flex-col flex-1 min-w-0">
<span className="text-sm leading-tight truncate">{o.title}</span>
{item.ref_type !== 'course' && <BindingLine courses={o.courses} />}
</div>
</div>
)}
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-destructive hover:text-destructive"
onClick={() => removeItem(item._key)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
);
})}
<Button type="button" variant="outline" size="sm" className="w-full gap-2" onClick={addItem}>
<Plus className="h-4 w-4" />
Add Prerequisite
</Button>
</div>
);
}