Files
starr-philproperties/src/modules/admin/components/tiers/CoursePicker.jsx
T
kennethobsequio 7e964f2432 add: more commits
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
2026-07-03 16:21:27 +08:00

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>
);
}