"use client"; import { MoreHorizontalIcon, SearchIcon, Trash2Icon, WrenchIcon, } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { ListPagination } from "@/components/list-pagination"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { ButtonGroup } from "@/components/ui/button-group"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { OptionCombobox } from "@/components/option-combobox"; import { TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { buildKnowledgeFAQIndex, createKnowledgeFAQ, deleteKnowledgeFAQ, fetchKnowledgeFAQs, updateKnowledgeFAQ, type CreateKnowledgeFAQPayload, type KnowledgeFAQ, type PageResult, } from "@/lib/api/admin"; import { KnowledgeDocumentIndexStatus, } from "@/lib/generated/enums"; import { useI18n } from "@/i18n/provider"; import { formatDateTime } from "@/lib/utils"; import { FAQEditDialog } from "./faq-edit"; import { FAQImportDialog } from "./faq-import-dialog"; type FAQListProps = { knowledgeBaseId: number | null; onActionStateChange?: (state: FAQListActionState) => void; }; export type FAQListActionState = { onRefresh: () => void; onCreate: () => void; onImport: () => void; loading: boolean; importing: boolean; }; type TFunction = (key: string, values?: Record) => string; function getIndexStatusOptions(t: TFunction) { return [ { value: "all", label: t("knowledge.allIndexStatus") }, { value: KnowledgeDocumentIndexStatus.Pending, label: t("knowledge.indexPending") }, { value: KnowledgeDocumentIndexStatus.Indexed, label: t("knowledge.indexIndexed") }, { value: KnowledgeDocumentIndexStatus.Failed, label: t("knowledge.indexFailed") }, ]; } function getIndexStatusLabel(status: string, t: TFunction) { if (status === KnowledgeDocumentIndexStatus.Pending) return t("knowledge.indexPending"); if (status === KnowledgeDocumentIndexStatus.Indexed) return t("knowledge.indexIndexed"); if (status === KnowledgeDocumentIndexStatus.Failed) return t("knowledge.indexFailed"); return status || "-"; } function getIndexStatusBadgeVariant(status: string) { switch (status) { case KnowledgeDocumentIndexStatus.Indexed: return "secondary" as const; case KnowledgeDocumentIndexStatus.Failed: return "destructive" as const; default: return "outline" as const; } } function renderIndexStatusBadge(item: KnowledgeFAQ, t: TFunction) { const badge = ( {getIndexStatusLabel(item.indexStatus, t)} ); if ( item.indexStatus !== KnowledgeDocumentIndexStatus.Failed || !item.indexError ) { return badge; } return ( {badge} {item.indexError} ); } export function FAQList({ knowledgeBaseId, onActionStateChange, }: FAQListProps) { const t = useI18n(); const [keywordInput, setKeywordInput] = useState(""); const [indexStatusFilterInput, setIndexStatusFilterInput] = useState("all"); const [keyword, setKeyword] = useState(""); const [indexStatusFilter, setIndexStatusFilter] = useState("all"); const [page, setPage] = useState(1); const [limit, setLimit] = useState(20); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [importing, setImporting] = useState(false); const [actionLoadingMap, setActionLoadingMap] = useState< Record >({}); const [dialogOpen, setDialogOpen] = useState(false); const [importDialogOpen, setImportDialogOpen] = useState(false); const [editingItem, setEditingItem] = useState(null); const [result, setResult] = useState>({ results: [], page: { page: 1, limit: 20, total: 0 }, }); const indexStatusOptions = useMemo(() => getIndexStatusOptions(t), [t]); const loadData = useCallback( async (options?: { keyword?: string; indexStatusFilter?: string; page?: number; limit?: number; }) => { const nextKeyword = options?.keyword ?? keyword; const nextIndexStatusFilter = options?.indexStatusFilter ?? indexStatusFilter; const nextPage = options?.page ?? page; const nextLimit = options?.limit ?? limit; if (!knowledgeBaseId) { setResult({ results: [], page: { page: 1, limit: 20, total: 0 }, }); return; } setLoading(true); try { const data = await fetchKnowledgeFAQs({ knowledgeBaseId, question: nextKeyword.trim() || undefined, indexStatus: nextIndexStatusFilter === "all" ? undefined : nextIndexStatusFilter, page: nextPage, limit: nextLimit, }); setResult(data); } catch (error) { toast.error(error instanceof Error ? error.message : t("knowledge.loadFAQFailed")); } finally { setLoading(false); } }, [indexStatusFilter, keyword, knowledgeBaseId, limit, page, t], ); useEffect(() => { void loadData(); }, [knowledgeBaseId, loadData]); useEffect(() => { onActionStateChange?.({ onRefresh: () => void loadData(), onCreate: () => { setEditingItem(null); setDialogOpen(true); }, onImport: () => setImportDialogOpen(true), loading, importing, }); }, [importing, loadData, loading, onActionStateChange]); function applyFilters() { const nextKeyword = keywordInput; const nextIndexStatusFilter = indexStatusFilterInput; setKeyword(nextKeyword); setIndexStatusFilter(nextIndexStatusFilter); setPage(1); void loadData({ keyword: nextKeyword, indexStatusFilter: nextIndexStatusFilter, page: 1, }); } async function handleSubmit(payload: CreateKnowledgeFAQPayload) { setSaving(true); try { if (editingItem) { await updateKnowledgeFAQ({ id: editingItem.id, ...payload }); } else { await createKnowledgeFAQ(payload); } setDialogOpen(false); setEditingItem(null); await loadData(); toast.success(t("knowledge.faqSaved")); } catch (error) { toast.error(error instanceof Error ? error.message : t("knowledge.faqSaveFailed")); } finally { setSaving(false); } } async function handleDelete(item: KnowledgeFAQ) { setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], delete: true }, })); try { await deleteKnowledgeFAQ(item.id); toast.success(t("knowledge.faqDeleted")); await loadData(); } catch (error) { toast.error(error instanceof Error ? error.message : t("knowledge.faqDeleteFailed")); } finally { setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], delete: false }, })); } } async function handleBuildIndex(item: KnowledgeFAQ) { setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], rebuildIndex: true }, })); try { await buildKnowledgeFAQIndex(item.id); toast.success(t("knowledge.faqIndexRebuilt")); await loadData(); } catch (error) { toast.error(error instanceof Error ? error.message : t("knowledge.faqIndexRebuildFailed")); } finally { setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], rebuildIndex: false }, })); } } if (!knowledgeBaseId) { return (
{t("knowledge.selectFAQBase")}
); } return ( <>
setKeywordInput(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { applyFilters(); } }} placeholder={t("knowledge.searchFAQ")} className="pl-9" />
setIndexStatusFilterInput(value ?? "all")} options={indexStatusOptions} placeholder={t("knowledge.allIndexStatus")} searchPlaceholder={t("knowledge.searchStatus")} emptyText={t("knowledge.emptyStatus")} />
{t("knowledge.question")} {t("knowledge.indexStatus")} {t("knowledge.similarQuestions")} {t("knowledge.updatedAt")} {t("knowledge.actions")} {result.results.map((item) => (
{item.question}
{item.answer}
{renderIndexStatusBadge(item, t)} {Array.isArray(item.similarQuestions) ? item.similarQuestions.length : 0} {formatDateTime(item.updatedAt)} } aria-label={t("knowledge.moreActions", { name: item.question })} > void handleBuildIndex(item)} > {actionLoadingMap[item.id]?.rebuildIndex ? t("knowledge.rebuilding") : t("knowledge.rebuildIndex")} void handleDelete(item)} > {actionLoadingMap[item.id]?.delete ? t("knowledge.deleting") : t("knowledge.delete")}
))} {!loading && result.results.length === 0 ? ( {t("knowledge.emptyFAQ")} ) : null}
{ setPage(nextPage); void loadData({ page: nextPage }); }} onLimitChange={(next: number) => { setLimit(next); setPage(1); void loadData({ limit: next, page: 1 }); }} />
{ if (!open) { setEditingItem(null); } setDialogOpen(open); }} onSubmit={handleSubmit} /> ); }