diff --git a/web/public/locales/en/views/exports.json b/web/public/locales/en/views/exports.json
index 6fca40cef..8f9e8205e 100644
--- a/web/public/locales/en/views/exports.json
+++ b/web/public/locales/en/views/exports.json
@@ -17,11 +17,21 @@
"shareExport": "Share export",
"downloadVideo": "Download video",
"editName": "Edit name",
- "deleteExport": "Delete export"
+ "deleteExport": "Delete export",
+ "assignToCase": "Add to case"
},
"toast": {
"error": {
- "renameExportFailed": "Failed to rename export: {{errorMessage}}"
+ "renameExportFailed": "Failed to rename export: {{errorMessage}}",
+ "assignCaseFailed": "Failed to update case assignment: {{errorMessage}}"
}
+ },
+ "caseDialog": {
+ "title": "Add to case",
+ "description": "Choose an existing case or create a new one.",
+ "selectLabel": "Case",
+ "newCaseOption": "Create new case",
+ "nameLabel": "Case name",
+ "descriptionLabel": "Description"
}
}
diff --git a/web/src/components/card/ExportCard.tsx b/web/src/components/card/ExportCard.tsx
index 7158a5bb9..88000e54c 100644
--- a/web/src/components/card/ExportCard.tsx
+++ b/web/src/components/card/ExportCard.tsx
@@ -35,8 +35,6 @@ type CaseCardProps = {
onSelect: () => void;
};
export function CaseCard({ className, exportCase, onSelect }: CaseCardProps) {
- const { t } = useTranslation(["views/exports"]);
-
return (
void;
onRename: (original: string, update: string) => void;
onDelete: ({ file, exportName }: DeleteClipType) => void;
+ onAssignToCase?: (selected: Export) => void;
};
export function ExportCard({
className,
@@ -66,6 +65,7 @@ export function ExportCard({
onSelect,
onRename,
onDelete,
+ onAssignToCase,
}: ExportCardProps) {
const { t } = useTranslation(["views/exports"]);
const isAdmin = useIsAdmin();
@@ -223,6 +223,18 @@ export function ExportCard({
{t("tooltip.downloadVideo")}
+ {isAdmin && onAssignToCase && (
+
{
+ e.stopPropagation();
+ onAssignToCase(exportedRecording);
+ }}
+ >
+ {t("tooltip.assignToCase")}
+
+ )}
{isAdmin && (
void;
+ onSave: (value: string) => void;
+ onCreateNew: (name: string, description: string) => void;
+};
+
+export default function OptionAndInputDialog({
+ open,
+ title,
+ description,
+ options,
+ newValueKey,
+ initialValue,
+ nameLabel,
+ descriptionLabel,
+ setOpen,
+ onSave,
+ onCreateNew,
+}: OptionAndInputDialogProps) {
+ const { t } = useTranslation("common");
+ const firstOption = useMemo(() => options[0]?.value, [options]);
+
+ const [selectedValue, setSelectedValue] = useState(
+ initialValue ?? firstOption,
+ );
+ const [name, setName] = useState("");
+ const [descriptionValue, setDescriptionValue] = useState("");
+
+ useEffect(() => {
+ if (open) {
+ setSelectedValue(initialValue ?? firstOption);
+ setName("");
+ setDescriptionValue("");
+ }
+ }, [open, initialValue, firstOption]);
+
+ const isNew = selectedValue === newValueKey;
+ const disableSave = !selectedValue || (isNew && name.trim().length === 0);
+
+ const handleSave = () => {
+ if (!selectedValue) {
+ return;
+ }
+
+ const trimmedName = name.trim();
+ const trimmedDescription = descriptionValue.trim();
+
+ if (isNew) {
+ onCreateNew(trimmedName, trimmedDescription);
+ } else {
+ onSave(selectedValue);
+ }
+ setOpen(false);
+ };
+
+ return (
+
+ );
+}
diff --git a/web/src/pages/Exports.tsx b/web/src/pages/Exports.tsx
index febbdf41e..fca5f7fac 100644
--- a/web/src/pages/Exports.tsx
+++ b/web/src/pages/Exports.tsx
@@ -18,6 +18,7 @@ import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state";
import { cn } from "@/lib/utils";
import { DeleteClipType, Export, ExportCase } from "@/types/export";
+import OptionAndInputDialog from "@/components/overlay/dialog/OptionAndInputDialog";
import axios from "axios";
import {
@@ -97,6 +98,7 @@ function Exports() {
// Modifying
const [deleteClip, setDeleteClip] = useState();
+ const [exportToAssign, setExportToAssign] = useState();
const onHandleDelete = useCallback(() => {
if (!deleteClip) {
@@ -146,10 +148,22 @@ function Exports() {
[cases, selectedCaseId],
);
+ const resetCaseDialog = useCallback(() => {
+ setExportToAssign(undefined);
+ }, []);
+
return (
+
+
setDeleteClip(undefined)}
@@ -238,6 +252,7 @@ function Exports() {
setSelected={setSelected}
renameClip={onHandleRename}
setDeleteClip={setDeleteClip}
+ onAssignToCase={setExportToAssign}
/>
) : (
)}
@@ -264,6 +280,7 @@ type AllExportsViewProps = {
setSelected: (e: Export) => void;
renameClip: (id: string, update: string) => void;
setDeleteClip: (d: DeleteClipType | undefined) => void;
+ onAssignToCase: (e: Export) => void;
};
function AllExportsView({
contentRef,
@@ -274,6 +291,7 @@ function AllExportsView({
setSelected,
renameClip,
setDeleteClip,
+ onAssignToCase,
}: AllExportsViewProps) {
const { t } = useTranslation(["views/exports"]);
@@ -354,6 +372,7 @@ function AllExportsView({
onDelete={({ file, exportName }) =>
setDeleteClip({ file, exportName })
}
+ onAssignToCase={onAssignToCase}
/>
))}
@@ -377,6 +396,7 @@ type CaseViewProps = {
setSelected: (e: Export) => void;
renameClip: (id: string, update: string) => void;
setDeleteClip: (d: DeleteClipType | undefined) => void;
+ onAssignToCase: (e: Export) => void;
};
function CaseView({
contentRef,
@@ -386,6 +406,7 @@ function CaseView({
setSelected,
renameClip,
setDeleteClip,
+ onAssignToCase,
}: CaseViewProps) {
const filteredExports = useMemo(() => {
const caseExports = (exports || []).filter(
@@ -418,7 +439,7 @@ function CaseView({
ref={contentRef}
className="scrollbar-container grid size-full gap-2 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
- {exports.map((item) => (
+ {exports?.map((item) => (
setDeleteClip({ file, exportName })
}
+ onAssignToCase={onAssignToCase}
/>
))}
@@ -435,4 +457,130 @@ function CaseView({
);
}
+type CaseAssignmentDialogProps = {
+ exportToAssign?: Export;
+ cases?: ExportCase[];
+ selectedCaseId?: string;
+ onClose: () => void;
+ mutate: () => void;
+};
+function CaseAssignmentDialog({
+ exportToAssign,
+ cases,
+ selectedCaseId,
+ onClose,
+ mutate,
+}: CaseAssignmentDialogProps) {
+ const { t } = useTranslation(["views/exports"]);
+ const caseOptions = useMemo(
+ () => [
+ ...(cases ?? []).map((c) => ({
+ value: c.id,
+ label: c.name,
+ })),
+ {
+ value: "new",
+ label: t("caseDialog.newCaseOption"),
+ },
+ ],
+ [cases, t],
+ );
+
+ const handleSave = useCallback(
+ async (caseId: string) => {
+ if (!exportToAssign) return;
+
+ try {
+ await axios.patch(`export/${exportToAssign.id}/case`, {
+ export_case_id: caseId,
+ });
+ mutate();
+ onClose();
+ } catch (error: unknown) {
+ const errorMessage =
+ (
+ error as {
+ response?: { data?: { message?: string; detail?: string } };
+ }
+ ).response?.data?.message ||
+ (
+ error as {
+ response?: { data?: { message?: string; detail?: string } };
+ }
+ ).response?.data?.detail ||
+ "Unknown error";
+ toast.error(t("toast.error.assignCaseFailed", { errorMessage }), {
+ position: "top-center",
+ });
+ }
+ },
+ [exportToAssign, mutate, onClose, t],
+ );
+
+ const handleCreateNew = useCallback(
+ async (name: string, description: string) => {
+ if (!exportToAssign) return;
+
+ try {
+ const createResp = await axios.post("cases", {
+ name,
+ description,
+ });
+
+ const newCaseId: string | undefined = createResp.data?.id;
+
+ if (newCaseId) {
+ await axios.patch(`export/${exportToAssign.id}/case`, {
+ export_case_id: newCaseId,
+ });
+ }
+
+ mutate();
+ onClose();
+ } catch (error: unknown) {
+ const errorMessage =
+ (
+ error as {
+ response?: { data?: { message?: string; detail?: string } };
+ }
+ ).response?.data?.message ||
+ (
+ error as {
+ response?: { data?: { message?: string; detail?: string } };
+ }
+ ).response?.data?.detail ||
+ "Unknown error";
+ toast.error(t("toast.error.assignCaseFailed", { errorMessage }), {
+ position: "top-center",
+ });
+ }
+ },
+ [exportToAssign, mutate, onClose, t],
+ );
+
+ if (!exportToAssign) {
+ return null;
+ }
+
+ return (
+ {
+ if (!open) {
+ onClose();
+ }
+ }}
+ options={caseOptions}
+ nameLabel={t("caseDialog.nameLabel")}
+ descriptionLabel={t("caseDialog.descriptionLabel")}
+ initialValue={selectedCaseId}
+ newValueKey="new"
+ onSave={handleSave}
+ onCreateNew={handleCreateNew}
+ />
+ );
+}
+
export default Exports;