feat: implement client overview dashboard and admin portal structure with API services and page components

This commit is contained in:
2026-07-09 17:37:54 +05:30
parent 3cf9e2997c
commit b667fe1082
17 changed files with 2196 additions and 73 deletions

View 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>
);
};