Files
lms-sb/src/components/admin/enrollment-manager.tsx
T

191 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useTransition } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { grantAccess, revokeAccess } from "@/app/admin/courses/[courseId]/actions";
interface Student {
id: string;
name: string;
email: string;
}
interface Enrollment {
userId: string;
expiresAt: Date | null;
}
interface LogEntry {
id: string;
action: string;
createdAt: Date;
note: string | null;
user: { name: string };
grantedBy: { name: string } | null;
}
interface Props {
courseId: string;
allStudents: Student[];
enrollments: Enrollment[];
accessLogs: LogEntry[];
}
export function EnrollmentManager({ courseId, allStudents, enrollments, accessLogs }: Props) {
const [enrolledMap, setEnrolledMap] = useState<Map<string, Date | null>>(
() => new Map(enrollments.map((e) => [e.userId, e.expiresAt]))
);
const [search, setSearch] = useState("");
const [expiryDate, setExpiryDate] = useState("");
const [note, setNote] = useState("");
const [showLog, setShowLog] = useState(false);
const [pending, startTransition] = useTransition();
const filtered = allStudents.filter(
(s) =>
s.name.toLowerCase().includes(search.toLowerCase()) ||
s.email.toLowerCase().includes(search.toLowerCase())
);
function handleGrant(userId: string) {
const newMap = new Map(enrolledMap);
newMap.set(userId, expiryDate ? new Date(expiryDate) : null);
setEnrolledMap(newMap);
startTransition(() => grantAccess(courseId, userId, expiryDate || null, note || undefined));
}
function handleRevoke(userId: string) {
const newMap = new Map(enrolledMap);
newMap.delete(userId);
setEnrolledMap(newMap);
startTransition(() => revokeAccess(courseId, userId, note || undefined));
}
const enrolledStudents = allStudents.filter((s) => enrolledMap.has(s.id));
function formatExpiry(date: Date | null) {
if (!date) return "Бессрочно";
const d = new Date(date);
const now = new Date();
const expired = d < now;
return (
<span style={{ color: expired ? "oklch(0.577 0.245 27.325)" : "var(--foreground)" }}>
{expired ? "Истёк " : "До "}
{d.toLocaleDateString("ru-RU")}
</span>
);
}
return (
<div className="space-y-5">
{/* Enrolled list */}
{enrolledStudents.length > 0 && (
<div>
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
Доступ открыт {enrolledStudents.length}
</p>
<div className="space-y-1.5">
{enrolledStudents.map((s) => (
<div key={s.id} className="flex items-center justify-between px-3 py-2" style={{ border: "2px solid var(--border)", background: "var(--color-surface)" }}>
<div>
<p className="text-sm font-medium">{s.name}</p>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>{s.email}</p>
</div>
<div className="flex items-center gap-3">
<span className="text-xs">{formatExpiry(enrolledMap.get(s.id) ?? null)}</span>
<button onClick={() => handleRevoke(s.id)} disabled={pending} className="text-xs underline" style={{ color: "oklch(0.577 0.245 27.325)" }}>
Отозвать
</button>
</div>
</div>
))}
</div>
</div>
)}
{/* Grant form */}
<div>
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
Добавить ученика
</p>
<div className="flex gap-3 mb-3 flex-wrap">
<div className="flex-1 min-w-48">
<label className="text-xs uppercase tracking-wider mb-1 block" style={{ color: "var(--muted-foreground)" }}>Поиск</label>
<Input placeholder="Имя или email..." value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<div>
<label className="text-xs uppercase tracking-wider mb-1 block" style={{ color: "var(--muted-foreground)" }}>
Срок доступа
</label>
<Input type="date" value={expiryDate} onChange={(e) => setExpiryDate(e.target.value)} className="w-44" />
</div>
<div className="flex-1 min-w-40">
<label className="text-xs uppercase tracking-wider mb-1 block" style={{ color: "var(--muted-foreground)" }}>
Примечание
</label>
<Input placeholder="Оплата, договор..." value={note} onChange={(e) => setNote(e.target.value)} />
</div>
</div>
<div className="space-y-1.5 max-h-52 overflow-y-auto">
{filtered.map((student) => {
const enrolled = enrolledMap.has(student.id);
return (
<div key={student.id} className="flex items-center justify-between px-3 py-2" style={{ border: "2px solid var(--border)", background: "var(--background)" }}>
<div>
<p className="text-sm font-medium">{student.name}</p>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>{student.email}</p>
</div>
{enrolled ? (
<button onClick={() => handleRevoke(student.id)} disabled={pending} className="text-xs px-3 py-1.5" style={{ border: "2px solid oklch(0.577 0.245 27.325)", color: "oklch(0.577 0.245 27.325)" }}>
Отозвать
</button>
) : (
<button onClick={() => handleGrant(student.id)} disabled={pending} className="btn-aubade text-xs py-1.5 px-3">
Дать доступ
</button>
)}
</div>
);
})}
{filtered.length === 0 && (
<p className="text-sm py-2" style={{ color: "var(--muted-foreground)" }}>Студентов не найдено</p>
)}
</div>
</div>
{/* Access log */}
{accessLogs.length > 0 && (
<div>
<button
onClick={() => setShowLog(!showLog)}
className="text-xs font-bold uppercase tracking-widest underline"
style={{ color: "var(--muted-foreground)" }}
>
История доступа ({accessLogs.length}) {showLog ? "▲" : "▼"}
</button>
{showLog && (
<div className="mt-3 space-y-1.5 max-h-64 overflow-y-auto">
{accessLogs.map((log) => (
<div key={log.id} className="flex items-start gap-3 px-3 py-2 text-xs" style={{ border: "2px solid var(--border)", background: "var(--background)" }}>
<span style={{ color: log.action === "granted" ? "#3A6A3A" : "oklch(0.577 0.245 27.325)", fontWeight: 700 }}>
{log.action === "granted" ? "▲ Выдан" : "▼ Отозван"}
</span>
<span className="flex-1">{log.user.name}</span>
<span style={{ color: "var(--muted-foreground)" }}>
{log.grantedBy?.name ?? "—"}
</span>
{log.note && <span style={{ color: "var(--muted-foreground)" }}>{log.note}</span>}
<span style={{ color: "var(--muted-foreground)" }}>
{new Date(log.createdAt).toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })}
</span>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}