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 (
+
+ );
+}
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 {