feat: add document status management with JSONB column for processing states in documents

This commit is contained in:
Anish Sarkar 2026-02-05 21:59:31 +05:30
parent 04884caeef
commit aef59d04eb
13 changed files with 526 additions and 135 deletions

View file

@ -1,7 +1,7 @@
"use client";
import { formatDistanceToNow } from "date-fns";
import { Calendar, ChevronDown, ChevronUp, FileText, FileX, Loader2, Network, Plus, User } from "lucide-react";
import { AlertCircle, Calendar, CheckCircle2, ChevronDown, ChevronUp, Clock, FileText, FileX, Loader2, Network, Plus, User } from "lucide-react";
import { motion } from "motion/react";
import { useTranslations } from "next-intl";
import React, { useRef, useState, useEffect, useCallback } from "react";
@ -17,6 +17,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import {
Table,
TableBody,
@ -29,7 +30,61 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { DocumentTypeChip } from "./DocumentTypeIcon";
import { RowActions } from "./RowActions";
import type { ColumnVisibility, Document } from "./types";
import type { ColumnVisibility, Document, DocumentStatus } from "./types";
// Status indicator component for document processing status
function StatusIndicator({ status }: { status?: DocumentStatus }) {
const state = status?.state ?? "ready";
switch (state) {
case "pending":
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center justify-center">
<Clock className="h-5 w-5 text-muted-foreground" />
</div>
</TooltipTrigger>
<TooltipContent side="top">Pending - waiting to be processed</TooltipContent>
</Tooltip>
);
case "processing":
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center justify-center">
<Spinner size="sm" className="text-primary" />
</div>
</TooltipTrigger>
<TooltipContent side="top">Processing...</TooltipContent>
</Tooltip>
);
case "failed":
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center justify-center">
<AlertCircle className="h-5 w-5 text-destructive" />
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
{status?.reason || "Processing failed"}
</TooltipContent>
</Tooltip>
);
case "ready":
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center justify-center">
<CheckCircle2 className="h-5 w-5 text-muted-foreground/60" />
</div>
</TooltipTrigger>
<TooltipContent side="top">Ready</TooltipContent>
</Tooltip>
);
}
}
export type SortKey = keyof Pick<Document, "title" | "document_type" | "created_at">;
@ -460,7 +515,7 @@ export function DocumentsTableShell({
</TableHead>
)}
{columnVisibility.created_at && (
<TableHead className="w-32">
<TableHead className="w-32 border-r border-border/40">
<SortableHeader
sortKey="created_at"
currentSortKey={sortKey}
@ -472,6 +527,13 @@ export function DocumentsTableShell({
</SortableHeader>
</TableHead>
)}
{columnVisibility.status && (
<TableHead className="w-20 text-center">
<span className="text-sm font-medium text-muted-foreground/70">
Status
</span>
</TableHead>
)}
<TableHead className="w-10">
<span className="sr-only">Actions</span>
</TableHead>
@ -552,7 +614,7 @@ export function DocumentsTableShell({
</TableCell>
)}
{columnVisibility.created_at && (
<TableCell className="w-32 py-2.5 text-sm text-foreground">
<TableCell className="w-32 py-2.5 text-sm text-foreground border-r border-border/40">
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default">{formatRelativeDate(doc.created_at)}</span>
@ -563,6 +625,11 @@ export function DocumentsTableShell({
</Tooltip>
</TableCell>
)}
{columnVisibility.status && (
<TableCell className="w-20 py-2.5 text-center">
<StatusIndicator status={doc.status} />
</TableCell>
)}
<TableCell className="w-10 py-2.5 text-center">
<RowActions
document={doc}
@ -647,11 +714,14 @@ export function DocumentsTableShell({
)}
</div>
</div>
<RowActions
document={doc}
deleteDocument={deleteDocument}
searchSpaceId={searchSpaceId}
/>
<div className="flex items-center gap-2">
{columnVisibility.status && <StatusIndicator status={doc.status} />}
<RowActions
document={doc}
deleteDocument={deleteDocument}
searchSpaceId={searchSpaceId}
/>
</div>
</div>
</motion.div>
);

View file

@ -45,10 +45,17 @@ export function RowActions({
document.document_type as (typeof EDITABLE_DOCUMENT_TYPES)[number]
);
const isDeletable = !NON_DELETABLE_DOCUMENT_TYPES.includes(
// Documents in "pending" or "processing" state should show disabled delete
const isBeingProcessed = document.status?.state === "pending" || document.status?.state === "processing";
// SURFSENSE_DOCS are system-managed and should not show delete at all
const shouldShowDelete = !NON_DELETABLE_DOCUMENT_TYPES.includes(
document.document_type as (typeof NON_DELETABLE_DOCUMENT_TYPES)[number]
);
// Delete is disabled while processing
const isDeleteDisabled = isBeingProcessed;
const handleDelete = async () => {
setIsDeleting(true);
try {
@ -87,10 +94,11 @@ export function RowActions({
<Pencil className="mr-2 h-4 w-4" />
<span>Edit</span>
</DropdownMenuItem>
{isDeletable && (
{shouldShowDelete && (
<DropdownMenuItem
onClick={() => setIsDeleteOpen(true)}
className="text-destructive focus:text-destructive"
onClick={() => !isDeleteDisabled && setIsDeleteOpen(true)}
disabled={isDeleteDisabled}
className={isDeleteDisabled ? "text-muted-foreground cursor-not-allowed opacity-50" : "text-destructive focus:text-destructive"}
>
<Trash2 className="mr-2 h-4 w-4" />
<span>Delete</span>
@ -100,13 +108,13 @@ export function RowActions({
</DropdownMenu>
) : (
// Non-editable documents: show only delete button directly
isDeletable && (
shouldShowDelete && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
onClick={() => setIsDeleteOpen(true)}
disabled={isDeleting}
className={`h-8 w-8 ${isDeleteDisabled ? "text-muted-foreground/50 cursor-not-allowed" : "text-muted-foreground hover:text-destructive hover:bg-destructive/10"}`}
onClick={() => !isDeleteDisabled && setIsDeleteOpen(true)}
disabled={isDeleting || isDeleteDisabled}
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Delete</span>
@ -131,10 +139,11 @@ export function RowActions({
<Pencil className="mr-2 h-4 w-4" />
<span>Edit</span>
</DropdownMenuItem>
{isDeletable && (
{shouldShowDelete && (
<DropdownMenuItem
onClick={() => setIsDeleteOpen(true)}
className="text-destructive focus:text-destructive"
onClick={() => !isDeleteDisabled && setIsDeleteOpen(true)}
disabled={isDeleteDisabled}
className={isDeleteDisabled ? "text-muted-foreground cursor-not-allowed opacity-50" : "text-destructive focus:text-destructive"}
>
<Trash2 className="mr-2 h-4 w-4" />
<span>Delete</span>
@ -144,13 +153,13 @@ export function RowActions({
</DropdownMenu>
) : (
// Non-editable documents: show only delete button directly
isDeletable && (
shouldShowDelete && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
onClick={() => setIsDeleteOpen(true)}
disabled={isDeleting}
className={`h-8 w-8 ${isDeleteDisabled ? "text-muted-foreground/50 cursor-not-allowed" : "text-muted-foreground hover:text-destructive hover:bg-destructive/10"}`}
onClick={() => !isDeleteDisabled && setIsDeleteOpen(true)}
disabled={isDeleting || isDeleteDisabled}
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Delete</span>

View file

@ -1,5 +1,10 @@
export type DocumentType = string;
export type DocumentStatus = {
state: "ready" | "pending" | "processing" | "failed";
reason?: string;
};
export type Document = {
id: number;
title: string;
@ -11,10 +16,12 @@ export type Document = {
search_space_id: number;
created_by_id?: string | null;
created_by_name?: string | null;
status?: DocumentStatus;
};
export type ColumnVisibility = {
document_type: boolean;
created_by: boolean;
created_at: boolean;
status: boolean;
};

View file

@ -38,6 +38,7 @@ export default function DocumentsTable() {
document_type: true,
created_by: true,
created_at: true,
status: true,
});
const [pageIndex, setPageIndex] = useState(0);
const [sortKey, setSortKey] = useState<SortKey>("created_at");
@ -115,6 +116,7 @@ export default function DocumentsTable() {
created_by_id: item.created_by_id ?? null,
created_by_name: item.created_by_name ?? null,
created_at: item.created_at,
status: (item as { status?: { state: "ready" | "pending" | "processing" | "failed"; reason?: string } }).status ?? { state: "ready" as const },
}))
: paginatedRealtimeDocuments;
@ -159,10 +161,35 @@ export default function DocumentsTable() {
toast.error(t("no_rows_selected"));
return;
}
// Filter out pending/processing documents - they cannot be deleted
// For real-time mode, use sortedRealtimeDocuments (which has status)
// For search mode, use searchResponse items (need to safely access status)
const allDocs = isSearchMode
? (searchResponse?.items || []).map(item => ({
id: item.id,
status: (item as { status?: { state: string } }).status,
}))
: sortedRealtimeDocuments.map(doc => ({ id: doc.id, status: doc.status }));
const selectedDocs = allDocs.filter((doc) => selectedIds.has(doc.id));
const deletableIds = selectedDocs
.filter((doc) => doc.status?.state !== "pending" && doc.status?.state !== "processing")
.map((doc) => doc.id);
const inProgressCount = selectedIds.size - deletableIds.length;
if (inProgressCount > 0) {
toast.warning(`${inProgressCount} document(s) are pending or processing and cannot be deleted.`);
}
if (deletableIds.length === 0) {
return;
}
try {
// Delete documents one by one using the mutation
const results = await Promise.all(
Array.from(selectedIds).map(async (id) => {
deletableIds.map(async (id) => {
try {
await deleteDocumentMutation({ id });
return true;
@ -172,7 +199,7 @@ export default function DocumentsTable() {
})
);
const okCount = results.filter((r) => r === true).length;
if (okCount === selectedIds.size)
if (okCount === deletableIds.length)
toast.success(t("delete_success_count", { count: okCount }));
else toast.error(t("delete_partial_failed"));