mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
313 lines
17 KiB
React
313 lines
17 KiB
React
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<string> of selected course_id strings (managed by parent)
|
|
* onChange — (Set<string>) => 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 (
|
|
<div className="space-y-4">
|
|
|
|
{/* ── Bundle question ──────────────────────────────────────────── */}
|
|
{loading ? (
|
|
<div className="flex gap-2">
|
|
<Skeleton className="h-8 w-36" />
|
|
<Skeleton className="h-8 w-40" />
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2.5">
|
|
<p className="text-sm">
|
|
Bundle <span className="font-semibold capitalize">{subscription}</span> courses with this plan?
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant={bundleAll ? "default" : "outline"}
|
|
onClick={handleBundleAll}
|
|
disabled={total === 0}
|
|
>
|
|
<Check className="size-3.5 mr-1.5" />
|
|
Yes, include all
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant={!bundleAll ? "default" : "outline"}
|
|
onClick={handleSelectSpecific}
|
|
disabled={total === 0}
|
|
>
|
|
No, choose specific
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Bundle all summary ───────────────────────────────────────── */}
|
|
{!loading && bundleAll && total > 0 && (
|
|
<p className="text-xs text-muted-foreground">
|
|
All {total} <span className="capitalize">{subscription}</span> course{total !== 1 ? "s" : ""} will be included.
|
|
</p>
|
|
)}
|
|
|
|
{/* ── Already-assigned-elsewhere warning ──────────────────────── */}
|
|
{!loading && conflicts.length > 0 && (
|
|
<div className="flex items-start gap-2 rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-950/30 dark:border-amber-800 p-3 text-xs text-amber-800 dark:text-amber-300">
|
|
<AlertTriangle className="size-4 shrink-0 mt-0.5" />
|
|
<div className="space-y-1">
|
|
<p className="font-medium">
|
|
{conflicts.length} course{conflicts.length !== 1 ? "s" : ""} already belong{conflicts.length === 1 ? "s" : ""} to another plan.
|
|
</p>
|
|
<p>
|
|
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) => (
|
|
<span key={label}>
|
|
<span className="font-medium">{label}</span> ({count}){i < conflictsByPlan.length - 1 ? ", " : ""}
|
|
</span>
|
|
))}. Uncheck them below if that's not what you want.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── No courses in tier ───────────────────────────────────────── */}
|
|
{!loading && total === 0 && (
|
|
<div className="flex items-center gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
|
|
<BookOpen className="size-4 shrink-0" />
|
|
No <span className="capitalize mx-1 font-medium">{subscription}</span> courses found. Add courses with this subscription first.
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Specific picker (Popover) ─────────────────────────────────── */}
|
|
{!loading && !bundleAll && total > 0 && (
|
|
<div className="space-y-1.5">
|
|
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className={cn(
|
|
"w-full justify-between gap-2",
|
|
selectedCount === 0 && "border-destructive text-destructive hover:border-destructive"
|
|
)}
|
|
>
|
|
{selectedCount === 0
|
|
? "No courses selected"
|
|
: `${selectedCount} of ${total} course${total !== 1 ? "s" : ""} selected`
|
|
}
|
|
<ChevronsUpDown className="size-4 opacity-50 shrink-0" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
|
|
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
|
|
<Command shouldFilter={false}>
|
|
<CommandInput
|
|
placeholder="Search courses…"
|
|
value={search}
|
|
onValueChange={setSearch}
|
|
/>
|
|
<CommandList>
|
|
{filtered.length === 0 ? (
|
|
<CommandEmpty>No courses match your search.</CommandEmpty>
|
|
) : (
|
|
<ScrollArea className="h-64">
|
|
{filtered.map((course) => {
|
|
const id = String(course.course_id);
|
|
const checked = selectedIds.has(id);
|
|
const conflict = isConflict(course);
|
|
return (
|
|
<CommandItem
|
|
key={id}
|
|
value={id}
|
|
onSelect={() => toggle(id)}
|
|
className="flex items-start gap-3 px-3 py-2.5 cursor-pointer"
|
|
>
|
|
<Checkbox
|
|
checked={checked}
|
|
onCheckedChange={() => toggle(id)}
|
|
className="mt-0.5 shrink-0"
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
<div className="flex flex-col gap-0.5 min-w-0">
|
|
<span className="text-sm font-medium leading-snug">{course.title}</span>
|
|
{course.description && (
|
|
<span className="text-xs text-muted-foreground line-clamp-1">
|
|
{course.description}
|
|
</span>
|
|
)}
|
|
{conflict && (
|
|
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-800 rounded px-1.5 py-0.5 w-fit mt-0.5">
|
|
<AlertTriangle className="size-3" />
|
|
In "{course.assigned_plan.label}"
|
|
</span>
|
|
)}
|
|
</div>
|
|
</CommandItem>
|
|
);
|
|
})}
|
|
</ScrollArea>
|
|
)}
|
|
</CommandList>
|
|
|
|
{/* Popover footer */}
|
|
<div className="border-t px-3 py-2 flex items-center justify-between gap-2">
|
|
<span className="text-xs text-muted-foreground">
|
|
{selectedCount} of {total} selected
|
|
</span>
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox
|
|
checked={selectedCount === total ? true : selectedCount > 0 ? "indeterminate" : false}
|
|
onCheckedChange={(v) => v ? checkAll() : resetAll()}
|
|
/>
|
|
<span className="text-xs text-muted-foreground select-none">
|
|
{selectedCount === total ? "Deselect all" : "Select all"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
{selectedCount === 0 && (
|
|
<p className="flex items-center gap-1.5 text-xs text-destructive">
|
|
<AlertTriangle className="size-3.5 shrink-0" />
|
|
Select at least one course to bundle with this plan.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|