ui tweaks with new frequency column

This commit is contained in:
2026-09-10 15:14:11 +05:30
parent af8f2f3679
commit c16ad7c737
14 changed files with 687 additions and 30 deletions

View File

@@ -13,9 +13,18 @@ root [CLAUDE.md](../CLAUDE.md); backend contracts in [auditor-backend/CLAUDE.md]
## Architecture notes (the non-obvious ones)
### Analytics (Microsoft Clarity)
`index.html` carries the Clarity snippet (project `y7dltvy3kb`), **gated to
`window.location.hostname === 'audit-frontend.seekright.com'`** — dev servers,
LAN-IP visits and test flows deliberately never record. If the production
domain ever changes, update the gate or Clarity silently stops collecting.
### API layer (`src/api/`)
- `axiosClient` auto-attaches the logged-in user's `db_name` as the `dbName` query param on every request (multi-tenancy), rewrites `/api/audit``VITE_AUDIT_API_URL`, and coalesces concurrent JWT refreshes (rotation-safe). Services (`activityFeedsService`, `adminService`) return the response body directly, not the axios envelope.
### Filter lock-step rule (learned via chainage/sort, applies to every new filter)
Any feed filter (chainage, assets, the frequency threshold `filterFrequencyGt` — a free numeric "Frequency > N" input, digits-only sanitized, backed by the pipeline's occurrence marker on audits/rectification, NOT anomaly.frequency; the session metadata row shows a FREQUENCY chip, hidden when the field is absent on stale cached rows; `filterHasCompleteImage` — the Complete Image Any/Present/Absent select (`hasCompleteImage` param, whitelisted server-side); and `filterAuditValues` — the Features multi-select over the fixed audit-option vocabulary exported from AuditSession (ANOMALY/SAFE/RECT_SAFE_CATEGORIES + rect-true 'Others'), matched case-insensitively server-side, options follow the audits/rectification toggle; NOTE it matches nothing on un-audited pending rows — Audit_value is NULL there — so it's effectively a history/date-tab + AI-audit filter) must flow to ALL of: FeedFilters (draft + seed + clear + apply + its filterKey), GlobalFeed (destructure + the IDENTICAL filterKey + every getHistory call + effect deps), AuditSession (both session page-load calls + both deps arrays), and Dashboard (currentFilterKey + its getHistory call) — plus the backend's page AND count queries in `anomalyModel` (they must never drift or pagination breaks). The two filterKey templates in FeedFilters and GlobalFeed must stay character-identical or the cache guard double-fetches.
### Feed store cache is a write-through cache — keep it in sync
`useFeedStore` caches each tab's rows (`anomalies` + `anomaliesCopy`) and the audit session is (re-)entered FROM that cache (`location.state.anomalies` passed by the feeds table, or `activeTab.anomalies`). Any mutation a session makes to a record must therefore be written to BOTH the session's local `anomalies` state AND the feed store tabs, or the change "disappears" on re-entry while the DB is actually fine. Existing write-throughs to mirror: `applyAuditedLocally`/`purgePendingCaches` (audit save), `handleSaveAnnotations` (bounding boxes — this one was learned the hard way). Match tabs per mode via `tabMatchesMode` in AuditSession — NOT `!!t.isRectificationTab === isRectActive` alone: the pending/review tabs are created with `isRectificationTab: false` even in rectification mode (their content follows the global `isRectificationEnabled` toggle), so the naive flag comparison silently skips them in rect mode while working in audits mode. Audits and rectification ids come from different tables and can collide, so a mode check is still mandatory.
@@ -26,6 +35,7 @@ root [CLAUDE.md](../CLAUDE.md); backend contracts in [auditor-backend/CLAUDE.md]
- **Save guards**: `submittingRef` blocks overlapping saves; `lastActionIdRef` blocks back-to-back saves of the SAME record id (Enter + click both firing). **Any flow that makes a locked record editable again (revert!) must clear both refs** or the next save is silently swallowed until the user navigates away and back.
- **Keyboard shortcuts** (`handleKeyDown`) fire on single letters/digits (S = Safe, A = Anomaly, …). They are paused by an explicit list of open-dialog states AND a generic guard (target is INPUT/TEXTAREA/contentEditable). Every new dialog with a text field must keep at least one of those true — the generic guard covers it, but add the dialog's open-state to the list (and to the "Shortcuts Paused" footer indicator) for visibility. ⚠️ The listener is registered in **CAPTURE phase** — required by the video Space/F branch, which must `stopPropagation()` before the focused `<video controls>` shadow DOM sees the key (its internal listeners toggle play/pause BEFORE any bubble handler, so the two toggles used to cancel out and Space "did nothing" after clicking the player). No other branch may stopPropagation, and the early guards return without touching the event, so dialog/text-field key handling is unaffected — keep it that way.
- **AI (auto) audits are reviewable, not locked**: `isAutoAudited` (IsAudited=1 + auto_audit=1) records show a purple "🤖 AI Audit — needs your review" banner with the FEATURE (= Audit_value) + verdict chips, keep the verdict panel ACTIVE, and count as pending in tile states / Audit-All / staged counters. A human save overwrites the AI verdict once (server flips auto_audit→0; `applyAuditedLocally` mirrors that so the record then locks), and reverting that human audit restores the AI verdict. The Revert button is hidden on un-reviewed AI audits (no snapshot exists to revert to).
- **COMPLETE IMAGE view**: when a record carries `complete_image` (pipeline-written), the MASTER pane header gains a Master|Complete segmented toggle that swaps the pane's content (header retitles to COMPLETE IMAGE; NOT a third pane — deliberate revision, the layout stays two-pane); the Complete view renders through the same `ZoomableImage` as the master/anomaly panes (scroll-wheel zoom toward cursor + pan while zoomed + the +/- buttons — no enlarge dialog; that was built then removed on user revision), and the swap resets to Master on every record change. ⚠️ **`complete_image` is NOT CDN-relative like `Frame_Test`** — the column holds BOTH shapes: the work-order layer writes an ABSOLUTE url on a different host (`https://sr-img.seekright.com/seekright-ims/<uuid>.jpg` — all 652 populated `roadis.rectification` rows), while a handful of rows carry a relative path. Always build the src through **`completeImageUrl()`** in `src/constants/media.ts` (passthrough when it matches `^(https?:)?//`, else `TEST_IMAGE_PREFIX`) — never concatenate the prefix directly: the original hardcoded prefix built `.../SeekRight/https://sr-img...`, which the CDN 404s, so every real record silently showed the not-available fallback (fixed 2026-09-10).
- **Verdict vocabulary** lives at the top of the file (`ANOMALY_CATEGORIES`, `SAFE_CATEGORIES`, `RECT_SAFE_CATEGORIES`, `SOFT_DELETE_AUDIT_VALUES`). The soft-delete list mirrors `anomalyModel.update`'s exact strings — the UI's "low light condition" ≠ backend's "low light", so only some values actually soft-delete. Rectification TRUE is always `'Others'`; rect FALSE picks `'true others'` (defect NOT fixed → re-opens it) or `'false others'`.
- **Revert** (`handleRevert`/`doRevert` + confirmation Dialog, shown on locked records): calls `POST /auditor/anomaly/revert`, applies the server's restored row locally, resets `itemAuditStates` to `'pending'`, clears the save guards + verdict form, and surfaces server `warnings` (Anton archive / work orders are warn-only) as toasts. The admin History tab (`src/pages/admin/AuditHistory.tsx`) has the same flow with its own Dialog.
- **Bulk revert** exists in two places, both via `activityFeedsService.revertRecords` (batch form of `/revert`; per-record `results`): (1) the feeds table (`AnomalyTable.tsx`) shows a checkbox column on **history/date tabs only** — select rows → "Revert Selected"; after success it drops reverted ids from the tab and invalidates the matching pending tab's `lastFetchedFilterKey` so they reappear as pending on next visit; (2) the admin console's dedicated **Bulk Revert** tab (`src/pages/admin/BulkRevert.tsx`) — self-contained filters (org, type, Audited_on date presets, auditor from `adminService.getUsers()`, site) over `GET /admin/audit-history/revertable`, with select-all + paginated revert.

View File

@@ -5,6 +5,19 @@
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SeekRight</title>
<!-- Microsoft Clarity (behavior analytics: heatmaps, session recordings,
rage/dead clicks). Loads async from clarity.ms; project y7dltvy3kb.
Gated to the production hostname so dev servers and LAN-IP visits
don't pollute the recordings with test sessions. -->
<script type="text/javascript">
if (window.location.hostname === 'audit-frontend.seekright.com') {
(function(c,l,a,r,i,t,y){
c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
})(window, document, "clarity", "script", "y7dltvy3kb");
}
</script>
</head>
<body>
<div id="root"></div>

70
serve.cjs Normal file
View File

@@ -0,0 +1,70 @@
// Static server for the built SPA (replaces `pm2 serve dist 8443 --spa`).
// Exists for ONE reason pm2-serve can't do: correct cache headers.
// - index.html (and any SPA-fallback response): Cache-Control: no-store
// -> every page load fetches fresh HTML, so deploys are visible on a
// normal reload. This ends the recurring "deployed but users still see
// the old UI" stale-tab problem (bit us three times on 2026-08-28).
// - /assets/* (Vite content-hashed filenames): immutable, cached 1 year
// -> repeat visits stay fast; a new build = new hash = new URL.
// Zero dependencies; run under PM2 as "audit-portal-fe".
const http = require("http");
const fs = require("fs");
const path = require("path");
const PORT = 8443;
const ROOT = path.join(__dirname, "dist");
const MIME = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json",
".map": "application/json",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".woff": "font/woff",
".woff2": "font/woff2",
".webmanifest": "application/manifest+json",
};
const sendFile = (res, filePath, cacheControl) => {
const ext = path.extname(filePath).toLowerCase();
res.writeHead(200, {
"Content-Type": MIME[ext] || "application/octet-stream",
"Cache-Control": cacheControl,
});
fs.createReadStream(filePath).pipe(res);
};
http
.createServer((req, res) => {
// Resolve inside ROOT only - refuse path traversal.
const urlPath = decodeURIComponent((req.url || "/").split("?")[0]);
const resolved = path.normalize(path.join(ROOT, urlPath));
if (!resolved.startsWith(ROOT)) {
res.writeHead(403).end();
return;
}
if (fs.existsSync(resolved) && fs.statSync(resolved).isFile()) {
// Hashed build assets are immutable; everything else served as a real
// file (favicon, images) gets a short cache.
const immutable = urlPath.startsWith("/assets/");
const cache = resolved.endsWith(".html")
? "no-store"
: immutable
? "public, max-age=31536000, immutable"
: "public, max-age=300";
sendFile(res, resolved, cache);
return;
}
// SPA fallback: every unknown path is the app (React Router owns routing).
sendFile(res, path.join(ROOT, "index.html"), "no-store");
})
.listen(PORT, "0.0.0.0", () => {
console.log(`audit-portal-fe static server on :${PORT} (root: ${ROOT})`);
});

View File

@@ -291,6 +291,25 @@ export interface AuditedStatsOrg {
error?: string;
}
// Row of the admin Anomalies browser (per-org anomaly table + merged names)
export interface AnomalyRecordRow {
anomaly_id: number;
site_id: number;
asset_id: number;
status: string;
chainage: string | null;
frequency: number | null;
ai_rejected: boolean | null;
manual_close: any;
manual_ticket: any;
master_id: string | null;
completed_on: string | null;
ai_completed_on: string | null;
created_on: string | null;
asset_name: string;
site_name: string;
}
export interface AuditedStatsResult {
type: 'audits' | 'rectification';
clients: AuditedStatsOrg[];
@@ -529,6 +548,27 @@ export const adminService = {
})) as unknown as ClientsOverviewResult;
},
// Admin Anomalies browser: per-org anomaly-table listing. orgDb (NOT
// dbName - the interceptor owns that param) selects the organisation.
getAnomalyRecords: async (params: {
orgDb: string;
table?: 'anomaly' | 'complete';
status?: string;
assetIds?: number[];
siteIds?: string;
frequencyGt?: string;
completedFrom?: string;
completedTo?: string;
aiRejected?: string;
pageNo?: number;
pageSize?: number;
}): Promise<{ rows: AnomalyRecordRow[]; count: number; statuses: string[] }> => {
const { assetIds, ...rest } = params;
return (await axiosClient.get('/api/audit/admin/anomaly-records', {
params: { ...rest, assetIds: assetIds && assetIds.length > 0 ? assetIds.join(',') : undefined },
})) as unknown as { rows: AnomalyRecordRow[]; count: number; statuses: string[] };
},
// Per-site True/False/Deleted verdict split among audited records. Dates
// scope Audited_on (when the verdict was given), not video_date.
getAuditedStats: async (params: {

View File

@@ -18,6 +18,9 @@ export const TopFilterBar: React.FC = () => {
filterFromDate, setFilterFromDate,
filterTillDate, setFilterTillDate,
filterMinChainage, setFilterMinChainage,
filterFrequencyGt, setFilterFrequencyGt,
filterAuditValues, setFilterAuditValues,
filterHasCompleteImage, setFilterHasCompleteImage,
filterMaxChainage, setFilterMaxChainage,
selectedAssetIds, setSelectedAssetIds,
assetTypes,
@@ -210,8 +213,11 @@ export const TopFilterBar: React.FC = () => {
const isAssetsFiltered = selectedAssetIds.length > 0;
const isSearchFiltered = !!searchAnomalyId;
const isPlazaFiltered = !!filterPlaza;
const isFrequencyFiltered = filterFrequencyGt !== '';
const isFeaturesFiltered = filterAuditValues.length > 0;
const isCompleteImageFiltered = filterHasCompleteImage !== '';
const isAnyFilterActive = isSiteFiltered || isStatusFiltered || isDateFiltered || isChainageFiltered || isAssetsFiltered || isSearchFiltered || isPlazaFiltered;
const isAnyFilterActive = isSiteFiltered || isStatusFiltered || isDateFiltered || isChainageFiltered || isAssetsFiltered || isSearchFiltered || isPlazaFiltered || isFrequencyFiltered || isFeaturesFiltered || isCompleteImageFiltered;
const handleClearAllFilters = () => {
setSelectedSite({ site_id: 'All Sites', site_name: 'All Sites' });
@@ -223,6 +229,9 @@ export const TopFilterBar: React.FC = () => {
setSelectedAssetIds([]);
setSearchAnomalyId('');
setFilterPlaza('');
setFilterFrequencyGt('');
setFilterAuditValues([]);
setFilterHasCompleteImage('');
};
return (
@@ -378,6 +387,7 @@ export const TopFilterBar: React.FC = () => {
<MenuItem value="Manual">Manual</MenuItem>
<MenuItem value="Auto Audits">Auto Audits</MenuItem>
<MenuItem value="False Audits">False Audits</MenuItem>
<MenuItem value="Verification">Verification</MenuItem>
</Select>
</FormControl>
)}
@@ -442,21 +452,20 @@ export const TopFilterBar: React.FC = () => {
</>
)}
<Button
variant="contained"
onClick={() => setIsRectificationEnabled(!isRectificationEnabled)}
sx={{
bgcolor: isRectificationEnabled ? '#ff4d4f' : 'rgba(255,255,255,0.1)',
color: '#fff',
'&:hover': { bgcolor: isRectificationEnabled ? '#ff7875' : 'rgba(255,255,255,0.2)' },
borderRadius: 2,
textTransform: 'none',
fontWeight: 'bold',
boxShadow: 'none'
}}
>
{isRectificationEnabled ? 'DISABLE RECTIFICATION' : 'ENABLE RECTIFICATION'}
</Button>
{/* Record type: replaced the old ENABLE/DISABLE RECTIFICATION toggle
button with a plain dropdown - same store value
(isRectificationEnabled), but the current choice is readable at a
glance instead of being implied by the button's label. */}
<FormControl size="small" sx={{ bgcolor: isRectificationEnabled ? 'rgba(255,77,79,0.25)' : 'rgba(255,255,255,0.1)', borderRadius: 2, minWidth: 150 }}>
<Select
value={isRectificationEnabled ? 'rectification' : 'audits'}
onChange={(e) => setIsRectificationEnabled(e.target.value === 'rectification')}
sx={{ '& .MuiSelect-select': { py: 1, px: 2 }, border: 'none', '& fieldset': { border: 'none' }, fontWeight: 'bold', color: '#fff' }}
>
<MenuItem value="audits">Audits</MenuItem>
<MenuItem value="rectification">Rectification</MenuItem>
</Select>
</FormControl>
</Box>
{/* Active Filter Chips Bar */}
@@ -527,6 +536,37 @@ export const TopFilterBar: React.FC = () => {
/>
)}
{isFrequencyFiltered && (
<Chip
label={`Frequency: > ${filterFrequencyGt}`}
size="small"
onDelete={() => setFilterFrequencyGt('')}
sx={{ bgcolor: 'rgba(34, 197, 94, 0.1)', color: '#4ade80', border: '1px solid rgba(34, 197, 94, 0.2)', '& .MuiChip-deleteIcon': { color: '#4ade80' } }}
/>
)}
{isFeaturesFiltered && (
<Chip
label={
filterAuditValues.length <= 2
? `Features: ${filterAuditValues.join(', ')}`
: `Features: ${filterAuditValues.length} selected`
}
size="small"
onDelete={() => setFilterAuditValues([])}
sx={{ bgcolor: 'rgba(249, 115, 22, 0.1)', color: '#fb923c', border: '1px solid rgba(249, 115, 22, 0.2)', '& .MuiChip-deleteIcon': { color: '#fb923c' } }}
/>
)}
{isCompleteImageFiltered && (
<Chip
label={`Complete Image: ${filterHasCompleteImage === 'true' ? 'Present' : 'Absent'}`}
size="small"
onDelete={() => setFilterHasCompleteImage('')}
sx={{ bgcolor: 'rgba(168, 85, 247, 0.1)', color: '#c084fc', border: '1px solid rgba(168, 85, 247, 0.2)', '& .MuiChip-deleteIcon': { color: '#c084fc' } }}
/>
)}
{isSearchFiltered && (
<Chip
label={`ID: ${searchAnomalyId}`}

View File

@@ -0,0 +1,38 @@
// Shared parser for GET /Master/site's response.
//
// The endpoint's shape varies and is deeply nested — the common case is
// `responseData[0].records` holding a SINGLE array of site groups, where each
// group carries `sub_sites` (one entry per direction, each with its own
// site_id). Paired directions share a display name, so a group becomes ONE
// picker entry whose value is the comma-joined id list ("454,455") — the
// convention every site filter in this app relies on.
//
// Logic lifted verbatim from the copies in Allocation.tsx / ClientsOverview.tsx
// (which predate this file); new screens should import this one.
export interface SiteOption {
site_id: string | number;
site_name: string;
}
export const parseSites = (sitesRes: any): SiteOption[] => {
let parsedSites: any[] = [];
if (sitesRes?.responseData?.[0]?.records) {
parsedSites = sitesRes.responseData[0].records;
if (parsedSites.length === 1 && Array.isArray(parsedSites[0])) {
parsedSites = parsedSites[0];
} else if (parsedSites.length > 0 && Array.isArray(parsedSites[0])) {
parsedSites = parsedSites.flat();
}
} else if (Array.isArray(sitesRes)) {
parsedSites = sitesRes;
} else if (sitesRes?.data && Array.isArray(sitesRes.data)) {
parsedSites = sitesRes.data;
}
return parsedSites
.map((site) => ({
site_id: site.site_id || (site.sub_sites || []).map((s: any) => s.site_id).filter(Boolean).join(','),
site_name: site.site_name || site.name || site.siteName || site.site_id,
}))
.filter((s) => s.site_id);
};

View File

@@ -12,3 +12,17 @@ export const masterImageUrl = (frameMaster: string | null | undefined, isRectifi
export const testImageUrl = (frameTest: string | null | undefined) =>
frameTest ? `${TEST_IMAGE_PREFIX}${frameTest}` : null;
// `complete_image` is NOT like Frame_Master/Frame_Test: the column holds TWO
// shapes. The work-order layer writes an ABSOLUTE URL on a different host
// (https://sr-img.seekright.com/seekright-ims/<uuid>.jpg - all 652 populated
// roadis.rectification rows), while other rows carry a CDN-RELATIVE path.
// Blindly prefixing an absolute value yields
// `.../SeekRight/https://sr-img...` which the CDN 404s, so absolute values
// must pass through untouched.
export const completeImageUrl = (completeImage: string | null | undefined) => {
if (!completeImage) return null;
const v = completeImage.trim();
if (!v) return null;
return /^(https?:)?\/\//i.test(v) ? v : `${TEST_IMAGE_PREFIX}${v}`;
};

View File

@@ -6,6 +6,7 @@ import {
} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import { useFeedStore } from '../../store/feedStore';
import { ANOMALY_CATEGORIES, SAFE_CATEGORIES, RECT_SAFE_CATEGORIES } from '../audit-session/AuditSession';
import { activityFeedsService } from '../../api/activityFeedsService';
// ── Memoized asset row — only re-renders when ITS OWN checked state changes ──
@@ -71,6 +72,10 @@ export const FeedFilters: React.FC = () => {
filterFromDate, filterTillDate,
filterMinChainage, filterMaxChainage,
setFilterMinChainage, setFilterMaxChainage,
filterFrequencyGt, setFilterFrequencyGt,
filterAuditValues, setFilterAuditValues,
filterHasCompleteImage, setFilterHasCompleteImage,
isRectificationEnabled,
sortBy, sortDir, setSortBy, setSortDir,
} = useFeedStore();
@@ -83,6 +88,9 @@ export const FeedFilters: React.FC = () => {
const [draftSortDir, setDraftSortDir] = useState<string>(() => sortDir);
const [draftMinChainage, setDraftMinChainage] = useState('');
const [draftMaxChainage, setDraftMaxChainage] = useState('');
const [draftFrequencyGt, setDraftFrequencyGt] = useState('');
const [draftAuditValues, setDraftAuditValues] = useState<string[]>([]);
const [draftHasCompleteImage, setDraftHasCompleteImage] = useState('');
const [assetSearch, setAssetSearch] = useState('');
const [loading, setLoading] = useState(false);
@@ -95,6 +103,9 @@ export const FeedFilters: React.FC = () => {
setDraftTillDate(filterTillDate); // ← seeded from store
setDraftMinChainage(filterMinChainage); // ← seeded from store
setDraftMaxChainage(filterMaxChainage); // ← seeded from store
setDraftFrequencyGt(filterFrequencyGt); // ← seeded from store
setDraftAuditValues(filterAuditValues); // ← seeded from store
setDraftHasCompleteImage(filterHasCompleteImage); // ← seeded from store
setAssetSearch('');
} else if (!isFiltersModalOpen && prevOpen) {
setPrevOpen(false);
@@ -150,6 +161,9 @@ export const FeedFilters: React.FC = () => {
setDraftTillDate('');
setDraftMinChainage('');
setDraftMaxChainage('');
setDraftFrequencyGt('');
setDraftAuditValues([]);
setDraftHasCompleteImage('');
setDraftSortBy('');
setDraftSortDir('asc');
setAssetSearch('');
@@ -167,6 +181,9 @@ export const FeedFilters: React.FC = () => {
setFilterTillDate(draftTillDate);
setFilterMinChainage(draftMinChainage); // persist chainage globally
setFilterMaxChainage(draftMaxChainage); // persist chainage globally
setFilterFrequencyGt(draftFrequencyGt); // persist frequency threshold globally
setFilterAuditValues(draftAuditValues); // persist feature filter globally
setFilterHasCompleteImage(draftHasCompleteImage); // persist complete-image presence globally
setSelectedAssetIds(draftAssetArr);
setSortBy(draftSortBy as any);
setSortDir(draftSortDir as any);
@@ -182,7 +199,7 @@ 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}|${activeTab.auditedByMe ?? false}|${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}|${draftFrequencyGt}|${draftAuditValues.join(',')}|${draftHasCompleteImage}`;
const historyRes: any = await activityFeedsService.getHistory({
date: activeTab.date,
@@ -197,8 +214,13 @@ export const FeedFilters: React.FC = () => {
isTrueFalse: isRectActive ? 'both' : (auditStatus === 'False Audits' ? 'true' : 'both'),
isRectificationEnabled: isRectActive,
auto_audit: isRectActive ? '' : (auditStatus === 'Auto Audits' ? 'true' : (auditStatus === 'Manual' ? 'false' : '')),
// Verification mode: only records whose anomaly was later manually closed
verification: auditStatus === 'Verification' ? 'true' : undefined,
fromChainage: draftMinChainage,
toChainage: draftMaxChainage,
frequencyGt: draftFrequencyGt,
auditValues: draftAuditValues.join(','),
hasCompleteImage: draftHasCompleteImage,
plaza: filterPlaza,
sortBy: draftSortBy,
sortDir: draftSortDir,
@@ -305,6 +327,62 @@ export const FeedFilters: React.FC = () => {
<TextField size="small" placeholder="To" value={draftMaxChainage} onChange={(e) => setDraftMaxChainage(e.target.value)}
sx={{ width: 90, bgcolor: 'rgba(255,255,255,0.07)', borderRadius: 1, input: { color: '#f8fafc' }, '& fieldset': { borderColor: '#334155' } }} />
</Box>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Typography variant="body2" sx={{ fontWeight: 'bold', color: '#94a3b8', whiteSpace: 'nowrap' }}>Frequency &gt;</Typography>
{/* Free numeric threshold; blank = any. Digits only - the backend
integer-guards too, but bad input here would also pollute the
cache filterKey. */}
<TextField
size="small"
placeholder="Any"
value={draftFrequencyGt}
onChange={(e) => {
const v = e.target.value.replace(/[^0-9]/g, '');
setDraftFrequencyGt(v);
}}
slotProps={{ htmlInput: { inputMode: 'numeric', pattern: '[0-9]*' } }}
sx={{ width: 80, bgcolor: 'rgba(255,255,255,0.07)', borderRadius: 1, input: { color: '#f8fafc' }, '& fieldset': { borderColor: '#334155' } }}
/>
</Box>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Typography variant="body2" sx={{ fontWeight: 'bold', color: '#94a3b8', whiteSpace: 'nowrap' }}>Features</Typography>
{/* The fixed audit-option vocabulary for the active mode; matched
case-insensitively server-side (human 'broken' vs AI 'Broken'). */}
<Select
size="small"
multiple
displayEmpty
value={draftAuditValues}
onChange={(e) => setDraftAuditValues(typeof e.target.value === 'string' ? e.target.value.split(',') : e.target.value)}
renderValue={(sel) => (sel as string[]).length === 0 ? 'Any' : `${(sel as string[]).length} selected`}
sx={{ minWidth: 130, bgcolor: 'rgba(255,255,255,0.07)', borderRadius: 1, color: '#f8fafc', '& fieldset': { borderColor: '#334155' }, '& .MuiSelect-icon': { color: '#94a3b8' } }}
MenuProps={{ slotProps: { paper: { sx: { bgcolor: '#0f172a', color: '#f8fafc', border: '1px solid #1e293b', maxHeight: 360 } } } }}
>
{(isRectificationEnabled
? [{ value: 'Others', label: 'Others (true)' }, ...RECT_SAFE_CATEGORIES]
: [...ANOMALY_CATEGORIES, ...SAFE_CATEGORIES]
).map((opt: any) => (
<MenuItem key={opt.value} value={opt.value} sx={{ fontSize: 13, '&.Mui-selected': { bgcolor: 'rgba(59,130,246,0.25)' } }}>
{opt.label}
</MenuItem>
))}
</Select>
</Box>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Typography variant="body2" sx={{ fontWeight: 'bold', color: '#94a3b8', whiteSpace: 'nowrap' }}>Complete Image</Typography>
<Select
size="small"
value={draftHasCompleteImage}
onChange={(e) => setDraftHasCompleteImage(String(e.target.value))}
displayEmpty
sx={{ minWidth: 110, bgcolor: 'rgba(255,255,255,0.07)', borderRadius: 1, color: '#f8fafc', '& fieldset': { borderColor: '#334155' }, '& .MuiSelect-icon': { color: '#94a3b8' } }}
MenuProps={{ slotProps: { paper: { sx: { bgcolor: '#0f172a', color: '#f8fafc', border: '1px solid #1e293b' } } } }}
>
<MenuItem value="">Any</MenuItem>
<MenuItem value="true">Present</MenuItem>
<MenuItem value="false">Absent</MenuItem>
</Select>
</Box>
</Box>
<Divider sx={{ borderColor: '#1e293b', mb: 2 }} />

View File

@@ -16,6 +16,7 @@ export const GlobalFeed: React.FC = () => {
isRectificationEnabled,
filterFromDate, filterTillDate,
filterMinChainage, filterMaxChainage,
filterFrequencyGt, filterAuditValues, filterHasCompleteImage,
selectedAssetIds,
setIsFeedLoading,
sortBy, sortDir
@@ -96,7 +97,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}|${activeTab.auditedByMe ?? false}|${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}|${filterFrequencyGt}|${filterAuditValues.join(',')}|${filterHasCompleteImage}`;
// CACHE HIT: This exact tab and filter combo was already fetched
if (filterKey === activeTab.filters?.lastFetchedFilterKey && activeTab.anomalies && activeTab.anomalies.length > 0) {
@@ -118,6 +119,8 @@ export const GlobalFeed: React.FC = () => {
auditStatus: auditStatus,
isTrueFalse: isRectActive ? 'both' : (auditStatus === 'False Audits' ? 'true' : 'both'),
auto_audit: isRectActive ? '' : (auditStatus === 'Auto Audits' ? 'true' : (auditStatus === 'Manual' ? 'false' : '')),
// Verification mode: only records whose anomaly was later manually closed
verification: auditStatus === 'Verification' ? 'true' : undefined,
isRectificationEnabled: isRectActive,
sortByDateAsc: false,
sortBy,
@@ -128,6 +131,9 @@ export const GlobalFeed: React.FC = () => {
fromDate: filterFromDate,
tillDate: filterTillDate,
fromChainage: filterMinChainage,
frequencyGt: filterFrequencyGt,
auditValues: filterAuditValues.join(','),
hasCompleteImage: filterHasCompleteImage,
toChainage: filterMaxChainage,
});
if (historyRes && historyRes.anomalies) {
@@ -140,6 +146,9 @@ export const GlobalFeed: React.FC = () => {
fromDate: filterFromDate,
tillDate: filterTillDate,
fromChainage: filterMinChainage,
frequencyGt: filterFrequencyGt,
auditValues: filterAuditValues.join(','),
hasCompleteImage: filterHasCompleteImage,
toChainage: filterMaxChainage,
lastFetchedFilterKey: filterKey
}
@@ -155,7 +164,7 @@ export const GlobalFeed: React.FC = () => {
fetchFilteredHistory();
}, [
selectedSite?.site_id, auditStatus, searchAnomalyId, filterPlaza, isRectificationEnabled, selectedTabIndex, tabs.length,
filterFromDate, filterTillDate, filterMinChainage, filterMaxChainage, selectedAssetIds, sortBy, sortDir
filterFromDate, filterTillDate, filterMinChainage, filterMaxChainage, filterFrequencyGt, filterAuditValues, filterHasCompleteImage, selectedAssetIds, sortBy, sortDir
]);
const handlePageChange = async (_event: unknown, newPage: number) => {
@@ -175,6 +184,8 @@ export const GlobalFeed: React.FC = () => {
auditStatus: auditStatus,
isTrueFalse: isRectActive ? 'both' : (auditStatus === 'False Audits' ? 'true' : 'both'),
auto_audit: isRectActive ? '' : (auditStatus === 'Auto Audits' ? 'true' : (auditStatus === 'Manual' ? 'false' : '')),
// Verification mode: only records whose anomaly was later manually closed
verification: auditStatus === 'Verification' ? 'true' : undefined,
isRectificationEnabled: isRectActive,
sortByDateAsc: false,
sortBy,
@@ -185,6 +196,9 @@ export const GlobalFeed: React.FC = () => {
fromDate: filterFromDate,
tillDate: filterTillDate,
fromChainage: filterMinChainage,
frequencyGt: filterFrequencyGt,
auditValues: filterAuditValues.join(','),
hasCompleteImage: filterHasCompleteImage,
toChainage: filterMaxChainage,
});
if (historyRes && historyRes.anomalies) {
@@ -218,6 +232,8 @@ export const GlobalFeed: React.FC = () => {
auditStatus: auditStatus,
isTrueFalse: isRectActive ? 'both' : (auditStatus === 'False Audits' ? 'true' : 'both'),
auto_audit: isRectActive ? '' : (auditStatus === 'Auto Audits' ? 'true' : (auditStatus === 'Manual' ? 'false' : '')),
// Verification mode: only records whose anomaly was later manually closed
verification: auditStatus === 'Verification' ? 'true' : undefined,
isRectificationEnabled: isRectActive,
anomalyIds: searchAnomalyId,
plaza: filterPlaza,
@@ -225,6 +241,9 @@ export const GlobalFeed: React.FC = () => {
fromDate: filterFromDate,
tillDate: filterTillDate,
fromChainage: filterMinChainage,
frequencyGt: filterFrequencyGt,
auditValues: filterAuditValues.join(','),
hasCompleteImage: filterHasCompleteImage,
toChainage: filterMaxChainage,
});
if (historyRes && historyRes.anomalies) {

View File

@@ -8,6 +8,7 @@ import { AuditHistory } from './AuditHistory';
import { BulkRevert } from './BulkRevert';
import { DevTickets } from './DevTickets';
import { MissingMedia } from './MissingMedia';
import { AnomalyBrowser } from './AnomalyBrowser';
export const AdminConsole: React.FC = () => {
const [tab, setTab] = useState(0);
@@ -28,6 +29,8 @@ export const AdminConsole: React.FC = () => {
<Tab label="Bulk Revert" />
<Tab label="Dev Tickets" />
<Tab label="Missing Media" />
<Tab label="Anomalies" />
<Tab label="Complete" />
</Tabs>
{tab === 0 && <ClientsOverview />}
@@ -38,6 +41,8 @@ export const AdminConsole: React.FC = () => {
{tab === 5 && <BulkRevert />}
{tab === 6 && <DevTickets />}
{tab === 7 && <MissingMedia />}
{tab === 8 && <AnomalyBrowser />}
{tab === 9 && <AnomalyBrowser table="complete" />}
</Box>
);
};

View File

@@ -0,0 +1,254 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
Paper, FormControl, InputLabel, Select, MenuItem, CircularProgress,
Typography, TextField, Chip, TablePagination,
} from '@mui/material';
import { adminService } from '../../api/adminService';
import type { AnomalyRecordRow } from '../../api/adminService';
import { accountService } from '../../api/accountService';
import { activityFeedsService } from '../../api/activityFeedsService';
import { AssetMultiSelect } from '../../components/common/AssetMultiSelect';
import { parseSites } from '../../components/common/parseSites';
import { useToastStore } from '../../store/toastStore';
interface Organization {
org_id: string;
org_name: string;
db_name: string;
}
const PAGE_SIZE = 25;
// Status chip colors: semantic, matching how these states read in the product.
const STATUS_STYLE: Record<string, { bg: string; fg: string }> = {
Anomaly: { bg: 'rgba(234, 179, 8, 0.15)', fg: '#facc15' },
Under_Inspection: { bg: 'rgba(59, 130, 246, 0.15)', fg: '#60a5fa' },
Manual_Close: { bg: 'rgba(168, 85, 247, 0.15)', fg: '#c084fc' },
Completed: { bg: 'rgba(16, 185, 129, 0.15)', fg: '#34d399' },
Ignored: { bg: 'rgba(100, 116, 139, 0.2)', fg: '#94a3b8' },
Deleted: { bg: 'rgba(239, 68, 68, 0.15)', fg: '#f87171' },
};
// Admin browser over a per-org work-order table, shared by TWO tabs:
// table="anomaly" (default) - live records: Anomaly/Under_Inspection/
// Manual_Close/Completed/Ignored
// table="complete" - the history table /close copies rows into; its status
// vocabulary is only Completed/Deleted (server-driven, so
// the Status dropdown always matches the chosen table).
// Filters: status / site / assets / frequency / completed_on window /
// ai_rejected. Backed by GET /admin/anomaly-records (orgDb param - the axios
// interceptor owns dbName).
interface AnomalyBrowserProps {
table?: 'anomaly' | 'complete';
}
export const AnomalyBrowser: React.FC<AnomalyBrowserProps> = ({ table = 'anomaly' }) => {
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [orgDb, setOrgDb] = useState('');
// Sites of the selected org, GROUPED by display name - paired directions
// share a name ("454,455" is one picker entry), so value = comma id list.
const [siteOptions, setSiteOptions] = useState<{ label: string; ids: string }[]>([]);
const [siteIds, setSiteIds] = useState('');
const [frequencyGt, setFrequencyGt] = useState('');
// Default view per table: the state each tab is actually opened for.
const [status, setStatus] = useState(table === 'complete' ? '' : 'Manual_Close');
const [statuses, setStatuses] = useState<string[]>(
table === 'complete' ? ['Completed', 'Deleted'] : ['Anomaly', 'Under_Inspection', 'Manual_Close', 'Completed', 'Ignored'],
);
const [assetIds, setAssetIds] = useState<number[]>([]);
const [completedFrom, setCompletedFrom] = useState('');
const [completedTo, setCompletedTo] = useState('');
const [aiRejected, setAiRejected] = useState('');
const [rows, setRows] = useState<AnomalyRecordRow[]>([]);
const [count, setCount] = useState(0);
const [page, setPage] = useState(0);
const [loading, setLoading] = useState(false);
useEffect(() => {
(async () => {
try {
const orgs = ((await accountService.getOrganizations()) as any) || [];
setOrganizations(orgs);
if (orgs.length > 0) setOrgDb(orgs[0].db_name);
} catch {
useToastStore.getState().showToast('Failed to load organizations.', 'error');
}
})();
}, []);
// Load the selected org's sites whenever the org changes. /Master/site's
// response is deeply nested and groups paired directions under one name -
// parseSites handles both (value = comma-joined ids, the "454,455" rule).
useEffect(() => {
setSiteIds('');
setSiteOptions([]);
const org = organizations.find((o) => o.db_name === orgDb);
if (!org) return;
(async () => {
try {
const res = await activityFeedsService.getOrganizationSites(String(org.org_id));
setSiteOptions(parseSites(res).map((s) => ({ label: String(s.site_name), ids: String(s.site_id) })));
} catch {
// site filter just stays empty - listing still works without it
}
})();
}, [orgDb, organizations]);
// Same inverted-range normalization as the feed filters; "to" extended to
// end of day so a single-day range matches its whole day.
const [effFrom, effTo] = (() => {
if (!completedFrom || !completedTo) {
return [completedFrom || undefined, completedTo ? `${completedTo}T23:59:59` : undefined];
}
return completedFrom <= completedTo
? [completedFrom, `${completedTo}T23:59:59`]
: [completedTo, `${completedFrom}T23:59:59`];
})();
const fetchRows = useCallback(async () => {
if (!orgDb) return;
setLoading(true);
try {
const res = await adminService.getAnomalyRecords({
orgDb,
table,
status: status || undefined,
assetIds,
siteIds: siteIds || undefined,
frequencyGt: frequencyGt || undefined,
completedFrom: effFrom,
completedTo: effTo,
aiRejected: aiRejected || undefined,
pageNo: page,
pageSize: PAGE_SIZE,
});
setRows(res.rows);
setCount(res.count);
if (res.statuses?.length) setStatuses(res.statuses);
} catch {
useToastStore.getState().showToast('Failed to load anomaly records.', 'error');
} finally {
setLoading(false);
}
}, [orgDb, table, status, assetIds, siteIds, frequencyGt, effFrom, effTo, aiRejected, page]);
useEffect(() => { fetchRows(); }, [fetchRows]);
// Any filter change goes back to page 0 so the pager can't point past the result.
useEffect(() => { setPage(0); }, [orgDb, status, assetIds, siteIds, frequencyGt, effFrom, effTo, aiRejected]);
const fmt = (d: string | null) => (d ? new Date(d).toLocaleString() : '—');
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={orgDb} onChange={(e) => setOrgDb(e.target.value)}>
{organizations.map((o) => <MenuItem key={o.org_id} value={o.db_name}>{o.org_name}</MenuItem>)}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 170 }}>
<InputLabel>Status</InputLabel>
<Select label="Status" value={status} onChange={(e) => setStatus(e.target.value)}>
<MenuItem value="">All statuses</MenuItem>
{statuses.map((s) => <MenuItem key={s} value={s}>{s.replace(/_/g, ' ')}</MenuItem>)}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>Site</InputLabel>
<Select label="Site" value={siteIds} onChange={(e) => setSiteIds(e.target.value)}>
<MenuItem value="">All sites</MenuItem>
{siteOptions.map((o) => <MenuItem key={o.ids} value={o.ids}>{o.label}</MenuItem>)}
</Select>
</FormControl>
<AssetMultiSelect value={assetIds} onChange={setAssetIds} />
<TextField
size="small" label="Frequency >" placeholder="Any" value={frequencyGt}
onChange={(e) => setFrequencyGt(e.target.value.replace(/[^0-9]/g, ''))}
slotProps={{ inputLabel: { shrink: true }, htmlInput: { inputMode: 'numeric', pattern: '[0-9]*' } }}
sx={{ width: 110 }}
/>
<TextField size="small" type="date" label="Completed from" value={completedFrom}
onChange={(e) => setCompletedFrom(e.target.value)} slotProps={{ inputLabel: { shrink: true } }} />
<TextField size="small" type="date" label="Completed to" value={completedTo}
onChange={(e) => setCompletedTo(e.target.value)} slotProps={{ inputLabel: { shrink: true } }} />
<FormControl size="small" sx={{ minWidth: 140 }}>
<InputLabel>AI Rejected</InputLabel>
<Select label="AI Rejected" value={aiRejected} onChange={(e) => setAiRejected(e.target.value)}>
<MenuItem value="">Any</MenuItem>
<MenuItem value="true">Yes</MenuItem>
<MenuItem value="false">No</MenuItem>
</Select>
</FormControl>
{loading && <CircularProgress size={20} />}
<Typography variant="body2" sx={{ color: '#94a3b8', ml: 'auto' }}>
{count.toLocaleString()} record{count === 1 ? '' : 's'}
</Typography>
</Box>
<TableContainer component={Paper} sx={{ bgcolor: '#0f172a', border: '1px solid #1e293b' }}>
<Table size="small">
<TableHead>
<TableRow>
{['ID', 'Site', 'Asset', 'Status', 'Chainage', 'Freq', 'AI Rejected', 'Manual Close', 'Completed On', 'AI Completed On', 'Master ID'].map((h) => (
<TableCell key={h} sx={{ color: '#94a3b8', borderColor: '#1e293b', fontWeight: 'bold', whiteSpace: 'nowrap' }}>{h}</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{rows.length === 0 && !loading ? (
<TableRow><TableCell colSpan={11} sx={{ color: '#64748b', borderColor: '#1e293b', textAlign: 'center', py: 6 }}>
No records match these filters
</TableCell></TableRow>
) : rows.map((r) => {
const st = STATUS_STYLE[r.status] || STATUS_STYLE.Ignored;
return (
<TableRow key={r.anomaly_id} hover>
<TableCell sx={{ color: '#60a5fa', borderColor: '#1e293b', fontWeight: 'bold' }}>#{r.anomaly_id}</TableCell>
<TableCell sx={{ color: '#e2e8f0', borderColor: '#1e293b' }}>{r.site_name}</TableCell>
<TableCell sx={{ color: '#e2e8f0', borderColor: '#1e293b' }}>{r.asset_name?.replace(/_/g, ' ')}</TableCell>
<TableCell sx={{ borderColor: '#1e293b' }}>
<Chip size="small" label={r.status?.replace(/_/g, ' ')} sx={{ bgcolor: st.bg, color: st.fg, fontWeight: 'bold' }} />
</TableCell>
<TableCell sx={{ color: '#cbd5e1', borderColor: '#1e293b' }}>{r.chainage ?? '—'}</TableCell>
<TableCell sx={{ color: '#cbd5e1', borderColor: '#1e293b' }}>{r.frequency ?? '—'}</TableCell>
<TableCell sx={{ borderColor: '#1e293b' }}>
{r.ai_rejected
? <Chip size="small" label="Yes" sx={{ bgcolor: 'rgba(239,68,68,0.15)', color: '#f87171', fontWeight: 'bold' }} />
: <Typography component="span" sx={{ color: '#475569', fontSize: '0.8rem' }}>No</Typography>}
</TableCell>
<TableCell sx={{ color: '#cbd5e1', borderColor: '#1e293b' }}>
{r.manual_close === null || r.manual_close === undefined ? '—' : String(r.manual_close)}
</TableCell>
<TableCell sx={{ color: '#cbd5e1', borderColor: '#1e293b', whiteSpace: 'nowrap' }}>{fmt(r.completed_on)}</TableCell>
<TableCell sx={{ color: r.ai_completed_on ? '#c084fc' : '#475569', borderColor: '#1e293b', whiteSpace: 'nowrap' }}>{fmt(r.ai_completed_on)}</TableCell>
<TableCell sx={{ color: '#64748b', borderColor: '#1e293b', maxWidth: 220, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={r.master_id || ''}>
{r.master_id ?? '—'}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
<TablePagination
component="div"
count={count}
page={page}
onPageChange={(_, p) => setPage(p)}
rowsPerPage={PAGE_SIZE}
rowsPerPageOptions={[PAGE_SIZE]}
sx={{ color: '#94a3b8', borderTop: '1px solid #1e293b' }}
/>
</TableContainer>
</Box>
);
};

View File

@@ -6,7 +6,7 @@ import { ZoomableImage } from './ZoomableImage';
import { AnnotationOverlay } from './AnnotationOverlay';
import { MapDialog } from '../../components/common/MapDialog';
import { VideoAnnotationOverlay } from './VideoAnnotationOverlay';
import { TEST_IMAGE_PREFIX } from '../../constants/media';
import { TEST_IMAGE_PREFIX, completeImageUrl } from '../../constants/media';
import SearchIcon from '@mui/icons-material/Search';
import { activityFeedsService } from '../../api/activityFeedsService';
import { axiosClient } from '../../api/axiosClient';
@@ -17,7 +17,7 @@ import { useFeedStore } from '../../store/feedStore';
// key = displayed badge AND keyboard shortcut (0-9, first item = 0, matching
// the legacy UI). id stays the stable internal identity (1-10) that drives
// selection and the id-1 sub-menu logic - id and key deliberately diverge.
const SAFE_CATEGORIES = [
export const SAFE_CATEGORIES = [
{ id: 1, value: "not an anomaly", label: "Not an Anomaly", color: "#10b981", key: "0" },
{ id: 2, value: "vehicle occlusion", label: "Vehicle Occlusion", color: "#3b82f6", key: "1" },
{ id: 3, value: "low light condition", label: "Low Light Condition", color: "#6366f1", key: "2" },
@@ -30,7 +30,7 @@ const SAFE_CATEGORIES = [
{ id: 10, value: "false others", label: "False Others", color: "#64748b", key: "9" }
];
const ANOMALY_CATEGORIES = [
export const ANOMALY_CATEGORIES = [
{ id: 1, value: "plant", label: "Plant", color: "#10b981", key: "0" },
{ id: 2, value: "bent", label: "Bent", color: "#3b82f6", key: "1" },
{ id: 3, value: "broken", label: "Broken", color: "#ef4444", key: "2" },
@@ -46,7 +46,7 @@ const ANOMALY_CATEGORIES = [
// Rectification FALSE verdicts - the only two the legacy flow accepts.
// 'true others' (exact lowercase) is what POST /auditor/rectified/false
// matches on; anything else would silently skip the defect re-opening.
const RECT_SAFE_CATEGORIES = [
export const RECT_SAFE_CATEGORIES = [
{ id: 1, value: "true others", label: "True Others", color: "#f97316", key: "1" },
{ id: 2, value: "false others", label: "False Others", color: "#64748b", key: "2" },
];
@@ -201,6 +201,9 @@ export const AuditSession: React.FC = () => {
// an audit verdict). Picker reads the startup-loaded asset catalog from
// the feed store; the backend resolves the canonical name from asset_id.
const [isEditAssetOpen, setIsEditAssetOpen] = useState(false);
// One-pane swap: MASTER vs COMPLETE view. Reset to Master per record so a
// record without a complete_image never shows a stale empty view.
const [showCompleteImage, setShowCompleteImage] = useState(false);
const [editAssetSearch, setEditAssetSearch] = useState('');
const [editAssetId, setEditAssetId] = useState<number | null>(null);
const [editAssetSaving, setEditAssetSaving] = useState(false);
@@ -294,7 +297,7 @@ export const AuditSession: React.FC = () => {
const {
tabs, selectedTabIndex, updateTab,
selectedSite, filterFromDate, filterTillDate,
filterMinChainage, filterMaxChainage, selectedAssetIds,
filterMinChainage, filterMaxChainage, filterFrequencyGt, filterAuditValues, filterHasCompleteImage, selectedAssetIds,
searchAnomalyId, auditStatus, isRectificationEnabled, filterPlaza,
setDashboardCache, sortBy, sortDir, setSortBy, setSortDir,
assetTypes
@@ -394,12 +397,17 @@ export const AuditSession: React.FC = () => {
isTrueFalse: isRectActive ? 'both' : (auditStatus === 'False Audits' ? 'true' : 'both'),
auditStatus: auditStatus,
auto_audit: isRectActive ? '' : (auditStatus === 'Auto Audits' ? 'true' : (auditStatus === 'Manual' ? 'false' : '')),
// Verification mode: only records whose anomaly was later manually closed
verification: auditStatus === 'Verification' ? 'true' : undefined,
isRectificationEnabled: isRectActive,
siteIds: (!selectedSiteId || selectedSiteId === 'All Sites') ? '' : selectedSiteId,
assetsIds: assetIdsKey,
fromDate: filterFromDate,
tillDate: filterTillDate,
fromChainage: filterMinChainage,
frequencyGt: filterFrequencyGt,
auditValues: filterAuditValues.join(','),
hasCompleteImage: filterHasCompleteImage,
toChainage: filterMaxChainage,
anomalyIds: searchAnomalyId,
plaza: filterPlaza,
@@ -425,7 +433,7 @@ export const AuditSession: React.FC = () => {
} finally {
setLoading(false);
}
}, [selectedSite, selectedAssetIds, auditStatus, isRectificationEnabled, filterFromDate, filterTillDate, filterMinChainage, filterMaxChainage, searchAnomalyId, activeDate, isRectActive, location.state, filterPlaza, sessionPageNo, navigate, sortBy, sortDir]);
}, [selectedSite, selectedAssetIds, auditStatus, isRectificationEnabled, filterFromDate, filterTillDate, filterMinChainage, filterMaxChainage, filterFrequencyGt, filterAuditValues, filterHasCompleteImage, searchAnomalyId, activeDate, isRectActive, location.state, filterPlaza, sessionPageNo, navigate, sortBy, sortDir]);
const handleLoadNextFeedPage = useCallback(async () => {
try {
@@ -446,12 +454,17 @@ export const AuditSession: React.FC = () => {
isTrueFalse: isRectActive ? 'both' : (auditStatus === 'False Audits' ? 'true' : 'both'),
auditStatus: auditStatus,
auto_audit: isRectActive ? '' : (auditStatus === 'Auto Audits' ? 'true' : (auditStatus === 'Manual' ? 'false' : '')),
// Verification mode: only records whose anomaly was later manually closed
verification: auditStatus === 'Verification' ? 'true' : undefined,
isRectificationEnabled: isRectActive,
siteIds: (!selectedSiteId || selectedSiteId === 'All Sites') ? '' : selectedSiteId,
assetsIds: assetIdsKey,
fromDate: filterFromDate,
tillDate: filterTillDate,
fromChainage: filterMinChainage,
frequencyGt: filterFrequencyGt,
auditValues: filterAuditValues.join(','),
hasCompleteImage: filterHasCompleteImage,
toChainage: filterMaxChainage,
anomalyIds: searchAnomalyId,
plaza: filterPlaza,
@@ -482,7 +495,7 @@ export const AuditSession: React.FC = () => {
} finally {
setLoading(false);
}
}, [selectedSite, selectedAssetIds, auditStatus, isRectificationEnabled, filterFromDate, filterTillDate, filterMinChainage, filterMaxChainage, searchAnomalyId, activeDate, isRectActive, location.state, activeTab, selectedTabIndex, updateTab, navigate, sortBy, sortDir]);
}, [selectedSite, selectedAssetIds, auditStatus, isRectificationEnabled, filterFromDate, filterTillDate, filterMinChainage, filterMaxChainage, filterFrequencyGt, filterAuditValues, filterHasCompleteImage, searchAnomalyId, activeDate, isRectActive, location.state, activeTab, selectedTabIndex, updateTab, navigate, sortBy, sortDir]);
useEffect(() => {
if (hasInitializedSession.current) return;
@@ -799,11 +812,13 @@ export const AuditSession: React.FC = () => {
});
}, [currentAnomaly, showingRecentImage, masterItems]);
// When the record changes, point the master carousel at the Frame_Master frame.
// When the record changes, point the master carousel at the Frame_Master frame
// and reset the pane back to the MASTER view (the swap is per-record).
useEffect(() => {
const fmUrl = IMAGES_AWS_PREFIX + (currentAnomaly?.Frame_Master || '');
const idx = masterItems.findIndex((u) => u === fmUrl);
setMasterCarouselIndex(idx >= 0 ? idx : 0);
setShowCompleteImage(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentAnomaly]);
@@ -2059,10 +2074,38 @@ export const AuditSession: React.FC = () => {
{/* Previous Image Pane */}
<Box sx={{ flex: 1, bgcolor: '#0f172a', borderRadius: 2, border: '1px solid #1e293b', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ px: 2, py: 1, borderBottom: '1px solid #1e293b', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography sx={{ fontSize: '0.75rem', fontWeight: 'bold', color: '#94a3b8', letterSpacing: 1 }}>{showingRecentImage ? 'RECENT IMAGE' : 'MASTER IMAGE'}</Typography>
<Typography sx={{ fontSize: '0.75rem', fontWeight: 'bold', color: '#94a3b8', letterSpacing: 1 }}>
{showCompleteImage && currentAnomaly?.complete_image ? 'COMPLETE IMAGE' : (showingRecentImage ? 'RECENT IMAGE' : 'MASTER IMAGE')}
</Typography>
{/* Swap control - only offered when the record carries a
complete_image; one pane, two views, layout untouched. */}
{currentAnomaly?.complete_image && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ display: 'flex', borderRadius: 1.5, overflow: 'hidden', border: '1px solid #334155' }}>
<Button size="small" onClick={() => setShowCompleteImage(false)}
sx={{ px: 1.25, py: 0.25, minWidth: 0, fontSize: '0.68rem', fontWeight: 'bold', textTransform: 'none', borderRadius: 0,
color: !showCompleteImage ? '#fff' : '#94a3b8', bgcolor: !showCompleteImage ? '#1e3a8a' : 'transparent',
'&:hover': { bgcolor: !showCompleteImage ? '#1e3a8a' : 'rgba(255,255,255,0.08)' } }}>
Master
</Button>
<Button size="small" onClick={() => setShowCompleteImage(true)}
sx={{ px: 1.25, py: 0.25, minWidth: 0, fontSize: '0.68rem', fontWeight: 'bold', textTransform: 'none', borderRadius: 0,
color: showCompleteImage ? '#fff' : '#94a3b8', bgcolor: showCompleteImage ? '#1e3a8a' : 'transparent',
'&:hover': { bgcolor: showCompleteImage ? '#1e3a8a' : 'rgba(255,255,255,0.08)' } }}>
Complete
</Button>
</Box>
</Box>
)}
</Box>
<Box sx={{ flex: 1, position: 'relative', overflow: 'hidden' }}>
{masterItems.length > 0 ? (
{showCompleteImage && currentAnomaly?.complete_image ? (
<ZoomableImage
src={completeImageUrl(currentAnomaly.complete_image) as string}
alt="Complete"
fallback="/assets/images/master_image_not_available.png"
/>
) : masterItems.length > 0 ? (
<>
<ZoomableImage src={masterItems[masterCarouselIndex] || masterImageSrc} alt="Master" fallback="/assets/images/master_image_not_available.png" />
{/* Per-master chainage + coordinates: follows the carousel
@@ -2212,6 +2255,12 @@ export const AuditSession: React.FC = () => {
<Chip size="small" label={chipLabel('DETECTED', currentAnomaly?.date_of_audit || currentAnomaly?.Created_on ? new Date(currentAnomaly?.date_of_audit || currentAnomaly?.Created_on).toLocaleString() : 'No Date')} sx={{ bgcolor: '#1e293b', borderRadius: 1.5 }} />
<Chip size="small" label={chipLabel('CHAINAGE', currentAnomaly?.Chainage || 'N/A')} sx={{ bgcolor: '#1e293b', borderRadius: 1.5 }} />
<Chip size="small" label={chipLabel('SIDE', currentAnomaly?.Side || 'N/A')} sx={{ bgcolor: '#1e293b', borderRadius: 1.5 }} />
{/* Pipeline occurrence marker; undefined on cached rows fetched
before the backend started returning it - hide rather than
show a misleading 0. */}
{currentAnomaly?.frequency !== undefined && currentAnomaly?.frequency !== null && (
<Chip size="small" label={chipLabel('FREQUENCY', String(currentAnomaly.frequency))} sx={{ bgcolor: '#1e293b', borderRadius: 1.5 }} />
)}
<Chip
size="small"
label={chipLabel('LOCATION', `${latStr}${longStr ? `, ${longStr}` : ''}${recordMapPoint ? ' 📍' : ''}`)}
@@ -2865,6 +2914,7 @@ export const AuditSession: React.FC = () => {
<MapDialog open={mapPoint !== null} onClose={() => setMapPoint(null)} lat={mapPoint?.lat ?? null} lng={mapPoint?.lng ?? null} title={mapPoint?.title} />
{/* Revert confirmation dialog */}
<Dialog
open={isRevertConfirmOpen}

View File

@@ -11,6 +11,7 @@ export const Dashboard: React.FC = () => {
const {
selectedSite, auditStatus, filterFromDate, filterTillDate,
filterMinChainage, filterMaxChainage,
filterFrequencyGt, filterAuditValues, filterHasCompleteImage,
selectedAssetIds, dashboardCache, setDashboardCache,
searchAnomalyId, filterPlaza, isRectificationEnabled,
tabs, selectedTabIndex, sortBy, sortDir
@@ -26,7 +27,7 @@ export const Dashboard: React.FC = () => {
const isRectActive = isRectificationEnabled || activeTab.isRectificationTab;
// Build a key that uniquely identifies the current filter state
const currentFilterKey = `${selectedSiteId}|${auditStatus}|${filterFromDate}|${filterTillDate}|${assetIdsKey}|${filterMinChainage}|${filterMaxChainage}|${searchAnomalyId}|${filterPlaza}|${isRectActive}|${activeDate}|${sortBy}|${sortDir}`;
const currentFilterKey = `${selectedSiteId}|${auditStatus}|${filterFromDate}|${filterTillDate}|${assetIdsKey}|${filterMinChainage}|${filterMaxChainage}|${searchAnomalyId}|${filterPlaza}|${isRectActive}|${activeDate}|${sortBy}|${sortDir}|${filterFrequencyGt}|${filterAuditValues.join(',')}|${filterHasCompleteImage}`;
// Session-start info (which page the audit session should open on) - still
// derived from the filtered feed, same as before. The headline stats below
@@ -78,11 +79,16 @@ export const Dashboard: React.FC = () => {
auditStatus: auditStatus,
isTrueFalse: isRectActive ? 'both' : (auditStatus === 'False Audits' ? 'true' : 'both'),
auto_audit: isRectActive ? '' : (auditStatus === 'Auto Audits' ? 'true' : (auditStatus === 'Manual' ? 'false' : '')),
// Verification mode: only records whose anomaly was later manually closed
verification: auditStatus === 'Verification' ? 'true' : undefined,
siteIds: (!selectedSiteId || selectedSiteId === 'All Sites') ? '' : selectedSiteId,
assetsIds: assetIdsKey,
fromDate: filterFromDate,
tillDate: filterTillDate,
fromChainage: filterMinChainage,
frequencyGt: filterFrequencyGt,
auditValues: filterAuditValues.join(','),
hasCompleteImage: filterHasCompleteImage,
toChainage: filterMaxChainage,
anomalyIds: searchAnomalyId,
plaza: filterPlaza,

View File

@@ -57,6 +57,14 @@ interface FeedState {
filterTillDate: string;
filterMinChainage: string;
filterMaxChainage: string;
// Frequency threshold ('' = any): records with frequency strictly greater
// than this. Backed by the pipeline's occurrence marker on audits/rect.
filterFrequencyGt: string;
// Feature filter: selected Audit_value strings (empty = any). Matched
// case-insensitively server-side (human 'broken' vs AI 'Broken').
filterAuditValues: string[];
// Complete-image presence: '' any | 'true' only-with | 'false' only-without
filterHasCompleteImage: string;
filterPlaza: string;
selectedAssetIds: number[];
sites: any[];
@@ -93,6 +101,9 @@ interface FeedState {
setFilterFromDate: (date: string) => void;
setFilterTillDate: (date: string) => void;
setFilterMinChainage: (val: string) => void;
setFilterFrequencyGt: (val: string) => void;
setFilterAuditValues: (vals: string[]) => void;
setFilterHasCompleteImage: (val: string) => void;
setFilterMaxChainage: (val: string) => void;
setFilterPlaza: (val: string) => void;
setSearchAnomalyId: (id: string) => void;
@@ -126,6 +137,9 @@ const initialFeedState = {
filterFromDate: '',
filterTillDate: '',
filterMinChainage: '',
filterFrequencyGt: '',
filterAuditValues: [],
filterHasCompleteImage: '',
filterMaxChainage: '',
filterPlaza: '',
selectedAssetIds: [],
@@ -165,6 +179,9 @@ export const useFeedStore = create<FeedState>()(
setFilterFromDate: (filterFromDate) => set({ filterFromDate }),
setFilterTillDate: (filterTillDate) => set({ filterTillDate }),
setFilterMinChainage: (filterMinChainage) => set({ filterMinChainage }),
setFilterFrequencyGt: (filterFrequencyGt) => set({ filterFrequencyGt }),
setFilterAuditValues: (filterAuditValues) => set({ filterAuditValues }),
setFilterHasCompleteImage: (filterHasCompleteImage) => set({ filterHasCompleteImage }),
setFilterMaxChainage: (filterMaxChainage) => set({ filterMaxChainage }),
setFilterPlaza: (filterPlaza) => set({ filterPlaza }),
setSearchAnomalyId: (searchAnomalyId) => set({ searchAnomalyId }),
@@ -201,6 +218,9 @@ export const useFeedStore = create<FeedState>()(
filterFromDate: state.filterFromDate,
filterTillDate: state.filterTillDate,
filterMinChainage: state.filterMinChainage,
filterFrequencyGt: state.filterFrequencyGt,
filterAuditValues: state.filterAuditValues,
filterHasCompleteImage: state.filterHasCompleteImage,
filterMaxChainage: state.filterMaxChainage,
filterPlaza: state.filterPlaza,
selectedAssetIds: state.selectedAssetIds,