dev tickets and issues raised
This commit is contained in:
@@ -6,9 +6,9 @@ export const activityFeedsService = {
|
|||||||
return response;
|
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', {
|
const response = await axiosClient.get('/api/audit/anomaly/get_history_dates', {
|
||||||
params: { isRectificationEnabled, siteIds: siteIds || '' }
|
params: { isRectificationEnabled, siteIds: siteIds || '', auditedByMe }
|
||||||
});
|
});
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -126,6 +126,43 @@ export interface DevTicket {
|
|||||||
retry_count: number;
|
retry_count: number;
|
||||||
last_retry_at: string | null;
|
last_retry_at: string | null;
|
||||||
created_at: string;
|
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 {
|
export interface DevTicketsResult {
|
||||||
@@ -193,27 +230,6 @@ export interface MissingMediaResult {
|
|||||||
summary: MissingMediaSummary;
|
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 = {
|
export const adminService = {
|
||||||
getUsers: async (): Promise<AdminUser[]> => {
|
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;
|
return (await axiosClient.get('/api/audit/admin/media-status', { params })) as unknown as MissingMediaResult;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Pending records quarantined by Report to Dev (under_review = 1) -
|
// Unified resolve: returns the quarantined record to the auditors' feed
|
||||||
// hidden from auditors until released here.
|
// and (best-effort) closes the linked Gitea issue.
|
||||||
getUnderReviewRecords: async (params: {
|
resolveTicket: async (ticketId: number): Promise<{ recordReleased: boolean; issueClosed: boolean }> => {
|
||||||
dbName: string;
|
return (await axiosClient.post('/api/audit/admin/dev-tickets/resolve', { ticketId })) as unknown as {
|
||||||
type: 'audits' | 'rectification';
|
recordReleased: boolean; issueClosed: boolean;
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
// Put up to 50 quarantined records back into the auditors' pending feed.
|
// Complete record + ticket details for the admin popup.
|
||||||
releaseUnderReview: async (payload: {
|
getTicketRecordDetails: async (ticketId: number): Promise<TicketRecordDetails> => {
|
||||||
dbName: string;
|
return (await axiosClient.get('/api/audit/admin/dev-tickets/record-details', {
|
||||||
type: 'audits' | 'rectification';
|
params: { ticketId },
|
||||||
ids: number[];
|
})) as unknown as TicketRecordDetails;
|
||||||
}): Promise<{ released: number }> => {
|
|
||||||
return (await axiosClient.post('/api/audit/admin/under-review/release', payload)) as unknown as {
|
|
||||||
released: number;
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Immediate CDN re-verify for up to 50 records (also resets the cron's
|
// 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 CloseIcon from '@mui/icons-material/Close';
|
||||||
import { useFeedStore } from '../../store/feedStore';
|
import { useFeedStore } from '../../store/feedStore';
|
||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import { useToastStore } from '../../store/toastStore';
|
||||||
import { activityFeedsService } from '../../api/activityFeedsService';
|
import { activityFeedsService } from '../../api/activityFeedsService';
|
||||||
|
|
||||||
interface BulkDeleteDialogProps {
|
interface BulkDeleteDialogProps {
|
||||||
@@ -17,8 +18,9 @@ interface BulkDeleteDialogProps {
|
|||||||
const REASONS = ['Low Light', 'Bad Image', 'Wrong Algorithm', 'Out of Range'];
|
const REASONS = ['Low Light', 'Bad Image', 'Wrong Algorithm', 'Out of Range'];
|
||||||
|
|
||||||
export const BulkDeleteDialog: React.FC<BulkDeleteDialogProps> = ({ open, onClose }) => {
|
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 { user } = useAuthStore();
|
||||||
|
const showToast = useToastStore((s) => s.showToast);
|
||||||
|
|
||||||
const [fromDate, setFromDate] = useState('');
|
const [fromDate, setFromDate] = useState('');
|
||||||
const [tillDate, setTillDate] = useState('');
|
const [tillDate, setTillDate] = useState('');
|
||||||
@@ -83,11 +85,11 @@ export const BulkDeleteDialog: React.FC<BulkDeleteDialogProps> = ({ open, onClos
|
|||||||
|
|
||||||
const handleApply = async () => {
|
const handleApply = async () => {
|
||||||
if (!fromDate || !tillDate) {
|
if (!fromDate || !tillDate) {
|
||||||
alert("Please select both From and Till dates.");
|
showToast("Please select both From and Till dates.", "warning");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selectedAssets.size === 0) {
|
if (selectedAssets.size === 0) {
|
||||||
alert("Please select at least one asset.");
|
showToast("Please select at least one asset.", "warning");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,20 +110,33 @@ export const BulkDeleteDialog: React.FC<BulkDeleteDialogProps> = ({ open, onClos
|
|||||||
site_id: siteIds,
|
site_id: siteIds,
|
||||||
fromDate: fromDate,
|
fromDate: fromDate,
|
||||||
toDate: tillDate,
|
toDate: tillDate,
|
||||||
user_id: Number(user?.user_id),
|
|
||||||
audit_value: reason,
|
audit_value: reason,
|
||||||
isBulkDelete: true,
|
isBulkDelete: true,
|
||||||
audit_type: auditType
|
audit_type: auditType,
|
||||||
|
isRectificationEnabled,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
await activityFeedsService.bulkDelete(payload);
|
const res: any = await activityFeedsService.bulkDelete(payload);
|
||||||
alert("Bulk delete successful!");
|
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();
|
onClose();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Bulk delete failed", err);
|
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 {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,18 +118,21 @@ export const TopFilterBar: React.FC = () => {
|
|||||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||||
const [loadingRectHistory, setLoadingRectHistory] = useState(false);
|
const [loadingRectHistory, setLoadingRectHistory] = useState(false);
|
||||||
const [isHistoryDialogOpen, setIsHistoryDialogOpen] = useState(false);
|
const [isHistoryDialogOpen, setIsHistoryDialogOpen] = useState(false);
|
||||||
const [selectedHistoryDate, setSelectedHistoryDate] = useState('2026-06-12');
|
// One dialog serves both History surfaces; the mode decides which table
|
||||||
const [historyDropdownValue, setHistoryDropdownValue] = useState('');
|
// (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
|
// Fetch normal history dates (isRectificationEnabled=false) for the audits-mode History dialog chips
|
||||||
// Rectification history (isRectificationEnabled=true) is fetched separately by the RECTIFICATION HISTORY button
|
// Rectification dates (isRectificationEnabled=true) are fetched when the dialog opens in rect mode
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedSite || selectedSite.site_id === 'All Sites') return;
|
if (!selectedSite || selectedSite.site_id === 'All Sites') return;
|
||||||
const fetchDates = async () => {
|
const fetchDates = async () => {
|
||||||
setLoadingHistory(true);
|
setLoadingHistory(true);
|
||||||
try {
|
try {
|
||||||
const siteIds = String(selectedSite.site_id);
|
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 || []));
|
setHistoryDates(Array.isArray(dates) ? dates : (dates?.responseData || []));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load history dates", err);
|
console.error("Failed to load history dates", err);
|
||||||
@@ -140,50 +143,41 @@ export const TopFilterBar: React.FC = () => {
|
|||||||
fetchDates();
|
fetchDates();
|
||||||
}, [selectedSite?.site_id]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [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);
|
setIsHistoryDialogOpen(true);
|
||||||
|
if (mode === 'rect') {
|
||||||
if (!selectedSite || selectedSite.site_id === 'All Sites') return;
|
if (!selectedSite || selectedSite.site_id === 'All Sites') return;
|
||||||
setLoadingRectHistory(true);
|
setLoadingRectHistory(true);
|
||||||
try {
|
try {
|
||||||
const siteIds = String(selectedSite.site_id);
|
const siteIds = String(selectedSite.site_id);
|
||||||
const dates: any = await activityFeedsService.getHistoryDates(true, siteIds);
|
const dates: any = await activityFeedsService.getHistoryDates(true, siteIds, true);
|
||||||
setRectHistoryDates(Array.isArray(dates) ? dates : (dates?.responseData || []));
|
setRectHistoryDates(Array.isArray(dates) ? dates : (dates?.responseData || []));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load rectification history dates", err);
|
console.error("Failed to load rectification history dates", err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingRectHistory(false);
|
setLoadingRectHistory(false);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
if (existingIndex !== -1) {
|
|
||||||
setSelectedTabIndex(existingIndex);
|
|
||||||
} else {
|
|
||||||
addTab({
|
|
||||||
date: date,
|
|
||||||
isRectificationTab: true,
|
|
||||||
totalAnomalyCount: 0,
|
|
||||||
anomalies: [],
|
|
||||||
anomaliesCopy: [],
|
|
||||||
selectedAnomalyIndex: 0,
|
|
||||||
filters: {},
|
|
||||||
page: 0,
|
|
||||||
rowsPerPage: 20
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
// 'audits' chips come from the historyDates effect (refetched per site).
|
||||||
};
|
};
|
||||||
|
|
||||||
// Used by History dropdown — isRectificationTab: false → fetches with isRectificationEnabled=false
|
// Opens (or focuses) a History tab: records audited BY the logged-in user
|
||||||
const handleHistoryDateSelect = (date: string) => {
|
// (auditedByMe) on one date or a From-Till range. The range is encoded in
|
||||||
const existingIndex = tabs.findIndex(t => t.date === date && !t.isRectificationTab);
|
// 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) {
|
if (existingIndex !== -1) {
|
||||||
setSelectedTabIndex(existingIndex);
|
setSelectedTabIndex(existingIndex);
|
||||||
} else {
|
} else {
|
||||||
addTab({
|
addTab({
|
||||||
date: date,
|
date: dateKey,
|
||||||
isRectificationTab: false,
|
isRectificationTab: isRect,
|
||||||
|
auditedByMe: true,
|
||||||
totalAnomalyCount: 0,
|
totalAnomalyCount: 0,
|
||||||
anomalies: [],
|
anomalies: [],
|
||||||
anomaliesCopy: [],
|
anomaliesCopy: [],
|
||||||
@@ -196,8 +190,12 @@ export const TopFilterBar: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleViewHistory = () => {
|
const handleViewHistory = () => {
|
||||||
if (!selectedHistoryDate) return;
|
if (!historyFrom || !historyTill) return;
|
||||||
handleSelectDate(selectedHistoryDate);
|
// Tolerate a reversed range instead of erroring.
|
||||||
|
const [from, till] = historyFrom <= historyTill
|
||||||
|
? [historyFrom, historyTill]
|
||||||
|
: [historyTill, historyFrom];
|
||||||
|
openHistoryTab(historyDialogMode === 'rect', from, till);
|
||||||
setIsHistoryDialogOpen(false);
|
setIsHistoryDialogOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -238,14 +236,6 @@ export const TopFilterBar: React.FC = () => {
|
|||||||
>
|
>
|
||||||
FILTERS
|
FILTERS
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={() => setIsBulkDeleteDialogOpen(true)}
|
onClick={() => setIsBulkDeleteDialogOpen(true)}
|
||||||
@@ -254,46 +244,20 @@ export const TopFilterBar: React.FC = () => {
|
|||||||
BULK DELETE
|
BULK DELETE
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* History dropdown — only visible when a specific site is selected */}
|
{/* History — one button for both modes: follows the active mode
|
||||||
{localSiteId !== 'All Sites' && <FormControl size="small" sx={{ bgcolor: 'rgba(255,255,255,0.1)', borderRadius: 2, minWidth: 160 }}>
|
(audits vs rectification) so the dialog and the tab it opens query
|
||||||
<Select
|
the right table. Only visible when a specific site is selected
|
||||||
value={historyDropdownValue}
|
(the audited-dates lookup is per-site). */}
|
||||||
displayEmpty
|
{localSiteId !== 'All Sites' && (
|
||||||
onChange={(e) => {
|
<Button
|
||||||
const date = e.target.value as string;
|
variant="contained"
|
||||||
if (date) {
|
startIcon={<HistoryIcon />}
|
||||||
handleHistoryDateSelect(date);
|
onClick={() => openHistoryDialog(isRectActive ? 'rect' : 'audits')}
|
||||||
setHistoryDropdownValue('');
|
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' }}
|
||||||
}
|
|
||||||
}}
|
|
||||||
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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<MenuItem disabled value="">
|
HISTORY
|
||||||
<em style={{ color: '#64748b' }}>{loadingHistory ? 'Loading...' : historyDates.length === 0 ? 'No dates available' : 'Select a date'}</em>
|
</Button>
|
||||||
</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>}
|
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={isHistoryDialogOpen}
|
open={isHistoryDialogOpen}
|
||||||
@@ -311,19 +275,23 @@ 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 }}>
|
<DialogContent sx={{ py: 1 }}>
|
||||||
<Typography variant="body2" sx={{ color: '#94a3b8', mb: 2 }}>
|
<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>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 'bold', color: '#94a3b8', mb: 1 }}>History Date</Typography>
|
<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
|
<TextField
|
||||||
type="date"
|
type="date"
|
||||||
value={selectedHistoryDate}
|
value={value}
|
||||||
onChange={(e) => setSelectedHistoryDate(e.target.value)}
|
onChange={(e) => setter(e.target.value)}
|
||||||
fullWidth
|
fullWidth
|
||||||
sx={{
|
sx={{
|
||||||
mb: 3,
|
|
||||||
'& .MuiOutlinedInput-root': {
|
'& .MuiOutlinedInput-root': {
|
||||||
color: '#f8fafc',
|
color: '#f8fafc',
|
||||||
'& fieldset': { borderColor: '#334155' },
|
'& fieldset': { borderColor: '#334155' },
|
||||||
@@ -336,33 +304,37 @@ export const TopFilterBar: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Typography variant="caption" sx={{ color: '#94a3b8', display: 'block', mb: 1, fontWeight: 'bold' }}>
|
<Typography variant="caption" sx={{ color: '#94a3b8', display: 'block', mb: 1, fontWeight: 'bold' }}>
|
||||||
Quick Select (Audited Dates)
|
Quick Select (Dates You Audited)
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, maxHeight: 150, overflowY: 'auto', p: 0.5, border: '1px solid #1e293b', borderRadius: 1, bgcolor: '#0b1121' }}>
|
<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 }}>
|
<Box sx={{ width: '100%', display: 'flex', justifyContent: 'center', py: 2 }}>
|
||||||
<CircularProgress size={20} color="inherit" />
|
<CircularProgress size={20} color="inherit" />
|
||||||
</Box>
|
</Box>
|
||||||
) : rectHistoryDates.length === 0 ? (
|
) : (historyDialogMode === 'rect' ? rectHistoryDates : historyDates).length === 0 ? (
|
||||||
<Typography variant="body2" sx={{ color: '#64748b', p: 1 }}>No dates found</Typography>
|
<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 dateStr = d.audited_on ? d.audited_on.split(' ')[0] : '';
|
||||||
|
const selected = historyFrom === dateStr && historyTill === dateStr;
|
||||||
return (
|
return (
|
||||||
<Chip
|
<Chip
|
||||||
key={d.audited_on}
|
key={d.audited_on}
|
||||||
label={dateStr || d.audited_on}
|
label={dateStr || d.audited_on}
|
||||||
onClick={() => setSelectedHistoryDate(dateStr || d.audited_on)}
|
onClick={() => { setHistoryFrom(dateStr); setHistoryTill(dateStr); }}
|
||||||
sx={{
|
sx={{
|
||||||
bgcolor: selectedHistoryDate === (dateStr || d.audited_on) ? '#3b82f6' : '#1e293b',
|
bgcolor: selected ? '#3b82f6' : '#1e293b',
|
||||||
color: '#f8fafc',
|
color: '#f8fafc',
|
||||||
'&:hover': { bgcolor: '#334155' },
|
'&:hover': { bgcolor: '#334155' },
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
border: '1px solid transparent',
|
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
|
<Button
|
||||||
onClick={handleViewHistory}
|
onClick={handleViewHistory}
|
||||||
variant="contained"
|
variant="contained"
|
||||||
disabled={!selectedHistoryDate}
|
disabled={!historyFrom || !historyTill}
|
||||||
sx={{
|
sx={{
|
||||||
bgcolor: '#3b82f6',
|
bgcolor: '#3b82f6',
|
||||||
color: '#fff',
|
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 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';
|
import { useFeedStore } from '../../store/feedStore';
|
||||||
|
|
||||||
const SKELETON_COLS = 13;
|
const SKELETON_COLS = 11;
|
||||||
|
|
||||||
export const AnomalyTable: React.FC = () => {
|
export const AnomalyTable: React.FC = () => {
|
||||||
const { tabs, selectedTabIndex, updateTab, filterPlaza, isRectificationEnabled, isFeedLoading } = useFeedStore();
|
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: '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' }}>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: '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>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -133,22 +131,6 @@ export const AnomalyTable: React.FC = () => {
|
|||||||
</TableCell>
|
</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.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: '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>
|
</TableRow>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -182,10 +182,11 @@ export const FeedFilters: React.FC = () => {
|
|||||||
// Build the correct filter key that will be calculated in GlobalFeed
|
// Build the correct filter key that will be calculated in GlobalFeed
|
||||||
// Must mirror GlobalFeed's filterKey format exactly or the cache
|
// Must mirror GlobalFeed's filterKey format exactly or the cache
|
||||||
// check there misses and double-fetches.
|
// 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({
|
const historyRes: any = await activityFeedsService.getHistory({
|
||||||
date: activeTab.date,
|
date: activeTab.date,
|
||||||
|
auditedByMe: activeTab.auditedByMe || false,
|
||||||
fromDate: draftFromDate,
|
fromDate: draftFromDate,
|
||||||
tillDate: draftTillDate,
|
tillDate: draftTillDate,
|
||||||
pageNo: 0,
|
pageNo: 0,
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export const FeedTabs: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
label={
|
label={
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
<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
|
{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 }}>
|
<IconButton size="small" onClick={(e) => { e.stopPropagation(); closeTab(index); }} sx={{ ml: 1, color: 'inherit', p: 0.5 }}>
|
||||||
<CloseIcon fontSize="small" />
|
<CloseIcon fontSize="small" />
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export const GlobalFeed: React.FC = () => {
|
|||||||
const assetIdsKey = selectedAssetIds.join(',');
|
const assetIdsKey = selectedAssetIds.join(',');
|
||||||
|
|
||||||
// Build a key for the current filter state on the active tab
|
// 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
|
// CACHE HIT: This exact tab and filter combo was already fetched
|
||||||
if (filterKey === activeTab.filters?.lastFetchedFilterKey && activeTab.anomalies && activeTab.anomalies.length > 0) {
|
if (filterKey === activeTab.filters?.lastFetchedFilterKey && activeTab.anomalies && activeTab.anomalies.length > 0) {
|
||||||
@@ -76,6 +76,7 @@ export const GlobalFeed: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const historyRes: any = await activityFeedsService.getHistory({
|
const historyRes: any = await activityFeedsService.getHistory({
|
||||||
date: activeTab.date || '',
|
date: activeTab.date || '',
|
||||||
|
auditedByMe: activeTab.auditedByMe || false,
|
||||||
pageNo: 0,
|
pageNo: 0,
|
||||||
pageSize: 20,
|
pageSize: 20,
|
||||||
siteIds: siteIdParam,
|
siteIds: siteIdParam,
|
||||||
@@ -132,6 +133,7 @@ export const GlobalFeed: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const historyRes: any = await activityFeedsService.getHistory({
|
const historyRes: any = await activityFeedsService.getHistory({
|
||||||
date: activeTab?.date || '',
|
date: activeTab?.date || '',
|
||||||
|
auditedByMe: activeTab?.auditedByMe || false,
|
||||||
pageNo: newPage * rowsPerPage,
|
pageNo: newPage * rowsPerPage,
|
||||||
pageSize: rowsPerPage,
|
pageSize: rowsPerPage,
|
||||||
siteIds: selectedSite?.site_id === 'All Sites' ? '' : (selectedSite?.site_id || ''),
|
siteIds: selectedSite?.site_id === 'All Sites' ? '' : (selectedSite?.site_id || ''),
|
||||||
@@ -174,6 +176,7 @@ export const GlobalFeed: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const historyRes: any = await activityFeedsService.getHistory({
|
const historyRes: any = await activityFeedsService.getHistory({
|
||||||
date: activeTab?.date || '',
|
date: activeTab?.date || '',
|
||||||
|
auditedByMe: activeTab?.auditedByMe || false,
|
||||||
pageNo: newPage * newRowsPerPage,
|
pageNo: newPage * newRowsPerPage,
|
||||||
pageSize: newRowsPerPage,
|
pageSize: newRowsPerPage,
|
||||||
siteIds: selectedSite?.site_id === 'All Sites' ? '' : (selectedSite?.site_id || ''),
|
siteIds: selectedSite?.site_id === 'All Sites' ? '' : (selectedSite?.site_id || ''),
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { Allocation } from './Allocation';
|
|||||||
import { AuditHistory } from './AuditHistory';
|
import { AuditHistory } from './AuditHistory';
|
||||||
import { DevTickets } from './DevTickets';
|
import { DevTickets } from './DevTickets';
|
||||||
import { MissingMedia } from './MissingMedia';
|
import { MissingMedia } from './MissingMedia';
|
||||||
import { UnderReview } from './UnderReview';
|
|
||||||
|
|
||||||
export const AdminConsole: React.FC = () => {
|
export const AdminConsole: React.FC = () => {
|
||||||
const [tab, setTab] = useState(0);
|
const [tab, setTab] = useState(0);
|
||||||
@@ -27,7 +26,6 @@ export const AdminConsole: React.FC = () => {
|
|||||||
<Tab label="Audit & Rectification History" />
|
<Tab label="Audit & Rectification History" />
|
||||||
<Tab label="Dev Tickets" />
|
<Tab label="Dev Tickets" />
|
||||||
<Tab label="Missing Media" />
|
<Tab label="Missing Media" />
|
||||||
<Tab label="Under Review" />
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{tab === 0 && <ClientsOverview />}
|
{tab === 0 && <ClientsOverview />}
|
||||||
@@ -37,7 +35,6 @@ export const AdminConsole: React.FC = () => {
|
|||||||
{tab === 4 && <AuditHistory />}
|
{tab === 4 && <AuditHistory />}
|
||||||
{tab === 5 && <DevTickets />}
|
{tab === 5 && <DevTickets />}
|
||||||
{tab === 6 && <MissingMedia />}
|
{tab === 6 && <MissingMedia />}
|
||||||
{tab === 7 && <UnderReview />}
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import React, { useEffect, useState, useCallback } from 'react';
|
|||||||
import {
|
import {
|
||||||
Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||||
Paper, TextField, FormControl, InputLabel, Select, MenuItem, CircularProgress,
|
Paper, TextField, FormControl, InputLabel, Select, MenuItem, CircularProgress,
|
||||||
Chip, TablePagination, Link, Tooltip,
|
Chip, TablePagination, Link, Tooltip, Button,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { adminService } from '../../api/adminService';
|
import { adminService } from '../../api/adminService';
|
||||||
import type { DevTicket } from '../../api/adminService';
|
import type { DevTicket } from '../../api/adminService';
|
||||||
import { useToastStore } from '../../store/toastStore';
|
import { useToastStore } from '../../store/toastStore';
|
||||||
|
import { RecordDetailsDialog } from './RecordDetailsDialog';
|
||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
@@ -16,6 +17,30 @@ const gitStatusColor = (status: DevTicket['git_status']): 'success' | 'warning'
|
|||||||
return '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);
|
const truncate = (text: string, max = 80) => (text.length > max ? `${text.slice(0, max)}…` : text);
|
||||||
|
|
||||||
export const DevTickets: React.FC = () => {
|
export const DevTickets: React.FC = () => {
|
||||||
@@ -45,6 +70,8 @@ export const DevTickets: React.FC = () => {
|
|||||||
// afterward, so this is fetched separately from the main list.
|
// afterward, so this is fetched separately from the main list.
|
||||||
const [issueStates, setIssueStates] = useState<Record<number, 'open' | 'closed' | null>>({});
|
const [issueStates, setIssueStates] = useState<Record<number, 'open' | 'closed' | null>>({});
|
||||||
const [issueStatesLoading, setIssueStatesLoading] = useState(false);
|
const [issueStatesLoading, setIssueStatesLoading] = useState(false);
|
||||||
|
const [resolvingId, setResolvingId] = useState<number | null>(null);
|
||||||
|
const [detailsTicket, setDetailsTicket] = useState<DevTicket | null>(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -96,6 +123,24 @@ export const DevTickets: React.FC = () => {
|
|||||||
.finally(() => setIssueStatesLoading(false));
|
.finally(() => setIssueStatesLoading(false));
|
||||||
}, [records]);
|
}, [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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', gap: 2, mb: 3, flexWrap: 'wrap', alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', gap: 2, mb: 3, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
@@ -141,6 +186,7 @@ export const DevTickets: React.FC = () => {
|
|||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>Anomaly ID</TableCell>
|
<TableCell>Anomaly ID</TableCell>
|
||||||
|
<TableCell>Org</TableCell>
|
||||||
<TableCell>Asset / Plaza</TableCell>
|
<TableCell>Asset / Plaza</TableCell>
|
||||||
<TableCell>Dev</TableCell>
|
<TableCell>Dev</TableCell>
|
||||||
<TableCell>Reporter</TableCell>
|
<TableCell>Reporter</TableCell>
|
||||||
@@ -148,13 +194,15 @@ export const DevTickets: React.FC = () => {
|
|||||||
<TableCell>RocketChat</TableCell>
|
<TableCell>RocketChat</TableCell>
|
||||||
<TableCell>Git</TableCell>
|
<TableCell>Git</TableCell>
|
||||||
<TableCell>Issue status</TableCell>
|
<TableCell>Issue status</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
<TableCell>Created</TableCell>
|
<TableCell>Created</TableCell>
|
||||||
|
<TableCell align="right">Action</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{records.length === 0 && (
|
{records.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={9} align="center" sx={{ color: 'text.secondary' }}>
|
<TableCell colSpan={12} align="center" sx={{ color: 'text.secondary' }}>
|
||||||
No dev tickets found.
|
No dev tickets found.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -162,8 +210,14 @@ export const DevTickets: React.FC = () => {
|
|||||||
{records.map((t) => {
|
{records.map((t) => {
|
||||||
const liveState = t.git_status === 'success' && t.git_issue_number != null ? issueStates[t.id] : undefined;
|
const liveState = t.git_status === 'success' && t.git_issue_number != null ? issueStates[t.id] : undefined;
|
||||||
return (
|
return (
|
||||||
<TableRow key={t.id}>
|
<TableRow
|
||||||
|
key={t.id}
|
||||||
|
hover
|
||||||
|
onClick={() => setDetailsTicket(t)}
|
||||||
|
sx={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
<TableCell>{t.anomaly_id}</TableCell>
|
<TableCell>{t.anomaly_id}</TableCell>
|
||||||
|
<TableCell>{t.org_db_name || '—'}</TableCell>
|
||||||
<TableCell>{[t.asset, t.plaza].filter(Boolean).join(' / ') || '—'}</TableCell>
|
<TableCell>{[t.asset, t.plaza].filter(Boolean).join(' / ') || '—'}</TableCell>
|
||||||
<TableCell>@{t.dev_handle}</TableCell>
|
<TableCell>@{t.dev_handle}</TableCell>
|
||||||
<TableCell>{t.reporter || '—'}</TableCell>
|
<TableCell>{t.reporter || '—'}</TableCell>
|
||||||
@@ -184,7 +238,7 @@ export const DevTickets: React.FC = () => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||||
{t.git_issue_url ? (
|
{t.git_issue_url ? (
|
||||||
<Link href={t.git_issue_url} target="_blank" rel="noopener noreferrer">
|
<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 />
|
<Chip size="small" label={`#${t.git_issue_number}`} color={gitStatusColor(t.git_status)} clickable />
|
||||||
@@ -213,7 +267,18 @@ export const DevTickets: React.FC = () => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>{statusChip(t)}</TableCell>
|
||||||
<TableCell>{new Date(t.created_at).toLocaleString()}</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>
|
</TableRow>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -229,6 +294,12 @@ export const DevTickets: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<RecordDetailsDialog
|
||||||
|
ticket={detailsTicket}
|
||||||
|
liveIssueState={detailsTicket ? issueStates[detailsTicket.id] : undefined}
|
||||||
|
onClose={() => setDetailsTicket(null)}
|
||||||
|
/>
|
||||||
</Box>
|
</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 {
|
} else {
|
||||||
goToIndex(Math.min(removedIdx, remaining.length - 1), remaining);
|
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 {
|
} else {
|
||||||
useToastStore.getState().showToast(`Message sent to @${reportDev}`, 'success');
|
useToastStore.getState().showToast(`Message sent to @${reportDev}`, 'success');
|
||||||
}
|
}
|
||||||
@@ -307,6 +307,7 @@ export const AuditSession: React.FC = () => {
|
|||||||
|
|
||||||
const res: any = await activityFeedsService.getHistory({
|
const res: any = await activityFeedsService.getHistory({
|
||||||
date: activeDate,
|
date: activeDate,
|
||||||
|
auditedByMe: activeTab.auditedByMe || false,
|
||||||
pageNo: targetPage * 50,
|
pageNo: targetPage * 50,
|
||||||
pageSize: 50,
|
pageSize: 50,
|
||||||
sortByDateAsc: false, // must match dashboard sort so firstPendingIndex page alignment is correct
|
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({
|
const res: any = await activityFeedsService.getHistory({
|
||||||
date: activeDate,
|
date: activeDate,
|
||||||
|
auditedByMe: activeTab.auditedByMe || false,
|
||||||
pageNo: nextPage * feedPageSize,
|
pageNo: nextPage * feedPageSize,
|
||||||
pageSize: feedPageSize,
|
pageSize: feedPageSize,
|
||||||
sortBy,
|
sortBy,
|
||||||
|
|||||||
@@ -9,8 +9,11 @@ export type FeedSortBy = '' | 'id' | 'date' | 'chainage' | 'latlong';
|
|||||||
export type FeedSortDir = 'asc' | 'desc';
|
export type FeedSortDir = 'asc' | 'desc';
|
||||||
|
|
||||||
export interface FeedTab {
|
export interface FeedTab {
|
||||||
|
// '' = pending tab; 'YYYY-MM-DD' or 'YYYY-MM-DD_YYYY-MM-DD' (range) = history tab
|
||||||
date: string;
|
date: string;
|
||||||
isRectificationTab: boolean;
|
isRectificationTab: boolean;
|
||||||
|
// History tabs only: show records audited BY the logged-in user
|
||||||
|
auditedByMe?: boolean;
|
||||||
totalAnomalyCount: number;
|
totalAnomalyCount: number;
|
||||||
anomalies: any[];
|
anomalies: any[];
|
||||||
anomaliesCopy: any[];
|
anomaliesCopy: any[];
|
||||||
|
|||||||
Reference in New Issue
Block a user