Yimaru-Admin/src/pages/content-management/PracticeDetailsPage.tsx

397 lines
17 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from "react"
import { ChevronDown, ChevronLeft, ChevronRight, RefreshCw } from "lucide-react"
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input"
import { Select } from "../../components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table"
import { Badge } from "../../components/ui/badge"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../../components/ui/dialog"
import { getQuestionSetById, getQuestionSets } from "../../api/courses.api"
import type { QuestionSet, QuestionSetDetail } from "../../types/course.types"
import { cn } from "../../lib/utils"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
const statusColor: Record<string, string> = {
PUBLISHED: "bg-green-100 text-green-700",
DRAFT: "bg-amber-100 text-amber-700",
ARCHIVED: "bg-grayScale-200 text-grayScale-600",
}
export function PracticeDetailsPage() {
const [practices, setPractices] = useState<QuestionSet[]>([])
const [selectedPracticeId, setSelectedPracticeId] = useState<number | null>(null)
const [selectedPracticeDetail, setSelectedPracticeDetail] = useState<QuestionSetDetail | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const [loadingList, setLoadingList] = useState(false)
const [loadingDetail, setLoadingDetail] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
const [statusFilter, setStatusFilter] = useState("all")
const [ownerTypeFilter, setOwnerTypeFilter] = useState("all")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const fetchPractices = useCallback(async () => {
setLoadingList(true)
try {
const batchSize = 100
let offset = 0
let total = Number.POSITIVE_INFINITY
const sets: QuestionSet[] = []
while (sets.length < total) {
const res = await getQuestionSets({ set_type: "PRACTICE", limit: batchSize, offset })
const payload = res.data?.data as unknown
let chunk: QuestionSet[] = []
let chunkTotal = 0
if (Array.isArray(payload)) {
chunk = payload as QuestionSet[]
chunkTotal = chunk.length
} else if (
payload &&
typeof payload === "object" &&
Array.isArray((payload as { question_sets?: unknown[] }).question_sets)
) {
const mapped = payload as { question_sets: QuestionSet[]; total_count?: number }
chunk = mapped.question_sets
chunkTotal = mapped.total_count ?? chunk.length
}
sets.push(...chunk)
total = chunkTotal
if (chunk.length < batchSize) break
offset += chunk.length
}
setPractices(sets)
if (sets.length > 0) {
setSelectedPracticeId((prev) => prev ?? sets[0].id)
} else {
setSelectedPracticeId(null)
setSelectedPracticeDetail(null)
}
} catch (error) {
console.error("Failed to fetch practices:", error)
setPractices([])
setSelectedPracticeId(null)
setSelectedPracticeDetail(null)
} finally {
setLoadingList(false)
}
}, [])
const fetchPracticeDetail = useCallback(async (practiceId: number) => {
setLoadingDetail(true)
try {
const res = await getQuestionSetById(practiceId)
setSelectedPracticeDetail(res.data?.data ?? null)
} catch (error) {
console.error("Failed to fetch practice detail:", error)
setSelectedPracticeDetail(null)
} finally {
setLoadingDetail(false)
}
}, [])
useEffect(() => {
fetchPractices()
}, [fetchPractices])
useEffect(() => {
if (selectedPracticeId) {
fetchPracticeDetail(selectedPracticeId)
}
}, [selectedPracticeId, fetchPracticeDetail])
useEffect(() => {
setPage(1)
}, [searchQuery, statusFilter, ownerTypeFilter])
const filteredPractices = useMemo(() => {
return practices.filter((practice) => {
const matchesSearch =
!searchQuery.trim() ||
practice.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
(practice.description || "").toLowerCase().includes(searchQuery.toLowerCase()) ||
String(practice.id).includes(searchQuery) ||
String(practice.owner_id).includes(searchQuery)
const matchesStatus = statusFilter === "all" || practice.status === statusFilter
const matchesOwnerType = ownerTypeFilter === "all" || practice.owner_type === ownerTypeFilter
return matchesSearch && matchesStatus && matchesOwnerType
})
}, [practices, searchQuery, statusFilter, ownerTypeFilter])
const totalCount = useMemo(() => filteredPractices.length, [filteredPractices])
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize))
const safePage = Math.min(page, totalPages)
const paginatedPractices = useMemo(() => {
const start = (safePage - 1) * pageSize
return filteredPractices.slice(start, start + pageSize)
}, [filteredPractices, safePage, pageSize])
const startEntry = totalCount === 0 ? 0 : (safePage - 1) * pageSize + 1
const endEntry = Math.min(safePage * pageSize, totalCount)
const getPageNumbers = () => {
const pages: (number | string)[] = []
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) pages.push(i)
} else {
pages.push(1, 2, 3)
if (safePage > 4) pages.push("...")
if (safePage > 3 && safePage < totalPages - 2) pages.push(safePage)
if (safePage < totalPages - 3) pages.push("...")
pages.push(totalPages)
}
return pages
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600">Practice Management</h1>
<p className="mt-1 text-sm text-grayScale-400">
Browse all practice question sets and view their details.
</p>
</div>
<Button variant="outline" onClick={fetchPractices} disabled={loadingList}>
{loadingList ? <SpinnerIcon className="h-4 w-4" /> : <RefreshCw className="h-4 w-4" />}
Refresh
</Button>
</div>
<Card className="shadow-soft">
<CardHeader className="border-b border-grayScale-200 pb-4">
<CardTitle className="text-base font-semibold text-grayScale-600">
Practices ({totalCount})
</CardTitle>
</CardHeader>
<CardContent className="pt-5">
<div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="flex-1">
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search by title, description, practice ID, or owner ID..."
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<Select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="all">All Statuses</option>
<option value="PUBLISHED">PUBLISHED</option>
<option value="DRAFT">DRAFT</option>
<option value="ARCHIVED">ARCHIVED</option>
</Select>
<Select value={ownerTypeFilter} onChange={(e) => setOwnerTypeFilter(e.target.value)}>
<option value="all">All Owner Types</option>
<option value="SUB_COURSE">SUB_COURSE</option>
<option value="COURSE">COURSE</option>
</Select>
<Button
variant="outline"
onClick={() => {
setSearchQuery("")
setStatusFilter("all")
setOwnerTypeFilter("all")
setPage(1)
}}
>
Clear
</Button>
</div>
</div>
<div className="rounded-xl border bg-white">
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead className="hidden md:table-cell">Owner</TableHead>
<TableHead>Status</TableHead>
<TableHead className="hidden md:table-cell">Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loadingList ? (
<TableRow>
<TableCell colSpan={4} className="py-12 text-center">
<div className="flex flex-col items-center gap-3">
<img src={spinnerSrc} alt="" className="h-6 w-6 animate-spin" />
<span className="text-sm text-grayScale-400">Loading practices...</span>
</div>
</TableCell>
</TableRow>
) : filteredPractices.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="py-12 text-center text-sm text-grayScale-500">
No practice sets found.
</TableCell>
</TableRow>
) : (
paginatedPractices.map((practice) => (
<TableRow
key={practice.id}
onClick={() => {
setSelectedPracticeId(practice.id)
setDetailOpen(true)
}}
className={cn(
"group cursor-pointer",
selectedPracticeId === practice.id && "bg-brand-100/30",
)}
>
<TableCell className="max-w-md py-3.5">
<p className="truncate text-sm font-medium text-grayScale-700">{practice.title}</p>
<p className="mt-1 truncate text-xs text-grayScale-500">{practice.description || "—"}</p>
</TableCell>
<TableCell className="hidden py-3.5 text-sm text-grayScale-500 md:table-cell">
{practice.owner_type} #{practice.owner_id}
</TableCell>
<TableCell className="py-3.5">
<Badge className={statusColor[practice.status] || "bg-grayScale-200 text-grayScale-600"}>
{practice.status}
</Badge>
</TableCell>
<TableCell className="hidden py-3.5 text-sm text-grayScale-500 md:table-cell">
{practice.created_at}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
<div className="flex flex-wrap items-center justify-between gap-3 border-t px-4 py-3 text-sm text-grayScale-500">
<div className="flex items-center gap-2">
<span>Showing</span>
<span className="font-medium text-grayScale-600">
{startEntry}-{endEntry}
</span>
<span>of</span>
<span className="font-medium text-grayScale-600">{totalCount}</span>
<span className="mr-4">entries</span>
<span className="border-l pl-4">Rows per page</span>
<div className="relative">
<select
value={pageSize}
onChange={(e) => {
setPageSize(Number(e.target.value))
setPage(1)
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{[10, 20, 50].map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" />
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => safePage > 1 && setPage(safePage - 1)}
disabled={safePage === 1}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
safePage === 1 && "cursor-not-allowed opacity-50",
)}
>
<ChevronLeft className="h-4 w-4" />
</button>
{getPageNumbers().map((n, idx) =>
typeof n === "string" ? (
<span key={`ellipsis-${idx}`} className="px-2 text-grayScale-400">
...
</span>
) : (
<button
key={n}
type="button"
onClick={() => setPage(n)}
className={cn(
"h-8 w-8 rounded-md border text-sm font-medium",
n === safePage
? "border-brand-500 bg-brand-500 text-white"
: "bg-white text-grayScale-600 hover:bg-grayScale-50",
)}
>
{n}
</button>
),
)}
<button
onClick={() => safePage < totalPages && setPage(safePage + 1)}
disabled={safePage === totalPages}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
safePage === totalPages && "cursor-not-allowed opacity-50",
)}
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
</div>
</CardContent>
</Card>
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Practice Detail</DialogTitle>
</DialogHeader>
{!selectedPracticeId ? (
<p className="text-sm text-grayScale-500">Select a practice from the list to view details.</p>
) : loadingDetail ? (
<p className="text-sm text-grayScale-500">Loading detail...</p>
) : !selectedPracticeDetail ? (
<p className="text-sm text-grayScale-500">Failed to load practice detail.</p>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50 p-3">
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Title</p>
<p className="mt-1 text-sm text-grayScale-700">{selectedPracticeDetail.title}</p>
</div>
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50 p-3">
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Set Type</p>
<p className="mt-1 text-sm text-grayScale-700">{selectedPracticeDetail.set_type}</p>
</div>
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50 p-3 sm:col-span-2">
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Description</p>
<p className="mt-1 text-sm text-grayScale-700">{selectedPracticeDetail.description || "—"}</p>
</div>
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50 p-3">
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Owner</p>
<p className="mt-1 text-sm text-grayScale-700">
{selectedPracticeDetail.owner_type} #{selectedPracticeDetail.owner_id}
</p>
</div>
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50 p-3">
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Status</p>
<p className="mt-1 text-sm text-grayScale-700">{selectedPracticeDetail.status}</p>
</div>
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50 p-3">
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Question Count</p>
<p className="mt-1 text-sm text-grayScale-700">{selectedPracticeDetail.question_count ?? 0}</p>
</div>
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50 p-3">
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Created At</p>
<p className="mt-1 text-sm text-grayScale-700">{selectedPracticeDetail.created_at}</p>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}