feat: add context-aware document delete from vehicle screen (refs #31)
- Created DeleteDocumentConfirmDialog with context-aware messaging: - Primary vehicle with no shares: Full delete - Shared vehicle: Remove association only - Primary vehicle with shares: Full delete (affects all) - Integrated documents display in VehicleDetailPage records table - Added delete button per document with 44px touch target - Document deletion uses appropriate backend calls based on context - Mobile-friendly dialog with responsive design Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Context-aware document delete confirmation dialog
|
||||||
|
* Shows different messages based on whether document is being removed from vehicle or fully deleted
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
Box,
|
||||||
|
useMediaQuery,
|
||||||
|
useTheme,
|
||||||
|
} from '@mui/material';
|
||||||
|
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
||||||
|
import type { DocumentRecord } from '../types/documents.types';
|
||||||
|
|
||||||
|
export interface DeleteDocumentConfirmDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: (fullDelete: boolean) => void;
|
||||||
|
document: DocumentRecord | null;
|
||||||
|
vehicleId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeleteDocumentConfirmDialog: React.FC<DeleteDocumentConfirmDialogProps> = ({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
document,
|
||||||
|
vehicleId,
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isSmallScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
|
|
||||||
|
if (!document || !vehicleId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine delete context
|
||||||
|
const isPrimaryVehicle = document.vehicleId === vehicleId;
|
||||||
|
const isSharedVehicle = document.sharedVehicleIds.includes(vehicleId);
|
||||||
|
const sharedCount = document.sharedVehicleIds.length;
|
||||||
|
|
||||||
|
let title: string;
|
||||||
|
let message: string;
|
||||||
|
let fullDelete: boolean;
|
||||||
|
let actionText: string;
|
||||||
|
|
||||||
|
if (isPrimaryVehicle && sharedCount === 0) {
|
||||||
|
// Primary vehicle with no shares: Full delete
|
||||||
|
title = 'Delete Document?';
|
||||||
|
message = 'This will permanently delete this document. This action cannot be undone.';
|
||||||
|
fullDelete = true;
|
||||||
|
actionText = 'Delete';
|
||||||
|
} else if (isSharedVehicle) {
|
||||||
|
// Shared vehicle: Remove association only
|
||||||
|
title = 'Remove Document from Vehicle?';
|
||||||
|
message = `This will remove the document from this vehicle. The document will remain shared with ${sharedCount - 1 === 1 ? '1 other vehicle' : `${sharedCount - 1} other vehicles`}.`;
|
||||||
|
fullDelete = false;
|
||||||
|
actionText = 'Remove';
|
||||||
|
} else if (isPrimaryVehicle && sharedCount > 0) {
|
||||||
|
// Primary vehicle with shares: Full delete (affects all)
|
||||||
|
title = 'Delete Document?';
|
||||||
|
message = `This document is shared with ${sharedCount === 1 ? '1 other vehicle' : `${sharedCount} other vehicles`}. Deleting it will remove it from all vehicles. This action cannot be undone.`;
|
||||||
|
fullDelete = true;
|
||||||
|
actionText = 'Delete';
|
||||||
|
} else {
|
||||||
|
// Fallback case (should not happen)
|
||||||
|
title = 'Delete Document?';
|
||||||
|
message = 'This will delete this document. This action cannot be undone.';
|
||||||
|
fullDelete = true;
|
||||||
|
actionText = 'Delete';
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
onConfirm(fullDelete);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
fullScreen={isSmallScreen}
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
borderRadius: isSmallScreen ? 0 : 2,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{ pb: 1 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<WarningAmberIcon color="warning" />
|
||||||
|
<Typography variant="h6" component="span" sx={{ fontWeight: 600 }}>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography variant="body1" sx={{ color: 'text.secondary', mt: 1 }}>
|
||||||
|
{message}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ mt: 2, p: 2, bgcolor: 'grey.100', borderRadius: 1 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||||
|
{document.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
{document.documentType.charAt(0).toUpperCase() + document.documentType.slice(1)}
|
||||||
|
{document.expirationDate && ` • Expires: ${new Date(document.expirationDate).toLocaleDateString()}`}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
|
<Button
|
||||||
|
onClick={onClose}
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ minWidth: 100, minHeight: 44 }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
variant="contained"
|
||||||
|
color={fullDelete ? 'error' : 'primary'}
|
||||||
|
sx={{ minWidth: 100, minHeight: 44 }}
|
||||||
|
>
|
||||||
|
{actionText}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -4,12 +4,13 @@
|
|||||||
|
|
||||||
import React, { useMemo, useState, useEffect } from 'react';
|
import React, { useMemo, useState, useEffect } from 'react';
|
||||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { Box, Typography, Button as MuiButton, Divider, FormControl, InputLabel, Select, MenuItem, Table, TableHead, TableRow, TableCell, TableBody, Dialog, DialogTitle, DialogContent, useMediaQuery } from '@mui/material';
|
import { Box, Typography, Button as MuiButton, Divider, FormControl, InputLabel, Select, MenuItem, Table, TableHead, TableRow, TableCell, TableBody, Dialog, DialogTitle, DialogContent, useMediaQuery, IconButton } from '@mui/material';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
import EditIcon from '@mui/icons-material/Edit';
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
import LocalGasStationIcon from '@mui/icons-material/LocalGasStation';
|
import LocalGasStationIcon from '@mui/icons-material/LocalGasStation';
|
||||||
import BuildIcon from '@mui/icons-material/Build';
|
import BuildIcon from '@mui/icons-material/Build';
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
import { Vehicle } from '../types/vehicles.types';
|
import { Vehicle } from '../types/vehicles.types';
|
||||||
import { vehiclesApi } from '../api/vehicles.api';
|
import { vehiclesApi } from '../api/vehicles.api';
|
||||||
import { Card } from '../../../shared-minimal/components/Card';
|
import { Card } from '../../../shared-minimal/components/Card';
|
||||||
@@ -23,6 +24,9 @@ import { FuelLogForm } from '../../fuel-logs/components/FuelLogForm';
|
|||||||
// Unit conversions now handled by backend
|
// Unit conversions now handled by backend
|
||||||
import { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.api';
|
import { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.api';
|
||||||
import { OwnershipCostsList } from '../../ownership-costs';
|
import { OwnershipCostsList } from '../../ownership-costs';
|
||||||
|
import { useDocumentsByVehicle, useDeleteDocument, useRemoveVehicleFromDocument } from '../../documents/hooks/useDocuments';
|
||||||
|
import { DeleteDocumentConfirmDialog } from '../../documents/components/DeleteDocumentConfirmDialog';
|
||||||
|
import type { DocumentRecord } from '../../documents/types/documents.types';
|
||||||
|
|
||||||
const DetailField: React.FC<{
|
const DetailField: React.FC<{
|
||||||
label: string;
|
label: string;
|
||||||
@@ -53,9 +57,14 @@ export const VehicleDetailPage: React.FC = () => {
|
|||||||
const [recordFilter, setRecordFilter] = useState<'All' | 'Fuel Logs' | 'Maintenance' | 'Documents'>('All');
|
const [recordFilter, setRecordFilter] = useState<'All' | 'Fuel Logs' | 'Maintenance' | 'Documents'>('All');
|
||||||
|
|
||||||
const { fuelLogs, isLoading: isFuelLoading } = useFuelLogs(id);
|
const { fuelLogs, isLoading: isFuelLoading } = useFuelLogs(id);
|
||||||
|
const { data: documents, isLoading: isDocumentsLoading } = useDocumentsByVehicle(id);
|
||||||
|
const { mutateAsync: deleteDocument } = useDeleteDocument();
|
||||||
|
const { mutateAsync: removeVehicleFromDocument } = useRemoveVehicleFromDocument();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [editingLog, setEditingLog] = useState<FuelLogResponse | null>(null);
|
const [editingLog, setEditingLog] = useState<FuelLogResponse | null>(null);
|
||||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [documentToDelete, setDocumentToDelete] = useState<DocumentRecord | null>(null);
|
||||||
const isSmallScreen = useMediaQuery('(max-width:600px)');
|
const isSmallScreen = useMediaQuery('(max-width:600px)');
|
||||||
// Unit conversions now handled by backend
|
// Unit conversions now handled by backend
|
||||||
|
|
||||||
@@ -105,8 +114,24 @@ export const VehicleDetailPage: React.FC = () => {
|
|||||||
list.push({ id: log.id, type: 'Fuel Logs', date: log.dateTime, summary, amount });
|
list.push({ id: log.id, type: 'Fuel Logs', date: log.dateTime, summary, amount });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add documents to records
|
||||||
|
if (documents && Array.isArray(documents)) {
|
||||||
|
for (const doc of documents) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
parts.push(doc.title);
|
||||||
|
parts.push(doc.documentType.charAt(0).toUpperCase() + doc.documentType.slice(1));
|
||||||
|
if (doc.expirationDate) {
|
||||||
|
parts.push(`Expires: ${new Date(doc.expirationDate).toLocaleDateString()}`);
|
||||||
|
}
|
||||||
|
const summary = parts.join(' • ');
|
||||||
|
const date = doc.issuedDate || doc.createdAt;
|
||||||
|
list.push({ id: doc.id, type: 'Documents', date, summary, amount: undefined });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return list.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
return list.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||||
}, [fuelLogs]);
|
}, [fuelLogs, documents]);
|
||||||
|
|
||||||
const filteredRecords = useMemo(() => {
|
const filteredRecords = useMemo(() => {
|
||||||
if (recordFilter === 'All') return records;
|
if (recordFilter === 'All') return records;
|
||||||
@@ -208,6 +233,46 @@ export const VehicleDetailPage: React.FC = () => {
|
|||||||
const log = (fuelLogs as FuelLogResponse[] | undefined)?.find(l => l.id === recId) || null;
|
const log = (fuelLogs as FuelLogResponse[] | undefined)?.find(l => l.id === recId) || null;
|
||||||
setEditingLog(log);
|
setEditingLog(log);
|
||||||
}
|
}
|
||||||
|
// Documents are handled via delete button, not row click
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteDocumentClick = (docId: string, event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation(); // Prevent row click
|
||||||
|
const doc = documents?.find(d => d.id === docId) || null;
|
||||||
|
if (doc) {
|
||||||
|
setDocumentToDelete(doc);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteConfirm = async (fullDelete: boolean) => {
|
||||||
|
if (!documentToDelete || !id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fullDelete) {
|
||||||
|
// Full delete
|
||||||
|
await deleteDocument(documentToDelete.id);
|
||||||
|
console.log('Document deleted permanently');
|
||||||
|
} else {
|
||||||
|
// Remove vehicle association only
|
||||||
|
await removeVehicleFromDocument({ docId: documentToDelete.id, vehicleId: id });
|
||||||
|
console.log('Document removed from vehicle');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate queries to refresh data
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['documents-by-vehicle', id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['documents'] });
|
||||||
|
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setDocumentToDelete(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting/removing document:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteCancel = () => {
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setDocumentToDelete(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseEdit = () => setEditingLog(null);
|
const handleCloseEdit = () => setEditingLog(null);
|
||||||
@@ -398,29 +463,43 @@ export const VehicleDetailPage: React.FC = () => {
|
|||||||
<TableCell>Type</TableCell>
|
<TableCell>Type</TableCell>
|
||||||
<TableCell>Summary</TableCell>
|
<TableCell>Summary</TableCell>
|
||||||
<TableCell align="right">Amount</TableCell>
|
<TableCell align="right">Amount</TableCell>
|
||||||
|
<TableCell align="right" sx={{ width: 80 }}>Actions</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{isFuelLoading && (
|
{(isFuelLoading || isDocumentsLoading) && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={4}>
|
<TableCell colSpan={5}>
|
||||||
<Typography color="text.secondary">Loading records…</Typography>
|
<Typography color="text.secondary">Loading records…</Typography>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
{!isFuelLoading && filteredRecords.length === 0 && (
|
{!isFuelLoading && !isDocumentsLoading && filteredRecords.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={4}>
|
<TableCell colSpan={5}>
|
||||||
<Typography color="text.secondary">No records found for this filter.</Typography>
|
<Typography color="text.secondary">No records found for this filter.</Typography>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
{!isFuelLoading && filteredRecords.map((rec) => (
|
{!isFuelLoading && !isDocumentsLoading && filteredRecords.map((rec) => (
|
||||||
<TableRow key={rec.id} hover sx={{ cursor: 'pointer' }} onClick={() => handleRowClick(rec.id, rec.type)}>
|
<TableRow key={rec.id} hover sx={{ cursor: rec.type === 'Documents' ? 'default' : 'pointer' }} onClick={() => handleRowClick(rec.id, rec.type)}>
|
||||||
<TableCell>{new Date(rec.date).toLocaleDateString()}</TableCell>
|
<TableCell>{new Date(rec.date).toLocaleDateString()}</TableCell>
|
||||||
<TableCell>{rec.type}</TableCell>
|
<TableCell>{rec.type}</TableCell>
|
||||||
<TableCell>{rec.summary}</TableCell>
|
<TableCell>{rec.summary}</TableCell>
|
||||||
<TableCell align="right">{rec.amount || '—'}</TableCell>
|
<TableCell align="right">{rec.amount || '—'}</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
{rec.type === 'Documents' && (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={(e) => handleDeleteDocumentClick(rec.id, e)}
|
||||||
|
sx={{ minWidth: 44, minHeight: 44 }}
|
||||||
|
aria-label="Delete document"
|
||||||
|
>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -461,6 +540,15 @@ export const VehicleDetailPage: React.FC = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete Document Confirmation Dialog */}
|
||||||
|
<DeleteDocumentConfirmDialog
|
||||||
|
open={deleteDialogOpen}
|
||||||
|
onClose={handleDeleteCancel}
|
||||||
|
onConfirm={handleDeleteConfirm}
|
||||||
|
document={documentToDelete}
|
||||||
|
vehicleId={id || null}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user