From 070bb0cc3a0b51f7a8b01024b201d74e91c8a5c4 Mon Sep 17 00:00:00 2001 From: luis-godinho <125367612+luis-godinho@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:03:44 +0100 Subject: [PATCH] Add unindex and remove actions for studies, series, and instances --- .../src/components/ui/ConfirmActionDialog.tsx | 106 +++++++++++++++ webapp/src/components/ui/Dialog.tsx | 1 + webapp/src/features/search/SeriesViewer.tsx | 65 ++++++++- webapp/src/features/search/StudyList.tsx | 63 +++++++++ .../features/search/components/ImageList.tsx | 68 +++++++++- webapp/src/features/search/hooks/index.ts | 1 + .../features/search/hooks/useEntryActions.ts | 123 ++++++++++++++++++ webapp/src/services/dicoogleService.ts | 10 ++ webapp/src/stores/SearchStore.ts | 1 + webapp/src/types/index.ts | 1 + 10 files changed, 436 insertions(+), 3 deletions(-) create mode 100644 webapp/src/components/ui/ConfirmActionDialog.tsx create mode 100644 webapp/src/features/search/hooks/useEntryActions.ts diff --git a/webapp/src/components/ui/ConfirmActionDialog.tsx b/webapp/src/components/ui/ConfirmActionDialog.tsx new file mode 100644 index 00000000..df3b8810 --- /dev/null +++ b/webapp/src/components/ui/ConfirmActionDialog.tsx @@ -0,0 +1,106 @@ +import { Trash2, Eraser } from "lucide-react"; +import { Button } from "@/components/ui/Button"; +import { Dialog } from "@/components/ui/Dialog"; + +type ActionKind = "unindex" | "remove"; + +interface ConfirmActionDialogProps { + open: boolean; + action: ActionKind; + level: "study" | "series" | "instance"; + description: string; + fileCount?: number; + onConfirm: () => void; + onCancel: () => void; + loading?: boolean; +} + +const ACTION_CONFIG = { + unindex: { + title: "Unindex", + icon: Eraser, + iconClass: "text-amber-500", + bgClass: "bg-amber-50 dark:bg-amber-950/30 border-amber-200 dark:border-amber-800", + message: + "This will remove the data from the search index. The files will remain on disk but will no longer appear in search results. This operation can be reversed by re-indexing the files.", + confirmLabel: "Unindex", + confirmVariant: "outline-solid" as const, + }, + remove: { + title: "Remove", + icon: Trash2, + iconClass: "text-destructive", + bgClass: "bg-red-50 dark:bg-red-950/30 border-red-200 dark:border-red-800", + message: + "This will permanently delete the files from disk AND remove them from the search index. This operation is irreversible and data cannot be recovered.", + confirmLabel: "Remove", + confirmVariant: "destructive" as const, + }, +} as const; + +const LEVEL_LABEL: Record = { + study: "Study", + series: "Series", + instance: "Instance", +}; + +export function ConfirmActionDialog({ + open, + action, + level, + description, + fileCount, + onConfirm, + onCancel, + loading = false, +}: ConfirmActionDialogProps) { + const config = ACTION_CONFIG[action]; + const Icon = config.icon; + + return ( + +
+ {/* Warning banner */} +
+ +
+

+ {LEVEL_LABEL[level]} level {config.title} +

+

{config.message}

+
+
+ + {/* Scope details */} +
+

+ {LEVEL_LABEL[level]}:{" "} + {description} +

+ {fileCount !== undefined && ( +

+ Files:{" "} + {fileCount} {fileCount === 1 ? "file" : "files"} will be affected +

+ )} +
+ + {/* Actions */} +
+ + +
+
+
+ ); +} diff --git a/webapp/src/components/ui/Dialog.tsx b/webapp/src/components/ui/Dialog.tsx index 23604d3e..c68d1f34 100644 --- a/webapp/src/components/ui/Dialog.tsx +++ b/webapp/src/components/ui/Dialog.tsx @@ -42,6 +42,7 @@ export function Dialog({ open, onClose, children, title, size = "md" }: DialogPr {/* Dialog */}
e.stopPropagation()} > {/* Header */} {title && ( diff --git a/webapp/src/features/search/SeriesViewer.tsx b/webapp/src/features/search/SeriesViewer.tsx index 5831a243..a927a82f 100644 --- a/webapp/src/features/search/SeriesViewer.tsx +++ b/webapp/src/features/search/SeriesViewer.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef } from "react"; import { useSearchStore } from "@/stores/SearchStore"; +import { useAuthStore } from "@/stores/AuthStore"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; import { Button } from "@/components/ui/Button"; import { @@ -9,18 +10,74 @@ import { List, FileText, ArrowLeft, + Eraser, + Trash2, } from "lucide-react"; import { dicoogleService } from "@/services/dicoogleService"; import { ImageList } from "./components/ImageList"; import { DICOMDumpModal } from "./components/DICOMDumpModal"; import { QuickViewer } from "./components/QuickViewer"; import { AdvancedViewer } from "./components/AdvancedViewer"; +import { ConfirmActionDialog } from "@/components/ui/ConfirmActionDialog"; +import { useEntryActions } from "./hooks"; import type { Study, Series } from "@/types"; interface SeriesViewerProps { study: Study; } +function SeriesActions({ study, series }: { study: Study; series: Series }) { + const { user } = useAuthStore(); + const { + openDialog, + pendingAction, + description, + fileCount, + loading, + handleUnindex, + handleRemove, + handleConfirm, + handleCancel, + } = useEntryActions({ level: "series", study, series }); + + if (!user?.admin) return null; + + return ( + <> +
e.stopPropagation()}> + + +
+ + + ); +} + export function SeriesViewer({ study }: SeriesViewerProps) { const { selectedSeries, deselectStudy } = useSearchStore(); const series = selectedSeries || []; @@ -163,6 +220,7 @@ export function SeriesViewer({ study }: SeriesViewerProps) { return (
Images
+ +
+ +
))} @@ -354,7 +416,7 @@ export function SeriesViewer({ study }: SeriesViewerProps) {

-
+
+
diff --git a/webapp/src/features/search/StudyList.tsx b/webapp/src/features/search/StudyList.tsx index 9b5ddc7b..891984bd 100644 --- a/webapp/src/features/search/StudyList.tsx +++ b/webapp/src/features/search/StudyList.tsx @@ -1,5 +1,6 @@ import { useState, Suspense } from "react"; import { useSearchStore } from "@/stores/SearchStore"; +import { useAuthStore } from "@/stores/AuthStore"; import { Card, CardContent } from "@/components/ui/Card"; import { Button } from "@/components/ui/Button"; import { @@ -12,6 +13,8 @@ import { ChevronLeft, LayoutList, Grid3X3, + Eraser, + Trash2, } from "lucide-react"; import type { Study } from "@/types"; import { @@ -22,6 +25,8 @@ import { invokeResultOptionAction, invokeResultBatchAction, } from "@/plugin-system"; +import { ConfirmActionDialog } from "@/components/ui/ConfirmActionDialog"; +import { useEntryActions } from "./hooks"; interface StudyListProps { studies: Study[]; @@ -37,6 +42,61 @@ interface StudyItemProps { const ITEMS_PER_PAGE_CARD = 3; const ITEMS_PER_PAGE_LIST = 10; +function StudyActions({ study }: { study: Study }) { + const { user } = useAuthStore(); + const { + openDialog, + pendingAction, + description, + fileCount, + loading, + handleUnindex, + handleRemove, + handleConfirm, + handleCancel, + } = useEntryActions({ level: "study", study }); + + if (!user?.admin) return null; + + return ( + <> +
e.stopPropagation()} + > + + +
+ + + ); +} + const formatDate = (date?: string) => { if (!date) return "N/A"; const year = date.substring(0, 4); @@ -116,6 +176,8 @@ const StudyCard = ({ study, isSelected, onClick }: StudyItemProps) => { + + {optionExtensions.length > 0 && (
))} +
)} diff --git a/webapp/src/features/search/components/ImageList.tsx b/webapp/src/features/search/components/ImageList.tsx index 4aaa7b77..7a3e2609 100644 --- a/webapp/src/features/search/components/ImageList.tsx +++ b/webapp/src/features/search/components/ImageList.tsx @@ -9,14 +9,20 @@ import { ArrowLeft, LayoutGrid, List, + Eraser, + Trash2, } from "lucide-react"; import { Button } from "@/components/ui/Button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; import { dicoogleService } from "@/services/dicoogleService"; +import { useAuthStore } from "@/stores/AuthStore"; +import { ConfirmActionDialog } from "@/components/ui/ConfirmActionDialog"; +import { useEntryActions } from "../hooks"; import { usePagination } from "../hooks/usePagination"; -import type { Series } from "@/types"; +import type { Series, Image, Study } from "@/types"; interface ImageListProps { + study: Study; series: Series; onViewQuick: (sopIndex: number, e?: React.MouseEvent) => void; onViewAdvanced: (sopIndex: number) => void; @@ -24,9 +30,62 @@ interface ImageListProps { onBack: () => void; } +function ImageActions({ study, image }: { study: Study; image: Image }) { + const { user } = useAuthStore(); + const { + openDialog, + pendingAction, + description, + fileCount, + loading, + handleUnindex, + handleRemove, + handleConfirm, + handleCancel, + } = useEntryActions({ level: "instance", study, image }); + + if (!user?.admin) return null; + + return ( + <> +
e.stopPropagation()}> + + +
+ + + ); +} + const PAGE_SIZE = 10; export function ImageList({ + study, series, onViewQuick, onViewDump, @@ -178,6 +237,10 @@ export function ImageList({ Metadata + +
+ +
); })} @@ -221,7 +284,7 @@ export function ImageList({ {/* Actions */} -
+
+
); diff --git a/webapp/src/features/search/hooks/index.ts b/webapp/src/features/search/hooks/index.ts index 0d7d81ed..52f097a2 100644 --- a/webapp/src/features/search/hooks/index.ts +++ b/webapp/src/features/search/hooks/index.ts @@ -1,4 +1,5 @@ export { usePagination } from './usePagination'; export { useMetadata } from './useMetadata'; +export { useEntryActions } from './useEntryActions'; export type { PaginationResult } from './usePagination'; export type { MetadataState } from './useMetadata'; diff --git a/webapp/src/features/search/hooks/useEntryActions.ts b/webapp/src/features/search/hooks/useEntryActions.ts new file mode 100644 index 00000000..cee9925b --- /dev/null +++ b/webapp/src/features/search/hooks/useEntryActions.ts @@ -0,0 +1,123 @@ +import { useState, useCallback, useMemo } from "react"; +import { useSearchStore } from "@/stores/SearchStore"; +import { dicoogleService } from "@/services/dicoogleService"; +import { toast } from "@/utils/toast"; +import type { Study, Series, Image } from "@/types"; + +type ActionLevel = "study" | "series" | "instance"; + +interface UseEntryActionsOptions { + level: ActionLevel; + study: Study; + series?: Series; + image?: Image; +} + +interface UseEntryActionsResult { + openDialog: boolean; + pendingAction: "unindex" | "remove" | null; + description: string; + fileCount: number; + loading: boolean; + handleUnindex: () => void; + handleRemove: () => void; + handleConfirm: () => void; + handleCancel: () => void; +} + +function collectImageUris(images: Image[]): string[] { + return images.map((img) => img.uri).filter((uri): uri is string => !!uri); +} + +export function useEntryActions({ + level, + study, + series, + image, +}: UseEntryActionsOptions): UseEntryActionsResult { + const [openDialog, setOpenDialog] = useState(false); + const [pendingAction, setPendingAction] = useState<"unindex" | "remove" | null>(null); + const [loading, setLoading] = useState(false); + + const { search, query } = useSearchStore(); + + const uris = useMemo(() => { + if (level === "instance" && image) { + return collectImageUris([image]); + } + if (level === "series" && series) { + return collectImageUris(series.images || []); + } + // study level: collect from all series in the study + const allImages = useSearchStore.getState().series + .filter((s) => s.studyInstanceUID === study.studyInstanceUID) + .flatMap((s) => s.images || []); + return collectImageUris(allImages); + }, [level, study.studyInstanceUID, series, image]); + + const description = useMemo(() => { + if (level === "instance" && image) { + return `SOP Instance UID: ${image.sopInstanceUID}`; + } + if (level === "series" && series) { + return `Series #${series.seriesNumber || "?"} — ${series.seriesDescription || "No description"}`; + } + return `${study.patientName || "Unknown"} — ${study.studyDescription || "No description"}`; + }, [level, study, series, image]); + + const fileCount = uris.length; + + const handleUnindex = useCallback(() => { + setPendingAction("unindex"); + setOpenDialog(true); + }, []); + + const handleRemove = useCallback(() => { + setPendingAction("remove"); + setOpenDialog(true); + }, []); + + const handleCancel = useCallback(() => { + setOpenDialog(false); + setPendingAction(null); + }, []); + + const handleConfirm = useCallback(async () => { + if (!pendingAction || uris.length === 0) return; + + setLoading(true); + try { + if (pendingAction === "remove") { + await dicoogleService.unindex(uris); + await dicoogleService.remove(uris); + toast.success(`Successfully unindexed and removed ${fileCount} file(s).`); + } else { + await dicoogleService.unindex(uris); + toast.success(`Successfully unindexed ${fileCount} file(s).`); + } + + setOpenDialog(false); + setPendingAction(null); + + if (query) { + search({ query }); + } + } catch (error: any) { + toast.error(error?.message || `Failed to ${pendingAction} files.`); + } finally { + setLoading(false); + } + }, [pendingAction, uris, fileCount, query, search]); + + return { + openDialog, + pendingAction, + description, + fileCount, + loading, + handleUnindex, + handleRemove, + handleConfirm, + handleCancel, + }; +} diff --git a/webapp/src/services/dicoogleService.ts b/webapp/src/services/dicoogleService.ts index db61319b..92e33397 100644 --- a/webapp/src/services/dicoogleService.ts +++ b/webapp/src/services/dicoogleService.ts @@ -393,6 +393,16 @@ class DicoogleService { return Array.isArray(uri) ? uri[0] : uri; } + async unindex(uri: string | string[]): Promise { + if (!dicoogleClient) throw new Error("Dicoogle client not initialized"); + await dicoogleClient.unindex(uri); + } + + async remove(uri: string | string[]): Promise { + if (!dicoogleClient) throw new Error("Dicoogle client not initialized"); + await dicoogleClient.remove(uri); + } + async listTasks(): Promise { if (!dicoogleClient) throw new Error("Dicoogle client not initialized"); return await dicoogleClient.tasks.list(); diff --git a/webapp/src/stores/SearchStore.ts b/webapp/src/stores/SearchStore.ts index bfe7e85b..5fa88db3 100644 --- a/webapp/src/stores/SearchStore.ts +++ b/webapp/src/stores/SearchStore.ts @@ -93,6 +93,7 @@ export const useSearchStore = create((set, get) => ({ sopInstanceUID: sopInstanceUID, instanceNumber: fields.InstanceNumber || fields.instanceNumber, seriesInstanceUID: seriesUID, + uri: result.uri, }); imagesMap.set(seriesUID, images); } diff --git a/webapp/src/types/index.ts b/webapp/src/types/index.ts index ad3a5cf9..e110e8f9 100644 --- a/webapp/src/types/index.ts +++ b/webapp/src/types/index.ts @@ -39,6 +39,7 @@ export interface Image { sopInstanceUID: string; instanceNumber?: string; seriesInstanceUID: string; + uri?: string; } export interface Series {