dev tickets and issues raised
This commit is contained in:
@@ -6,9 +6,9 @@ export const activityFeedsService = {
|
||||
return response;
|
||||
},
|
||||
|
||||
getHistoryDates: async (isRectificationEnabled: boolean, siteIds?: string) => {
|
||||
getHistoryDates: async (isRectificationEnabled: boolean, siteIds?: string, auditedByMe = false) => {
|
||||
const response = await axiosClient.get('/api/audit/anomaly/get_history_dates', {
|
||||
params: { isRectificationEnabled, siteIds: siteIds || '' }
|
||||
params: { isRectificationEnabled, siteIds: siteIds || '', auditedByMe }
|
||||
});
|
||||
return response;
|
||||
},
|
||||
|
||||
@@ -126,6 +126,43 @@ export interface DevTicket {
|
||||
retry_count: number;
|
||||
last_retry_at: string | null;
|
||||
created_at: string;
|
||||
// Which org table the record lives in; null on legacy tickets.
|
||||
record_type: 'audits' | 'rectification' | null;
|
||||
// Unified resolution stamp - set by the admin Resolve button or by the
|
||||
// git-state cron when the Gitea issue is closed.
|
||||
resolved_at: string | null;
|
||||
resolved_by: string | null;
|
||||
// Live record state, attached by the list endpoint.
|
||||
record_status: 'under_review' | 'resolved' | 'audited' | 'deleted' | 'unknown';
|
||||
}
|
||||
|
||||
// Full record row as returned by /admin/dev-tickets/record-details.
|
||||
export interface TicketRecordDetails {
|
||||
ticket: DevTicket;
|
||||
recordType: 'audits' | 'rectification';
|
||||
record: {
|
||||
id: number;
|
||||
Asset: string | null;
|
||||
asset_id: number | null;
|
||||
Plaza: string | null;
|
||||
Chainage: string | null;
|
||||
Side: string | null;
|
||||
Pos: string | null;
|
||||
video_date: string | null;
|
||||
Created_on: string | null;
|
||||
Frame_Test: string | null;
|
||||
Frame_Master: string | null;
|
||||
Extra_Image: string | null;
|
||||
video_url: string | null;
|
||||
Comments: string | null;
|
||||
IsAudited: number;
|
||||
Audit_status: number | null;
|
||||
Audit_value: string | null;
|
||||
under_review: number | null;
|
||||
deleted: number;
|
||||
road?: string | null;
|
||||
updated_user_name: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DevTicketsResult {
|
||||
@@ -193,27 +230,6 @@ export interface MissingMediaResult {
|
||||
summary: MissingMediaSummary;
|
||||
}
|
||||
|
||||
// A pending record quarantined by Report to Dev (under_review = 1), with
|
||||
// its latest dev ticket merged in (null if the ticket predates ticket
|
||||
// logging or was raised outside this org).
|
||||
export interface UnderReviewRecord {
|
||||
id: number;
|
||||
Asset: string | null;
|
||||
Plaza: string | null;
|
||||
Chainage: string | null;
|
||||
Side: string | null;
|
||||
video_date: string | null;
|
||||
updated_user_name: string | null;
|
||||
Frame_Test: string | null;
|
||||
ticket: {
|
||||
anomaly_id: number;
|
||||
dev_handle: string;
|
||||
reporter: string | null;
|
||||
created_at: string;
|
||||
git_status: 'pending' | 'success' | 'failed';
|
||||
git_issue_url: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export const adminService = {
|
||||
getUsers: async (): Promise<AdminUser[]> => {
|
||||
@@ -331,28 +347,19 @@ export const adminService = {
|
||||
return (await axiosClient.get('/api/audit/admin/media-status', { params })) as unknown as MissingMediaResult;
|
||||
},
|
||||
|
||||
// Pending records quarantined by Report to Dev (under_review = 1) -
|
||||
// hidden from auditors until released here.
|
||||
getUnderReviewRecords: async (params: {
|
||||
dbName: string;
|
||||
type: 'audits' | 'rectification';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<{ records: UnderReviewRecord[]; total: number }> => {
|
||||
return (await axiosClient.get('/api/audit/admin/under-review', { params })) as unknown as {
|
||||
records: UnderReviewRecord[]; total: number;
|
||||
// Unified resolve: returns the quarantined record to the auditors' feed
|
||||
// and (best-effort) closes the linked Gitea issue.
|
||||
resolveTicket: async (ticketId: number): Promise<{ recordReleased: boolean; issueClosed: boolean }> => {
|
||||
return (await axiosClient.post('/api/audit/admin/dev-tickets/resolve', { ticketId })) as unknown as {
|
||||
recordReleased: boolean; issueClosed: boolean;
|
||||
};
|
||||
},
|
||||
|
||||
// Put up to 50 quarantined records back into the auditors' pending feed.
|
||||
releaseUnderReview: async (payload: {
|
||||
dbName: string;
|
||||
type: 'audits' | 'rectification';
|
||||
ids: number[];
|
||||
}): Promise<{ released: number }> => {
|
||||
return (await axiosClient.post('/api/audit/admin/under-review/release', payload)) as unknown as {
|
||||
released: number;
|
||||
};
|
||||
// Complete record + ticket details for the admin popup.
|
||||
getTicketRecordDetails: async (ticketId: number): Promise<TicketRecordDetails> => {
|
||||
return (await axiosClient.get('/api/audit/admin/dev-tickets/record-details', {
|
||||
params: { ticketId },
|
||||
})) as unknown as TicketRecordDetails;
|
||||
},
|
||||
|
||||
// Immediate CDN re-verify for up to 50 records (also resets the cron's
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { useFeedStore } from '../../store/feedStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useToastStore } from '../../store/toastStore';
|
||||
import { activityFeedsService } from '../../api/activityFeedsService';
|
||||
|
||||
interface BulkDeleteDialogProps {
|
||||
@@ -17,8 +18,9 @@ interface BulkDeleteDialogProps {
|
||||
const REASONS = ['Low Light', 'Bad Image', 'Wrong Algorithm', 'Out of Range'];
|
||||
|
||||
export const BulkDeleteDialog: React.FC<BulkDeleteDialogProps> = ({ open, onClose }) => {
|
||||
const { assetTypes, selectedSite, sites } = useFeedStore();
|
||||
const { assetTypes, selectedSite, sites, isRectificationEnabled, tabs, selectedTabIndex, updateTab, dashboardCache, setDashboardCache } = useFeedStore();
|
||||
const { user } = useAuthStore();
|
||||
const showToast = useToastStore((s) => s.showToast);
|
||||
|
||||
const [fromDate, setFromDate] = useState('');
|
||||
const [tillDate, setTillDate] = useState('');
|
||||
@@ -83,11 +85,11 @@ export const BulkDeleteDialog: React.FC<BulkDeleteDialogProps> = ({ open, onClos
|
||||
|
||||
const handleApply = async () => {
|
||||
if (!fromDate || !tillDate) {
|
||||
alert("Please select both From and Till dates.");
|
||||
showToast("Please select both From and Till dates.", "warning");
|
||||
return;
|
||||
}
|
||||
if (selectedAssets.size === 0) {
|
||||
alert("Please select at least one asset.");
|
||||
showToast("Please select at least one asset.", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -108,20 +110,33 @@ export const BulkDeleteDialog: React.FC<BulkDeleteDialogProps> = ({ open, onClos
|
||||
site_id: siteIds,
|
||||
fromDate: fromDate,
|
||||
toDate: tillDate,
|
||||
user_id: Number(user?.user_id),
|
||||
audit_value: reason,
|
||||
isBulkDelete: true,
|
||||
audit_type: auditType
|
||||
audit_type: auditType,
|
||||
isRectificationEnabled,
|
||||
};
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await activityFeedsService.bulkDelete(payload);
|
||||
alert("Bulk delete successful!");
|
||||
const res: any = await activityFeedsService.bulkDelete(payload);
|
||||
const affected = res?.data?.affectedRows ?? res?.data?.rowCount;
|
||||
// Drop the deleted records from view: clear the active tab's cache so
|
||||
// GlobalFeed refetches (its guard refetches when anomalies is empty),
|
||||
// and invalidate dashboard stats.
|
||||
if (selectedTabIndex >= 0 && tabs[selectedTabIndex]) {
|
||||
updateTab(selectedTabIndex, { anomalies: [], anomaliesCopy: [], filters: {} });
|
||||
}
|
||||
setDashboardCache({ ...dashboardCache, stats: null });
|
||||
showToast(
|
||||
typeof affected === 'number'
|
||||
? `Bulk delete successful — ${affected} record(s) removed.`
|
||||
: 'Bulk delete successful.',
|
||||
'success'
|
||||
);
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
console.error("Bulk delete failed", err);
|
||||
alert("Bulk delete failed. " + (err?.response?.data?.message || err.message || ""));
|
||||
showToast("Bulk delete failed. " + (err?.response?.data?.message || err.message || ""), "error");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
@@ -118,18 +118,21 @@ export const TopFilterBar: React.FC = () => {
|
||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||
const [loadingRectHistory, setLoadingRectHistory] = useState(false);
|
||||
const [isHistoryDialogOpen, setIsHistoryDialogOpen] = useState(false);
|
||||
const [selectedHistoryDate, setSelectedHistoryDate] = useState('2026-06-12');
|
||||
const [historyDropdownValue, setHistoryDropdownValue] = useState('');
|
||||
// One dialog serves both History surfaces; the mode decides which table
|
||||
// (audits vs rectification) the tab it opens will query.
|
||||
const [historyDialogMode, setHistoryDialogMode] = useState<'audits' | 'rect'>('rect');
|
||||
const [historyFrom, setHistoryFrom] = useState('');
|
||||
const [historyTill, setHistoryTill] = useState('');
|
||||
|
||||
// Fetch normal history dates (isRectificationEnabled=false) for the History dropdown
|
||||
// Rectification history (isRectificationEnabled=true) is fetched separately by the RECTIFICATION HISTORY button
|
||||
// Fetch normal history dates (isRectificationEnabled=false) for the audits-mode History dialog chips
|
||||
// Rectification dates (isRectificationEnabled=true) are fetched when the dialog opens in rect mode
|
||||
useEffect(() => {
|
||||
if (!selectedSite || selectedSite.site_id === 'All Sites') return;
|
||||
const fetchDates = async () => {
|
||||
setLoadingHistory(true);
|
||||
try {
|
||||
const siteIds = String(selectedSite.site_id);
|
||||
const dates: any = await activityFeedsService.getHistoryDates(false, siteIds);
|
||||
const dates: any = await activityFeedsService.getHistoryDates(false, siteIds, true);
|
||||
setHistoryDates(Array.isArray(dates) ? dates : (dates?.responseData || []));
|
||||
} catch (err) {
|
||||
console.error("Failed to load history dates", err);
|
||||
@@ -140,50 +143,41 @@ export const TopFilterBar: React.FC = () => {
|
||||
fetchDates();
|
||||
}, [selectedSite?.site_id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleHistoryClick = async () => {
|
||||
const openHistoryDialog = async (mode: 'audits' | 'rect') => {
|
||||
setHistoryDialogMode(mode);
|
||||
setHistoryFrom('');
|
||||
setHistoryTill('');
|
||||
setIsHistoryDialogOpen(true);
|
||||
if (!selectedSite || selectedSite.site_id === 'All Sites') return;
|
||||
setLoadingRectHistory(true);
|
||||
try {
|
||||
const siteIds = String(selectedSite.site_id);
|
||||
const dates: any = await activityFeedsService.getHistoryDates(true, siteIds);
|
||||
setRectHistoryDates(Array.isArray(dates) ? dates : (dates?.responseData || []));
|
||||
} catch (err) {
|
||||
console.error("Failed to load rectification history dates", err);
|
||||
} finally {
|
||||
setLoadingRectHistory(false);
|
||||
if (mode === 'rect') {
|
||||
if (!selectedSite || selectedSite.site_id === 'All Sites') return;
|
||||
setLoadingRectHistory(true);
|
||||
try {
|
||||
const siteIds = String(selectedSite.site_id);
|
||||
const dates: any = await activityFeedsService.getHistoryDates(true, siteIds, true);
|
||||
setRectHistoryDates(Array.isArray(dates) ? dates : (dates?.responseData || []));
|
||||
} catch (err) {
|
||||
console.error("Failed to load rectification history dates", err);
|
||||
} finally {
|
||||
setLoadingRectHistory(false);
|
||||
}
|
||||
}
|
||||
// 'audits' chips come from the historyDates effect (refetched per site).
|
||||
};
|
||||
|
||||
// Used by RECTIFICATION HISTORY dialog — isRectificationTab: true → fetches with isRectificationEnabled=true
|
||||
const handleSelectDate = (date: string) => {
|
||||
const existingIndex = tabs.findIndex(t => t.date === date && t.isRectificationTab);
|
||||
// Opens (or focuses) a History tab: records audited BY the logged-in user
|
||||
// (auditedByMe) on one date or a From-Till range. The range is encoded in
|
||||
// the tab's `date` as "from_till" - the backend parses it; single day =
|
||||
// plain date. Audits and rectification stay separate via isRectificationTab.
|
||||
const openHistoryTab = (isRect: boolean, from: string, till: string) => {
|
||||
const dateKey = from === till ? from : `${from}_${till}`;
|
||||
const existingIndex = tabs.findIndex(t => t.date === dateKey && !!t.isRectificationTab === isRect);
|
||||
if (existingIndex !== -1) {
|
||||
setSelectedTabIndex(existingIndex);
|
||||
} else {
|
||||
addTab({
|
||||
date: date,
|
||||
isRectificationTab: true,
|
||||
totalAnomalyCount: 0,
|
||||
anomalies: [],
|
||||
anomaliesCopy: [],
|
||||
selectedAnomalyIndex: 0,
|
||||
filters: {},
|
||||
page: 0,
|
||||
rowsPerPage: 20
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Used by History dropdown — isRectificationTab: false → fetches with isRectificationEnabled=false
|
||||
const handleHistoryDateSelect = (date: string) => {
|
||||
const existingIndex = tabs.findIndex(t => t.date === date && !t.isRectificationTab);
|
||||
if (existingIndex !== -1) {
|
||||
setSelectedTabIndex(existingIndex);
|
||||
} else {
|
||||
addTab({
|
||||
date: date,
|
||||
isRectificationTab: false,
|
||||
date: dateKey,
|
||||
isRectificationTab: isRect,
|
||||
auditedByMe: true,
|
||||
totalAnomalyCount: 0,
|
||||
anomalies: [],
|
||||
anomaliesCopy: [],
|
||||
@@ -196,8 +190,12 @@ export const TopFilterBar: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleViewHistory = () => {
|
||||
if (!selectedHistoryDate) return;
|
||||
handleSelectDate(selectedHistoryDate);
|
||||
if (!historyFrom || !historyTill) return;
|
||||
// Tolerate a reversed range instead of erroring.
|
||||
const [from, till] = historyFrom <= historyTill
|
||||
? [historyFrom, historyTill]
|
||||
: [historyTill, historyFrom];
|
||||
openHistoryTab(historyDialogMode === 'rect', from, till);
|
||||
setIsHistoryDialogOpen(false);
|
||||
};
|
||||
|
||||
@@ -238,14 +236,6 @@ export const TopFilterBar: React.FC = () => {
|
||||
>
|
||||
FILTERS
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<HistoryIcon />}
|
||||
onClick={handleHistoryClick}
|
||||
sx={{ bgcolor: 'rgba(255,255,255,0.1)', color: '#fff', '&:hover': { bgcolor: 'rgba(255,255,255,0.2)' }, borderRadius: 2, textTransform: 'none', fontWeight: 'bold', boxShadow: 'none' }}
|
||||
>
|
||||
RECTIFICATION HISTORY
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setIsBulkDeleteDialogOpen(true)}
|
||||
@@ -254,46 +244,20 @@ export const TopFilterBar: React.FC = () => {
|
||||
BULK DELETE
|
||||
</Button>
|
||||
|
||||
{/* History dropdown — only visible when a specific site is selected */}
|
||||
{localSiteId !== 'All Sites' && <FormControl size="small" sx={{ bgcolor: 'rgba(255,255,255,0.1)', borderRadius: 2, minWidth: 160 }}>
|
||||
<Select
|
||||
value={historyDropdownValue}
|
||||
displayEmpty
|
||||
onChange={(e) => {
|
||||
const date = e.target.value as string;
|
||||
if (date) {
|
||||
handleHistoryDateSelect(date);
|
||||
setHistoryDropdownValue('');
|
||||
}
|
||||
}}
|
||||
renderValue={(val) => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{loadingHistory && <CircularProgress size={12} sx={{ color: '#fff' }} />}
|
||||
<span>{val || 'History'}</span>
|
||||
</Box>
|
||||
)}
|
||||
sx={{ '& .MuiSelect-select': { py: 1, px: 2 }, border: 'none', '& fieldset': { border: 'none' }, fontWeight: 'bold', color: '#fff', '& .MuiSvgIcon-root': { color: '#fff' } }}
|
||||
MenuProps={{
|
||||
slotProps: {
|
||||
paper: {
|
||||
sx: { bgcolor: '#0f172a', color: '#f8fafc', border: '1px solid #1e293b', maxHeight: 300 }
|
||||
}
|
||||
}
|
||||
}}
|
||||
{/* History — one button for both modes: follows the active mode
|
||||
(audits vs rectification) so the dialog and the tab it opens query
|
||||
the right table. Only visible when a specific site is selected
|
||||
(the audited-dates lookup is per-site). */}
|
||||
{localSiteId !== 'All Sites' && (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<HistoryIcon />}
|
||||
onClick={() => openHistoryDialog(isRectActive ? 'rect' : 'audits')}
|
||||
sx={{ bgcolor: 'rgba(255,255,255,0.1)', color: '#fff', '&:hover': { bgcolor: 'rgba(255,255,255,0.2)' }, borderRadius: 2, textTransform: 'none', fontWeight: 'bold', boxShadow: 'none' }}
|
||||
>
|
||||
<MenuItem disabled value="">
|
||||
<em style={{ color: '#64748b' }}>{loadingHistory ? 'Loading...' : historyDates.length === 0 ? 'No dates available' : 'Select a date'}</em>
|
||||
</MenuItem>
|
||||
{historyDates.map((d) => {
|
||||
const dateStr = d.audited_on ? d.audited_on.split(' ')[0] : d.audited_on;
|
||||
return (
|
||||
<MenuItem key={dateStr} value={dateStr} sx={{ '&:hover': { bgcolor: '#1e293b' } }}>
|
||||
{dateStr}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>}
|
||||
HISTORY
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
open={isHistoryDialogOpen}
|
||||
@@ -311,58 +275,66 @@ export const TopFilterBar: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ fontWeight: 'bold', pb: 1 }}>Rectification History</DialogTitle>
|
||||
<DialogTitle sx={{ fontWeight: 'bold', pb: 1 }}>
|
||||
{historyDialogMode === 'rect' ? 'Rectification History' : 'Audit History'}
|
||||
</DialogTitle>
|
||||
<DialogContent sx={{ py: 1 }}>
|
||||
<Typography variant="body2" sx={{ color: '#94a3b8', mb: 2 }}>
|
||||
Enter a date (YYYY-MM-DD) or pick one from the list to view historical rectification records.
|
||||
Pick a date or a From–Till range to view the {historyDialogMode === 'rect' ? 'rectification' : 'audit'} records you audited. A quick-select chip fills both fields with one day.
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold', color: '#94a3b8', mb: 1 }}>History Date</Typography>
|
||||
<TextField
|
||||
type="date"
|
||||
value={selectedHistoryDate}
|
||||
onChange={(e) => setSelectedHistoryDate(e.target.value)}
|
||||
fullWidth
|
||||
sx={{
|
||||
mb: 3,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
color: '#f8fafc',
|
||||
'& fieldset': { borderColor: '#334155' },
|
||||
'&:hover fieldset': { borderColor: '#475569' },
|
||||
'&.Mui-focused fieldset': { borderColor: '#3b82f6' },
|
||||
},
|
||||
'& input[type="date"]::-webkit-calendar-picker-indicator': {
|
||||
filter: 'invert(1)',
|
||||
cursor: 'pointer',
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 3 }}>
|
||||
{([['From', historyFrom, setHistoryFrom], ['Till', historyTill, setHistoryTill]] as const).map(([label, value, setter]) => (
|
||||
<Box key={label} sx={{ flex: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold', color: '#94a3b8', mb: 1 }}>{label}</Typography>
|
||||
<TextField
|
||||
type="date"
|
||||
value={value}
|
||||
onChange={(e) => setter(e.target.value)}
|
||||
fullWidth
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
color: '#f8fafc',
|
||||
'& fieldset': { borderColor: '#334155' },
|
||||
'&:hover fieldset': { borderColor: '#475569' },
|
||||
'&.Mui-focused fieldset': { borderColor: '#3b82f6' },
|
||||
},
|
||||
'& input[type="date"]::-webkit-calendar-picker-indicator': {
|
||||
filter: 'invert(1)',
|
||||
cursor: 'pointer',
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8', display: 'block', mb: 1, fontWeight: 'bold' }}>
|
||||
Quick Select (Audited Dates)
|
||||
Quick Select (Dates You Audited)
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, maxHeight: 150, overflowY: 'auto', p: 0.5, border: '1px solid #1e293b', borderRadius: 1, bgcolor: '#0b1121' }}>
|
||||
{loadingRectHistory ? (
|
||||
{(historyDialogMode === 'rect' ? loadingRectHistory : loadingHistory) ? (
|
||||
<Box sx={{ width: '100%', display: 'flex', justifyContent: 'center', py: 2 }}>
|
||||
<CircularProgress size={20} color="inherit" />
|
||||
</Box>
|
||||
) : rectHistoryDates.length === 0 ? (
|
||||
) : (historyDialogMode === 'rect' ? rectHistoryDates : historyDates).length === 0 ? (
|
||||
<Typography variant="body2" sx={{ color: '#64748b', p: 1 }}>No dates found</Typography>
|
||||
) : (
|
||||
rectHistoryDates.map((d) => {
|
||||
(historyDialogMode === 'rect' ? rectHistoryDates : historyDates).map((d) => {
|
||||
const dateStr = d.audited_on ? d.audited_on.split(' ')[0] : '';
|
||||
const selected = historyFrom === dateStr && historyTill === dateStr;
|
||||
return (
|
||||
<Chip
|
||||
key={d.audited_on}
|
||||
label={dateStr || d.audited_on}
|
||||
onClick={() => setSelectedHistoryDate(dateStr || d.audited_on)}
|
||||
onClick={() => { setHistoryFrom(dateStr); setHistoryTill(dateStr); }}
|
||||
sx={{
|
||||
bgcolor: selectedHistoryDate === (dateStr || d.audited_on) ? '#3b82f6' : '#1e293b',
|
||||
bgcolor: selected ? '#3b82f6' : '#1e293b',
|
||||
color: '#f8fafc',
|
||||
'&:hover': { bgcolor: '#334155' },
|
||||
borderRadius: 1,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid transparent',
|
||||
borderColor: selectedHistoryDate === (dateStr || d.audited_on) ? '#60a5fa' : 'transparent',
|
||||
borderColor: selected ? '#60a5fa' : 'transparent',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -380,7 +352,7 @@ export const TopFilterBar: React.FC = () => {
|
||||
<Button
|
||||
onClick={handleViewHistory}
|
||||
variant="contained"
|
||||
disabled={!selectedHistoryDate}
|
||||
disabled={!historyFrom || !historyTill}
|
||||
sx={{
|
||||
bgcolor: '#3b82f6',
|
||||
color: '#fff',
|
||||
|
||||
14
src/constants/media.ts
Normal file
14
src/constants/media.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// CDN prefixes for record media. These values are duplicated inline in
|
||||
// AuditSession.tsx and PreviewPanel.tsx (pre-existing) - new code should
|
||||
// import from here instead of adding more copies.
|
||||
export const MASTER_IMAGE_PREFIX_AUDITS = 'https://auditor-master-images.seekright.com/SeekRight/';
|
||||
export const MASTER_IMAGE_PREFIX_RECT = 'https://sr-img.seekright.com/';
|
||||
export const TEST_IMAGE_PREFIX = 'https://dashboard-images.seekright.com/SeekRight/';
|
||||
|
||||
export const masterImageUrl = (frameMaster: string | null | undefined, isRectification: boolean) =>
|
||||
frameMaster
|
||||
? `${isRectification ? MASTER_IMAGE_PREFIX_RECT : MASTER_IMAGE_PREFIX_AUDITS}${frameMaster}`
|
||||
: null;
|
||||
|
||||
export const testImageUrl = (frameTest: string | null | undefined) =>
|
||||
frameTest ? `${TEST_IMAGE_PREFIX}${frameTest}` : null;
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Slider, Checkbox, Skeleton } from '@mui/material';
|
||||
import { Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Slider, Skeleton } from '@mui/material';
|
||||
import { useFeedStore } from '../../store/feedStore';
|
||||
|
||||
const SKELETON_COLS = 13;
|
||||
const SKELETON_COLS = 11;
|
||||
|
||||
export const AnomalyTable: React.FC = () => {
|
||||
const { tabs, selectedTabIndex, updateTab, filterPlaza, isRectificationEnabled, isFeedLoading } = useFeedStore();
|
||||
@@ -61,8 +61,6 @@ export const AnomalyTable: React.FC = () => {
|
||||
<TableCell sx={{ width: '10%', fontWeight: 'bold', bgcolor: 'background.paper', color: 'text.primary', borderBottom: '1px solid rgba(255,255,255,0.1)', verticalAlign: 'middle', whiteSpace: 'nowrap' }}>ANOMALY<br/>True/False</TableCell>
|
||||
<TableCell sx={{ width: '6%', fontWeight: 'bold', bgcolor: 'background.paper', color: 'text.primary', borderBottom: '1px solid rgba(255,255,255,0.1)', verticalAlign: 'middle', whiteSpace: 'nowrap' }}>FEATURES</TableCell>
|
||||
<TableCell sx={{ width: '6%', fontWeight: 'bold', bgcolor: 'background.paper', color: 'text.primary', borderBottom: '1px solid rgba(255,255,255,0.1)', verticalAlign: 'middle', whiteSpace: 'nowrap' }}>{(activeTab?.date === '' && isRectificationEnabled) ? 'ISSUE' : 'COMMENTS'}</TableCell>
|
||||
<TableCell sx={{ width: '5%', fontWeight: 'bold', bgcolor: 'background.paper', color: 'text.primary', borderBottom: '1px solid rgba(255,255,255,0.1)', verticalAlign: 'middle', textAlign: 'center', whiteSpace: 'nowrap' }}>CHECKED</TableCell>
|
||||
<TableCell sx={{ width: '8%', fontWeight: 'bold', bgcolor: 'background.paper', color: 'text.primary', borderBottom: '1px solid rgba(255,255,255,0.1)', verticalAlign: 'middle', textAlign: 'center', whiteSpace: 'nowrap' }}>BULK AUDIT</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
@@ -133,22 +131,6 @@ export const AnomalyTable: React.FC = () => {
|
||||
</TableCell>
|
||||
<TableCell sx={{ width: '6%', borderBottom: '1px solid rgba(255,255,255,0.05)', verticalAlign: 'middle', whiteSpace: 'nowrap' }}>{anomaly.Features || 'N/A'}</TableCell>
|
||||
<TableCell sx={{ width: '6%', borderBottom: '1px solid rgba(255,255,255,0.05)', verticalAlign: 'middle', whiteSpace: 'nowrap' }}>{anomaly.Comments || '-'}</TableCell>
|
||||
<TableCell sx={{ width: '5%', borderBottom: '1px solid rgba(255,255,255,0.05)', verticalAlign: 'middle', textAlign: 'center' }}>
|
||||
<Checkbox size="small" checked={!!anomaly.IsAudited} />
|
||||
</TableCell>
|
||||
<TableCell sx={{ width: '8%', borderBottom: '1px solid rgba(255,255,255,0.05)', verticalAlign: 'middle', textAlign: 'center' }}>
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={!!anomaly.isSelectedForBulkAudit}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation(); // prevent row click selection
|
||||
anomaly.isSelectedForBulkAudit = e.target.checked;
|
||||
// Trigger a re-render of the current active tab
|
||||
const updatedAnomalies = [...rawAnomalies];
|
||||
updateTab(selectedTabIndex, { anomalies: updatedAnomalies });
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -182,10 +182,11 @@ export const FeedFilters: React.FC = () => {
|
||||
// Build the correct filter key that will be calculated in GlobalFeed
|
||||
// Must mirror GlobalFeed's filterKey format exactly or the cache
|
||||
// check there misses and double-fetches.
|
||||
const filterKey = `${selectedSite?.site_id ?? ''}|${auditStatus}|${searchAnomalyId}|${filterPlaza}|${isRectActive}|${activeTab.date}|${draftFromDate}|${draftTillDate}|${draftMinChainage}|${draftMaxChainage}|${assetIdsKey}|${draftSortBy}|${draftSortDir}`;
|
||||
const filterKey = `${selectedSite?.site_id ?? ''}|${auditStatus}|${searchAnomalyId}|${filterPlaza}|${isRectActive}|${activeTab.date}|${activeTab.auditedByMe ?? false}|${draftFromDate}|${draftTillDate}|${draftMinChainage}|${draftMaxChainage}|${assetIdsKey}|${draftSortBy}|${draftSortDir}`;
|
||||
|
||||
const historyRes: any = await activityFeedsService.getHistory({
|
||||
date: activeTab.date,
|
||||
auditedByMe: activeTab.auditedByMe || false,
|
||||
fromDate: draftFromDate,
|
||||
tillDate: draftTillDate,
|
||||
pageNo: 0,
|
||||
|
||||
@@ -39,7 +39,7 @@ export const FeedTabs: React.FC = () => {
|
||||
}}
|
||||
label={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
{tab.date === '' ? 'Unaudited' : tab.date}
|
||||
{tab.date === '' ? 'Unaudited' : tab.date.replace('_', ' – ')}
|
||||
{index !== 0 && ( // Don't allow closing the first tab usually
|
||||
<IconButton size="small" onClick={(e) => { e.stopPropagation(); closeTab(index); }} sx={{ ml: 1, color: 'inherit', p: 0.5 }}>
|
||||
<CloseIcon fontSize="small" />
|
||||
|
||||
@@ -61,7 +61,7 @@ export const GlobalFeed: React.FC = () => {
|
||||
const assetIdsKey = selectedAssetIds.join(',');
|
||||
|
||||
// Build a key for the current filter state on the active tab
|
||||
const filterKey = `${selectedSite?.site_id ?? ''}|${auditStatus}|${searchAnomalyId}|${filterPlaza}|${isRectActive}|${activeTab.date}|${filterFromDate}|${filterTillDate}|${filterMinChainage}|${filterMaxChainage}|${assetIdsKey}|${sortBy}|${sortDir}`;
|
||||
const filterKey = `${selectedSite?.site_id ?? ''}|${auditStatus}|${searchAnomalyId}|${filterPlaza}|${isRectActive}|${activeTab.date}|${activeTab.auditedByMe ?? false}|${filterFromDate}|${filterTillDate}|${filterMinChainage}|${filterMaxChainage}|${assetIdsKey}|${sortBy}|${sortDir}`;
|
||||
|
||||
// CACHE HIT: This exact tab and filter combo was already fetched
|
||||
if (filterKey === activeTab.filters?.lastFetchedFilterKey && activeTab.anomalies && activeTab.anomalies.length > 0) {
|
||||
@@ -76,6 +76,7 @@ export const GlobalFeed: React.FC = () => {
|
||||
try {
|
||||
const historyRes: any = await activityFeedsService.getHistory({
|
||||
date: activeTab.date || '',
|
||||
auditedByMe: activeTab.auditedByMe || false,
|
||||
pageNo: 0,
|
||||
pageSize: 20,
|
||||
siteIds: siteIdParam,
|
||||
@@ -132,6 +133,7 @@ export const GlobalFeed: React.FC = () => {
|
||||
try {
|
||||
const historyRes: any = await activityFeedsService.getHistory({
|
||||
date: activeTab?.date || '',
|
||||
auditedByMe: activeTab?.auditedByMe || false,
|
||||
pageNo: newPage * rowsPerPage,
|
||||
pageSize: rowsPerPage,
|
||||
siteIds: selectedSite?.site_id === 'All Sites' ? '' : (selectedSite?.site_id || ''),
|
||||
@@ -174,6 +176,7 @@ export const GlobalFeed: React.FC = () => {
|
||||
try {
|
||||
const historyRes: any = await activityFeedsService.getHistory({
|
||||
date: activeTab?.date || '',
|
||||
auditedByMe: activeTab?.auditedByMe || false,
|
||||
pageNo: newPage * newRowsPerPage,
|
||||
pageSize: newRowsPerPage,
|
||||
siteIds: selectedSite?.site_id === 'All Sites' ? '' : (selectedSite?.site_id || ''),
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Allocation } from './Allocation';
|
||||
import { AuditHistory } from './AuditHistory';
|
||||
import { DevTickets } from './DevTickets';
|
||||
import { MissingMedia } from './MissingMedia';
|
||||
import { UnderReview } from './UnderReview';
|
||||
|
||||
export const AdminConsole: React.FC = () => {
|
||||
const [tab, setTab] = useState(0);
|
||||
@@ -27,7 +26,6 @@ export const AdminConsole: React.FC = () => {
|
||||
<Tab label="Audit & Rectification History" />
|
||||
<Tab label="Dev Tickets" />
|
||||
<Tab label="Missing Media" />
|
||||
<Tab label="Under Review" />
|
||||
</Tabs>
|
||||
|
||||
{tab === 0 && <ClientsOverview />}
|
||||
@@ -37,7 +35,6 @@ export const AdminConsole: React.FC = () => {
|
||||
{tab === 4 && <AuditHistory />}
|
||||
{tab === 5 && <DevTickets />}
|
||||
{tab === 6 && <MissingMedia />}
|
||||
{tab === 7 && <UnderReview />}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,11 +2,12 @@ import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
Paper, TextField, FormControl, InputLabel, Select, MenuItem, CircularProgress,
|
||||
Chip, TablePagination, Link, Tooltip,
|
||||
Chip, TablePagination, Link, Tooltip, Button,
|
||||
} from '@mui/material';
|
||||
import { adminService } from '../../api/adminService';
|
||||
import type { DevTicket } from '../../api/adminService';
|
||||
import { useToastStore } from '../../store/toastStore';
|
||||
import { RecordDetailsDialog } from './RecordDetailsDialog';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
@@ -16,6 +17,30 @@ const gitStatusColor = (status: DevTicket['git_status']): 'success' | 'warning'
|
||||
return 'warning';
|
||||
};
|
||||
|
||||
// One unified lifecycle: a ticket is Resolved (from the UI or by closing
|
||||
// the Gitea issue), or its record is still Under Review / already handled.
|
||||
const statusChip = (t: DevTicket) => {
|
||||
if (t.resolved_at) {
|
||||
return (
|
||||
<Tooltip title={`Resolved by ${t.resolved_by || '—'} · ${new Date(t.resolved_at).toLocaleString()}`}>
|
||||
<Chip size="small" label="Resolved" color="success" />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
switch (t.record_status) {
|
||||
case 'under_review': return <Chip size="small" label="Under Review" color="warning" />;
|
||||
case 'resolved': return <Chip size="small" label="Back in feed" color="success" variant="outlined" />;
|
||||
case 'audited': return <Chip size="small" label="Audited" color="info" />;
|
||||
case 'deleted': return <Chip size="small" label="Deleted" color="default" />;
|
||||
default:
|
||||
return (
|
||||
<Tooltip title="Record not found or its org DB is unreachable">
|
||||
<Chip size="small" label="Unknown" variant="outlined" />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const truncate = (text: string, max = 80) => (text.length > max ? `${text.slice(0, max)}…` : text);
|
||||
|
||||
export const DevTickets: React.FC = () => {
|
||||
@@ -45,6 +70,8 @@ export const DevTickets: React.FC = () => {
|
||||
// afterward, so this is fetched separately from the main list.
|
||||
const [issueStates, setIssueStates] = useState<Record<number, 'open' | 'closed' | null>>({});
|
||||
const [issueStatesLoading, setIssueStatesLoading] = useState(false);
|
||||
const [resolvingId, setResolvingId] = useState<number | null>(null);
|
||||
const [detailsTicket, setDetailsTicket] = useState<DevTicket | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -96,6 +123,24 @@ export const DevTickets: React.FC = () => {
|
||||
.finally(() => setIssueStatesLoading(false));
|
||||
}, [records]);
|
||||
|
||||
const handleResolve = async (t: DevTicket) => {
|
||||
setResolvingId(t.id);
|
||||
try {
|
||||
const res = await adminService.resolveTicket(t.id);
|
||||
useToastStore.getState().showToast(
|
||||
res.recordReleased
|
||||
? `Resolved — record #${t.anomaly_id} returned to the feed${res.issueClosed ? ', git issue closed' : ''}.`
|
||||
: `Resolved — record was already handled${res.issueClosed ? '; git issue closed' : ''}.`,
|
||||
'success'
|
||||
);
|
||||
load();
|
||||
} catch (err: any) {
|
||||
useToastStore.getState().showToast(err?.response?.data?.message || 'Failed to resolve ticket.', 'error');
|
||||
} finally {
|
||||
setResolvingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 3, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
@@ -141,6 +186,7 @@ export const DevTickets: React.FC = () => {
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Anomaly ID</TableCell>
|
||||
<TableCell>Org</TableCell>
|
||||
<TableCell>Asset / Plaza</TableCell>
|
||||
<TableCell>Dev</TableCell>
|
||||
<TableCell>Reporter</TableCell>
|
||||
@@ -148,13 +194,15 @@ export const DevTickets: React.FC = () => {
|
||||
<TableCell>RocketChat</TableCell>
|
||||
<TableCell>Git</TableCell>
|
||||
<TableCell>Issue status</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Created</TableCell>
|
||||
<TableCell align="right">Action</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{records.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} align="center" sx={{ color: 'text.secondary' }}>
|
||||
<TableCell colSpan={12} align="center" sx={{ color: 'text.secondary' }}>
|
||||
No dev tickets found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -162,8 +210,14 @@ export const DevTickets: React.FC = () => {
|
||||
{records.map((t) => {
|
||||
const liveState = t.git_status === 'success' && t.git_issue_number != null ? issueStates[t.id] : undefined;
|
||||
return (
|
||||
<TableRow key={t.id}>
|
||||
<TableRow
|
||||
key={t.id}
|
||||
hover
|
||||
onClick={() => setDetailsTicket(t)}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
>
|
||||
<TableCell>{t.anomaly_id}</TableCell>
|
||||
<TableCell>{t.org_db_name || '—'}</TableCell>
|
||||
<TableCell>{[t.asset, t.plaza].filter(Boolean).join(' / ') || '—'}</TableCell>
|
||||
<TableCell>@{t.dev_handle}</TableCell>
|
||||
<TableCell>{t.reporter || '—'}</TableCell>
|
||||
@@ -184,7 +238,7 @@ export const DevTickets: React.FC = () => {
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
{t.git_issue_url ? (
|
||||
<Link href={t.git_issue_url} target="_blank" rel="noopener noreferrer">
|
||||
<Chip size="small" label={`#${t.git_issue_number}`} color={gitStatusColor(t.git_status)} clickable />
|
||||
@@ -213,7 +267,18 @@ export const DevTickets: React.FC = () => {
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{statusChip(t)}</TableCell>
|
||||
<TableCell>{new Date(t.created_at).toLocaleString()}</TableCell>
|
||||
<TableCell align="right" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
disabled={!!t.resolved_at || t.record_status !== 'under_review' || resolvingId === t.id}
|
||||
onClick={() => handleResolve(t)}
|
||||
>
|
||||
{resolvingId === t.id ? <CircularProgress size={16} /> : 'Resolve'}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
@@ -229,6 +294,12 @@ export const DevTickets: React.FC = () => {
|
||||
/>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
<RecordDetailsDialog
|
||||
ticket={detailsTicket}
|
||||
liveIssueState={detailsTicket ? issueStates[detailsTicket.id] : undefined}
|
||||
onClose={() => setDetailsTicket(null)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
212
src/pages/admin/RecordDetailsDialog.tsx
Normal file
212
src/pages/admin/RecordDetailsDialog.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, Button, Box,
|
||||
Typography, Chip, CircularProgress, Link, Divider, IconButton,
|
||||
} from '@mui/material';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import { adminService } from '../../api/adminService';
|
||||
import type { DevTicket, TicketRecordDetails } from '../../api/adminService';
|
||||
import { masterImageUrl, testImageUrl, TEST_IMAGE_PREFIX } from '../../constants/media';
|
||||
|
||||
interface RecordDetailsDialogProps {
|
||||
ticket: DevTicket | null; // null = closed
|
||||
liveIssueState?: 'open' | 'closed' | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const Field: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
|
||||
<Box sx={{ minWidth: 140 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>{label}</Typography>
|
||||
<Typography variant="body2">{children ?? '—'}</Typography>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const MediaImage: React.FC<{ label: string; src: string | null }> = ({ label, src }) => {
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => { setFailed(false); }, [src]);
|
||||
return (
|
||||
<Box sx={{ flex: 1, minWidth: 240 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mb: 0.5 }}>{label}</Typography>
|
||||
{src && !failed ? (
|
||||
<Box component="img" src={src} onError={() => setFailed(true)}
|
||||
sx={{ width: '100%', maxHeight: 260, objectFit: 'contain', borderRadius: 1, bgcolor: 'rgba(0,0,0,0.2)' }} />
|
||||
) : (
|
||||
<Box sx={{ height: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 1, border: '1px dashed', borderColor: 'divider' }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>{src ? 'Image unavailable' : 'No image'}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
interface MediaItem {
|
||||
kind: 'image' | 'video';
|
||||
src: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// Right-hand pane: the anomaly's test-side media (test image, extra images,
|
||||
// video) as a carousel. The <video> unmounts on navigation, so playback
|
||||
// stops automatically when moving to another item.
|
||||
const MediaCarousel: React.FC<{ items: MediaItem[] }> = ({ items }) => {
|
||||
const [index, setIndex] = useState(0);
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => { setIndex(0); }, [items.length]);
|
||||
useEffect(() => { setFailed(false); }, [index]);
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<Box sx={{ flex: 1, minWidth: 240 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mb: 0.5 }}>Anomaly media</Typography>
|
||||
<Box sx={{ height: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 1, border: '1px dashed', borderColor: 'divider' }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>No media</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const item = items[Math.min(index, items.length - 1)];
|
||||
return (
|
||||
<Box sx={{ flex: 1, minWidth: 240 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mb: 0.5 }}>{item.label}</Typography>
|
||||
{item.kind === 'video' ? (
|
||||
<Box component="video" key={item.src} src={item.src} controls
|
||||
sx={{ width: '100%', maxHeight: 260, borderRadius: 1, bgcolor: 'rgba(0,0,0,0.2)' }} />
|
||||
) : failed ? (
|
||||
<Box sx={{ height: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 1, border: '1px dashed', borderColor: 'divider' }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>Image unavailable</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Box component="img" key={item.src} src={item.src} onError={() => setFailed(true)}
|
||||
sx={{ width: '100%', maxHeight: 260, objectFit: 'contain', borderRadius: 1, bgcolor: 'rgba(0,0,0,0.2)' }} />
|
||||
)}
|
||||
{items.length > 1 && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, mt: 0.5 }}>
|
||||
<IconButton size="small" disabled={index === 0} onClick={() => setIndex((i) => i - 1)}>
|
||||
<ChevronLeftIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>{index + 1} / {items.length}</Typography>
|
||||
<IconButton size="small" disabled={index === items.length - 1} onClick={() => setIndex((i) => i + 1)}>
|
||||
<ChevronRightIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const RecordDetailsDialog: React.FC<RecordDetailsDialogProps> = ({ ticket, liveIssueState, onClose }) => {
|
||||
const [details, setDetails] = useState<TicketRecordDetails | null>(null);
|
||||
const [recordGone, setRecordGone] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ticket) return;
|
||||
setDetails(null);
|
||||
setRecordGone(false);
|
||||
setLoading(true);
|
||||
adminService.getTicketRecordDetails(ticket.id)
|
||||
.then(setDetails)
|
||||
.catch((err: any) => {
|
||||
if (err?.response?.status === 404) setRecordGone(true);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [ticket?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!ticket) return null;
|
||||
const rec = details?.record;
|
||||
const isRect = details?.recordType === 'rectification';
|
||||
|
||||
return (
|
||||
<Dialog open onClose={onClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle sx={{ pb: 1 }}>
|
||||
Record #{ticket.anomaly_id}
|
||||
{ticket.org_db_name && <Chip size="small" label={ticket.org_db_name} sx={{ ml: 1 }} variant="outlined" />}
|
||||
{details && <Chip size="small" label={details.recordType} sx={{ ml: 1 }} variant="outlined" />}
|
||||
{ticket.resolved_at
|
||||
? <Chip size="small" label="Resolved" color="success" sx={{ ml: 1 }} />
|
||||
: rec?.under_review === 1 && <Chip size="small" label="Under Review" color="warning" sx={{ ml: 1 }} />}
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{loading && <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>}
|
||||
{recordGone && (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
|
||||
The record no longer exists in the org database (hard-deleted or the table was recreated). Ticket details below.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{rec && (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2.5, mb: 2 }}>
|
||||
<Field label="Asset">{rec.Asset?.replace(/_/g, ' ')}</Field>
|
||||
<Field label="Plaza">{rec.Plaza}</Field>
|
||||
<Field label="Chainage">{rec.Chainage}</Field>
|
||||
<Field label="Side">{rec.Side}</Field>
|
||||
<Field label="Position">{rec.Pos}</Field>
|
||||
{isRect && <Field label="Road">{rec.road}</Field>}
|
||||
<Field label="Video date">{rec.video_date ? new Date(rec.video_date).toLocaleString() : null}</Field>
|
||||
<Field label="Created">{rec.Created_on ? new Date(rec.Created_on).toLocaleString() : null}</Field>
|
||||
<Field label="Audit state">
|
||||
{rec.deleted === 1 ? 'Deleted' : rec.IsAudited === 1 ? `Audited (${rec.Audit_value || '—'})` : 'Pending'}
|
||||
</Field>
|
||||
<Field label="Comments">{rec.Comments}</Field>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, mb: 1 }}>
|
||||
<MediaImage label="Master image" src={masterImageUrl(rec.Frame_Master, isRect)} />
|
||||
{/* Carousel holds just the anomaly image + playable video;
|
||||
extra images render as a static row below. */}
|
||||
<MediaCarousel
|
||||
items={[
|
||||
...(testImageUrl(rec.Frame_Test)
|
||||
? [{ kind: 'image' as const, src: testImageUrl(rec.Frame_Test)!, label: 'Anomaly image' }]
|
||||
: []),
|
||||
...(testImageUrl(rec.video_url)
|
||||
? [{ kind: 'video' as const, src: testImageUrl(rec.video_url)!, label: 'Video' }]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
{rec.Extra_Image && (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, mb: 1 }}>
|
||||
{rec.Extra_Image.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 4)
|
||||
.map((src, i) => (
|
||||
<MediaImage key={i} label={`Extra image ${i + 1}`} src={`${TEST_IMAGE_PREFIX}${src}`} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>Dev ticket</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2.5, mb: 1.5 }}>
|
||||
<Field label="Dev">@{ticket.dev_handle}</Field>
|
||||
<Field label="Reporter">{ticket.reporter}</Field>
|
||||
<Field label="Reported on">{new Date(ticket.created_at).toLocaleString()}</Field>
|
||||
<Field label="RocketChat">{ticket.rocketchat_sent ? 'Sent' : ticket.rocketchat_error || 'Not sent'}</Field>
|
||||
<Field label="Git issue">
|
||||
{ticket.git_issue_url ? (
|
||||
<Link href={ticket.git_issue_url} target="_blank" rel="noopener noreferrer">
|
||||
#{ticket.git_issue_number}{liveIssueState ? ` (${liveIssueState})` : ''}
|
||||
</Link>
|
||||
) : ticket.git_status}
|
||||
</Field>
|
||||
{ticket.resolved_at && (
|
||||
<Field label="Resolved">{`${ticket.resolved_by || '—'} · ${new Date(ticket.resolved_at).toLocaleString()}`}</Field>
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>Message</Typography>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', maxHeight: 180, overflowY: 'auto' }}>
|
||||
{ticket.message_text}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,209 +0,0 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
Paper, Checkbox, FormControl, InputLabel, Select, MenuItem, ToggleButtonGroup,
|
||||
ToggleButton, Button, CircularProgress, TablePagination, Typography, Chip, Link, Tooltip,
|
||||
} from '@mui/material';
|
||||
import { adminService } from '../../api/adminService';
|
||||
import type { UnderReviewRecord } from '../../api/adminService';
|
||||
import { accountService } from '../../api/accountService';
|
||||
import { useToastStore } from '../../store/toastStore';
|
||||
|
||||
interface Organization {
|
||||
org_id: string;
|
||||
org_name: string;
|
||||
db_name: string;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
const gitChipColor = (status: string): 'default' | 'success' | 'error' => {
|
||||
if (status === 'success') return 'success';
|
||||
if (status === 'failed') return 'error';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
export const UnderReview: React.FC = () => {
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
const [dbName, setDbName] = useState('');
|
||||
const [type, setType] = useState<'audits' | 'rectification'>('audits');
|
||||
const [records, setRecords] = useState<UnderReviewRecord[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [releasing, setReleasing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const orgs = ((await accountService.getOrganizations()) as any) || [];
|
||||
setOrganizations(orgs);
|
||||
if (orgs.length > 0) setDbName(orgs[0].db_name);
|
||||
} catch {
|
||||
useToastStore.getState().showToast('Failed to load organizations.', 'error');
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!dbName) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await adminService.getUnderReviewRecords({
|
||||
dbName, type, limit: PAGE_SIZE, offset: page * PAGE_SIZE,
|
||||
});
|
||||
setRecords(result.records);
|
||||
setTotal(result.total);
|
||||
// Releasing can shrink the total below the current page - snap back.
|
||||
if (page > 0 && result.total <= page * PAGE_SIZE) setPage(0);
|
||||
} catch {
|
||||
useToastStore.getState().showToast('Failed to load under-review records.', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [dbName, type, page]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
setSelectedIds(new Set());
|
||||
}, [dbName, type]);
|
||||
|
||||
const toggleRow = (id: number) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
setSelectedIds(selectedIds.size === records.length ? new Set() : new Set(records.map((r) => r.id)));
|
||||
};
|
||||
|
||||
const handleRelease = async (ids: number[]) => {
|
||||
setReleasing(true);
|
||||
try {
|
||||
const result = await adminService.releaseUnderReview({ dbName, type, ids });
|
||||
useToastStore.getState().showToast(
|
||||
`Released ${result.released} record(s) back to the auditors' feed.`,
|
||||
result.released > 0 ? 'success' : 'info'
|
||||
);
|
||||
setSelectedIds(new Set());
|
||||
load();
|
||||
} catch (err: any) {
|
||||
useToastStore.getState().showToast(err?.response?.data?.message || 'Release failed.', 'error');
|
||||
} finally {
|
||||
setReleasing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||
<InputLabel>Organization</InputLabel>
|
||||
<Select label="Organization" value={dbName} onChange={(e) => setDbName(e.target.value)}>
|
||||
{organizations.map((o) => <MenuItem key={o.org_id} value={o.db_name}>{o.org_name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<ToggleButtonGroup value={type} exclusive onChange={(_, v) => v && setType(v)} size="small">
|
||||
<ToggleButton value="audits">Audits</ToggleButton>
|
||||
<ToggleButton value="rectification">Rectification</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={selectedIds.size === 0 || selectedIds.size > 50 || releasing}
|
||||
onClick={() => handleRelease(Array.from(selectedIds))}
|
||||
>
|
||||
{releasing ? <CircularProgress size={20} sx={{ color: 'white' }} /> : `Release selected (${selectedIds.size})`}
|
||||
</Button>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
records reported to a dev are hidden from auditors until released here (max 50 at a time)
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>
|
||||
) : (
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox
|
||||
checked={records.length > 0 && selectedIds.size === records.length}
|
||||
indeterminate={selectedIds.size > 0 && selectedIds.size < records.length}
|
||||
onChange={toggleAll}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>Record ID</TableCell>
|
||||
<TableCell>Asset</TableCell>
|
||||
<TableCell>Plaza</TableCell>
|
||||
<TableCell>Chainage</TableCell>
|
||||
<TableCell>Side</TableCell>
|
||||
<TableCell>Video Date</TableCell>
|
||||
<TableCell>Reported By</TableCell>
|
||||
<TableCell>Dev</TableCell>
|
||||
<TableCell>Reported On</TableCell>
|
||||
<TableCell>Git Issue</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{records.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} align="center" sx={{ color: 'text.secondary' }}>
|
||||
No records under review — nothing is currently reported to the devs.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{records.map((r) => (
|
||||
<TableRow key={r.id} selected={selectedIds.has(r.id)}>
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox checked={selectedIds.has(r.id)} onChange={() => toggleRow(r.id)} />
|
||||
</TableCell>
|
||||
<TableCell>{r.id}</TableCell>
|
||||
<TableCell>{r.Asset?.replace(/_/g, ' ') || '—'}</TableCell>
|
||||
<TableCell>{r.Plaza || '—'}</TableCell>
|
||||
<TableCell>{r.Chainage || '—'}</TableCell>
|
||||
<TableCell>{r.Side || '—'}</TableCell>
|
||||
<TableCell>{r.video_date ? new Date(r.video_date).toLocaleDateString() : '—'}</TableCell>
|
||||
<TableCell>{r.ticket?.reporter || r.updated_user_name || '—'}</TableCell>
|
||||
<TableCell>{r.ticket ? `@${r.ticket.dev_handle}` : '—'}</TableCell>
|
||||
<TableCell>{r.ticket?.created_at ? new Date(r.ticket.created_at).toLocaleString() : '—'}</TableCell>
|
||||
<TableCell>
|
||||
{r.ticket ? (
|
||||
r.ticket.git_issue_url ? (
|
||||
<Link href={r.ticket.git_issue_url} target="_blank" rel="noopener" underline="hover">
|
||||
<Chip size="small" label={r.ticket.git_status} color={gitChipColor(r.ticket.git_status)} clickable />
|
||||
</Link>
|
||||
) : (
|
||||
<Chip size="small" label={r.ticket.git_status} color={gitChipColor(r.ticket.git_status)} />
|
||||
)
|
||||
) : (
|
||||
<Tooltip title="No dev ticket found for this record in this org">
|
||||
<Chip size="small" label="no ticket" variant="outlined" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={total}
|
||||
page={page}
|
||||
onPageChange={(_, newPage) => setPage(newPage)}
|
||||
rowsPerPage={PAGE_SIZE}
|
||||
rowsPerPageOptions={[PAGE_SIZE]}
|
||||
/>
|
||||
</TableContainer>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -210,7 +210,7 @@ export const AuditSession: React.FC = () => {
|
||||
} else {
|
||||
goToIndex(Math.min(removedIdx, remaining.length - 1), remaining);
|
||||
}
|
||||
useToastStore.getState().showToast(`#${reportedId} reported to @${reportDev} — moved to Under Review (admin console)`, 'success');
|
||||
useToastStore.getState().showToast(`#${reportedId} reported to @${reportDev} — hidden from the feed until the dev ticket is resolved`, 'success');
|
||||
} else {
|
||||
useToastStore.getState().showToast(`Message sent to @${reportDev}`, 'success');
|
||||
}
|
||||
@@ -307,6 +307,7 @@ export const AuditSession: React.FC = () => {
|
||||
|
||||
const res: any = await activityFeedsService.getHistory({
|
||||
date: activeDate,
|
||||
auditedByMe: activeTab.auditedByMe || false,
|
||||
pageNo: targetPage * 50,
|
||||
pageSize: 50,
|
||||
sortByDateAsc: false, // must match dashboard sort so firstPendingIndex page alignment is correct
|
||||
@@ -359,6 +360,7 @@ export const AuditSession: React.FC = () => {
|
||||
|
||||
const res: any = await activityFeedsService.getHistory({
|
||||
date: activeDate,
|
||||
auditedByMe: activeTab.auditedByMe || false,
|
||||
pageNo: nextPage * feedPageSize,
|
||||
pageSize: feedPageSize,
|
||||
sortBy,
|
||||
|
||||
@@ -9,8 +9,11 @@ export type FeedSortBy = '' | 'id' | 'date' | 'chainage' | 'latlong';
|
||||
export type FeedSortDir = 'asc' | 'desc';
|
||||
|
||||
export interface FeedTab {
|
||||
// '' = pending tab; 'YYYY-MM-DD' or 'YYYY-MM-DD_YYYY-MM-DD' (range) = history tab
|
||||
date: string;
|
||||
isRectificationTab: boolean;
|
||||
// History tabs only: show records audited BY the logged-in user
|
||||
auditedByMe?: boolean;
|
||||
totalAnomalyCount: number;
|
||||
anomalies: any[];
|
||||
anomaliesCopy: any[];
|
||||
|
||||
Reference in New Issue
Block a user