feat: implement client overview dashboard and admin portal structure with API services and page components
This commit is contained in:
@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { accountService } from '../../api/accountService';
|
||||
import { DbSelectionDialog } from './DbSelectionDialog';
|
||||
import { useToastStore } from '../../store/toastStore';
|
||||
|
||||
export const Login: React.FC = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
@@ -11,8 +12,8 @@ export const Login: React.FC = () => {
|
||||
const [isInvalid, setIsInvalid] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [tempUserData, setTempUserData] = useState<any>(null);
|
||||
|
||||
const [tempLoginResult, setTempLoginResult] = useState<Awaited<ReturnType<typeof accountService.login>> | null>(null);
|
||||
|
||||
const { login, isAuthenticated } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -26,53 +27,44 @@ export const Login: React.FC = () => {
|
||||
const handleLogin = async () => {
|
||||
setIsInvalid(false);
|
||||
setLoading(true);
|
||||
|
||||
|
||||
try {
|
||||
const userData = await accountService.login(username, password);
|
||||
|
||||
if (!userData) {
|
||||
const result = await accountService.login(username, password);
|
||||
|
||||
if (!result?.user) {
|
||||
setIsInvalid(true);
|
||||
useToastStore.getState().showToast('Invalid username or password.', 'error');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const roles = userData.user_roles ? userData.user_roles.toLowerCase().split(',') : [];
|
||||
|
||||
if (roles.includes('sr_auditor')) {
|
||||
// Open DB Selection Dialog
|
||||
setTempUserData(userData);
|
||||
setDialogOpen(true);
|
||||
} else if (roles.includes('auditor')) {
|
||||
// Direct login
|
||||
login(userData);
|
||||
navigate('/activity-feeds', { replace: true });
|
||||
} else if (roles.includes('video_uploader')) {
|
||||
// Direct login for dashboard
|
||||
login(userData);
|
||||
navigate('/activity-feeds', { replace: true });
|
||||
} else {
|
||||
alert("Your account is not configured to use this website. Please contact your admin for more info.");
|
||||
}
|
||||
// Every user picks which org/DB to work in after login.
|
||||
setTempLoginResult(result);
|
||||
setDialogOpen(true);
|
||||
} catch (error) {
|
||||
setIsInvalid(true);
|
||||
useToastStore.getState().showToast('Invalid username or password.', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDbSelection = (selectedOrg?: any) => {
|
||||
const handleDbSelection = (selectedOrg?: { org_id: string; db_name: string; org_name?: string }) => {
|
||||
setDialogOpen(false);
|
||||
if (selectedOrg && tempUserData) {
|
||||
// Merge selected org details into user object
|
||||
const finalUserData = {
|
||||
...tempUserData,
|
||||
if (selectedOrg && tempLoginResult) {
|
||||
login({
|
||||
access_token: tempLoginResult.access_token,
|
||||
refresh_token: tempLoginResult.refresh_token,
|
||||
user_id: tempLoginResult.user.user_id,
|
||||
username: tempLoginResult.user.username,
|
||||
email: tempLoginResult.user.email,
|
||||
role: tempLoginResult.user.role_name,
|
||||
org_id: selectedOrg.org_id,
|
||||
db_name: selectedOrg.db_name,
|
||||
selected_org_name: selectedOrg.org_name
|
||||
};
|
||||
login(finalUserData);
|
||||
});
|
||||
navigate('/activity-feeds', { replace: true });
|
||||
}
|
||||
setTempLoginResult(null);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -206,7 +198,6 @@ export const Login: React.FC = () => {
|
||||
</Paper>
|
||||
</Container>
|
||||
|
||||
{/* Database Selection Dialog for SR_AUDITOR */}
|
||||
<DbSelectionDialog open={dialogOpen} onClose={handleDbSelection} />
|
||||
</Box>
|
||||
);
|
||||
|
||||
37
src/pages/admin/AdminConsole.tsx
Normal file
37
src/pages/admin/AdminConsole.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Tabs, Tab } from '@mui/material';
|
||||
import { ClientsOverview } from '../clients/ClientsOverview';
|
||||
import { UserManagement } from './UserManagement';
|
||||
import { LoginHistory } from './LoginHistory';
|
||||
import { Allocation } from './Allocation';
|
||||
import { AuditHistory } from './AuditHistory';
|
||||
import { DevTickets } from './DevTickets';
|
||||
|
||||
export const AdminConsole: React.FC = () => {
|
||||
const [tab, setTab] = useState(0);
|
||||
|
||||
return (
|
||||
// MainLayout's <main> is height:100vh + overflow:hidden (by design, so other
|
||||
// pages can own their own internal scroll regions - see AnomalyTable.tsx/
|
||||
// GlobalFeed.tsx for the same pattern). This tab's content (filters + large
|
||||
// tables) can exceed the viewport, so it needs its own bounded, scrollable
|
||||
// container rather than relying on the page ever growing taller than 100vh.
|
||||
<Box sx={{ p: 3, height: 'calc(100vh - 64px)', overflowY: 'auto' }}>
|
||||
<Tabs value={tab} onChange={(_, value) => setTab(value)} sx={{ mb: 3 }}>
|
||||
<Tab label="Clients" />
|
||||
<Tab label="Users" />
|
||||
<Tab label="Login History" />
|
||||
<Tab label="Allocation" />
|
||||
<Tab label="Audit & Rectification History" />
|
||||
<Tab label="Dev Tickets" />
|
||||
</Tabs>
|
||||
|
||||
{tab === 0 && <ClientsOverview />}
|
||||
{tab === 1 && <UserManagement />}
|
||||
{tab === 2 && <LoginHistory />}
|
||||
{tab === 3 && <Allocation />}
|
||||
{tab === 4 && <AuditHistory />}
|
||||
{tab === 5 && <DevTickets />}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
384
src/pages/admin/Allocation.tsx
Normal file
384
src/pages/admin/Allocation.tsx
Normal file
@@ -0,0 +1,384 @@
|
||||
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, Divider,
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, DialogContentText, TextField,
|
||||
} from '@mui/material';
|
||||
import { adminService } from '../../api/adminService';
|
||||
import type { AllocationRecord, AdminUser } from '../../api/adminService';
|
||||
import { accountService } from '../../api/accountService';
|
||||
import { activityFeedsService } from '../../api/activityFeedsService';
|
||||
import { useFeedStore } from '../../store/feedStore';
|
||||
import { useToastStore } from '../../store/toastStore';
|
||||
|
||||
interface Organization {
|
||||
org_id: string;
|
||||
org_name: string;
|
||||
db_name: string;
|
||||
}
|
||||
|
||||
interface SiteOption {
|
||||
site_id: string;
|
||||
site_name: string;
|
||||
}
|
||||
|
||||
// Mirrors the site-parsing logic in ClientsOverview.tsx/MainLayout.tsx —
|
||||
// /Master/site's response shape varies (responseData[0].records, nested
|
||||
// sub_sites, or a plain array).
|
||||
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);
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
const BULK_PRESETS = [1000, 2000, 5000, 10000];
|
||||
|
||||
export const Allocation: React.FC = () => {
|
||||
const { assets } = useFeedStore();
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
const [dbName, setDbName] = useState('');
|
||||
const [type, setType] = useState<'audits' | 'rectification'>('audits');
|
||||
const [sort, setSort] = useState<'oldest' | 'newest'>('oldest');
|
||||
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
const [dateTo, setDateTo] = useState('');
|
||||
const [assetId, setAssetId] = useState('');
|
||||
const [siteId, setSiteId] = useState('');
|
||||
const [sites, setSites] = useState<SiteOption[]>([]);
|
||||
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [selectedUserId, setSelectedUserId] = useState('');
|
||||
|
||||
const [records, setRecords] = useState<AllocationRecord[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [assigning, setAssigning] = useState(false);
|
||||
const [bulkConfirmCount, setBulkConfirmCount] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const [orgsRes, usersRes] = await Promise.all([
|
||||
accountService.getOrganizations(),
|
||||
adminService.getUsers(),
|
||||
]);
|
||||
const orgs = (orgsRes as any) || [];
|
||||
setOrganizations(orgs);
|
||||
setUsers(usersRes.filter((u) => !u.disabled));
|
||||
if (orgs.length > 0) setDbName(orgs[0].db_name);
|
||||
} catch (err) {
|
||||
useToastStore.getState().showToast('Failed to load organizations/users.', 'error');
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// Sites are org-scoped, and the org being managed here may differ from the
|
||||
// admin's own logged-in org, so this refetches whenever the selection changes
|
||||
// rather than reusing the global feedStore (which is scoped to the admin's own org).
|
||||
useEffect(() => {
|
||||
const org = organizations.find((o) => o.db_name === dbName);
|
||||
if (!org) return;
|
||||
(async () => {
|
||||
try {
|
||||
const sitesRes = await activityFeedsService.getOrganizationSites(org.org_id);
|
||||
setSites(parseSites(sitesRes));
|
||||
} catch (err) {
|
||||
useToastStore.getState().showToast('Failed to load sites for this organization.', 'error');
|
||||
}
|
||||
})();
|
||||
setSiteId('');
|
||||
}, [dbName, organizations]);
|
||||
|
||||
// dateTo is a bare date (e.g. "2026-07-09"); video_date is a full timestamp,
|
||||
// so comparing "<= 2026-07-09" would exclude same-day records after
|
||||
// midnight - extend it to end-of-day. Also normalize the order: both
|
||||
// fields are independent and unset by default, so it's easy to end up with
|
||||
// From after To while filling them in, which would silently match zero
|
||||
// rows instead of erroring.
|
||||
const [effectiveDateFrom, effectiveDateTo] = (() => {
|
||||
if (!dateFrom || !dateTo) return [dateFrom || undefined, dateTo ? `${dateTo}T23:59:59` : undefined];
|
||||
return dateFrom <= dateTo
|
||||
? [dateFrom, `${dateTo}T23:59:59`]
|
||||
: [dateTo, `${dateFrom}T23:59:59`];
|
||||
})();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!dbName) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await adminService.getAllocationRecords({
|
||||
dbName, type, sort, limit: PAGE_SIZE, offset: page * PAGE_SIZE,
|
||||
dateFrom: effectiveDateFrom,
|
||||
dateTo: effectiveDateTo,
|
||||
assetId: assetId || undefined,
|
||||
siteId: siteId || undefined,
|
||||
});
|
||||
setRecords(result.records);
|
||||
setTotal(result.total);
|
||||
} catch (err) {
|
||||
useToastStore.getState().showToast('Failed to load records.', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [dbName, type, sort, page, effectiveDateFrom, effectiveDateTo, assetId, siteId]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
setSelectedIds(new Set());
|
||||
}, [dbName, type, sort, dateFrom, dateTo, assetId, siteId]);
|
||||
|
||||
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 = () => {
|
||||
if (selectedIds.size === records.length) {
|
||||
setSelectedIds(new Set());
|
||||
} else {
|
||||
setSelectedIds(new Set(records.map((r) => r.id)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAllocate = async () => {
|
||||
if (selectedIds.size === 0 || !selectedUserId) return;
|
||||
setAssigning(true);
|
||||
try {
|
||||
const result = await adminService.assignAllocation({
|
||||
dbName, type, ids: Array.from(selectedIds), user_id: Number(selectedUserId),
|
||||
});
|
||||
useToastStore.getState().showToast(`Allocated ${result.assigned} record(s).`, 'success');
|
||||
setSelectedIds(new Set());
|
||||
load();
|
||||
} catch (err) {
|
||||
useToastStore.getState().showToast('Failed to allocate records.', 'error');
|
||||
} finally {
|
||||
setAssigning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkAllocate = async (count: number) => {
|
||||
if (!selectedUserId) return;
|
||||
setBulkConfirmCount(null);
|
||||
setAssigning(true);
|
||||
try {
|
||||
const result = await adminService.assignAllocation({
|
||||
dbName, type, count, sort, user_id: Number(selectedUserId),
|
||||
dateFrom: effectiveDateFrom,
|
||||
dateTo: effectiveDateTo,
|
||||
assetId: assetId || undefined,
|
||||
siteId: siteId || undefined,
|
||||
});
|
||||
useToastStore.getState().showToast(`Allocated ${result.assigned} record(s).`, 'success');
|
||||
setSelectedIds(new Set());
|
||||
setPage(0);
|
||||
load();
|
||||
} catch (err) {
|
||||
useToastStore.getState().showToast('Failed to bulk-allocate records.', 'error');
|
||||
} finally {
|
||||
setAssigning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedUser = users.find((u) => String(u.user_id) === selectedUserId);
|
||||
const selectedOrg = organizations.find((o) => o.db_name === dbName);
|
||||
|
||||
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={(_, value) => value && setType(value)}
|
||||
size="small"
|
||||
>
|
||||
<ToggleButton value="audits">Audits</ToggleButton>
|
||||
<ToggleButton value="rectification">Rectification</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
<ToggleButtonGroup
|
||||
value={sort}
|
||||
exclusive
|
||||
onChange={(_, value) => value && setSort(value)}
|
||||
size="small"
|
||||
>
|
||||
<ToggleButton value="oldest">Oldest first</ToggleButton>
|
||||
<ToggleButton value="newest">Newest first</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 3, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<TextField
|
||||
label="Video date from"
|
||||
type="date"
|
||||
size="small"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<TextField
|
||||
label="Video date to"
|
||||
type="date"
|
||||
size="small"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<FormControl size="small" sx={{ minWidth: 200 }}>
|
||||
<InputLabel>Asset</InputLabel>
|
||||
<Select label="Asset" value={assetId} onChange={(e) => setAssetId(e.target.value)}>
|
||||
<MenuItem value="">All assets</MenuItem>
|
||||
{assets.map((a: any) => <MenuItem key={a.id} value={a.id}>{a.name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" sx={{ minWidth: 200 }}>
|
||||
<InputLabel>Site</InputLabel>
|
||||
<Select label="Site" value={siteId} onChange={(e) => setSiteId(e.target.value)}>
|
||||
<MenuItem value="">All sites</MenuItem>
|
||||
{sites.map((s) => <MenuItem key={s.site_id} value={s.site_id}>{s.site_name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 220, mb: 2 }}>
|
||||
<InputLabel>Allocate to</InputLabel>
|
||||
<Select label="Allocate to" value={selectedUserId} onChange={(e) => setSelectedUserId(e.target.value)}>
|
||||
{users.map((u) => <MenuItem key={u.user_id} value={u.user_id}>{u.username} ({u.role_name})</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 1, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Typography variant="body2">{selectedIds.size} selected on this page</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={selectedIds.size === 0 || !selectedUserId || assigning}
|
||||
onClick={handleAllocate}
|
||||
>
|
||||
Allocate selected
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1.5, mb: 3, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>Quick bulk assign (matches current filters):</Typography>
|
||||
{BULK_PRESETS.map((count) => (
|
||||
<Button
|
||||
key={count}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
disabled={!selectedUserId || assigning}
|
||||
onClick={() => setBulkConfirmCount(count)}
|
||||
>
|
||||
{count.toLocaleString()}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{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>Anomaly ID</TableCell>
|
||||
<TableCell>Asset</TableCell>
|
||||
<TableCell>Plaza</TableCell>
|
||||
<TableCell>Chainage</TableCell>
|
||||
<TableCell>Video Date</TableCell>
|
||||
<TableCell>Assigned To</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{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 || '—'}</TableCell>
|
||||
<TableCell>{r.Plaza || '—'}</TableCell>
|
||||
<TableCell>{r.Chainage || '—'}</TableCell>
|
||||
<TableCell>{r.video_date ? new Date(r.video_date).toLocaleDateString() : '—'}</TableCell>
|
||||
<TableCell>{r.assigned_username || '—'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={total}
|
||||
page={page}
|
||||
onPageChange={(_, newPage) => setPage(newPage)}
|
||||
rowsPerPage={PAGE_SIZE}
|
||||
rowsPerPageOptions={[PAGE_SIZE]}
|
||||
/>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
<Dialog open={bulkConfirmCount !== null} onClose={() => setBulkConfirmCount(null)}>
|
||||
<DialogTitle>Confirm bulk allocation</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Assign the first {bulkConfirmCount?.toLocaleString()} {sort} unaudited {type} record(s) in{' '}
|
||||
{selectedOrg?.org_name || dbName} (matching the current date/asset/site filters) to {selectedUser?.username}?
|
||||
If fewer records are available, all of them will be assigned.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setBulkConfirmCount(null)}>Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => bulkConfirmCount !== null && handleBulkAllocate(bulkConfirmCount)}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
352
src/pages/admin/AuditHistory.tsx
Normal file
352
src/pages/admin/AuditHistory.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
Paper, FormControl, InputLabel, Select, MenuItem, ToggleButtonGroup,
|
||||
ToggleButton, CircularProgress, Typography, TextField, Chip, TablePagination,
|
||||
} from '@mui/material';
|
||||
import { adminService } from '../../api/adminService';
|
||||
import type { AuditHistoryUserCount, AuditHistoryRecord } from '../../api/adminService';
|
||||
import { accountService } from '../../api/accountService';
|
||||
import { useToastStore } from '../../store/toastStore';
|
||||
|
||||
interface Organization {
|
||||
org_id: string;
|
||||
org_name: string;
|
||||
db_name: string;
|
||||
}
|
||||
|
||||
type Preset = 'today' | 'overall' | 'custom_day' | 'custom_range';
|
||||
|
||||
const startOfDay = (dateStr: string) => `${dateStr}T00:00:00`;
|
||||
const endOfDay = (dateStr: string) => `${dateStr}T23:59:59`;
|
||||
const todayStr = () => new Date().toISOString().split('T')[0];
|
||||
const RECORDS_PAGE_SIZE = 25;
|
||||
|
||||
const efficiencyColor = (value: number): 'success' | 'warning' | 'error' => {
|
||||
if (value >= 75) return 'success';
|
||||
if (value >= 40) return 'warning';
|
||||
return 'error';
|
||||
};
|
||||
|
||||
// "Assigned" is video_date-based (work whose video arrived in this window)
|
||||
// and "Completed" is Audited_on-based (work actually finished in this
|
||||
// window) - two independent measures, not two views of the same row set. A
|
||||
// user can complete backlog items today with nothing newly assigned today.
|
||||
|
||||
const SummaryCard: React.FC<{ label: string; value: string; color?: string }> = ({ label, value, color }) => (
|
||||
<Paper sx={{ p: 2, minWidth: 160 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>{label}</Typography>
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold', color }}>{value}</Typography>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
export const AuditHistory: React.FC = () => {
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
const [dbName, setDbName] = useState('');
|
||||
const [type, setType] = useState<'audits' | 'rectification'>('audits');
|
||||
const [preset, setPreset] = useState<Preset>('today');
|
||||
const [customDay, setCustomDay] = useState(todayStr());
|
||||
const [rangeFrom, setRangeFrom] = useState(todayStr());
|
||||
const [rangeTo, setRangeTo] = useState(todayStr());
|
||||
|
||||
const [total, setTotal] = useState(0);
|
||||
const [audited, setAudited] = useState(0);
|
||||
const [notAudited, setNotAudited] = useState(0);
|
||||
const [byUser, setByUser] = useState<AuditHistoryUserCount[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Drill-down: which user's individual records are currently shown below.
|
||||
const [drillUser, setDrillUser] = useState<AuditHistoryUserCount | null>(null);
|
||||
const [drillRecords, setDrillRecords] = useState<AuditHistoryRecord[]>([]);
|
||||
const [drillTotal, setDrillTotal] = useState(0);
|
||||
const [drillPage, setDrillPage] = useState(0);
|
||||
const [drillLoading, setDrillLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const orgsRes = await accountService.getOrganizations();
|
||||
const orgs = (orgsRes as any) || [];
|
||||
setOrganizations(orgs);
|
||||
if (orgs.length > 0) setDbName(orgs[0].db_name);
|
||||
} catch (err) {
|
||||
useToastStore.getState().showToast('Failed to load organizations.', 'error');
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const { from, to } = (() => {
|
||||
switch (preset) {
|
||||
case 'today':
|
||||
return { from: startOfDay(todayStr()), to: endOfDay(todayStr()) };
|
||||
case 'custom_day':
|
||||
return { from: startOfDay(customDay), to: endOfDay(customDay) };
|
||||
case 'custom_range':
|
||||
// Both fields default to today, so it's easy to end up with an
|
||||
// inverted range while adjusting them one at a time (e.g. picking a
|
||||
// past "To" before touching "From") - that would silently match zero
|
||||
// rows instead of erroring, so normalize the order here.
|
||||
return rangeFrom <= rangeTo
|
||||
? { from: startOfDay(rangeFrom), to: endOfDay(rangeTo) }
|
||||
: { from: startOfDay(rangeTo), to: endOfDay(rangeFrom) };
|
||||
case 'overall':
|
||||
default:
|
||||
return { from: undefined, to: undefined };
|
||||
}
|
||||
})();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!dbName) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await adminService.getAuditHistorySummary({ dbName, type, from, to });
|
||||
setTotal(result.total);
|
||||
setAudited(result.audited);
|
||||
setNotAudited(result.notAudited);
|
||||
setByUser(result.byUser);
|
||||
} catch (err) {
|
||||
useToastStore.getState().showToast('Failed to load audit history.', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dbName, type, from, to]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// Changing org/type/date invalidates whatever drill-down was open.
|
||||
setDrillUser(null);
|
||||
}, [load]);
|
||||
|
||||
const loadDrillRecords = useCallback(async (user: AuditHistoryUserCount, pageIndex: number) => {
|
||||
setDrillLoading(true);
|
||||
try {
|
||||
const result = await adminService.getAuditHistoryUserRecords({
|
||||
dbName, type, userId: user.user_id, from, to,
|
||||
limit: RECORDS_PAGE_SIZE, offset: pageIndex * RECORDS_PAGE_SIZE,
|
||||
});
|
||||
setDrillRecords(result.records);
|
||||
setDrillTotal(result.total);
|
||||
} catch (err) {
|
||||
useToastStore.getState().showToast('Failed to load user records.', 'error');
|
||||
} finally {
|
||||
setDrillLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dbName, type, from, to]);
|
||||
|
||||
const handleRowClick = (user: AuditHistoryUserCount) => {
|
||||
if (drillUser?.user_id === user.user_id) {
|
||||
setDrillUser(null);
|
||||
return;
|
||||
}
|
||||
setDrillUser(user);
|
||||
setDrillPage(0);
|
||||
loadDrillRecords(user, 0);
|
||||
};
|
||||
|
||||
const handleDrillPageChange = (newPage: number) => {
|
||||
setDrillPage(newPage);
|
||||
if (drillUser) loadDrillRecords(drillUser, newPage);
|
||||
};
|
||||
|
||||
const completionRate = total > 0 ? Math.round((audited / total) * 1000) / 10 : 0;
|
||||
|
||||
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={(_, value) => value && setType(value)}
|
||||
size="small"
|
||||
>
|
||||
<ToggleButton value="audits">Audits</ToggleButton>
|
||||
<ToggleButton value="rectification">Rectification</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
<ToggleButtonGroup
|
||||
value={preset}
|
||||
exclusive
|
||||
onChange={(_, value) => value && setPreset(value)}
|
||||
size="small"
|
||||
>
|
||||
<ToggleButton value="today">Today</ToggleButton>
|
||||
<ToggleButton value="overall">Overall</ToggleButton>
|
||||
<ToggleButton value="custom_day">Custom day</ToggleButton>
|
||||
<ToggleButton value="custom_range">Custom range</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 3, alignItems: 'center' }}>
|
||||
{preset === 'custom_day' && (
|
||||
<TextField
|
||||
label="Day"
|
||||
type="date"
|
||||
size="small"
|
||||
value={customDay}
|
||||
onChange={(e) => setCustomDay(e.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
)}
|
||||
{preset === 'custom_range' && (
|
||||
<>
|
||||
<TextField
|
||||
label="From"
|
||||
type="date"
|
||||
size="small"
|
||||
value={rangeFrom}
|
||||
onChange={(e) => setRangeFrom(e.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<TextField
|
||||
label="To"
|
||||
type="date"
|
||||
size="small"
|
||||
value={rangeTo}
|
||||
onChange={(e) => setRangeTo(e.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>
|
||||
) : (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 3, flexWrap: 'wrap' }}>
|
||||
<SummaryCard label="Total in range" value={total.toLocaleString()} />
|
||||
<SummaryCard label="Audited" value={audited.toLocaleString()} color="#22c55e" />
|
||||
<SummaryCard label="Not audited" value={notAudited.toLocaleString()} color="#f59e0b" />
|
||||
<SummaryCard label="Completion rate" value={`${completionRate}%`} />
|
||||
</Box>
|
||||
|
||||
<Typography variant="subtitle1" sx={{ mb: 0.5 }}>
|
||||
Per-user activity — click a row for their individual records
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ mb: 1, display: 'block', color: 'text.secondary' }}>
|
||||
Assigned = video arrived in this window · Completed = audited in this window (may include older backlog)
|
||||
</Typography>
|
||||
<TableContainer component={Paper} sx={{ mb: drillUser ? 3 : 0 }}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>User</TableCell>
|
||||
<TableCell align="right">Assigned</TableCell>
|
||||
<TableCell align="right">Completed</TableCell>
|
||||
<TableCell align="right">Efficiency</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{byUser.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} align="center" sx={{ color: 'text.secondary' }}>
|
||||
No activity in this range.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{byUser.map((u) => (
|
||||
<TableRow
|
||||
key={u.user_id}
|
||||
hover
|
||||
selected={drillUser?.user_id === u.user_id}
|
||||
onClick={() => handleRowClick(u)}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
>
|
||||
<TableCell>{u.username}</TableCell>
|
||||
<TableCell align="right">{u.assigned.toLocaleString()}</TableCell>
|
||||
<TableCell align="right">{u.completed.toLocaleString()}</TableCell>
|
||||
<TableCell align="right">
|
||||
{u.efficiency === null ? (
|
||||
<Chip size="small" label="N/A" variant="outlined" />
|
||||
) : (
|
||||
<Chip size="small" label={`${u.efficiency}%`} color={efficiencyColor(u.efficiency)} />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{drillUser && (
|
||||
<>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>
|
||||
Records assigned to {drillUser.username}
|
||||
</Typography>
|
||||
{drillLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>
|
||||
) : (
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Anomaly ID</TableCell>
|
||||
<TableCell>Asset</TableCell>
|
||||
<TableCell>Plaza</TableCell>
|
||||
<TableCell>Chainage</TableCell>
|
||||
<TableCell>Video Date</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Audited On</TableCell>
|
||||
<TableCell>Audited By</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{drillRecords.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} align="center" sx={{ color: 'text.secondary' }}>
|
||||
No records found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{drillRecords.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell>{r.id}</TableCell>
|
||||
<TableCell>{r.Asset || '—'}</TableCell>
|
||||
<TableCell>{r.Plaza || '—'}</TableCell>
|
||||
<TableCell>{r.Chainage || '—'}</TableCell>
|
||||
<TableCell>{r.video_date ? new Date(r.video_date).toLocaleDateString() : '—'}</TableCell>
|
||||
<TableCell>
|
||||
{r.IsAudited ? (
|
||||
<Chip size="small" label="Audited" color="success" />
|
||||
) : (
|
||||
<Chip size="small" label="Pending" color="default" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{r.Audited_on ? new Date(r.Audited_on).toLocaleString() : '—'}</TableCell>
|
||||
<TableCell>
|
||||
{r.audited_username
|
||||
? r.audited_username === drillUser.username
|
||||
? r.audited_username
|
||||
: `${r.audited_username} (reassigned)`
|
||||
: '—'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={drillTotal}
|
||||
page={drillPage}
|
||||
onPageChange={(_, newPage) => handleDrillPageChange(newPage)}
|
||||
rowsPerPage={RECORDS_PAGE_SIZE}
|
||||
rowsPerPageOptions={[RECORDS_PAGE_SIZE]}
|
||||
/>
|
||||
</TableContainer>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
234
src/pages/admin/DevTickets.tsx
Normal file
234
src/pages/admin/DevTickets.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
Paper, TextField, FormControl, InputLabel, Select, MenuItem, CircularProgress,
|
||||
Chip, TablePagination, Link, Tooltip,
|
||||
} from '@mui/material';
|
||||
import { adminService } from '../../api/adminService';
|
||||
import type { DevTicket } from '../../api/adminService';
|
||||
import { useToastStore } from '../../store/toastStore';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
const gitStatusColor = (status: DevTicket['git_status']): 'success' | 'warning' | 'error' => {
|
||||
if (status === 'success') return 'success';
|
||||
if (status === 'failed') return 'error';
|
||||
return 'warning';
|
||||
};
|
||||
|
||||
const truncate = (text: string, max = 80) => (text.length > max ? `${text.slice(0, max)}…` : text);
|
||||
|
||||
export const DevTickets: React.FC = () => {
|
||||
const [search, setSearch] = useState('');
|
||||
const [gitStatus, setGitStatus] = useState<'' | 'pending' | 'success' | 'failed'>('');
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
const [dateTo, setDateTo] = useState('');
|
||||
const [records, setRecords] = useState<DevTicket[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// dateTo is a bare date; created_at is a full timestamp, and both fields
|
||||
// are independently editable, so normalize the order and extend "to" to
|
||||
// end-of-day - see Allocation.tsx/AuditHistory.tsx for the same fix after
|
||||
// hitting this exact bug there (an inverted range silently matches zero
|
||||
// rows instead of erroring).
|
||||
const [effectiveDateFrom, effectiveDateTo] = (() => {
|
||||
if (!dateFrom || !dateTo) return [dateFrom || undefined, dateTo ? `${dateTo}T23:59:59` : undefined];
|
||||
return dateFrom <= dateTo
|
||||
? [dateFrom, `${dateTo}T23:59:59`]
|
||||
: [dateTo, `${dateFrom}T23:59:59`];
|
||||
})();
|
||||
|
||||
// Live Gitea issue state (open/closed) - git_status only ever records
|
||||
// whether we succeeded in *filing* the issue, never what happened to it
|
||||
// afterward, so this is fetched separately from the main list.
|
||||
const [issueStates, setIssueStates] = useState<Record<number, 'open' | 'closed' | null>>({});
|
||||
const [issueStatesLoading, setIssueStatesLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await adminService.getDevTickets({
|
||||
search: search || undefined,
|
||||
gitStatus: gitStatus || undefined,
|
||||
dateFrom: effectiveDateFrom,
|
||||
dateTo: effectiveDateTo,
|
||||
limit: PAGE_SIZE,
|
||||
offset: page * PAGE_SIZE,
|
||||
});
|
||||
setRecords(result.records);
|
||||
setTotal(result.total);
|
||||
} catch (err) {
|
||||
useToastStore.getState().showToast('Failed to load dev tickets.', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [search, gitStatus, effectiveDateFrom, effectiveDateTo, page]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(load, 300);
|
||||
return () => clearTimeout(timeout);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [search, gitStatus, effectiveDateFrom, effectiveDateTo, page]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
}, [search, gitStatus, effectiveDateFrom, effectiveDateTo]);
|
||||
|
||||
useEffect(() => {
|
||||
// Keyed by ticket id, not issue number - the backend resolves each
|
||||
// ticket's own stored git_issue_url, so this stays correct even for
|
||||
// tickets filed before a repo migration.
|
||||
const ticketIds = records
|
||||
.filter((t) => t.git_status === 'success' && t.git_issue_number != null)
|
||||
.map((t) => t.id);
|
||||
|
||||
if (ticketIds.length === 0) {
|
||||
setIssueStates({});
|
||||
return;
|
||||
}
|
||||
|
||||
setIssueStatesLoading(true);
|
||||
adminService.getIssueStates(ticketIds)
|
||||
.then(setIssueStates)
|
||||
.catch(() => useToastStore.getState().showToast('Failed to check live Gitea issue status.', 'error'))
|
||||
.finally(() => setIssueStatesLoading(false));
|
||||
}, [records]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 3, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<TextField
|
||||
label="Search (dev, reporter, message, anomaly ID)"
|
||||
size="small"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
sx={{ minWidth: 300 }}
|
||||
/>
|
||||
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||
<InputLabel>Git status</InputLabel>
|
||||
<Select label="Git status" value={gitStatus} onChange={(e) => setGitStatus(e.target.value as any)}>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
<MenuItem value="pending">Pending</MenuItem>
|
||||
<MenuItem value="success">Success</MenuItem>
|
||||
<MenuItem value="failed">Failed</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
label="Created from"
|
||||
type="date"
|
||||
size="small"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<TextField
|
||||
label="Created to"
|
||||
type="date"
|
||||
size="small"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>
|
||||
) : (
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Anomaly ID</TableCell>
|
||||
<TableCell>Asset / Plaza</TableCell>
|
||||
<TableCell>Dev</TableCell>
|
||||
<TableCell>Reporter</TableCell>
|
||||
<TableCell>Message</TableCell>
|
||||
<TableCell>RocketChat</TableCell>
|
||||
<TableCell>Git</TableCell>
|
||||
<TableCell>Issue status</TableCell>
|
||||
<TableCell>Created</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{records.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} align="center" sx={{ color: 'text.secondary' }}>
|
||||
No dev tickets found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{records.map((t) => {
|
||||
const liveState = t.git_status === 'success' && t.git_issue_number != null ? issueStates[t.id] : undefined;
|
||||
return (
|
||||
<TableRow key={t.id}>
|
||||
<TableCell>{t.anomaly_id}</TableCell>
|
||||
<TableCell>{[t.asset, t.plaza].filter(Boolean).join(' / ') || '—'}</TableCell>
|
||||
<TableCell>@{t.dev_handle}</TableCell>
|
||||
<TableCell>{t.reporter || '—'}</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip title={t.message_text}>
|
||||
<span>{truncate(t.message_text)}</span>
|
||||
</Tooltip>
|
||||
{t.duplicate_of_id && (
|
||||
<Chip size="small" label={`dup of #${t.duplicate_of_id}`} sx={{ ml: 1 }} variant="outlined" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{t.rocketchat_sent ? (
|
||||
<Chip size="small" label="Sent" color="success" />
|
||||
) : (
|
||||
<Tooltip title={t.rocketchat_error || 'Not sent'}>
|
||||
<Chip size="small" label="Failed" color="error" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{t.git_issue_url ? (
|
||||
<Link href={t.git_issue_url} target="_blank" rel="noopener noreferrer">
|
||||
<Chip size="small" label={`#${t.git_issue_number}`} color={gitStatusColor(t.git_status)} clickable />
|
||||
</Link>
|
||||
) : (
|
||||
<Tooltip title={t.git_error || ''}>
|
||||
<Chip size="small" label={t.git_status} color={gitStatusColor(t.git_status)} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{t.retry_count > 0 && (
|
||||
<Chip size="small" label={`retries: ${t.retry_count}`} sx={{ ml: 1 }} variant="outlined" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{t.git_status !== 'success' ? (
|
||||
'—'
|
||||
) : issueStatesLoading && liveState === undefined ? (
|
||||
<CircularProgress size={14} />
|
||||
) : liveState === 'closed' ? (
|
||||
<Chip size="small" label="Closed" color="default" />
|
||||
) : liveState === 'open' ? (
|
||||
<Chip size="small" label="Open" color="info" />
|
||||
) : (
|
||||
<Tooltip title="Could not reach Gitea to check">
|
||||
<Chip size="small" label="Unknown" variant="outlined" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{new Date(t.created_at).toLocaleString()}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={total}
|
||||
page={page}
|
||||
onPageChange={(_, newPage) => setPage(newPage)}
|
||||
rowsPerPage={PAGE_SIZE}
|
||||
rowsPerPageOptions={[PAGE_SIZE]}
|
||||
/>
|
||||
</TableContainer>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
77
src/pages/admin/LoginHistory.tsx
Normal file
77
src/pages/admin/LoginHistory.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
Paper, TextField, CircularProgress, Chip,
|
||||
} from '@mui/material';
|
||||
import { adminService } from '../../api/adminService';
|
||||
import type { UserSession } from '../../api/adminService';
|
||||
import { useToastStore } from '../../store/toastStore';
|
||||
|
||||
const formatDate = (value: string | null) => (value ? new Date(value).toLocaleString() : '—');
|
||||
|
||||
export const LoginHistory: React.FC = () => {
|
||||
const [sessions, setSessions] = useState<UserSession[]>([]);
|
||||
const [username, setUsername] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async (usernameFilter: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await adminService.getSessions({ username: usernameFilter || undefined });
|
||||
setSessions(result);
|
||||
} catch (err) {
|
||||
useToastStore.getState().showToast('Failed to load login history.', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load('');
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => load(username), 300);
|
||||
return () => clearTimeout(timeout);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [username]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<TextField
|
||||
label="Filter by username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
size="small"
|
||||
sx={{ mb: 2, width: 300 }}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>
|
||||
) : (
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Username</TableCell>
|
||||
<TableCell>Login At</TableCell>
|
||||
<TableCell>Logout At</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{sessions.map((s) => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell>{s.username}</TableCell>
|
||||
<TableCell>{formatDate(s.login_at)}</TableCell>
|
||||
<TableCell>
|
||||
{s.logout_at ? formatDate(s.logout_at) : <Chip size="small" label="Active" color="success" />}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
234
src/pages/admin/UserManagement.tsx
Normal file
234
src/pages/admin/UserManagement.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Button, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
Paper, Dialog, DialogTitle, DialogContent, DialogActions, TextField, MenuItem,
|
||||
Select, FormControl, InputLabel, Switch, FormControlLabel, CircularProgress, Chip,
|
||||
} from '@mui/material';
|
||||
import { adminService } from '../../api/adminService';
|
||||
import type { AdminUser, Role } from '../../api/adminService';
|
||||
import { accountService } from '../../api/accountService';
|
||||
import { useToastStore } from '../../store/toastStore';
|
||||
|
||||
interface Organization {
|
||||
org_id: string;
|
||||
org_name: string;
|
||||
db_name: string;
|
||||
}
|
||||
|
||||
export const UserManagement: React.FC = () => {
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [newUser, setNewUser] = useState({ username: '', email: '', name: '', password: '', role_id: '', org_id: '', db_name: '' });
|
||||
|
||||
const [editUser, setEditUser] = useState<AdminUser | null>(null);
|
||||
const [editForm, setEditForm] = useState({ username: '', role_id: '', disabled: false });
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [usersRes, rolesRes, orgsRes] = await Promise.all([
|
||||
adminService.getUsers(),
|
||||
adminService.getRoles(),
|
||||
accountService.getOrganizations(),
|
||||
]);
|
||||
setUsers(usersRes);
|
||||
setRoles(rolesRes);
|
||||
setOrganizations((orgsRes as any) || []);
|
||||
} catch (err) {
|
||||
useToastStore.getState().showToast('Failed to load users.', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const handleOrgChange = (orgId: string | number, setter: (v: any) => void, current: any) => {
|
||||
// MUI Select passes e.target.value through with its original type - org_id
|
||||
// comes back as a number from the API, so compare both sides as strings.
|
||||
const org = organizations.find((o) => String(o.org_id) === String(orgId));
|
||||
setter({ ...current, org_id: orgId, db_name: org?.db_name || '' });
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newUser.username || !newUser.password || !newUser.role_id || !newUser.db_name) {
|
||||
useToastStore.getState().showToast('Username, password, role and org are required.', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await adminService.createUser({
|
||||
username: newUser.username,
|
||||
email: newUser.email,
|
||||
name: newUser.name,
|
||||
password: newUser.password,
|
||||
role_id: Number(newUser.role_id),
|
||||
org_id: Number(newUser.org_id),
|
||||
db_name: newUser.db_name,
|
||||
});
|
||||
useToastStore.getState().showToast('User created.', 'success');
|
||||
setAddOpen(false);
|
||||
setNewUser({ username: '', email: '', name: '', password: '', role_id: '', org_id: '', db_name: '' });
|
||||
load();
|
||||
} catch (err: any) {
|
||||
const message = err?.response?.status === 409 ? 'That username is already taken.' : 'Failed to create user.';
|
||||
useToastStore.getState().showToast(message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (user: AdminUser) => {
|
||||
setEditUser(user);
|
||||
setEditForm({ username: user.username, role_id: String(user.role_id), disabled: user.disabled });
|
||||
};
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!editUser) return;
|
||||
try {
|
||||
await adminService.updateUser(editUser.user_id, {
|
||||
username: editForm.username,
|
||||
role_id: Number(editForm.role_id),
|
||||
disabled: editForm.disabled,
|
||||
});
|
||||
useToastStore.getState().showToast('User updated.', 'success');
|
||||
setEditUser(null);
|
||||
load();
|
||||
} catch (err: any) {
|
||||
const message = err?.response?.status === 409 ? 'That username is already taken.' : 'Failed to update user.';
|
||||
useToastStore.getState().showToast(message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter((u) => {
|
||||
if (!search.trim()) return true;
|
||||
const needle = search.trim().toLowerCase();
|
||||
return (
|
||||
u.username.toLowerCase().includes(needle) ||
|
||||
u.email?.toLowerCase().includes(needle) ||
|
||||
u.name?.toLowerCase().includes(needle) ||
|
||||
u.role_name.toLowerCase().includes(needle)
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2, gap: 2, flexWrap: 'wrap' }}>
|
||||
<TextField
|
||||
label="Search users"
|
||||
placeholder="Username, email, name or role"
|
||||
size="small"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
sx={{ minWidth: 280 }}
|
||||
/>
|
||||
<Button variant="contained" onClick={() => setAddOpen(true)}>Add User</Button>
|
||||
</Box>
|
||||
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>
|
||||
) : (
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Username</TableCell>
|
||||
<TableCell>Email</TableCell>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Role</TableCell>
|
||||
<TableCell>Org (DB)</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filteredUsers.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} align="center" sx={{ color: 'text.secondary' }}>
|
||||
No users match "{search}".
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{filteredUsers.map((u) => (
|
||||
<TableRow key={u.user_id}>
|
||||
<TableCell>{u.username}</TableCell>
|
||||
<TableCell>{u.email}</TableCell>
|
||||
<TableCell>{u.name}</TableCell>
|
||||
<TableCell>{u.role_name}</TableCell>
|
||||
<TableCell>{u.db_name}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
size="small"
|
||||
label={u.disabled ? 'Disabled' : 'Active'}
|
||||
color={u.disabled ? 'default' : 'success'}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Button size="small" onClick={() => openEdit(u)}>Edit</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
{/* Add User Dialog */}
|
||||
<Dialog open={addOpen} onClose={() => setAddOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Add User</DialogTitle>
|
||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
|
||||
<TextField label="Username" value={newUser.username} onChange={(e) => setNewUser({ ...newUser, username: e.target.value })} fullWidth />
|
||||
<TextField label="Email" value={newUser.email} onChange={(e) => setNewUser({ ...newUser, email: e.target.value })} fullWidth />
|
||||
<TextField label="Name" value={newUser.name} onChange={(e) => setNewUser({ ...newUser, name: e.target.value })} fullWidth />
|
||||
<TextField label="Password" type="password" value={newUser.password} onChange={(e) => setNewUser({ ...newUser, password: e.target.value })} fullWidth />
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Role</InputLabel>
|
||||
<Select label="Role" value={newUser.role_id} onChange={(e) => setNewUser({ ...newUser, role_id: e.target.value })}>
|
||||
{roles.map((r) => <MenuItem key={r.id} value={r.id}>{r.role_name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Organization</InputLabel>
|
||||
<Select
|
||||
label="Organization"
|
||||
value={newUser.org_id}
|
||||
onChange={(e) => handleOrgChange(e.target.value, setNewUser, newUser)}
|
||||
>
|
||||
{organizations.map((o) => <MenuItem key={o.org_id} value={o.org_id}>{o.org_name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setAddOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleCreate}>Create</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit User Dialog */}
|
||||
<Dialog open={!!editUser} onClose={() => setEditUser(null)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Edit User</DialogTitle>
|
||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
|
||||
<TextField label="Username" value={editForm.username} onChange={(e) => setEditForm({ ...editForm, username: e.target.value })} fullWidth />
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Role</InputLabel>
|
||||
<Select label="Role" value={editForm.role_id} onChange={(e) => setEditForm({ ...editForm, role_id: e.target.value })}>
|
||||
{roles.map((r) => <MenuItem key={r.id} value={r.id}>{r.role_name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={!editForm.disabled} onChange={(e) => setEditForm({ ...editForm, disabled: !e.target.checked })} />}
|
||||
label={editForm.disabled ? 'Disabled' : 'Active'}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setEditUser(null)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleUpdate}>Save</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,20 @@
|
||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { Box, Typography, Paper, TextField, Button, CircularProgress, FormControl, Select, MenuItem } from '@mui/material';
|
||||
import React, { useEffect, useState, useCallback, useMemo, Fragment } from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, TextField, Button, CircularProgress, FormControl, Select, MenuItem,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, IconButton, Collapse,
|
||||
} from '@mui/material';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import {
|
||||
ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend,
|
||||
} from 'recharts';
|
||||
import { accountService } from '../../api/accountService';
|
||||
import { activityFeedsService } from '../../api/activityFeedsService';
|
||||
import { auditSummaryService } from '../../api/auditSummaryService';
|
||||
import { adminService } from '../../api/adminService';
|
||||
import type { ClientOverviewOrg } from '../../api/adminService';
|
||||
import { useToastStore } from '../../store/toastStore';
|
||||
import { AssetMultiSelect } from '../../components/common/AssetMultiSelect';
|
||||
|
||||
interface ClientBarData {
|
||||
client: string;
|
||||
@@ -209,6 +218,73 @@ export const ClientsOverview: React.FC = () => {
|
||||
? (availableSites.find((s) => s.site_id === selectedSite)?.site_name || selectedSite)
|
||||
: selectedClient;
|
||||
|
||||
// Per-org / per-site record counts, independent of the chart above's date
|
||||
// range and single-site drill-down. All-time by default (empty date
|
||||
// fields), optionally scoped by a date range and/or one-or-more assets.
|
||||
const [overviewType, setOverviewType] = useState<'audits' | 'rectification'>('audits');
|
||||
const [overviewDateFrom, setOverviewDateFrom] = useState('');
|
||||
const [overviewDateTo, setOverviewDateTo] = useState('');
|
||||
const [overviewAssetIds, setOverviewAssetIds] = useState<number[]>([]);
|
||||
const [overview, setOverview] = useState<ClientOverviewOrg[]>([]);
|
||||
const [overviewLoading, setOverviewLoading] = useState(true);
|
||||
const [expandedOrgs, setExpandedOrgs] = useState<Set<number>>(new Set());
|
||||
|
||||
// Two independently-editable date fields can produce an inverted range
|
||||
// that silently matches zero rows - normalize the order and extend "to"
|
||||
// to end-of-day, same fix as Allocation.tsx/AuditHistory.tsx/DevTickets.tsx.
|
||||
const [effectiveOverviewFrom, effectiveOverviewTo] = (() => {
|
||||
if (!overviewDateFrom || !overviewDateTo) {
|
||||
return [overviewDateFrom || undefined, overviewDateTo ? `${overviewDateTo}T23:59:59` : undefined];
|
||||
}
|
||||
return overviewDateFrom <= overviewDateTo
|
||||
? [overviewDateFrom, `${overviewDateTo}T23:59:59`]
|
||||
: [overviewDateTo, `${overviewDateFrom}T23:59:59`];
|
||||
})();
|
||||
|
||||
const fetchOverview = useCallback(async () => {
|
||||
setOverviewLoading(true);
|
||||
try {
|
||||
const result = await adminService.getClientsOverview({
|
||||
type: overviewType,
|
||||
dateFrom: effectiveOverviewFrom,
|
||||
dateTo: effectiveOverviewTo,
|
||||
assetIds: overviewAssetIds,
|
||||
});
|
||||
setOverview(result.clients);
|
||||
} catch (err) {
|
||||
useToastStore.getState().showToast('Failed to load client records overview.', 'error');
|
||||
} finally {
|
||||
setOverviewLoading(false);
|
||||
}
|
||||
}, [overviewType, effectiveOverviewFrom, effectiveOverviewTo, overviewAssetIds]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOverview();
|
||||
}, [fetchOverview]);
|
||||
|
||||
const toggleOrgExpanded = (orgId: number) => {
|
||||
setExpandedOrgs((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(orgId)) next.delete(orgId);
|
||||
else next.add(orgId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const overviewTotals = useMemo(
|
||||
() =>
|
||||
overview.reduce(
|
||||
(acc, o) => ({
|
||||
total: acc.total + o.total,
|
||||
audited: acc.audited + o.audited,
|
||||
deleted: acc.deleted + o.deleted,
|
||||
nonAudited: acc.nonAudited + o.nonAudited,
|
||||
}),
|
||||
{ total: 0, audited: 0, deleted: 0, nonAudited: 0 }
|
||||
),
|
||||
[overview]
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%', maxWidth: '100vw', display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, px: 2, py: 1.5, bgcolor: '#0b1121', flexWrap: 'wrap' }}>
|
||||
@@ -347,6 +423,167 @@ export const ClientsOverview: React.FC = () => {
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3, bgcolor: '#0f172a', border: '1px solid #1e293b', borderRadius: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2, flexWrap: 'wrap', gap: 1.5 }}>
|
||||
<Typography variant="h6" sx={{ color: '#f8fafc', fontWeight: 'bold' }}>
|
||||
All Clients — Records by Site
|
||||
{(overviewDateFrom || overviewDateTo || overviewAssetIds.length > 0) ? '' : ' (all-time)'}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<TextField
|
||||
type="date"
|
||||
size="small"
|
||||
label="From"
|
||||
value={overviewDateFrom}
|
||||
onChange={(e) => setOverviewDateFrom(e.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
sx={{ '& .MuiOutlinedInput-root': { color: '#f8fafc', bgcolor: 'rgba(255,255,255,0.1)' } }}
|
||||
/>
|
||||
<TextField
|
||||
type="date"
|
||||
size="small"
|
||||
label="To"
|
||||
value={overviewDateTo}
|
||||
onChange={(e) => setOverviewDateTo(e.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
sx={{ '& .MuiOutlinedInput-root': { color: '#f8fafc', bgcolor: 'rgba(255,255,255,0.1)' } }}
|
||||
/>
|
||||
<AssetMultiSelect value={overviewAssetIds} onChange={setOverviewAssetIds} />
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => setOverviewType(overviewType === 'audits' ? 'rectification' : 'audits')}
|
||||
sx={{
|
||||
bgcolor: overviewType === 'rectification' ? '#ff4d4f' : 'rgba(255,255,255,0.1)',
|
||||
color: '#fff',
|
||||
'&:hover': { bgcolor: overviewType === 'rectification' ? '#ff7875' : 'rgba(255,255,255,0.2)' },
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 'bold',
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
>
|
||||
{overviewType === 'rectification' ? 'RECTIFICATION' : 'AUDITS'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={fetchOverview}
|
||||
sx={{ borderColor: '#334155', color: '#94a3b8', textTransform: 'none', fontWeight: 'bold' }}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{overviewLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : overview.length === 0 ? (
|
||||
<Typography sx={{ color: '#64748b', textAlign: 'center', py: 8 }}>No data found</Typography>
|
||||
) : (
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ color: '#94a3b8', borderColor: '#1e293b', width: 40 }} />
|
||||
<TableCell sx={{ color: '#94a3b8', borderColor: '#1e293b', fontWeight: 'bold' }}>Organization / Site</TableCell>
|
||||
<TableCell sx={{ color: '#94a3b8', borderColor: '#1e293b', fontWeight: 'bold' }} align="right">Total</TableCell>
|
||||
<TableCell sx={{ color: '#94a3b8', borderColor: '#1e293b', fontWeight: 'bold' }} align="right">Audited</TableCell>
|
||||
<TableCell sx={{ color: '#94a3b8', borderColor: '#1e293b', fontWeight: 'bold' }} align="right">Deleted</TableCell>
|
||||
<TableCell sx={{ color: '#94a3b8', borderColor: '#1e293b', fontWeight: 'bold' }} align="right">Non-Audited</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{overview.map((org) => {
|
||||
const isExpanded = expandedOrgs.has(org.org_id);
|
||||
return (
|
||||
<Fragment key={org.org_id}>
|
||||
<TableRow>
|
||||
<TableCell sx={{ borderColor: '#1e293b' }}>
|
||||
{org.sites.length > 0 && (
|
||||
<IconButton size="small" onClick={() => toggleOrgExpanded(org.org_id)} sx={{ color: '#94a3b8' }}>
|
||||
{isExpanded ? <KeyboardArrowUpIcon fontSize="small" /> : <KeyboardArrowDownIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: '#f8fafc', borderColor: '#1e293b', fontWeight: 'bold' }}>
|
||||
{org.org_name}
|
||||
{org.error && (
|
||||
<Typography component="span" sx={{ color: '#ef4444', ml: 1, fontSize: '0.75rem' }}>
|
||||
(failed to load: {org.error})
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: '#3b82f6', borderColor: '#1e293b', fontWeight: 'bold' }} align="right">
|
||||
{org.total.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: '#22c55e', borderColor: '#1e293b', fontWeight: 'bold' }} align="right">
|
||||
{org.audited.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: '#ef4444', borderColor: '#1e293b', fontWeight: 'bold' }} align="right">
|
||||
{org.deleted.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: '#eab308', borderColor: '#1e293b', fontWeight: 'bold' }} align="right">
|
||||
{org.nonAudited.toLocaleString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{org.sites.length > 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} sx={{ p: 0, borderColor: '#1e293b' }}>
|
||||
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
|
||||
<Table size="small">
|
||||
<TableBody>
|
||||
{org.sites.map((site) => (
|
||||
<TableRow key={site.site_id}>
|
||||
<TableCell sx={{ borderColor: '#1e293b', width: 40 }} />
|
||||
<TableCell sx={{ color: '#cbd5e1', borderColor: '#1e293b', pl: 4 }}>
|
||||
{site.site_name}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: '#93c5fd', borderColor: '#1e293b' }} align="right">
|
||||
{site.total.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: '#86efac', borderColor: '#1e293b' }} align="right">
|
||||
{site.audited.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: '#fca5a5', borderColor: '#1e293b' }} align="right">
|
||||
{site.deleted.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: '#fde047', borderColor: '#1e293b' }} align="right">
|
||||
{site.nonAudited.toLocaleString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
<TableRow>
|
||||
<TableCell sx={{ borderColor: '#1e293b' }} />
|
||||
<TableCell sx={{ color: '#f8fafc', borderColor: '#1e293b', fontWeight: 'bold' }}>All Clients</TableCell>
|
||||
<TableCell sx={{ color: '#3b82f6', borderColor: '#1e293b', fontWeight: 'bold' }} align="right">
|
||||
{overviewTotals.total.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: '#22c55e', borderColor: '#1e293b', fontWeight: 'bold' }} align="right">
|
||||
{overviewTotals.audited.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: '#ef4444', borderColor: '#1e293b', fontWeight: 'bold' }} align="right">
|
||||
{overviewTotals.deleted.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: '#eab308', borderColor: '#1e293b', fontWeight: 'bold' }} align="right">
|
||||
{overviewTotals.nonAudited.toLocaleString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user