mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
218 lines
11 KiB
React
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>
|
|
);
|
|
}
|