import { useState, useEffect, useMemo } from "react"; import { ChevronsUpDown, Check, BookOpen, AlertTriangle } from "lucide-react"; import api from "@/utils/api.util"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandInput, CommandEmpty, CommandList, CommandItem } from "@/components/ui/command"; import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; /** * CoursePicker * * Props: * subscription — tier slug ("premium"). Null/undefined = hidden. * selectedIds — Set of selected course_id strings (managed by parent) * onChange — (Set) => void * isPreloaded — true in EditPlan (CoursePicker only mounts AFTER existing * assignments are already in selectedIds, so no race condition). * false in AddPlan (always bundle all on first load). * currentPlanId — plan being edited (undefined in AddPlan). A course already * assigned to a DIFFERENT plan is flagged as a conflict, since * plan_courses.course_id is UNIQUE — a course belongs to at * most one plan, and reassigning it here silently steals it * away from that plan on save. * onConflictsChange — (count: number) => void. Called whenever the number of * currently-SELECTED courses that conflict with another plan * changes, so the parent can block submission until resolved. * * Flow: * • Shows "Bundle all?" question with two buttons. * • "Yes, include all" → selects every course in the tier, hides picker. * • "No, choose specific" → opens a Popover with Command+Search+Checkboxes. * • Selected courses already owned by another plan are called out, and the * parent is expected to disable submission until they're unchecked. */ export function CoursePicker({ subscription, selectedIds, onChange, isPreloaded = false, currentPlanId, onConflictsChange }) { const [courses, setCourses] = useState([]); const [loading, setLoading] = useState(false); const [bundleAll, setBundleAll] = useState(true); const [popoverOpen, setPopoverOpen] = useState(false); const [search, setSearch] = useState(""); useEffect(() => { if (!subscription) { setCourses([]); setBundleAll(true); return; } setLoading(true); setSearch(""); setBundleAll(true); // reset question to "Yes" whenever subscription changes api.get(`/admin/courses/by-subscription?slug=${encodeURIComponent(subscription)}`) .then(({ data }) => { const loaded = data.data ?? []; setCourses(loaded); if (!isPreloaded) { // AddPlan: bundle all by default setBundleAll(true); onChange(new Set(loaded.map((c) => String(c.course_id)))); } else { // EditPlan: CoursePicker mounts only after assignments loaded into selectedIds. // Detect initial mode from current selectedIds vs total courses. const size = selectedIds.size; if (size > 0 && size < loaded.length) { // Partial selection saved previously → specific mode setBundleAll(false); } else { // All selected, or none (no courses assigned yet) → bundle all setBundleAll(true); onChange(new Set(loaded.map((c) => String(c.course_id)))); } } }) .catch(() => setCourses([])) .finally(() => setLoading(false)); }, [subscription]); // eslint-disable-line react-hooks/exhaustive-deps const filtered = useMemo(() => { const q = search.toLowerCase(); if (!q) return courses; return courses.filter( (c) => c.title?.toLowerCase().includes(q) || c.description?.toLowerCase().includes(q) ); }, [courses, search]); // Courses already owned by a DIFFERENT plan — selecting them here will move them. const isConflict = (course) => course.assigned_plan && String(course.assigned_plan.plan_id) !== String(currentPlanId ?? ""); // Only courses actually SELECTED matter — unchecking a conflicting course clears it. const conflicts = useMemo( () => courses.filter((c) => isConflict(c) && selectedIds.has(String(c.course_id))), [courses, currentPlanId, selectedIds] // eslint-disable-line react-hooks/exhaustive-deps ); const conflictsByPlan = useMemo(() => { const map = new Map(); conflicts.forEach((c) => { const label = c.assigned_plan.label; map.set(label, (map.get(label) ?? 0) + 1); }); return [...map.entries()]; }, [conflicts]); useEffect(() => { onConflictsChange?.(conflicts.length); }, [conflicts.length]); // eslint-disable-line react-hooks/exhaustive-deps const toggle = (id) => { const next = new Set(selectedIds); if (next.has(id)) next.delete(id); else next.add(id); onChange(next); }; const checkAll = () => onChange(new Set(courses.map((c) => String(c.course_id)))); const resetAll = () => onChange(new Set()); // "Yes, include all" clicked const handleBundleAll = () => { setBundleAll(true); setPopoverOpen(false); onChange(new Set(courses.map((c) => String(c.course_id)))); }; // "No, choose specific" clicked — keeps current selection (all) so admin can uncheck specific ones const handleSelectSpecific = () => { setBundleAll(false); }; const total = courses.length; const selectedCount = selectedIds.size; if (!subscription) return null; return (
{/* ── Bundle question ──────────────────────────────────────────── */} {loading ? (
) : (

Bundle {subscription} courses with this plan?

)} {/* ── Bundle all summary ───────────────────────────────────────── */} {!loading && bundleAll && total > 0 && (

All {total} {subscription} course{total !== 1 ? "s" : ""} will be included.

)} {/* ── Already-assigned-elsewhere warning ──────────────────────── */} {!loading && conflicts.length > 0 && (

{conflicts.length} course{conflicts.length !== 1 ? "s" : ""} already belong{conflicts.length === 1 ? "s" : ""} to another plan.

A course can only belong to one plan at a time. Assigning {conflicts.length === 1 ? "it" : "them"} here will move {conflicts.length === 1 ? "it" : "them"} out of{" "} {conflictsByPlan.map(([label, count], i) => ( {label} ({count}){i < conflictsByPlan.length - 1 ? ", " : ""} ))}. Uncheck them below if that's not what you want.

)} {/* ── No courses in tier ───────────────────────────────────────── */} {!loading && total === 0 && (
No {subscription} courses found. Add courses with this subscription first.
)} {/* ── Specific picker (Popover) ─────────────────────────────────── */} {!loading && !bundleAll && total > 0 && (
{filtered.length === 0 ? ( No courses match your search. ) : ( {filtered.map((course) => { const id = String(course.course_id); const checked = selectedIds.has(id); const conflict = isConflict(course); return ( toggle(id)} className="flex items-start gap-3 px-3 py-2.5 cursor-pointer" > toggle(id)} className="mt-0.5 shrink-0" onClick={(e) => e.stopPropagation()} />
{course.title} {course.description && ( {course.description} )} {conflict && ( In "{course.assigned_plan.label}" )}
); })}
)}
{/* Popover footer */}
{selectedCount} of {total} selected
0 ? "indeterminate" : false} onCheckedChange={(v) => v ? checkAll() : resetAll()} /> {selectedCount === total ? "Deselect all" : "Select all"}
{selectedCount === 0 && (

Select at least one course to bundle with this plan.

)}
)}
); }