"use client"; import { FileTextIcon, FolderInputIcon, EyeIcon, MoreHorizontalIcon, PencilIcon, SearchIcon, Trash2Icon, WrenchIcon, XIcon, } from "lucide-react"; import { useCallback, useEffect, useMemo, useState, type KeyboardEvent } from "react"; import { toast } from "sonner"; import { useConfirm } from "@/components/confirm-provider"; import { useDashboardPagedList, type DashboardPagedListFilter, } from "@/components/dashboard/list"; import { ListPagination } from "@/components/list-pagination"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Checkbox } from "@/components/ui/checkbox"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, } from "@/components/ui/context-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { OptionCombobox } from "@/components/option-combobox"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { batchDeleteKnowledgeDocuments, batchMoveKnowledgeDocuments, buildKnowledgeDocumentIndex, createKnowledgeDocument, deleteKnowledgeDocument, fetchKnowledgeDocuments, updateKnowledgeDocument, type CreateKnowledgeDocumentPayload, type KnowledgeDocumentListItem, } from "@/lib/api/admin"; import { useI18n } from "@/i18n/provider"; import { KnowledgeDocumentIndexStatus, Status, } from "@/lib/generated/enums"; import { cn, formatDateTime } from "@/lib/utils"; import { DocumentEditDialog } from "./document-edit"; import { KnowledgeContentDetailDialog } from "./knowledge-content-detail"; import { KnowledgeBulkMoveDialog } from "./knowledge-bulk-move-dialog"; import { KnowledgeDirectoryPanel } from "./knowledge-directory-panel"; type DocumentListProps = { knowledgeBaseId: number | null; onActionStateChange?: (state: DocumentListActionState) => void; }; export type DocumentListActionState = { onRefresh: () => void; onChangeViewMode: (mode: "list" | "grid") => void; onCreate: () => void; viewMode: "list" | "grid"; loading: boolean; }; type TFunction = (key: string, values?: Record) => string; function getStatusOptions(t: TFunction) { return [ { value: "all", label: t("knowledge.allStatus") }, { value: String(Status.Ok), label: t("knowledge.statusOk") }, { value: String(Status.Disabled), label: t("knowledge.statusDisabled") }, { value: String(Status.Deleted), label: t("knowledge.statusDeleted") }, ]; } 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: KnowledgeDocumentListItem, t: TFunction) { const badge = ( {getIndexStatusLabel(item.indexStatus, t)} ) if ( item.indexStatus !== KnowledgeDocumentIndexStatus.Failed || !item.indexError ) { return badge } return ( {badge} {item.indexError} ) } const VIEW_MODE_STORAGE_KEY = "knowledge-document-view-mode"; export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentListProps) { const t = useI18n(); const confirm = useConfirm(); const [saving, setSaving] = useState(false); const [moving, setMoving] = useState(false); const [bulkMoveOpen, setBulkMoveOpen] = useState(false); const [selectedIds, setSelectedIds] = useState([]); const [moveTargetIds, setMoveTargetIds] = useState([]); const [contextMenuDocumentId, setContextMenuDocumentId] = useState(null); const [detailItemId, setDetailItemId] = useState(null); const [detailOpen, setDetailOpen] = useState(false); const [selectedDirectoryId, setSelectedDirectoryId] = useState(null); const [actionLoadingMap, setActionLoadingMap] = useState>({}); const [dialogOpen, setDialogOpen] = useState(false); const [editingItem, setEditingItem] = useState( null, ); const [viewMode, setViewMode] = useState<"list" | "grid">(() => { if (typeof window === "undefined") return "grid"; const saved = localStorage.getItem(VIEW_MODE_STORAGE_KEY); return saved === "list" || saved === "grid" ? saved : "grid"; }); const statusOptions = useMemo(() => getStatusOptions(t), [t]); const indexStatusOptions = useMemo(() => getIndexStatusOptions(t), [t]); useEffect(() => { setSelectedDirectoryId(null); setSelectedIds([]); }, [knowledgeBaseId]); const filters = useMemo(() => [ { name: "title", defaultValue: "", trim: true, }, { name: "status", defaultValue: "all", allValue: "all", }, { name: "indexStatus", defaultValue: "all", allValue: "all", }, ], []); const fetchList = useCallback(async (query: Record) => { return fetchKnowledgeDocuments({ title: typeof query.title === "string" ? query.title : undefined, status: typeof query.status === "string" ? query.status : undefined, indexStatus: typeof query.indexStatus === "string" ? query.indexStatus : undefined, knowledgeBaseId: knowledgeBaseId ?? 0, directoryId: selectedDirectoryId === null ? undefined : selectedDirectoryId, page: typeof query.page === "number" ? query.page : Number(query.page ?? 1), limit: typeof query.limit === "number" ? query.limit : Number(query.limit ?? 20), }); }, [knowledgeBaseId, selectedDirectoryId]); const { draftFilters, setDraftFilter, applyFilter, applyFilters: applyDraftFilters, loading, result: documents, loadData, handlePageChange, handleLimitChange, } = useDashboardPagedList({ filters, fetchList, enabled: Boolean(knowledgeBaseId), reloadKey: `${knowledgeBaseId ?? 0}-${selectedDirectoryId ?? "all"}`, loadFailed: t("knowledge.loadDocumentsFailed"), }); const currentPageIds = useMemo( () => documents.results.map((item) => item.id), [documents.results], ); const currentPageSelectedCount = useMemo( () => currentPageIds.filter((id) => selectedIds.includes(id)).length, [currentPageIds, selectedIds], ); const allCurrentPageSelected = currentPageIds.length > 0 && currentPageSelectedCount === currentPageIds.length; useEffect(() => { setSelectedIds((prev) => prev.filter((id) => currentPageIds.includes(id))); }, [currentPageIds]); useEffect(() => { localStorage.setItem(VIEW_MODE_STORAGE_KEY, viewMode); }, [viewMode]); function handleStatusFilterChange(value: string | null) { applyFilter("status", value ?? "all"); } function handleIndexStatusFilterChange(value: string | null) { applyFilter("indexStatus", value ?? "all"); } function applyFilters() { applyDraftFilters(); } function handleFilterKeyDown(event: KeyboardEvent) { if (event.key !== "Enter") { return; } event.preventDefault(); applyFilters(); } function toggleSelected(id: number, checked: boolean) { setSelectedIds((prev) => { if (checked) { return prev.includes(id) ? prev : [...prev, id]; } return prev.filter((currentId) => currentId !== id); }); } function toggleCurrentPage(checked: boolean) { setSelectedIds(checked ? currentPageIds : []); } function openMoveDialog(ids: number[]) { setMoveTargetIds(ids); setBulkMoveOpen(true); } const openCreateDialog = useCallback(() => { setEditingItem(null); setDialogOpen(true); }, []); useEffect(() => { if (!onActionStateChange) { return; } onActionStateChange({ onRefresh: () => void loadData(), onChangeViewMode: setViewMode, onCreate: openCreateDialog, viewMode, loading, }); }, [onActionStateChange, loadData, openCreateDialog, viewMode, loading]); function openEditDialog(item: KnowledgeDocumentListItem) { setEditingItem(item); setDialogOpen(true); } function openDetailDialog(item: KnowledgeDocumentListItem) { setDetailItemId(item.id); setDetailOpen(true); } function handleDialogOpenChange(open: boolean) { if (saving) { return; } if (!open) { setEditingItem(null); } setDialogOpen(open); } async function handleSubmit(payload: CreateKnowledgeDocumentPayload) { if (saving) { return; } setSaving(true); try { if (editingItem) { await updateKnowledgeDocument({ id: editingItem.id, ...payload, }); toast.success(t("knowledge.documentUpdated", { title: editingItem.title })); } else { await createKnowledgeDocument(payload); toast.success(t("knowledge.documentCreated", { title: payload.title })); } setDialogOpen(false); setEditingItem(null); await loadData(); } catch (error) { toast.error(error instanceof Error ? error.message : t("knowledge.documentSaveFailed")); } finally { setSaving(false); } } async function handleDelete(item: KnowledgeDocumentListItem) { setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], delete: true } })); try { await deleteKnowledgeDocument(item.id); toast.success(t("knowledge.documentDeleted", { title: item.title })); await loadData(); } catch (error) { toast.error(error instanceof Error ? error.message : t("knowledge.documentDeleteFailed")); } finally { setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], delete: false } })); } } async function handleBatchDelete() { if (selectedIds.length === 0) { return; } const confirmed = await confirm({ title: t("knowledge.batchDelete"), description: t("knowledge.batchDeleteConfirm", { count: selectedIds.length }), confirmText: t("knowledge.delete"), variant: "destructive", }); if (!confirmed) { return; } setSaving(true); try { await batchDeleteKnowledgeDocuments(selectedIds); toast.success(t("knowledge.batchDeleted", { count: selectedIds.length })); setSelectedIds([]); await loadData(); } catch (error) { toast.error(error instanceof Error ? error.message : t("knowledge.batchDeleteFailed")); } finally { setSaving(false); } } async function handleBatchMove(directoryId: number) { const ids = moveTargetIds.length > 0 ? moveTargetIds : selectedIds; if (!knowledgeBaseId || ids.length === 0) { return; } setMoving(true); try { await batchMoveKnowledgeDocuments({ knowledgeBaseId, directoryId, ids, }); toast.success(t("knowledge.batchMoved", { count: ids.length })); setBulkMoveOpen(false); setMoveTargetIds([]); setSelectedIds([]); await loadData(); } catch (error) { toast.error(error instanceof Error ? error.message : t("knowledge.batchMoveFailed")); } finally { setMoving(false); } } async function handleBuildIndex(item: KnowledgeDocumentListItem) { setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], rebuildIndex: true } })); try { await buildKnowledgeDocumentIndex(item.id); toast.success(t("knowledge.documentIndexRebuilt", { title: item.title })); await loadData(); } catch (error) { toast.error(error instanceof Error ? error.message : t("knowledge.documentIndexRebuildFailed")); } finally { setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], rebuildIndex: false } })); } } if (!knowledgeBaseId) { return (

{t("knowledge.selectKnowledgeBaseForDocuments")}

); } return ( <>
void loadData()} />
setDraftFilter("title", event.target.value)} onKeyDown={handleFilterKeyDown} placeholder={t("knowledge.searchDocumentTitle")} className="h-8 pl-8 text-xs" />
{documents.results.length > 0 ? ( ) : null} {documents.results.map((item) => ( viewMode === "grid" ? ( setContextMenuDocumentId(open ? item.id : null)} >
openDetailDialog(item)} >
event.stopPropagation()} onCheckedChange={(checked) => toggleSelected(item.id, Boolean(checked))} aria-label={t("knowledge.selectItem", { name: item.title })} className="mt-0.5" /> {/* */}
{item.title}
{renderIndexStatusBadge(item, t)}
{item.createUserName || "-"} {formatDateTime(item.createdAt)} {item.indexStatus === KnowledgeDocumentIndexStatus.Indexed ? t("knowledge.indexedAt", { time: formatDateTime(item.indexedAt) }) : getIndexStatusLabel(item.indexStatus, t)}
event.stopPropagation()} render={
openDetailDialog(item)}> {t("knowledge.view")} openEditDialog(item)}> {t("knowledge.edit")} openMoveDialog([item.id])}> {t("knowledge.move")} void handleBuildIndex(item)} disabled={actionLoadingMap[item.id]?.rebuildIndex}> {actionLoadingMap[item.id]?.rebuildIndex ? t("knowledge.running") : t("knowledge.rebuildIndex")} void handleDelete(item)} variant="destructive" disabled={actionLoadingMap[item.id]?.delete} > {actionLoadingMap[item.id]?.delete ? t("knowledge.deleting") : t("knowledge.delete")}
) : ( setContextMenuDocumentId(open ? item.id : null)} >
openDetailDialog(item)} > event.stopPropagation()} onCheckedChange={(checked) => toggleSelected(item.id, Boolean(checked))} aria-label={t("knowledge.selectItem", { name: item.title })} /> {/* */}
{item.title}
{renderIndexStatusBadge(item, t)}
{item.indexStatus === KnowledgeDocumentIndexStatus.Indexed ? t("knowledge.indexTime", { time: formatDateTime(item.indexedAt) }) : item.indexError || getIndexStatusLabel(item.indexStatus, t)}
{formatDateTime(item.createdAt)}
event.stopPropagation()} render={
openDetailDialog(item)}> {t("knowledge.view")} openEditDialog(item)}> {t("knowledge.edit")} openMoveDialog([item.id])}> {t("knowledge.move")} void handleBuildIndex(item)} disabled={actionLoadingMap[item.id]?.rebuildIndex}> {actionLoadingMap[item.id]?.rebuildIndex ? t("knowledge.running") : t("knowledge.rebuildIndex")} void handleDelete(item)} variant="destructive" disabled={actionLoadingMap[item.id]?.delete} > {actionLoadingMap[item.id]?.delete ? t("knowledge.deleting") : t("knowledge.delete")}
) ))} {!loading && documents.results.length === 0 ? (
{t("knowledge.emptyDocuments")}
) : null}
{selectedIds.length > 0 ? (
{t("knowledge.selectedCount", { count: selectedIds.length })}
) : null}
{ setDetailOpen(open); if (!open) { setDetailItemId(null); } }} /> { setBulkMoveOpen(open); if (!open) { setMoveTargetIds([]); } }} onSubmit={(directoryId) => void handleBatchMove(directoryId)} /> ); }