"use client" import Link from "next/link" import { useEffect, useMemo, useState } from "react" import { Check, Loader2, RefreshCw, Save, X } from "lucide-react" import { toast } from "sonner" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" type MissingKey = "author" | "cover" | "description" | "genres" type Genre = { id: string name: string } type MissingNovel = { id: string title: string slug: string authorName: string coverUrl: string | null description: string totalChapters: number updatedAt: string series: { id: string name: string slug: string } | null genres: Genre[] missing: Record } type RowDraft = { authorName: string coverUrl: string description: string genreIds: string[] } const missingKeyLabel: Record = { author: "Thiếu tác giả", cover: "Thiếu ảnh bìa", description: "Thiếu giới thiệu", genres: "Thiếu thể loại", } const allMissingKeys: MissingKey[] = ["author", "cover", "description", "genres"] function isBlank(value: unknown): boolean { return typeof value !== "string" || value.trim() === "" } function normalizeMissingFlags(row: any): Record { const safeGenres = Array.isArray(row?.genres) ? row.genres : [] const incoming = row?.missing && typeof row.missing === "object" ? row.missing : {} return { author: typeof incoming.author === "boolean" ? incoming.author : isBlank(row?.authorName), cover: typeof incoming.cover === "boolean" ? incoming.cover : isBlank(row?.coverUrl), description: typeof incoming.description === "boolean" ? incoming.description : isBlank(row?.description), genres: typeof incoming.genres === "boolean" ? incoming.genres : safeGenres.length === 0, } } function toDraft(novel: MissingNovel): RowDraft { const safeGenres = Array.isArray(novel.genres) ? novel.genres : [] return { authorName: novel.authorName || "", coverUrl: novel.coverUrl || "", description: novel.description || "", genreIds: safeGenres.map((genre) => genre.id), } } function normalizeGenreName(value: string): string { return value.trim().replace(/\s+/g, " ") } type GenreTagSelectorProps = { genres: Genre[] selectedIds: string[] onChange: (next: string[]) => void onEnsureGenre: (name: string) => Promise placeholder?: string } function GenreTagSelector({ genres, selectedIds, onChange, onEnsureGenre, placeholder = "Nhập để tìm thể loại...", }: GenreTagSelectorProps) { const [query, setQuery] = useState("") const [saving, setSaving] = useState(false) const normalizedQuery = query.trim().toLowerCase() const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]) const selectedItems = useMemo(() => { const byId = new Map(genres.map((genre) => [genre.id, genre])) return selectedIds .map((id) => byId.get(id)) .filter((genre): genre is Genre => Boolean(genre)) }, [genres, selectedIds]) const matchedGenres = useMemo(() => { if (!normalizedQuery) return [] return genres .filter((genre) => genre.name.toLowerCase().includes(normalizedQuery)) .slice(0, 8) }, [genres, normalizedQuery]) const exactMatchedGenre = useMemo(() => { if (!normalizedQuery) return null return genres.find((genre) => genre.name.trim().toLowerCase() === normalizedQuery) || null }, [genres, normalizedQuery]) const toggleGenre = (id: string) => { onChange(selectedSet.has(id) ? selectedIds.filter((item) => item !== id) : [...selectedIds, id]) } const handleAddOrPick = async () => { const name = normalizeGenreName(query) if (!name) return if (exactMatchedGenre) { if (!selectedSet.has(exactMatchedGenre.id)) { onChange([...selectedIds, exactMatchedGenre.id]) } setQuery("") return } setSaving(true) try { const createdId = await onEnsureGenre(name) if (createdId && !selectedSet.has(createdId)) { onChange([...selectedIds, createdId]) } if (createdId) { setQuery("") } } finally { setSaving(false) } } const actionLabel = exactMatchedGenre ? (selectedSet.has(exactMatchedGenre.id) ? "Đã chọn" : "Chọn") : "Tạo" return (
setQuery(e.target.value)} placeholder={placeholder} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault() handleAddOrPick() } }} />
{selectedItems.length === 0 && ( Chưa chọn thể loại )} {selectedItems.map((genre) => ( ))}
{query.trim().length > 0 && (

Gợi ý

{matchedGenres.length === 0 && ( Không có thể loại phù hợp, bấm Tạo để thêm mới. )} {matchedGenres.map((genre) => { const selected = selectedSet.has(genre.id) return ( ) })}
)}
) } export function MissingFieldsClient() { const [loading, setLoading] = useState(true) const [reloading, setReloading] = useState(false) const [items, setItems] = useState([]) const [genres, setGenres] = useState([]) const [drafts, setDrafts] = useState>({}) const [savingIds, setSavingIds] = useState([]) const [queryInput, setQueryInput] = useState("") const [searchKeyword, setSearchKeyword] = useState("") const [selectedMissing, setSelectedMissing] = useState>({ author: true, cover: true, description: true, genres: true, }) const [selectedNovelIds, setSelectedNovelIds] = useState([]) const [bulkSaving, setBulkSaving] = useState(false) const [bulkApplyAuthor, setBulkApplyAuthor] = useState(false) const [bulkApplyCover, setBulkApplyCover] = useState(false) const [bulkApplyDescription, setBulkApplyDescription] = useState(false) const [bulkApplyGenres, setBulkApplyGenres] = useState(false) const [bulkAuthorName, setBulkAuthorName] = useState("") const [bulkCoverUrl, setBulkCoverUrl] = useState("") const [bulkDescription, setBulkDescription] = useState("") const [bulkGenreIds, setBulkGenreIds] = useState([]) const activeMissingKeys = useMemo(() => { return allMissingKeys.filter((key) => selectedMissing[key]) }, [selectedMissing]) const selectedNovelSet = useMemo(() => new Set(selectedNovelIds), [selectedNovelIds]) const pendingCount = items.length const fetchGenres = async (): Promise => { try { const res = await fetch("/api/mod/the-loai") if (!res.ok) return [] const data = await res.json() const rows = Array.isArray(data) ? data : [] setGenres(rows) return rows } catch { // Ignore genre preload errors for now. return [] } } const ensureGenre = async (rawName: string): Promise => { const name = normalizeGenreName(rawName) if (!name) return null const existed = genres.find((genre) => genre.name.trim().toLowerCase() === name.toLowerCase()) if (existed) return existed.id try { const res = await fetch("/api/mod/the-loai", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, description: "" }), }) const data = await res.json() if (!res.ok) { if (res.status === 400) { const refreshed = await fetchGenres() const maybeExisted = refreshed.find((genre) => genre.name.trim().toLowerCase() === name.toLowerCase()) if (maybeExisted) return maybeExisted.id } throw new Error(data?.error || "Không thể tạo thể loại") } const created = data as Genre setGenres((prev) => { if (prev.some((genre) => genre.id === created.id)) return prev return [...prev, created].sort((a, b) => a.name.localeCompare(b.name, "vi")) }) toast.success(`Đã tạo thể loại ${created.name}`) return created.id } catch (error: any) { toast.error(error?.message || "Không thể tạo thể loại") return null } } const fetchMissingNovels = async (isReload = false) => { try { if (isReload) setReloading(true) else setLoading(true) const params = new URLSearchParams() params.set("missing", activeMissingKeys.join(",")) if (searchKeyword.trim()) { params.set("q", searchKeyword.trim()) } const res = await fetch(`/api/mod/truyen/missing?${params.toString()}`) if (!res.ok) { throw new Error("Không thể tải danh sách truyện thiếu thông tin") } const data = await res.json() const rawRows: any[] = Array.isArray(data?.items) ? data.items : [] const rows: MissingNovel[] = rawRows.map((row) => ({ ...row, genres: Array.isArray(row?.genres) ? row.genres : [], missing: normalizeMissingFlags(row), })) setItems(rows) setSelectedNovelIds((prev) => prev.filter((id) => rows.some((row) => row.id === id))) setDrafts((prev) => { const next: Record = {} for (const row of rows) { next[row.id] = prev[row.id] || toDraft(row) } return next }) } catch (error: any) { toast.error(error?.message || "Không thể tải dữ liệu") } finally { setLoading(false) setReloading(false) } } useEffect(() => { fetchGenres() }, []) useEffect(() => { fetchMissingNovels() // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchKeyword, activeMissingKeys.join(",")]) useEffect(() => { if (!selectedMissing.author) setBulkApplyAuthor(false) if (!selectedMissing.cover) setBulkApplyCover(false) if (!selectedMissing.description) setBulkApplyDescription(false) if (!selectedMissing.genres) setBulkApplyGenres(false) }, [selectedMissing]) const toggleMissingFilter = (key: MissingKey) => { setSelectedMissing((prev) => { const next = { ...prev, [key]: !prev[key] } const selectedCount = allMissingKeys.filter((item) => next[item]).length if (selectedCount === 0) { return prev } return next }) } const toggleSelectNovel = (id: string) => { setSelectedNovelIds((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id])) } const toggleSelectAll = () => { if (selectedNovelIds.length === items.length) { setSelectedNovelIds([]) return } setSelectedNovelIds(items.map((item) => item.id)) } const updateDraft = (id: string, patch: Partial) => { setDrafts((prev) => ({ ...prev, [id]: { ...(prev[id] || { authorName: "", coverUrl: "", description: "", genreIds: [] }), ...patch, }, })) } const saveOne = async (id: string) => { const draft = drafts[id] if (!draft) return const update: Record = { id } if (selectedMissing.author) update.authorName = draft.authorName if (selectedMissing.cover) update.coverUrl = draft.coverUrl if (selectedMissing.description) update.description = draft.description if (selectedMissing.genres) update.genreIds = draft.genreIds setSavingIds((prev) => [...prev, id]) try { const res = await fetch("/api/mod/truyen/missing", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ updates: [update], }), }) const data = await res.json() if (!res.ok) { throw new Error(data?.error || "Cập nhật thất bại") } if (data.failureCount > 0) { throw new Error(data.failures?.[0]?.error || "Có lỗi khi cập nhật") } toast.success("Đã cập nhật truyện") await fetchMissingNovels(true) } catch (error: any) { toast.error(error?.message || "Không thể cập nhật") } finally { setSavingIds((prev) => prev.filter((item) => item !== id)) } } const applyBulkUpdate = async () => { if (selectedNovelIds.length === 0) { toast.error("Chưa chọn truyện để cập nhật") return } const hasAnyVisibleBulkApply = (selectedMissing.author && bulkApplyAuthor) || (selectedMissing.cover && bulkApplyCover) || (selectedMissing.description && bulkApplyDescription) || (selectedMissing.genres && bulkApplyGenres) if (!hasAnyVisibleBulkApply) { toast.error("Chọn ít nhất một trường để áp dụng hàng loạt") return } const updates = selectedNovelIds.map((id) => { const next: Record = { id } if (selectedMissing.author && bulkApplyAuthor) { next.authorName = bulkAuthorName } if (selectedMissing.cover && bulkApplyCover) { next.coverUrl = bulkCoverUrl } if (selectedMissing.description && bulkApplyDescription) { next.description = bulkDescription } if (selectedMissing.genres && bulkApplyGenres) { next.genreIds = bulkGenreIds } return next }) setBulkSaving(true) try { const res = await fetch("/api/mod/truyen/missing", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ updates }), }) const data = await res.json() if (!res.ok) { throw new Error(data?.error || "Cập nhật hàng loạt thất bại") } if (data.failureCount > 0) { toast.warning(`Đã cập nhật ${data.updatedCount} truyện, lỗi ${data.failureCount} truyện`) } else { toast.success(`Đã cập nhật ${data.updatedCount} truyện`) } setSelectedNovelIds([]) await fetchMissingNovels(true) } catch (error: any) { toast.error(error?.message || "Không thể cập nhật hàng loạt") } finally { setBulkSaving(false) } } return (

Bổ sung dữ liệu truyện còn thiếu

Lọc nhanh truyện thiếu tác giả, ảnh bìa, giới thiệu hoặc thể loại và sửa trực tiếp ngay tại bảng.

setQueryInput(e.target.value)} placeholder="Tìm theo tên truyện, slug, tác giả, series..." onKeyDown={(e) => { if (e.key === "Enter") { setSearchKeyword(queryInput) } }} />
Đang hiển thị {pendingCount} truyện cần bổ sung.
{allMissingKeys.map((key) => ( ))}
{selectedNovelIds.length > 0 && (

Cập nhật hàng loạt cho {selectedNovelIds.length} truyện đã chọn

{selectedMissing.author && ( )} {selectedMissing.cover && ( )}
{selectedMissing.description && (