From 5c56104751d5106065f08da106b6779e226a9135 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Thu, 25 Jun 2026 09:21:24 -0500 Subject: [PATCH 1/4] feat(Attachments): Add menu to assets --- .../WellShow/AssetPreviewWithOverlay.tsx | 302 ++++++++++++++---- src/components/WellShow/Attachments.tsx | 2 + 2 files changed, 248 insertions(+), 56 deletions(-) diff --git a/src/components/WellShow/AssetPreviewWithOverlay.tsx b/src/components/WellShow/AssetPreviewWithOverlay.tsx index f6e8404a..f8272fa5 100644 --- a/src/components/WellShow/AssetPreviewWithOverlay.tsx +++ b/src/components/WellShow/AssetPreviewWithOverlay.tsx @@ -1,9 +1,34 @@ +import { useState } from 'react' import { Box, Typography, Button } from '@mui/material' import type { IAsset } from '@/interfaces/ocotillo' import { QueryObserverResult } from '@tanstack/react-query' -import { GetListResponse, HttpError } from '@refinedev/core' +import { + GetListResponse, + HttpError, + useCustomMutation, + useNotification, +} from '@refinedev/core' import { HttpStatus } from '@/enums' import { isImage, isPdf, isText } from '@/utils' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { Button as UiButton } from '@/components/ui/button' +import { MoreVertical, Trash2, Unlink } from 'lucide-react' const previewStyles = { grid: { @@ -78,14 +103,22 @@ export const AssetPreviewWithOverlay = ({ asset, variant, refetchAssets, + canManageAsset = false, }: { asset: IAsset variant: 'grid' | 'slideshow' refetchAssets: () => Promise< QueryObserverResult, HttpError> > + canManageAsset?: boolean }) => { const isSlideshow = variant === 'slideshow' + const { open: notify } = useNotification() + const { mutateAsync: mutateAsset, mutation: assetMutation } = + useCustomMutation() + const [confirmAction, setConfirmAction] = useState< + 'remove-association' | 'delete-asset' | null + >(null) const getRefreshedAsset = async ( assetId: IAsset['id'], @@ -151,62 +184,114 @@ export const AssetPreviewWithOverlay = ({ } } - return ( - - + const getMutationErrorMessage = (error: unknown) => { + if ( + typeof error === 'object' && + error !== null && + 'response' in error && + typeof error.response === 'object' && + error.response !== null && + 'data' in error.response + ) { + const responseData = error.response.data as { detail?: string } - + if (typeof responseData.detail === 'string') { + return responseData.detail + } + } + + if (error instanceof Error && error.message) { + return error.message + } + + return 'Request failed. Please try again.' + } + + const handleConfirmAssetAction = async () => { + if (!confirmAction) return + + const isRemoveAssociation = confirmAction === 'remove-association' + + try { + await mutateAsset({ + url: isRemoveAssociation + ? `asset/${asset.id}/remove` + : `asset/${asset.id}`, + method: 'delete', + values: {}, + dataProviderName: 'ocotillo', + }) + + await refetchAssets() - + - {asset.name} - + - {asset.signed_url && ( - - )} - + {asset.signed_url && ( + + )} + + {canManageAsset && ( + + + event.stopPropagation()} + disabled={assetMutation.isPending} + className="bg-background hover:bg-background" + > + + + + + { + event.stopPropagation() + setConfirmAction('remove-association') + }} + > + + Remove from this well + + + { + event.stopPropagation() + setConfirmAction('delete-asset') + }} + > + + Delete asset + + + + )} + + + + { + if (!open && !assetMutation.isPending) { + setConfirmAction(null) + } + }} + > + + + + {confirmAction === 'remove-association' + ? 'Remove attachment from this well?' + : 'Delete this asset?'} + + + {confirmAction === 'remove-association' + ? 'The asset will remain uploaded, but it will no longer be associated with this well.' + : 'This permanently deletes the uploaded asset record. Use this only when the file should not be kept.'} + + + + + Cancel + + { + event.preventDefault() + void handleConfirmAssetAction() + }} + disabled={assetMutation.isPending} + className={ + confirmAction === 'delete-asset' + ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' + : undefined + } + > + {assetMutation.isPending + ? 'Working...' + : confirmAction === 'remove-association' + ? 'Remove from well' + : 'Delete asset'} + + + + + ) } diff --git a/src/components/WellShow/Attachments.tsx b/src/components/WellShow/Attachments.tsx index 976bdaf1..dcc7e618 100644 --- a/src/components/WellShow/Attachments.tsx +++ b/src/components/WellShow/Attachments.tsx @@ -163,6 +163,7 @@ export const AttachmentsCard = ({ asset={asset} variant="grid" refetchAssets={refetchAssets} + canManageAsset={canManageAmp} /> ))} @@ -186,6 +187,7 @@ export const AttachmentsCard = ({ asset={currentAsset} variant="slideshow" refetchAssets={refetchAssets} + canManageAsset={canManageAmp} /> )} {previewAssets.length > 1 && ( From f75891478d42f9910d1f4ca7ac3784cdac034695 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Thu, 25 Jun 2026 09:32:08 -0500 Subject: [PATCH 2/4] feat(AssetPreviewWithOverlay): Add dialog for asset reassociation --- .../WellShow/AssetPreviewWithOverlay.tsx | 204 +++++++++++++++--- 1 file changed, 177 insertions(+), 27 deletions(-) diff --git a/src/components/WellShow/AssetPreviewWithOverlay.tsx b/src/components/WellShow/AssetPreviewWithOverlay.tsx index f8272fa5..7dbc9ad6 100644 --- a/src/components/WellShow/AssetPreviewWithOverlay.tsx +++ b/src/components/WellShow/AssetPreviewWithOverlay.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' -import { Box, Typography, Button } from '@mui/material' -import type { IAsset } from '@/interfaces/ocotillo' +import { Autocomplete, Box, Typography, Button, TextField } from '@mui/material' +import type { IAsset, IWell } from '@/interfaces/ocotillo' import { QueryObserverResult } from '@tanstack/react-query' import { GetListResponse, @@ -8,6 +8,7 @@ import { useCustomMutation, useNotification, } from '@refinedev/core' +import { useAutocomplete } from '@refinedev/mui' import { HttpStatus } from '@/enums' import { isImage, isPdf, isText } from '@/utils' import { @@ -20,6 +21,14 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' import { DropdownMenu, DropdownMenuContent, @@ -28,7 +37,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Button as UiButton } from '@/components/ui/button' -import { MoreVertical, Trash2, Unlink } from 'lucide-react' +import { Link2, MoreVertical, Trash2, Unlink } from 'lucide-react' const previewStyles = { grid: { @@ -117,8 +126,24 @@ export const AssetPreviewWithOverlay = ({ const { mutateAsync: mutateAsset, mutation: assetMutation } = useCustomMutation() const [confirmAction, setConfirmAction] = useState< - 'remove-association' | 'delete-asset' | null + 'disassociate-asset' | 'delete-asset' | null >(null) + const [isReassociateDialogOpen, setIsReassociateDialogOpen] = useState(false) + const [selectedWell, setSelectedWell] = useState(null) + const { autocompleteProps: wellAutocompleteProps } = useAutocomplete({ + resource: 'thing/water-well', + dataProviderName: 'ocotillo', + queryOptions: { + enabled: canManageAsset && isReassociateDialogOpen, + }, + onSearch: (value) => [ + { + field: 'name', + operator: 'contains', + value, + }, + ], + }) const getRefreshedAsset = async ( assetId: IAsset['id'], @@ -210,33 +235,38 @@ export const AssetPreviewWithOverlay = ({ const handleConfirmAssetAction = async () => { if (!confirmAction) return - const isRemoveAssociation = confirmAction === 'remove-association' + const isDisassociate = confirmAction === 'disassociate-asset' try { - await mutateAsset({ - url: isRemoveAssociation - ? `asset/${asset.id}/remove` - : `asset/${asset.id}`, - method: 'delete', - values: {}, - dataProviderName: 'ocotillo', - }) + if (isDisassociate) { + await mutateAsset({ + url: `asset/${asset.id}/association`, + method: 'patch', + values: { thing_id: null }, + dataProviderName: 'ocotillo', + }) + } else { + await mutateAsset({ + url: `asset/${asset.id}`, + method: 'delete', + values: {}, + dataProviderName: 'ocotillo', + }) + } await refetchAssets() notify?.({ type: 'success', - message: isRemoveAssociation - ? 'Attachment removed from well' - : 'Attachment deleted', + message: isDisassociate ? 'Attachment disassociated' : 'Asset deleted', }) } catch (error) { console.error(error) notify?.({ type: 'error', - message: isRemoveAssociation - ? 'Could not remove attachment from well' - : 'Could not delete attachment', + message: isDisassociate + ? 'Could not disassociate attachment' + : 'Could not delete asset', description: getMutationErrorMessage(error), }) } finally { @@ -244,6 +274,42 @@ export const AssetPreviewWithOverlay = ({ } } + const handleReassociateAsset = async () => { + if (!selectedWell) return + + try { + await mutateAsset({ + url: `asset/${asset.id}/association`, + method: 'patch', + values: { thing_id: selectedWell.id }, + dataProviderName: 'ocotillo', + }) + + await refetchAssets() + notify?.({ + type: 'success', + message: 'Attachment reassociated', + description: selectedWell.name + ? `Attachment moved to ${selectedWell.name}.` + : undefined, + }) + setIsReassociateDialogOpen(false) + setSelectedWell(null) + } catch (error) { + console.error(error) + notify?.({ + type: 'error', + message: 'Could not reassociate attachment', + description: getMutationErrorMessage(error), + }) + } + } + + const currentThingId = asset.thing_id ?? null + const wellOptions = ((wellAutocompleteProps.options ?? []) as IWell[]).filter( + (well) => well.id !== currentThingId + ) + return ( <> { event.stopPropagation() - setConfirmAction('remove-association') + setConfirmAction('disassociate-asset') }} > - Remove from this well + Disassociate attachment + + { + event.stopPropagation() + setSelectedWell(null) + setIsReassociateDialogOpen(true) + }} + > + + Reassociate attachment - {confirmAction === 'remove-association' - ? 'Remove attachment from this well?' + {confirmAction === 'disassociate-asset' + ? 'Disassociate this attachment?' : 'Delete this asset?'} - {confirmAction === 'remove-association' - ? 'The asset will remain uploaded, but it will no longer be associated with this well.' + {confirmAction === 'disassociate-asset' + ? 'The asset will remain uploaded, but it will no longer be associated with any well.' : 'This permanently deletes the uploaded asset record. Use this only when the file should not be kept.'} @@ -406,13 +482,87 @@ export const AssetPreviewWithOverlay = ({ > {assetMutation.isPending ? 'Working...' - : confirmAction === 'remove-association' - ? 'Remove from well' + : confirmAction === 'disassociate-asset' + ? 'Disassociate attachment' : 'Delete asset'} + + { + if (!open && assetMutation.isPending) { + return + } + + setIsReassociateDialogOpen(open) + + if (!open) { + setSelectedWell(null) + } + }} + > + + + Reassociate attachment + + Move this attachment to one other well. It will be removed from + any current well association. + + + + + options} + getOptionLabel={(well) => well?.name ?? ''} + isOptionEqualToValue={(option, value) => option.id === value.id} + onChange={(_, value) => setSelectedWell(value)} + renderOption={(props, well) => ( + + + {well.name} + + ID {well.id} + + + + )} + renderInput={(params) => ( + + )} + /> + + + + setIsReassociateDialogOpen(false)} + disabled={assetMutation.isPending} + > + Cancel + + void handleReassociateAsset()} + disabled={!selectedWell || assetMutation.isPending} + > + {assetMutation.isPending ? 'Working...' : 'Reassociate'} + + + + ) } From 021087fe59b6d2fc78ff2c319cbc93e2eebe1e53 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Thu, 25 Jun 2026 09:42:09 -0500 Subject: [PATCH 3/4] fix(AssetPreviewWithOverlay): Rm database ids from dropdown menu --- .../WellShow/AssetPreviewWithOverlay.tsx | 10 +++---- src/components/WellShow/Attachments.tsx | 30 ++++++++++++++----- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/components/WellShow/AssetPreviewWithOverlay.tsx b/src/components/WellShow/AssetPreviewWithOverlay.tsx index 7dbc9ad6..4b1ecb7d 100644 --- a/src/components/WellShow/AssetPreviewWithOverlay.tsx +++ b/src/components/WellShow/AssetPreviewWithOverlay.tsx @@ -358,6 +358,9 @@ export const AssetPreviewWithOverlay = ({ event.stopPropagation()} + onMouseDown={(event) => event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} sx={{ position: 'absolute', right: 8, @@ -525,12 +528,7 @@ export const AssetPreviewWithOverlay = ({ onChange={(_, value) => setSelectedWell(value)} renderOption={(props, well) => ( - - {well.name} - - ID {well.id} - - + {well.name} )} renderInput={(params) => ( diff --git a/src/components/WellShow/Attachments.tsx b/src/components/WellShow/Attachments.tsx index dcc7e618..df2973c2 100644 --- a/src/components/WellShow/Attachments.tsx +++ b/src/components/WellShow/Attachments.tsx @@ -1,7 +1,6 @@ import { useMemo, useState } from 'react' import { Box, - ButtonBase, IconButton, Stack, Typography, @@ -65,6 +64,11 @@ export const AttachmentsCard = ({ const { canManageAmp } = useAccessCapabilities() + const openSlideshow = (index: number) => { + setSlideshowIndex(index) + setPreviewViewMode('slideshow') + } + return ( {previewAssets.map((asset, idx) => ( - { - setSlideshowIndex(idx) - setPreviewViewMode('slideshow') + onClick={() => openSlideshow(idx)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + openSlideshow(idx) + } }} sx={{ display: 'block', @@ -157,6 +165,14 @@ export const AttachmentsCard = ({ overflow: 'hidden', boxShadow: 2, textAlign: 'left', + cursor: 'pointer', + outline: 'none', + '&:focus-visible': { + boxShadow: 4, + outline: '2px solid', + outlineColor: 'primary.main', + outlineOffset: 2, + }, }} > - + ))} ) : ( From d71627ec6a791a4dab928e1ea71f5af084f169ea Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Thu, 25 Jun 2026 09:47:43 -0500 Subject: [PATCH 4/4] fix(Attachment): Patch go to slideshow bug when clicking the options menu --- src/components/WellShow/AssetPreviewWithOverlay.tsx | 12 +++++++----- src/components/WellShow/Attachments.tsx | 13 ++++++++++++- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/components/WellShow/AssetPreviewWithOverlay.tsx b/src/components/WellShow/AssetPreviewWithOverlay.tsx index 4b1ecb7d..c73eeabc 100644 --- a/src/components/WellShow/AssetPreviewWithOverlay.tsx +++ b/src/components/WellShow/AssetPreviewWithOverlay.tsx @@ -258,7 +258,9 @@ export const AssetPreviewWithOverlay = ({ notify?.({ type: 'success', - message: isDisassociate ? 'Attachment disassociated' : 'Asset deleted', + message: isDisassociate + ? 'Attachment disassociated' + : 'Attachment deleted', }) } catch (error) { console.error(error) @@ -266,7 +268,7 @@ export const AssetPreviewWithOverlay = ({ type: 'error', message: isDisassociate ? 'Could not disassociate attachment' - : 'Could not delete asset', + : 'Could not delete attachment', description: getMutationErrorMessage(error), }) } finally { @@ -438,7 +440,7 @@ export const AssetPreviewWithOverlay = ({ }} > - Delete asset + Delete attachment @@ -459,7 +461,7 @@ export const AssetPreviewWithOverlay = ({ {confirmAction === 'disassociate-asset' ? 'Disassociate this attachment?' - : 'Delete this asset?'} + : 'Delete this attachment?'} {confirmAction === 'disassociate-asset' @@ -487,7 +489,7 @@ export const AssetPreviewWithOverlay = ({ ? 'Working...' : confirmAction === 'disassociate-asset' ? 'Disassociate attachment' - : 'Delete asset'} + : 'Delete attachment'} diff --git a/src/components/WellShow/Attachments.tsx b/src/components/WellShow/Attachments.tsx index df2973c2..2981391f 100644 --- a/src/components/WellShow/Attachments.tsx +++ b/src/components/WellShow/Attachments.tsx @@ -151,7 +151,18 @@ export const AttachmentsCard = ({ role="button" tabIndex={0} aria-label={`Open ${asset.name || `attachment ${idx + 1}`} in slideshow`} - onClick={() => openSlideshow(idx)} + onClick={(event) => { + const target = event.target + + if ( + target instanceof Node && + !event.currentTarget.contains(target) + ) { + return + } + + openSlideshow(idx) + }} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault()