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 ( + + { + if (isMobile) { + e.preventDefault(); + } + }} + > + + {title} + {description && {description}} + + +
+ +
+ + {isNew && ( +
+
+ + setName(e.target.value)} /> +
+
+ + setDescriptionValue(e.target.value)} + /> +
+
+ )} + + + + + +
+
+ ); +} 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;