image scanning and asset edit
This commit is contained in:
@@ -62,6 +62,16 @@ export const activityFeedsService = {
|
|||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Corrects a single record's asset classification. The backend resolves
|
||||||
|
// the canonical asset_name from the central catalog and returns it.
|
||||||
|
updateAnomalyAsset: async (id: number, assetId: number, isRectificationEnabled: boolean) => {
|
||||||
|
const response = await axiosClient.put('/api/audit/anomaly/asset',
|
||||||
|
{ id, asset_id: assetId },
|
||||||
|
{ params: { isRectificationEnabled } }
|
||||||
|
);
|
||||||
|
return response as unknown as { id: number; asset_id: number; asset_name: string };
|
||||||
|
},
|
||||||
|
|
||||||
// Called after normal (non-rectification) true anomaly audit
|
// Called after normal (non-rectification) true anomaly audit
|
||||||
updateAnomalyInDashboard: async (payload: { site_id: string, anomaly_id: number[] }) => {
|
updateAnomalyInDashboard: async (payload: { site_id: string, anomaly_id: number[] }) => {
|
||||||
const response = await axiosClient.post('/api/audit/auditor/anomaly/submit', payload);
|
const response = await axiosClient.post('/api/audit/auditor/anomaly/submit', payload);
|
||||||
|
|||||||
@@ -177,11 +177,16 @@ export interface MissingMediaRecord {
|
|||||||
export interface MissingMediaSummary {
|
export interface MissingMediaSummary {
|
||||||
total: number;
|
total: number;
|
||||||
neverChecked: number;
|
neverChecked: number;
|
||||||
|
// Independent, inclusive counts: missingImage/missingVideo each include
|
||||||
|
// records missing both; missingBoth is the overlap.
|
||||||
missingImage: number;
|
missingImage: number;
|
||||||
missingVideo: number;
|
missingVideo: number;
|
||||||
|
missingBoth: number;
|
||||||
gaveUp: number;
|
gaveUp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MissingMediaFilter = 'image' | 'video' | 'both';
|
||||||
|
|
||||||
export interface MissingMediaResult {
|
export interface MissingMediaResult {
|
||||||
records: MissingMediaRecord[];
|
records: MissingMediaRecord[];
|
||||||
total: number;
|
total: number;
|
||||||
@@ -299,6 +304,7 @@ export const adminService = {
|
|||||||
type: 'audits' | 'rectification';
|
type: 'audits' | 'rectification';
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
|
missing?: MissingMediaFilter;
|
||||||
}): Promise<MissingMediaResult> => {
|
}): Promise<MissingMediaResult> => {
|
||||||
return (await axiosClient.get('/api/audit/admin/media-status', { params })) as unknown as MissingMediaResult;
|
return (await axiosClient.get('/api/audit/admin/media-status', { params })) as unknown as MissingMediaResult;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
ToggleButton, Button, CircularProgress, TablePagination, Typography, Chip, Tooltip,
|
ToggleButton, Button, CircularProgress, TablePagination, Typography, Chip, Tooltip,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { adminService } from '../../api/adminService';
|
import { adminService } from '../../api/adminService';
|
||||||
import type { MissingMediaRecord, MissingMediaSummary } from '../../api/adminService';
|
import type { MissingMediaRecord, MissingMediaSummary, MissingMediaFilter } from '../../api/adminService';
|
||||||
import { accountService } from '../../api/accountService';
|
import { accountService } from '../../api/accountService';
|
||||||
import { useToastStore } from '../../store/toastStore';
|
import { useToastStore } from '../../store/toastStore';
|
||||||
|
|
||||||
@@ -27,6 +27,7 @@ export const MissingMedia: React.FC = () => {
|
|||||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||||
const [dbName, setDbName] = useState('');
|
const [dbName, setDbName] = useState('');
|
||||||
const [type, setType] = useState<'audits' | 'rectification'>('audits');
|
const [type, setType] = useState<'audits' | 'rectification'>('audits');
|
||||||
|
const [missing, setMissing] = useState<'all' | MissingMediaFilter>('all');
|
||||||
const [records, setRecords] = useState<MissingMediaRecord[]>([]);
|
const [records, setRecords] = useState<MissingMediaRecord[]>([]);
|
||||||
const [summary, setSummary] = useState<MissingMediaSummary | null>(null);
|
const [summary, setSummary] = useState<MissingMediaSummary | null>(null);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
@@ -53,23 +54,27 @@ export const MissingMedia: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const result = await adminService.getMissingMedia({
|
const result = await adminService.getMissingMedia({
|
||||||
dbName, type, limit: PAGE_SIZE, offset: page * PAGE_SIZE,
|
dbName, type, limit: PAGE_SIZE, offset: page * PAGE_SIZE,
|
||||||
|
...(missing !== 'all' ? { missing } : {}),
|
||||||
});
|
});
|
||||||
setRecords(result.records);
|
setRecords(result.records);
|
||||||
setTotal(result.total);
|
setTotal(result.total);
|
||||||
setSummary(result.summary);
|
setSummary(result.summary);
|
||||||
|
// A recheck under an active filter can shrink the filtered total below
|
||||||
|
// the current page - snap back instead of stranding on an empty page.
|
||||||
|
if (page > 0 && result.total <= page * PAGE_SIZE) setPage(0);
|
||||||
} catch {
|
} catch {
|
||||||
useToastStore.getState().showToast('Failed to load missing-media records.', 'error');
|
useToastStore.getState().showToast('Failed to load missing-media records.', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [dbName, type, page]);
|
}, [dbName, type, page, missing]);
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(0);
|
setPage(0);
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
}, [dbName, type]);
|
}, [dbName, type, missing]);
|
||||||
|
|
||||||
const toggleRow = (id: number) => {
|
const toggleRow = (id: number) => {
|
||||||
setSelectedIds((prev) => {
|
setSelectedIds((prev) => {
|
||||||
@@ -119,6 +124,13 @@ export const MissingMedia: React.FC = () => {
|
|||||||
<ToggleButton value="rectification">Rectification</ToggleButton>
|
<ToggleButton value="rectification">Rectification</ToggleButton>
|
||||||
</ToggleButtonGroup>
|
</ToggleButtonGroup>
|
||||||
|
|
||||||
|
<ToggleButtonGroup value={missing} exclusive onChange={(_, v) => v && setMissing(v)} size="small">
|
||||||
|
<ToggleButton value="all">All</ToggleButton>
|
||||||
|
<ToggleButton value="image">Image missing</ToggleButton>
|
||||||
|
<ToggleButton value="video">Video missing</ToggleButton>
|
||||||
|
<ToggleButton value="both">Both missing</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
disabled={selectedIds.size === 0 || selectedIds.size > 50 || rechecking}
|
disabled={selectedIds.size === 0 || selectedIds.size > 50 || rechecking}
|
||||||
@@ -136,6 +148,7 @@ export const MissingMedia: React.FC = () => {
|
|||||||
{summaryChip('Not checked yet', summary?.neverChecked, 'default')}
|
{summaryChip('Not checked yet', summary?.neverChecked, 'default')}
|
||||||
{summaryChip('Image missing', summary?.missingImage, 'error')}
|
{summaryChip('Image missing', summary?.missingImage, 'error')}
|
||||||
{summaryChip('Video missing', summary?.missingVideo, 'warning')}
|
{summaryChip('Video missing', summary?.missingVideo, 'warning')}
|
||||||
|
{summaryChip('Both missing', summary?.missingBoth, 'error')}
|
||||||
{summaryChip('Gave up (24h of retries)', summary?.gaveUp, 'info')}
|
{summaryChip('Gave up (24h of retries)', summary?.gaveUp, 'info')}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -167,7 +180,9 @@ export const MissingMedia: React.FC = () => {
|
|||||||
{records.length === 0 && (
|
{records.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={9} align="center" sx={{ color: 'text.secondary' }}>
|
<TableCell colSpan={9} align="center" sx={{ color: 'text.secondary' }}>
|
||||||
No pending records with missing media — everything is auditable. 🎉
|
{missing !== 'all'
|
||||||
|
? 'No records match this filter.'
|
||||||
|
: 'No pending records with missing media — everything is auditable. 🎉'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import { Box, Typography, Button, Chip, Switch, Dialog, DialogTitle, DialogContent, DialogActions, IconButton, Select, MenuItem, TextField, CircularProgress } from '@mui/material';
|
import { Box, Typography, Button, Chip, Switch, Dialog, DialogTitle, DialogContent, DialogActions, IconButton, Select, MenuItem, TextField, CircularProgress, InputAdornment, ListItemButton } from '@mui/material';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
import ZoomInIcon from '@mui/icons-material/ZoomIn';
|
import ZoomInIcon from '@mui/icons-material/ZoomIn';
|
||||||
import ZoomOutIcon from '@mui/icons-material/ZoomOut';
|
import ZoomOutIcon from '@mui/icons-material/ZoomOut';
|
||||||
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
import { activityFeedsService } from '../../api/activityFeedsService';
|
import { activityFeedsService } from '../../api/activityFeedsService';
|
||||||
import { axiosClient } from '../../api/axiosClient';
|
import { axiosClient } from '../../api/axiosClient';
|
||||||
import { useToastStore } from '../../store/toastStore';
|
import { useToastStore } from '../../store/toastStore';
|
||||||
@@ -128,6 +129,14 @@ export const AuditSession: React.FC = () => {
|
|||||||
const [reportMessage, setReportMessage] = useState('');
|
const [reportMessage, setReportMessage] = useState('');
|
||||||
const [reportSending, setReportSending] = useState(false);
|
const [reportSending, setReportSending] = useState(false);
|
||||||
|
|
||||||
|
// Edit Asset: corrects a misclassified record's asset (metadata edit, not
|
||||||
|
// an audit verdict). Picker reads the startup-loaded asset catalog from
|
||||||
|
// the feed store; the backend resolves the canonical name from asset_id.
|
||||||
|
const [isEditAssetOpen, setIsEditAssetOpen] = useState(false);
|
||||||
|
const [editAssetSearch, setEditAssetSearch] = useState('');
|
||||||
|
const [editAssetId, setEditAssetId] = useState<number | null>(null);
|
||||||
|
const [editAssetSaving, setEditAssetSaving] = useState(false);
|
||||||
|
|
||||||
const DEVS = ['aromal', 'divya', 'Poonam', 'ravi', 'Kaushik', 'Saleth', 'Jagan', 'Yash', 'faraz_siddiqui', 'brinda.n', 'SasiVardhan', 'Shubham', 'sohail'];
|
const DEVS = ['aromal', 'divya', 'Poonam', 'ravi', 'Kaushik', 'Saleth', 'Jagan', 'Yash', 'faraz_siddiqui', 'brinda.n', 'SasiVardhan', 'Shubham', 'sohail'];
|
||||||
|
|
||||||
const handleSendReport = async () => {
|
const handleSendReport = async () => {
|
||||||
@@ -199,7 +208,8 @@ export const AuditSession: React.FC = () => {
|
|||||||
selectedSite, filterFromDate, filterTillDate,
|
selectedSite, filterFromDate, filterTillDate,
|
||||||
filterMinChainage, filterMaxChainage, selectedAssetIds,
|
filterMinChainage, filterMaxChainage, selectedAssetIds,
|
||||||
searchAnomalyId, auditStatus, isRectificationEnabled, filterPlaza,
|
searchAnomalyId, auditStatus, isRectificationEnabled, filterPlaza,
|
||||||
setDashboardCache, sortBy, sortDir, setSortBy, setSortDir
|
setDashboardCache, sortBy, sortDir, setSortBy, setSortDir,
|
||||||
|
assetTypes
|
||||||
} = useFeedStore();
|
} = useFeedStore();
|
||||||
|
|
||||||
// Call this before every navigate('/dashboard') so the dashboard re-fetches fresh stats
|
// Call this before every navigate('/dashboard') so the dashboard re-fetches fresh stats
|
||||||
@@ -221,6 +231,53 @@ export const AuditSession: React.FC = () => {
|
|||||||
const activeDate = activeTab.date || '';
|
const activeDate = activeTab.date || '';
|
||||||
const isRectActive = isRectificationEnabled || activeTab.isRectificationTab;
|
const isRectActive = isRectificationEnabled || activeTab.isRectificationTab;
|
||||||
|
|
||||||
|
// Same search-filter shape as AssetMultiSelect: filter each group's
|
||||||
|
// assets by name, drop groups that end up empty.
|
||||||
|
const filteredAssetTypes = useMemo(() => {
|
||||||
|
if (!editAssetSearch.trim()) return assetTypes;
|
||||||
|
const q = editAssetSearch.toLowerCase();
|
||||||
|
return assetTypes
|
||||||
|
.map((type: any) => ({
|
||||||
|
...type,
|
||||||
|
assets: type.assets.filter((a: any) => a.asset_name.toLowerCase().includes(q)),
|
||||||
|
}))
|
||||||
|
.filter((type: any) => type.assets.length > 0);
|
||||||
|
}, [assetTypes, editAssetSearch]);
|
||||||
|
|
||||||
|
const handleSaveAssetEdit = async () => {
|
||||||
|
if (!currentAnomaly || editAssetId === null || editAssetId === currentAnomaly.asset_id) return;
|
||||||
|
const toast = useToastStore.getState().showToast;
|
||||||
|
setEditAssetSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await activityFeedsService.updateAnomalyAsset(currentAnomaly.id, editAssetId, isRectActive);
|
||||||
|
const patchOne = (a: any) =>
|
||||||
|
a.id === res.id ? { ...a, Asset: res.asset_name, asset_id: res.asset_id } : a;
|
||||||
|
// Session state first (drives the metadata chip immediately), then the
|
||||||
|
// persisted feed-tab caches - both anomalies AND anomaliesCopy, and only
|
||||||
|
// tabs of the same mode: audits and rectification ids come from
|
||||||
|
// different tables and can collide (same rule as applyAuditedLocally).
|
||||||
|
setAnomalies((prev: any[]) => prev.map(patchOne));
|
||||||
|
const feed = useFeedStore.getState();
|
||||||
|
feed.setTabs(feed.tabs.map((t: any) =>
|
||||||
|
!!t.isRectificationTab === isRectActive
|
||||||
|
? { ...t, anomalies: (t.anomalies || []).map(patchOne), anomaliesCopy: (t.anomaliesCopy || []).map(patchOne) }
|
||||||
|
: t
|
||||||
|
));
|
||||||
|
setIsEditAssetOpen(false);
|
||||||
|
setEditAssetId(null);
|
||||||
|
setEditAssetSearch('');
|
||||||
|
toast(`#${res.id}: asset changed to "${res.asset_name.replace(/_/g, ' ')}"`, 'success');
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.response?.status === 409) {
|
||||||
|
toast(`#${currentAnomaly.id} is already audited — asset is locked.`, 'warning');
|
||||||
|
} else {
|
||||||
|
toast(`Failed to update asset — ${backendMsg(err)}`, 'error');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setEditAssetSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const loadSessionData = useCallback(async (customPageNo?: number) => {
|
const loadSessionData = useCallback(async (customPageNo?: number) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -1052,7 +1109,7 @@ export const AuditSession: React.FC = () => {
|
|||||||
}, [currentIndex, anomalies.length, currentAnomaly, isLocked, notes, saveAuditState]);
|
}, [currentIndex, anomalies.length, currentAnomaly, isLocked, notes, saveAuditState]);
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||||
if (isNotesFocused || isReportDialogOpen || isShortcutsDialogOpen || isBulkConfirmOpen || isAuditAllOpen || bulkResult !== null) return;
|
if (isNotesFocused || isReportDialogOpen || isShortcutsDialogOpen || isBulkConfirmOpen || isAuditAllOpen || isEditAssetOpen || bulkResult !== null) return;
|
||||||
|
|
||||||
const key = e.key.toLowerCase();
|
const key = e.key.toLowerCase();
|
||||||
|
|
||||||
@@ -1182,7 +1239,7 @@ export const AuditSession: React.FC = () => {
|
|||||||
handleSkip();
|
handleSkip();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}, [currentIndex, anomalies, isLocked, isAnomaly, selectedCategory, notes, plantSubCategory, isQuickAudit, isBulkMode, isNotesFocused, isReportDialogOpen, isShortcutsDialogOpen, isBulkConfirmOpen, isAuditAllOpen, bulkResult, showBatchEndPanel, stagedCount, handleNextBatch, handleLeaveSession, handleQuickSaveAndNext, handleSaveAndNext, handleSkip, handleNext]);
|
}, [currentIndex, anomalies, isLocked, isAnomaly, selectedCategory, notes, plantSubCategory, isQuickAudit, isBulkMode, isNotesFocused, isReportDialogOpen, isShortcutsDialogOpen, isBulkConfirmOpen, isAuditAllOpen, isEditAssetOpen, bulkResult, showBatchEndPanel, stagedCount, handleNextBatch, handleLeaveSession, handleQuickSaveAndNext, handleSaveAndNext, handleSkip, handleNext]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
@@ -1524,7 +1581,16 @@ export const AuditSession: React.FC = () => {
|
|||||||
<Chip size="small" label={`Side: ${currentAnomaly?.Side || 'N/A'}`} sx={{ bgcolor: '#1e293b', color: '#e2e8f0', borderRadius: 1.5 }} />
|
<Chip size="small" label={`Side: ${currentAnomaly?.Side || 'N/A'}`} sx={{ bgcolor: '#1e293b', color: '#e2e8f0', borderRadius: 1.5 }} />
|
||||||
<Chip size="small" label={`${latStr}${longStr ? `, ${longStr}` : ''}`} sx={{ bgcolor: '#1e293b', color: '#e2e8f0', borderRadius: 1.5 }} />
|
<Chip size="small" label={`${latStr}${longStr ? `, ${longStr}` : ''}`} sx={{ bgcolor: '#1e293b', color: '#e2e8f0', borderRadius: 1.5 }} />
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
disabled={isLocked}
|
||||||
|
onClick={() => { setEditAssetId(null); setEditAssetSearch(''); setIsEditAssetOpen(true); }}
|
||||||
|
sx={{ borderColor: '#3b82f6', color: '#60a5fa', textTransform: 'none', fontSize: '0.75rem', borderRadius: 1.5, '&:hover': { bgcolor: 'rgba(59,130,246,0.1)', borderColor: '#3b82f6' }, '&.Mui-disabled': { borderColor: '#334155', color: '#475569' } }}
|
||||||
|
>
|
||||||
|
Edit Asset
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -1908,9 +1974,9 @@ export const AuditSession: React.FC = () => {
|
|||||||
{/* Footer Shortcuts */}
|
{/* Footer Shortcuts */}
|
||||||
<Box sx={{ borderTop: '1px solid #1e293b', p: 1, px: 2, display: 'flex', gap: 3, alignItems: 'center', overflowX: 'auto', '& > div': { display: 'flex', alignItems: 'center', gap: 1, whiteSpace: 'nowrap' } }}>
|
<Box sx={{ borderTop: '1px solid #1e293b', p: 1, px: 2, display: 'flex', gap: 3, alignItems: 'center', overflowX: 'auto', '& > div': { display: 'flex', alignItems: 'center', gap: 1, whiteSpace: 'nowrap' } }}>
|
||||||
<Typography sx={{ fontSize: '0.75rem', color: '#64748b' }}>
|
<Typography sx={{ fontSize: '0.75rem', color: '#64748b' }}>
|
||||||
{(isNotesFocused || isReportDialogOpen || isShortcutsDialogOpen) ? "Shortcuts Paused" : "Keys:"}
|
{(isNotesFocused || isReportDialogOpen || isShortcutsDialogOpen || isEditAssetOpen) ? "Shortcuts Paused" : "Keys:"}
|
||||||
</Typography>
|
</Typography>
|
||||||
{!(isNotesFocused || isReportDialogOpen || isShortcutsDialogOpen) && (
|
{!(isNotesFocused || isReportDialogOpen || isShortcutsDialogOpen || isEditAssetOpen) && (
|
||||||
<>
|
<>
|
||||||
<Box>
|
<Box>
|
||||||
<Chip label="A" size="small" sx={{ height: 20, fontSize: '0.65rem', bgcolor: '#1e293b', color: '#cbd5e1' }} />
|
<Chip label="A" size="small" sx={{ height: 20, fontSize: '0.65rem', bgcolor: '#1e293b', color: '#cbd5e1' }} />
|
||||||
@@ -2015,6 +2081,77 @@ export const AuditSession: React.FC = () => {
|
|||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Edit Asset Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={isEditAssetOpen}
|
||||||
|
onClose={() => { setIsEditAssetOpen(false); setEditAssetId(null); setEditAssetSearch(''); }}
|
||||||
|
sx={{ zIndex: 10000 }}
|
||||||
|
slotProps={{ paper: { sx: { bgcolor: '#0f172a', color: '#f8fafc', border: '1px solid #1e293b', minWidth: 400 } } }}
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{ fontWeight: 'bold', pb: 1, borderBottom: '1px solid #1e293b' }}>
|
||||||
|
Edit Asset
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent sx={{ pt: 2, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ color: '#94a3b8', display: 'block', mb: 0.5 }}>Anomaly</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: '#f8fafc' }}>
|
||||||
|
ID #{currentAnomaly?.id} — current: {currentAnomaly?.Asset?.replace(/_/gi, ' ') || 'Unknown'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<TextField
|
||||||
|
placeholder="Search assets..."
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
autoFocus
|
||||||
|
value={editAssetSearch}
|
||||||
|
onChange={(e) => setEditAssetSearch(e.target.value)}
|
||||||
|
sx={{ bgcolor: 'rgba(255,255,255,0.05)', borderRadius: 1, input: { color: '#f8fafc' }, '& fieldset': { borderColor: '#334155' } }}
|
||||||
|
slotProps={{ input: { startAdornment: <InputAdornment position="start"><SearchIcon sx={{ color: '#64748b' }} fontSize="small" /></InputAdornment> } }}
|
||||||
|
/>
|
||||||
|
<Box sx={{ maxHeight: 320, overflowY: 'auto', pr: 1 }}>
|
||||||
|
{filteredAssetTypes.length === 0 ? (
|
||||||
|
<Typography variant="body2" sx={{ color: '#64748b', py: 1 }}>No assets match your search.</Typography>
|
||||||
|
) : (
|
||||||
|
filteredAssetTypes.map((type: any) => (
|
||||||
|
<Box key={type.type_id ?? 'uncategorized'} sx={{ mb: 1 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 'bold', color: '#60a5fa', textTransform: 'uppercase', fontSize: '0.7rem', letterSpacing: 1, py: 0.5 }}>
|
||||||
|
{type.type_name || 'Uncategorized'}
|
||||||
|
</Typography>
|
||||||
|
{type.assets.map((asset: any) => (
|
||||||
|
<ListItemButton
|
||||||
|
key={asset.asset_id}
|
||||||
|
dense
|
||||||
|
selected={editAssetId === asset.asset_id}
|
||||||
|
onClick={() => setEditAssetId(asset.asset_id)}
|
||||||
|
sx={{ color: '#f8fafc', borderRadius: 1, py: 0.5, '&:hover': { bgcolor: '#1e293b' }, '&.Mui-selected': { bgcolor: '#1e3a5f' }, '&.Mui-selected:hover': { bgcolor: '#1e3a5f' } }}
|
||||||
|
>
|
||||||
|
{asset.asset_name.replace(/_/g, ' ')}
|
||||||
|
{asset.asset_id === currentAnomaly?.asset_id && (
|
||||||
|
<Typography component="span" sx={{ ml: 1, fontSize: '0.7rem', color: '#64748b' }}>(current)</Typography>
|
||||||
|
)}
|
||||||
|
</ListItemButton>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ borderTop: '1px solid #1e293b', px: 3, py: 1.5, gap: 1 }}>
|
||||||
|
<Button onClick={() => { setIsEditAssetOpen(false); setEditAssetId(null); setEditAssetSearch(''); }} sx={{ color: '#94a3b8', textTransform: 'none' }}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveAssetEdit}
|
||||||
|
variant="contained"
|
||||||
|
disabled={editAssetId === null || editAssetId === currentAnomaly?.asset_id || editAssetSaving}
|
||||||
|
sx={{ bgcolor: '#3b82f6', color: '#fff', fontWeight: 'bold', textTransform: 'none', '&:hover': { bgcolor: '#2563eb' }, '&.Mui-disabled': { bgcolor: 'rgba(59,130,246,0.3)', color: 'rgba(255,255,255,0.4)' } }}
|
||||||
|
>
|
||||||
|
{editAssetSaving ? <CircularProgress size={18} sx={{ color: '#fff' }} /> : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* Keyboard Shortcuts Dialog */}
|
{/* Keyboard Shortcuts Dialog */}
|
||||||
<Dialog
|
<Dialog
|
||||||
open={isShortcutsDialogOpen}
|
open={isShortcutsDialogOpen}
|
||||||
|
|||||||
Reference in New Issue
Block a user