feat: implement activity feed module with debounced filtering, history tracking, and axios service integration

This commit is contained in:
2026-06-24 11:28:37 +05:30
parent de2b5a808f
commit fc61b4cdb6
13 changed files with 442 additions and 156 deletions

View File

@@ -1,2 +1,4 @@
VITE_DASHBOARD_API_URL=https://python.seekright.com # VITE_DASHBOARD_API_URL=https://python.seekright.com
VITE_AUDIT_API_URL=https://node-audit.seekright.com # VITE_AUDIT_API_URL=https://node-audit.seekright.com
VITE_DASHBOARD_API_URL=https://sr-backend-api.takeleap.in
VITE_AUDIT_API_URL=https://sr-audit.takeleap.in

View File

@@ -43,11 +43,13 @@ import { Login } from './pages/account/Login';
import { Dashboard } from './pages/dashboard/Dashboard'; import { Dashboard } from './pages/dashboard/Dashboard';
import { GlobalFeed } from './pages/activity-feeds/GlobalFeed'; import { GlobalFeed } from './pages/activity-feeds/GlobalFeed';
import { AuditSession } from './pages/audit-session/AuditSession'; import { AuditSession } from './pages/audit-session/AuditSession';
import { ToastProvider } from './components/common/ToastProvider';
export default function App() { export default function App() {
return ( return (
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<CssBaseline /> <CssBaseline />
<ToastProvider />
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
{/* Public Routes */} {/* Public Routes */}

View File

@@ -62,8 +62,15 @@ export const activityFeedsService = {
return response; return response;
}, },
// Called after normal (non-rectification) true anomaly audit
updateAnomalyInDashboard: async (payload: { site_id: string, anomaly_id: number[] }) => { updateAnomalyInDashboard: async (payload: { site_id: string, anomaly_id: number[] }) => {
const response = await axiosClient.post(`/api/audit/auditor/anomaly/update`, payload); const response = await axiosClient.post('/api/audit/auditor/anomaly/update', payload);
return response;
},
// Called after rectification true anomaly audit
markAnomalyCompleted: async (payload: { anomaly_id: number[], site_id: number }) => {
const response = await axiosClient.post('/api/audit/auditor/anomaly/completed', payload);
return response; return response;
}, },

View File

@@ -1,5 +1,6 @@
import axios from 'axios'; import axios from 'axios';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useToastStore } from '../store/toastStore';
// You can move this to an environment variable later // You can move this to an environment variable later
// Base URL is empty because we use specific proxy prefixes in the service calls // Base URL is empty because we use specific proxy prefixes in the service calls
@@ -15,6 +16,18 @@ export const axiosClient = axios.create({
// Request Interceptor: Attach token if available // Request Interceptor: Attach token if available
axiosClient.interceptors.request.use( axiosClient.interceptors.request.use(
(config) => { (config) => {
// Rocket.Chat webhook: skip auth headers, skip dbName, let Vite proxy handle in dev
if (config.url?.startsWith('/api/rocketchat')) {
if (import.meta.env.DEV) {
// Leave URL as-is — Vite proxy rewrites /api/rocketchat → https://text.seekright.com
return config;
}
// Production: rewrite URL directly (no Vite proxy)
const rcUrl = import.meta.env.VITE_ROCKETCHAT_URL || 'https://text.seekright.com';
config.url = config.url.replace('/api/rocketchat', rcUrl);
return config;
}
const user = useAuthStore.getState().user; const user = useAuthStore.getState().user;
if (user?.access_token) { if (user?.access_token) {
config.headers['x-access-token'] = user.access_token; config.headers['x-access-token'] = user.access_token;
@@ -23,8 +36,6 @@ axiosClient.interceptors.request.use(
config.params = { ...config.params, dbName: user.db_name }; config.params = { ...config.params, dbName: user.db_name };
} }
// Automatically rewrite local proxy paths to the real URLs from environment variables
// This allows us to remove the Vite/Nginx proxy completely!
if (config.url?.startsWith('/api/dashboard')) { if (config.url?.startsWith('/api/dashboard')) {
const dashboardUrl = import.meta.env.VITE_DASHBOARD_API_URL || 'https://sr-backend-api.takeleap.in'; const dashboardUrl = import.meta.env.VITE_DASHBOARD_API_URL || 'https://sr-backend-api.takeleap.in';
config.url = config.url.replace('/api/dashboard', dashboardUrl); config.url = config.url.replace('/api/dashboard', dashboardUrl);
@@ -43,22 +54,21 @@ axiosClient.interceptors.response.use(
(response) => response.data, (response) => response.data,
(error) => { (error) => {
if (!error.response) { if (!error.response) {
// Network offline or CORS error (status 0 in Angular) useToastStore.getState().showToast('Network error — one of the services appears to be offline.', 'error');
alert('Seems one of the sub-application is offline. Please restart the application again.');
return Promise.reject(error); return Promise.reject(error);
} }
const { status, data } = error.response; const { status, data } = error.response;
if (status === 400) { if (status === 400) {
const message = data?.responseStatus?.responseMessage || data?.errorMessage || 'OOPS! Something wrong has happened. Please refresh the browser and try again.'; const message = data?.responseStatus?.responseMessage || data?.errorMessage || 'Something went wrong. Please try again.';
alert(message); useToastStore.getState().showToast(message, 'error');
} else if (status === 401) { } else if (status === 401) {
console.error('Login issue'); console.error('Login issue');
useAuthStore.getState().logout(); useAuthStore.getState().logout();
window.location.href = '/login'; // Redirect to login window.location.href = '/login';
} else { } else {
alert('OOPS! Something wrong has happened. Please refresh the browser and try again.'); useToastStore.getState().showToast('Something went wrong. Please refresh and try again.', 'error');
} }
return Promise.reject(error); return Promise.reject(error);

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { Alert, Stack } from '@mui/material';
import { useToastStore } from '../../store/toastStore';
export const ToastProvider: React.FC = () => {
const { toasts, removeToast } = useToastStore();
if (toasts.length === 0) return null;
return (
<Stack
spacing={1}
sx={{
position: 'fixed',
bottom: 24,
right: 24,
zIndex: 99999,
pointerEvents: 'none',
}}
>
{toasts.map((toast) => (
<Alert
key={toast.id}
severity={toast.severity}
variant="filled"
onClose={() => removeToast(toast.id)}
sx={{
minWidth: 280,
maxWidth: 420,
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
pointerEvents: 'all',
}}
>
{toast.message}
</Alert>
))}
</Stack>
);
};

View File

@@ -155,6 +155,7 @@ export const TopFilterBar: React.FC = () => {
} }
}; };
// Used by RECTIFICATION HISTORY dialog — isRectificationTab: true → fetches with isRectificationEnabled=true
const handleSelectDate = (date: string) => { const handleSelectDate = (date: string) => {
const existingIndex = tabs.findIndex(t => t.date === date && t.isRectificationTab); const existingIndex = tabs.findIndex(t => t.date === date && t.isRectificationTab);
if (existingIndex !== -1) { if (existingIndex !== -1) {
@@ -174,6 +175,26 @@ export const TopFilterBar: React.FC = () => {
} }
}; };
// Used by History dropdown — isRectificationTab: false → fetches with isRectificationEnabled=false
const handleHistoryDateSelect = (date: string) => {
const existingIndex = tabs.findIndex(t => t.date === date && !t.isRectificationTab);
if (existingIndex !== -1) {
setSelectedTabIndex(existingIndex);
} else {
addTab({
date: date,
isRectificationTab: false,
totalAnomalyCount: 0,
anomalies: [],
anomaliesCopy: [],
selectedAnomalyIndex: 0,
filters: {},
page: 0,
rowsPerPage: 20
});
}
};
const handleViewHistory = () => { const handleViewHistory = () => {
if (!selectedHistoryDate) return; if (!selectedHistoryDate) return;
handleSelectDate(selectedHistoryDate); handleSelectDate(selectedHistoryDate);
@@ -241,7 +262,7 @@ export const TopFilterBar: React.FC = () => {
onChange={(e) => { onChange={(e) => {
const date = e.target.value as string; const date = e.target.value as string;
if (date) { if (date) {
handleSelectDate(date); handleHistoryDateSelect(date);
setHistoryDropdownValue(''); setHistoryDropdownValue('');
} }
}} }}

View File

@@ -1,9 +1,11 @@
import React from 'react'; import React from 'react';
import { Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Slider, Checkbox } from '@mui/material'; import { Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Slider, Checkbox, Skeleton } from '@mui/material';
import { useFeedStore } from '../../store/feedStore'; import { useFeedStore } from '../../store/feedStore';
const SKELETON_COLS = 13;
export const AnomalyTable: React.FC = () => { export const AnomalyTable: React.FC = () => {
const { tabs, selectedTabIndex, updateTab, filterPlaza, isRectificationEnabled } = useFeedStore(); const { tabs, selectedTabIndex, updateTab, filterPlaza, isRectificationEnabled, isFeedLoading } = useFeedStore();
if (tabs.length === 0 || selectedTabIndex < 0) { if (tabs.length === 0 || selectedTabIndex < 0) {
return <Typography sx={{ p: 2 }}>Loading feeds...</Typography>; return <Typography sx={{ p: 2 }}>Loading feeds...</Typography>;
@@ -64,7 +66,16 @@ export const AnomalyTable: React.FC = () => {
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{anomalies.map((anomaly: any, _filteredIndex: number) => { {isFeedLoading && Array.from({ length: 8 }).map((_, i) => (
<TableRow key={`skel-${i}`}>
{Array.from({ length: SKELETON_COLS }).map((__, j) => (
<TableCell key={j} sx={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<Skeleton variant="text" sx={{ bgcolor: 'rgba(255,255,255,0.06)', borderRadius: 1 }} />
</TableCell>
))}
</TableRow>
))}
{!isFeedLoading && anomalies.map((anomaly: any, _filteredIndex: number) => {
const realIndex = rawAnomalies.indexOf(anomaly); const realIndex = rawAnomalies.indexOf(anomaly);
const isSelected = activeTab.selectedAnomalyIndex === realIndex; const isSelected = activeTab.selectedAnomalyIndex === realIndex;
const assetName = anomaly?.Asset?.replace(/_/gi, ' ') || 'Unknown Asset'; const assetName = anomaly?.Asset?.replace(/_/gi, ' ') || 'Unknown Asset';

View File

@@ -15,7 +15,8 @@ export const GlobalFeed: React.FC = () => {
isRectificationEnabled, isRectificationEnabled,
filterFromDate, filterTillDate, filterFromDate, filterTillDate,
filterMinChainage, filterMaxChainage, filterMinChainage, filterMaxChainage,
selectedAssetIds selectedAssetIds,
setIsFeedLoading
} = useFeedStore(); } = useFeedStore();
useEffect(() => { useEffect(() => {
@@ -70,6 +71,7 @@ export const GlobalFeed: React.FC = () => {
const siteIdParam = (!selectedSite || selectedSite?.site_id === 'All Sites') const siteIdParam = (!selectedSite || selectedSite?.site_id === 'All Sites')
? '' : String(selectedSite.site_id || ''); ? '' : String(selectedSite.site_id || '');
setIsFeedLoading(true);
try { try {
const historyRes: any = await activityFeedsService.getHistory({ const historyRes: any = await activityFeedsService.getHistory({
date: activeTab.date || '', date: activeTab.date || '',
@@ -106,6 +108,8 @@ export const GlobalFeed: React.FC = () => {
} }
} catch (err) { } catch (err) {
console.error('[GlobalFeed] Failed to load history for active tab/filters change', err); console.error('[GlobalFeed] Failed to load history for active tab/filters change', err);
} finally {
setIsFeedLoading(false);
} }
}; };

View File

@@ -1,10 +1,12 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Box, Typography, Button, Chip, Switch, Dialog, DialogTitle, DialogContent, DialogActions, IconButton } from '@mui/material'; import { Box, Typography, Button, Chip, Switch, Dialog, DialogTitle, DialogContent, DialogActions, IconButton, Select, MenuItem, TextField, CircularProgress } from '@mui/material';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ZoomInIcon from '@mui/icons-material/ZoomIn'; import ZoomInIcon from '@mui/icons-material/ZoomIn';
import ZoomOutIcon from '@mui/icons-material/ZoomOut'; import ZoomOutIcon from '@mui/icons-material/ZoomOut';
import { activityFeedsService } from '../../api/activityFeedsService'; import { activityFeedsService } from '../../api/activityFeedsService';
import { axiosClient } from '../../api/axiosClient';
import { useToastStore } from '../../store/toastStore';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import { useFeedStore } from '../../store/feedStore'; import { useFeedStore } from '../../store/feedStore';
@@ -59,6 +61,68 @@ export const AuditSession: React.FC = () => {
const [isNotesFocused, setIsNotesFocused] = useState(false); const [isNotesFocused, setIsNotesFocused] = useState(false);
const [showCompletionModal, setShowCompletionModal] = useState(false); const [showCompletionModal, setShowCompletionModal] = useState(false);
const [isShortcutsDialogOpen, setIsShortcutsDialogOpen] = useState(false); const [isShortcutsDialogOpen, setIsShortcutsDialogOpen] = useState(false);
const [isReportDialogOpen, setIsReportDialogOpen] = useState(false);
const [reportDev, setReportDev] = useState('');
const [reportMessage, setReportMessage] = useState('');
const [reportSending, setReportSending] = useState(false);
const DEVS = ['aromal', 'divya', 'Poonam', 'ravi', 'Kaushik', 'Saleth', 'Jagan', 'Yash', 'faraz_siddiqui', 'brinda.n', 'SasiVardhan', 'Shubham'];
const ROCKETCHAT_WEBHOOK_PATH = '/api/rocketchat/hooks/6a22ad73a88367bc6e1095a2/xcHkbDSce5rCwofdBZF3ri2rb6ntxT2mahqT6Gg7iKdectYk';
const handleSendReport = async () => {
if (!reportDev || !reportMessage.trim()) return;
setReportSending(true);
try {
const a = currentAnomaly;
const masterPrefix = isRectActive
? 'https://sr-img.seekright.com/'
: 'https://auditor-master-images.seekright.com/SeekRight/';
const testPrefix = 'https://dashboard-images.seekright.com/SeekRight/';
const masterImageUrl = a?.Frame_Master ? `${masterPrefix}${a.Frame_Master}` : null;
const anomalyImageUrl = a?.Frame_Test ? `${testPrefix}${a.Frame_Test}` : null;
const videoUrl = a?.video_url ? `${testPrefix}${a.video_url}` : null;
const extraImages: string[] = a?.Extra_Image
? a.Extra_Image.split(',').map((src: string) => `${testPrefix}${src.trim()}`)
: [];
const context = [
`*Anomaly Report*`,
`• *ID:* #${a?.id}`,
`• *Asset:* ${a?.Asset?.replace(/_/gi, ' ') || 'N/A'}`,
`• *Plaza:* ${a?.Plaza || 'N/A'}`,
`• *Chainage:* ${a?.Chainage || 'N/A'}`,
`• *Side:* ${a?.Side || 'N/A'}`,
`• *Position:* ${a?.Pos || 'N/A'}`,
`• *Road:* ${a?.road || 'N/A'}`,
`• *Algorithm:* ${a?.Algorithm || 'N/A'}`,
`• *Created:* ${a?.Created_on ? new Date(a.Created_on).toLocaleString() : 'N/A'}`,
``,
`*Media:*`,
masterImageUrl ? `• *Master Image:* ${masterImageUrl}` : `• *Master Image:* Not available`,
anomalyImageUrl ? `• *Anomaly Image:* ${anomalyImageUrl}` : `• *Anomaly Image:* Not available`,
videoUrl ? `• *Video:* ${videoUrl}` : `• *Video:* Not available`,
...(extraImages.length > 1
? [`• *Extra Images:*`, ...extraImages.map((url, i) => ` ${i + 1}. ${url}`)]
: []
),
``,
`*Message from ${user?.username || user?.email}:*`,
reportMessage.trim(),
].join('\n');
await axiosClient.post(ROCKETCHAT_WEBHOOK_PATH, { channel: `@${reportDev}`, text: context });
setIsReportDialogOpen(false);
setReportDev('');
setReportMessage('');
useToastStore.getState().showToast(`Message sent to @${reportDev}`, 'success');
} catch (err) {
console.error('Failed to send report:', err);
useToastStore.getState().showToast('Failed to send message. Please try again.', 'error');
} finally {
setReportSending(false);
}
};
const location = useLocation(); const location = useLocation();
const { const {
@@ -124,7 +188,7 @@ export const AuditSession: React.FC = () => {
setNotAnAnomalySubCategory(null); setNotAnAnomalySubCategory(null);
setItemAuditStates(res.anomalies.map((a: any) => a.IsAudited === 1 ? 'audited' : 'pending')); setItemAuditStates(res.anomalies.map((a: any) => a.IsAudited === 1 ? 'audited' : 'pending'));
} else { } else {
alert("No more anomalies left in the feed."); useToastStore.getState().showToast('No more anomalies left in the feed.', 'info');
setShowCompletionModal(false); setShowCompletionModal(false);
navigate('/activity-feeds'); navigate('/activity-feeds');
} }
@@ -180,7 +244,7 @@ export const AuditSession: React.FC = () => {
setAnomalyZoom(1); setAnomalyZoom(1);
setItemAuditStates(res.anomalies.map((a: any) => a.IsAudited === 1 ? 'audited' : 'pending')); setItemAuditStates(res.anomalies.map((a: any) => a.IsAudited === 1 ? 'audited' : 'pending'));
} else { } else {
alert("No more anomalies left in the feed."); useToastStore.getState().showToast('No more anomalies left in the feed.', 'info');
navigate('/activity-feeds'); navigate('/activity-feeds');
} }
} catch (err) { } catch (err) {
@@ -287,9 +351,9 @@ export const AuditSession: React.FC = () => {
} }
} }
const mediaItems: { type: 'image'|'video', url: string }[] = React.useMemo(() => { const mediaItems: { type: 'image' | 'video', url: string }[] = React.useMemo(() => {
if (!currentAnomaly) return []; if (!currentAnomaly) return [];
const items: { type: 'image'|'video', url: string }[] = []; const items: { type: 'image' | 'video', url: string }[] = [];
if (currentAnomaly.images && currentAnomaly.images.length > 0) { if (currentAnomaly.images && currentAnomaly.images.length > 0) {
currentAnomaly.images.forEach((img: any) => { currentAnomaly.images.forEach((img: any) => {
@@ -351,7 +415,6 @@ export const AuditSession: React.FC = () => {
if (!currentAnomaly || !user) return; if (!currentAnomaly || !user) return;
try { try {
const isAnomalyBool = anomalyVal ?? true; const isAnomalyBool = anomalyVal ?? true;
const auditStatusVal = isAnomalyBool ? 1 : 0;
const categories = isAnomalyBool ? ANOMALY_CATEGORIES : SAFE_CATEGORIES; const categories = isAnomalyBool ? ANOMALY_CATEGORIES : SAFE_CATEGORIES;
const matchedCategory = categories.find(c => c.id === catVal); const matchedCategory = categories.find(c => c.id === catVal);
@@ -362,49 +425,55 @@ export const AuditSession: React.FC = () => {
auditValueVal = notAnAnomalySub; auditValueVal = notAnAnomalySub;
} }
// Construct properties matching Angular properties list: // Body matches exactly what the API expects (dbName + isRectificationEnabled go in URL params via axiosClient)
// ["dbName", "isRectificationEnabled", "Audit_value", "Comments", "Audit_status", "Frame_Test", "Frame_Master", "Chainage", "Pos", "id", "road", "user_id", "Algorithm"]
const auditPayload = { const auditPayload = {
dbName: user.db_name || '',
isRectificationEnabled: isRectActive,
Audit_value: auditValueVal, Audit_value: auditValueVal,
Comments: notesVal || '', Comments: notesVal || '',
Audit_status: auditStatusVal, Audit_status: isAnomalyBool, // boolean true/false, not 1/0
Frame_Test: currentAnomaly.Frame_Test || '', Frame_Test: currentAnomaly.Frame_Test || '',
Frame_Master: currentAnomaly.Frame_Master || '', Frame_Master: currentAnomaly.Frame_Master || '',
Chainage: currentAnomaly.Chainage || '', Chainage: currentAnomaly.Chainage || '',
Pos: currentAnomaly.Pos || '', Pos: currentAnomaly.Pos || '',
id: currentAnomaly.id, id: currentAnomaly.id,
road: currentAnomaly.road || 'mcw', road: currentAnomaly.road || 'mcw',
Algorithm: currentAnomaly.Algorithm || '',
user_id: user.user_id || 0, user_id: user.user_id || 0,
Algorithm: currentAnomaly.Algorithm || ''
}; };
// Call API 1: Update anomaly // Call API 1: Save audit result
await activityFeedsService.updateAnomaly([auditPayload], isRectActive); await activityFeedsService.updateAnomaly([auditPayload], isRectActive);
// Call API 2: If true anomaly, update anomaly status in dashboard to notify client portal // Call API 2: Notify dashboard — only for true anomaly, endpoint differs by mode
if (auditStatusVal === 1) { if (isAnomalyBool) {
if (isRectActive) {
// Rectification mode: /auditor/anomaly/completed, site_id as number
await activityFeedsService.markAnomalyCompleted({
anomaly_id: [currentAnomaly.id],
site_id: currentAnomaly.site_id
});
} else {
// Normal audit mode: /auditor/anomaly/update, site_id as string
await activityFeedsService.updateAnomalyInDashboard({ await activityFeedsService.updateAnomalyInDashboard({
site_id: String(currentAnomaly.site_id), site_id: String(currentAnomaly.site_id),
anomaly_id: [currentAnomaly.id] anomaly_id: [currentAnomaly.id]
}); });
} }
}
} catch (err) { } catch (err) {
console.error('Failed to submit audit state:', err); console.error('Failed to submit audit state:', err);
} }
}, [currentAnomaly, user]); }, [currentAnomaly, user, isRectActive]);
const handleSaveAndNext = useCallback(async () => { const handleSaveAndNext = useCallback(async () => {
if (!currentAnomaly) return; if (!currentAnomaly) return;
if (selectedCategory === 1) { if (selectedCategory === 1) {
if ((isAnomaly === null || isAnomaly === true) && !plantSubCategory) { if ((isAnomaly === null || isAnomaly === true) && !plantSubCategory) {
alert("Please select feature before submit."); useToastStore.getState().showToast('Please select a feature before submitting.', 'warning');
return; return;
} }
if (isAnomaly === false && !notAnAnomalySubCategory) { if (isAnomaly === false && !notAnAnomalySubCategory) {
alert("Please select feature before submit."); useToastStore.getState().showToast('Please select a feature before submitting.', 'warning');
return; return;
} }
} }
@@ -451,7 +520,7 @@ export const AuditSession: React.FC = () => {
}, [currentIndex, anomalies.length, currentAnomaly, notes, saveAuditState]); }, [currentIndex, anomalies.length, currentAnomaly, notes, saveAuditState]);
// Keyboard shortcuts // Keyboard shortcuts
const handleKeyDown = useCallback((e: KeyboardEvent) => { const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (isNotesFocused) return; if (isNotesFocused || isReportDialogOpen || isShortcutsDialogOpen) return;
const key = e.key.toLowerCase(); const key = e.key.toLowerCase();
@@ -558,7 +627,7 @@ export const AuditSession: React.FC = () => {
handleSkip(); handleSkip();
break; break;
} }
}, [currentIndex, anomalies, isAnomaly, selectedCategory, notes, plantSubCategory, isQuickAudit, isNotesFocused, handleQuickSaveAndNext, handleSaveAndNext, handleSkip]); }, [currentIndex, anomalies, isAnomaly, selectedCategory, notes, plantSubCategory, isQuickAudit, isNotesFocused, isReportDialogOpen, isShortcutsDialogOpen, handleQuickSaveAndNext, handleSaveAndNext, handleSkip]);
useEffect(() => { useEffect(() => {
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
@@ -791,6 +860,16 @@ export const AuditSession: React.FC = () => {
<Chip size="small" label={`Side: ${currentAnomaly?.Side || 'N/A'}`} sx={{ bgcolor: '#1e293b', color: '#e2e8f0', borderRadius: 1.5 }} /> <Chip size="small" label={`Side: ${currentAnomaly?.Side || 'N/A'}`} sx={{ bgcolor: '#1e293b', color: '#e2e8f0', borderRadius: 1.5 }} />
<Chip size="small" label={`${latStr}${longStr ? `, ${longStr}` : ''}`} sx={{ bgcolor: '#1e293b', color: '#e2e8f0', borderRadius: 1.5 }} /> <Chip size="small" label={`${latStr}${longStr ? `, ${longStr}` : ''}`} sx={{ bgcolor: '#1e293b', color: '#e2e8f0', borderRadius: 1.5 }} />
</Box> </Box>
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
size="small"
variant="outlined"
onClick={() => setIsReportDialogOpen(true)}
sx={{ borderColor: '#f59e0b', color: '#f59e0b', textTransform: 'none', fontSize: '0.75rem', borderRadius: 1.5, '&:hover': { bgcolor: 'rgba(245,158,11,0.1)', borderColor: '#f59e0b' } }}
>
Report to Dev
</Button>
</Box>
</Box> </Box>
</Box> </Box>
@@ -1070,9 +1149,9 @@ export const AuditSession: React.FC = () => {
{/* Footer Shortcuts */} {/* Footer Shortcuts */}
<Box sx={{ borderTop: '1px solid #1e293b', p: 1, px: 2, display: 'flex', gap: 3, alignItems: 'center', overflowX: 'auto', '& > div': { display: 'flex', alignItems: 'center', gap: 1, whiteSpace: 'nowrap' } }}> <Box sx={{ borderTop: '1px solid #1e293b', p: 1, px: 2, display: 'flex', gap: 3, alignItems: 'center', overflowX: 'auto', '& > div': { display: 'flex', alignItems: 'center', gap: 1, whiteSpace: 'nowrap' } }}>
<Typography sx={{ fontSize: '0.75rem', color: '#64748b' }}> <Typography sx={{ fontSize: '0.75rem', color: '#64748b' }}>
{isNotesFocused ? "Shortcuts Paused (Typing notes)" : "Keys:"} {(isNotesFocused || isReportDialogOpen || isShortcutsDialogOpen) ? "Shortcuts Paused" : "Keys:"}
</Typography> </Typography>
{!isNotesFocused && ( {!(isNotesFocused || isReportDialogOpen || isShortcutsDialogOpen) && (
<> <>
<Box> <Box>
<Chip label="A" size="small" sx={{ height: 20, fontSize: '0.65rem', bgcolor: '#1e293b', color: '#cbd5e1' }} /> <Chip label="A" size="small" sx={{ height: 20, fontSize: '0.65rem', bgcolor: '#1e293b', color: '#cbd5e1' }} />
@@ -1106,6 +1185,77 @@ export const AuditSession: React.FC = () => {
)} )}
</Box> </Box>
{/* Report to Dev Dialog */}
<Dialog
open={isReportDialogOpen}
onClose={() => { setIsReportDialogOpen(false); setReportDev(''); setReportMessage(''); }}
sx={{ zIndex: 10000 }}
slotProps={{ paper: { sx: { bgcolor: '#0f172a', color: '#f8fafc', border: '1px solid #1e293b', minWidth: 400 } } }}
>
<DialogTitle sx={{ fontWeight: 'bold', pb: 1, borderBottom: '1px solid #1e293b' }}>
Report to Dev
</DialogTitle>
<DialogContent sx={{ pt: 2, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
<Box>
<Typography variant="caption" sx={{ color: '#94a3b8', display: 'block', mb: 0.5 }}>Anomaly</Typography>
<Typography variant="body2" sx={{ color: '#f8fafc' }}>
ID #{currentAnomaly?.id} {currentAnomaly?.Asset?.replace(/_/gi, ' ')} @ {currentAnomaly?.Plaza} (CH: {currentAnomaly?.Chainage})
</Typography>
</Box>
<Box>
<Typography variant="caption" sx={{ color: '#94a3b8', display: 'block', mb: 0.5 }}>Select Developer</Typography>
<Select
value={reportDev}
onChange={(e) => setReportDev(e.target.value)}
displayEmpty
size="small"
fullWidth
sx={{ color: '#f8fafc', '& fieldset': { borderColor: '#334155' }, '& .MuiSvgIcon-root': { color: '#94a3b8' } }}
slotProps={{ input: { sx: { color: '#f8fafc' } } }}
MenuProps={{ sx: { zIndex: 10001 }, slotProps: { paper: { sx: { bgcolor: '#0f172a', border: '1px solid #1e293b' } } } }}
>
<MenuItem disabled value=""><em style={{ color: '#64748b' }}>Choose a developer</em></MenuItem>
{DEVS.map(dev => (
<MenuItem key={dev} value={dev} sx={{ color: '#f8fafc', bgcolor: '#0f172a', '&:hover': { bgcolor: '#1e293b' }, '&.Mui-selected': { bgcolor: '#1e3a5f' }, '&.Mui-selected:hover': { bgcolor: '#1e3a5f' } }}>@{dev}</MenuItem>
))}
</Select>
</Box>
<Box>
<Typography variant="caption" sx={{ color: '#94a3b8', display: 'block', mb: 0.5 }}>Message</Typography>
<TextField
multiline
rows={4}
fullWidth
placeholder="Describe the issue..."
value={reportMessage}
onChange={(e) => setReportMessage(e.target.value)}
sx={{
'& .MuiOutlinedInput-root': {
color: '#f8fafc',
'& fieldset': { borderColor: '#334155' },
'&:hover fieldset': { borderColor: '#475569' },
'&.Mui-focused fieldset': { borderColor: '#3b82f6' },
},
'& .MuiInputBase-input::placeholder': { color: '#64748b', opacity: 1 },
}}
/>
</Box>
</DialogContent>
<DialogActions sx={{ borderTop: '1px solid #1e293b', px: 3, py: 1.5, gap: 1 }}>
<Button onClick={() => { setIsReportDialogOpen(false); setReportDev(''); setReportMessage(''); }} sx={{ color: '#94a3b8', textTransform: 'none' }}>
Cancel
</Button>
<Button
onClick={handleSendReport}
variant="contained"
disabled={!reportDev || !reportMessage.trim() || reportSending}
sx={{ bgcolor: '#f59e0b', color: '#000', fontWeight: 'bold', textTransform: 'none', '&:hover': { bgcolor: '#d97706' }, '&.Mui-disabled': { bgcolor: 'rgba(245,158,11,0.3)', color: 'rgba(0,0,0,0.4)' } }}
>
{reportSending ? <CircularProgress size={18} sx={{ color: '#000' }} /> : 'Send'}
</Button>
</DialogActions>
</Dialog>
{/* Keyboard Shortcuts Dialog */} {/* Keyboard Shortcuts Dialog */}
<Dialog <Dialog
open={isShortcutsDialogOpen} open={isShortcutsDialogOpen}

View File

@@ -58,6 +58,7 @@ interface FeedState {
dashboardCache: DashboardCache; dashboardCache: DashboardCache;
searchAnomalyId: string; searchAnomalyId: string;
isRectificationEnabled: boolean; isRectificationEnabled: boolean;
isFeedLoading: boolean;
// Actions // Actions
setTabs: (tabs: FeedTab[]) => void; setTabs: (tabs: FeedTab[]) => void;
@@ -82,6 +83,7 @@ interface FeedState {
setFilterPlaza: (val: string) => void; setFilterPlaza: (val: string) => void;
setSearchAnomalyId: (id: string) => void; setSearchAnomalyId: (id: string) => void;
setIsRectificationEnabled: (val: boolean) => void; setIsRectificationEnabled: (val: boolean) => void;
setIsFeedLoading: (val: boolean) => void;
resetStore: () => void; resetStore: () => void;
} }
@@ -120,6 +122,7 @@ const initialFeedState = {
dashboardCache: EMPTY_DASHBOARD_CACHE, dashboardCache: EMPTY_DASHBOARD_CACHE,
searchAnomalyId: '', searchAnomalyId: '',
isRectificationEnabled: false, isRectificationEnabled: false,
isFeedLoading: false,
}; };
export const useFeedStore = create<FeedState>()( export const useFeedStore = create<FeedState>()(
@@ -148,6 +151,7 @@ export const useFeedStore = create<FeedState>()(
setFilterPlaza: (filterPlaza) => set({ filterPlaza }), setFilterPlaza: (filterPlaza) => set({ filterPlaza }),
setSearchAnomalyId: (searchAnomalyId) => set({ searchAnomalyId }), setSearchAnomalyId: (searchAnomalyId) => set({ searchAnomalyId }),
setIsRectificationEnabled: (isRectificationEnabled) => set({ isRectificationEnabled }), setIsRectificationEnabled: (isRectificationEnabled) => set({ isRectificationEnabled }),
setIsFeedLoading: (isFeedLoading) => set({ isFeedLoading }),
addTab: (tab) => set((state) => ({ tabs: [...state.tabs, tab], selectedTabIndex: state.tabs.length })), addTab: (tab) => set((state) => ({ tabs: [...state.tabs, tab], selectedTabIndex: state.tabs.length })),
closeTab: (index) => set((state) => { closeTab: (index) => set((state) => {
const newTabs = [...state.tabs]; const newTabs = [...state.tabs];

30
src/store/toastStore.ts Normal file
View File

@@ -0,0 +1,30 @@
import { create } from 'zustand';
export type ToastSeverity = 'success' | 'error' | 'warning' | 'info';
interface Toast {
id: string;
message: string;
severity: ToastSeverity;
}
interface ToastState {
toasts: Toast[];
showToast: (message: string, severity?: ToastSeverity) => void;
removeToast: (id: string) => void;
}
export const useToastStore = create<ToastState>((set) => ({
toasts: [],
showToast: (message, severity = 'info') => {
const id = `${Date.now()}-${Math.random()}`;
set((state) => ({ toasts: [...state.toasts, { id, message, severity }] }));
setTimeout(() => {
useToastStore.getState().removeToast(id);
}, 4000);
},
removeToast: (id) =>
set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) })),
}));

View File

@@ -6,7 +6,13 @@ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
allowedHosts: true, allowedHosts: true,
// Reverse proxy is no longer needed since endpoints allow CORS natively proxy: {
'/api/rocketchat': {
target: 'https://text.seekright.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/rocketchat/, ''),
},
},
}, },
preview: { preview: {
allowedHosts: true, allowedHosts: true,