Merge pull request 'Removed settings page mock data and replaced with api calls' (#103) from finishline into main
Reviewed-on: #103
This commit was merged in pull request #103.
This commit is contained in:
@@ -12,6 +12,7 @@
|
|||||||
*.env
|
*.env
|
||||||
*.env.json
|
*.env.json
|
||||||
certificates/
|
certificates/
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||||
*.userprefs
|
*.userprefs
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ export const RulesSection: React.FC<RulesSectionProps> = ({
|
|||||||
programmeId: Number(rule.programmeId),
|
programmeId: Number(rule.programmeId),
|
||||||
name: rule.name || '',
|
name: rule.name || '',
|
||||||
description: rule.description || '',
|
description: rule.description || '',
|
||||||
version: Number(rule.version) || 1,
|
version: Number(rule.version) > 0 ? Number(rule.version) : 1,
|
||||||
status: Number(rule.status),
|
status: Number(rule.status),
|
||||||
});
|
});
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
@@ -577,19 +577,26 @@ export const RulesSection: React.FC<RulesSectionProps> = ({
|
|||||||
{/* Version & Status */}
|
{/* Version & Status */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-1 mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<label className="block text-xs font-semibold text-slate-300">Rule Version</label>
|
<div className="flex items-center gap-1">
|
||||||
<HelpTooltip
|
<label className="block text-xs font-semibold text-slate-300">Rule Version</label>
|
||||||
title="Rule Version"
|
<HelpTooltip
|
||||||
content="Integer version tracking revisions of this statutory rule."
|
title="Rule Version"
|
||||||
size="sm"
|
content="System-managed version number tracking revisions of this statutory rule (read-only)."
|
||||||
/>
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-mono uppercase tracking-wider text-slate-500 bg-slate-800/60 px-1.5 py-0.5 rounded border border-slate-700/50">
|
||||||
|
Read-Only
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
|
readOnly
|
||||||
|
tabIndex={-1}
|
||||||
{...ruleForm.register('version')}
|
{...ruleForm.register('version')}
|
||||||
className="w-full px-3 py-2 text-xs bg-[#090d16] border border-slate-700 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 font-mono"
|
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/70 rounded-xl text-slate-400 font-mono cursor-not-allowed select-none focus:outline-none focus:ring-0 focus:border-slate-700/70"
|
||||||
/>
|
/>
|
||||||
{ruleForm.formState.errors.version && (
|
{ruleForm.formState.errors.version && (
|
||||||
<p className="text-red-400 text-[11px] mt-1">
|
<p className="text-red-400 text-[11px] mt-1">
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
Paperclip,
|
Paperclip,
|
||||||
Check,
|
Check,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
|
RotateCcw,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
getContracts,
|
getContracts,
|
||||||
@@ -39,6 +40,8 @@ import { toolsService } from '../../services/toolsService';
|
|||||||
import { Modal } from '../ui/Modal';
|
import { Modal } from '../ui/Modal';
|
||||||
import { useToast } from '../ui/Toast';
|
import { useToast } from '../ui/Toast';
|
||||||
import { ProblemDetailsAlert } from '../ui/ProblemDetailsAlert';
|
import { ProblemDetailsAlert } from '../ui/ProblemDetailsAlert';
|
||||||
|
import { ExportButton } from '../ui/ExportButton';
|
||||||
|
import { ExportColumn } from '../../utils/exportUtils';
|
||||||
import {
|
import {
|
||||||
Contract,
|
Contract,
|
||||||
Award,
|
Award,
|
||||||
@@ -390,6 +393,19 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
|||||||
Number(c.status) === ContractStatus.PendingApproval
|
Number(c.status) === ContractStatus.PendingApproval
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
|
const contractExportColumns: ExportColumn<Contract>[] = [
|
||||||
|
{ header: 'Contract ID', key: 'id' },
|
||||||
|
{ header: 'Description / Title', key: 'description' },
|
||||||
|
{ header: 'Project', key: 'projectId', format: (val) => (val ? `Project #${val}` : '—') },
|
||||||
|
{ header: 'Award ID', key: 'awardId', format: (val) => (val ? `Award #${val}` : '—') },
|
||||||
|
{ header: 'Organisation ID', key: 'organisationId', format: (val) => (val ? `Org #${val}` : '—') },
|
||||||
|
{ header: 'Status', key: 'status', format: (val) => getContractStatusBadge(val).label },
|
||||||
|
{ header: 'Signed Date', key: 'signedDate', format: (val) => (val ? formatDate(val) : '—') },
|
||||||
|
{ header: 'Expiry Date', key: 'expiryDate', format: (val) => (val ? formatDate(val) : '—') },
|
||||||
|
{ header: 'Budget Allocated', key: 'budgetAllocated', format: (val) => (val ? formatZAR(val) : '—') },
|
||||||
|
{ header: 'Document URI', key: 'fileUri', format: (val) => val || '—' },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Summary Cards */}
|
{/* Summary Cards */}
|
||||||
@@ -466,27 +482,30 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action and Filter Bar */}
|
{/* Action and Filter Bar */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-[#0f1523]/80 backdrop-blur-md p-4 rounded-2xl border border-slate-800/80 shadow-md">
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 bg-[#0d121f]/90 p-4 rounded-2xl border border-slate-800/80 backdrop-blur-sm">
|
||||||
<div className="flex flex-wrap items-center gap-2 flex-1">
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 flex-1 flex-wrap">
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="relative flex-1 min-w-[220px] max-w-sm">
|
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-3" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search contracts, award, org, programme..."
|
placeholder="Search contracts, award, org, programme..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="w-full bg-[#151b2c] border border-slate-700/80 rounded-xl pl-9 pr-3 py-1.5 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-blue-500"
|
className="w-full pl-9 pr-4 py-2.5 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-slate-200 placeholder:text-slate-500 transition shadow-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status Filter */}
|
{/* Status Filter */}
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-2">
|
||||||
<Filter className="w-3.5 h-3.5 text-slate-400" />
|
<label className="text-xs text-slate-400 whitespace-nowrap flex items-center gap-1 font-medium">
|
||||||
|
<Filter className="w-3.5 h-3.5 text-slate-400" />
|
||||||
|
Status:
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(e) => setStatusFilter(e.target.value)}
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
className="bg-[#151b2c] border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-blue-500"
|
className="px-3 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900"
|
||||||
>
|
>
|
||||||
<option value="all">All Statuses</option>
|
<option value="all">All Statuses</option>
|
||||||
{CONTRACT_STATUS_OPTIONS.map((opt) => (
|
{CONTRACT_STATUS_OPTIONS.map((opt) => (
|
||||||
@@ -497,28 +516,57 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
{(searchTerm || statusFilter !== 'all') && (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => refetchContracts()}
|
type="button"
|
||||||
disabled={refetchingContracts}
|
onClick={() => {
|
||||||
className="p-2 rounded-xl bg-[#151b2c] border border-slate-700/80 text-slate-400 hover:text-white hover:bg-slate-800 transition"
|
setSearchTerm('');
|
||||||
title="Refresh contracts"
|
setStatusFilter('all');
|
||||||
>
|
}}
|
||||||
<RefreshCw className={`w-3.5 h-3.5 ${refetchingContracts ? 'animate-spin' : ''}`} />
|
className="inline-flex items-center gap-1 text-[11px] font-semibold text-slate-400 hover:text-white px-2.5 py-2 bg-slate-800 hover:bg-slate-700 rounded-xl transition cursor-pointer"
|
||||||
</button>
|
>
|
||||||
|
<RotateCcw className="w-3 h-3" />
|
||||||
|
<span>Reset</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
{/* Action Buttons: Refresh, Export & Create */}
|
||||||
type="button"
|
<div className="flex items-center gap-2 self-end lg:self-center">
|
||||||
onClick={() => {
|
<button
|
||||||
resetCreateForm();
|
type="button"
|
||||||
setCreateModalOpen(true);
|
onClick={() => {
|
||||||
}}
|
refetchContracts();
|
||||||
className="inline-flex items-center justify-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition cursor-pointer"
|
success('Refreshed', 'Contract registry and award linkages updated.');
|
||||||
>
|
}}
|
||||||
<Plus className="w-4 h-4" />
|
disabled={refetchingContracts}
|
||||||
Create Contract & Upload PDF
|
className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-slate-300 bg-[#0d121f] hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
|
||||||
</button>
|
title="Refresh contracts from API"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${refetchingContracts ? 'animate-spin' : ''}`} />
|
||||||
|
<span>Refresh</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ExportButton
|
||||||
|
title="Contract Registry & Agreements"
|
||||||
|
subtitle="Authoritative repository of bilateral legal agreements and contract files"
|
||||||
|
filename={`pfm-contracts-${new Date().toISOString().split('T')[0]}`}
|
||||||
|
columns={contractExportColumns}
|
||||||
|
data={filteredContracts}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
resetCreateForm();
|
||||||
|
setCreateModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center justify-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Create Contract
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contracts Table */}
|
{/* Contracts Table */}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
FileCheck,
|
FileCheck,
|
||||||
|
RefreshCw,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
getAllEvidence,
|
getAllEvidence,
|
||||||
@@ -53,6 +54,8 @@ import {
|
|||||||
} from '../../lib/formatters';
|
} from '../../lib/formatters';
|
||||||
import { Modal } from '../ui/Modal';
|
import { Modal } from '../ui/Modal';
|
||||||
import { useToast } from '../ui/Toast';
|
import { useToast } from '../ui/Toast';
|
||||||
|
import { ExportButton } from '../ui/ExportButton';
|
||||||
|
import { ExportColumn } from '../../utils/exportUtils';
|
||||||
|
|
||||||
export interface EvidenceReviewQueueProps {
|
export interface EvidenceReviewQueueProps {
|
||||||
initialProjectId?: string | number | 'all';
|
initialProjectId?: string | number | 'all';
|
||||||
@@ -154,7 +157,12 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Query evidence: if a project is selected (e.g. the first project by default), fetch via /v1/api/evidence/{projectId}
|
// Query evidence: if a project is selected (e.g. the first project by default), fetch via /v1/api/evidence/{projectId}
|
||||||
const { data: rawEvidenceList = [], isLoading: loadingEvidence } = useQuery<Evidence[]>({
|
const {
|
||||||
|
data: rawEvidenceList = [],
|
||||||
|
isLoading: loadingEvidence,
|
||||||
|
refetch: refetchEvidence,
|
||||||
|
isFetching: isFetchingEvidence,
|
||||||
|
} = useQuery<Evidence[]>({
|
||||||
queryKey: ['evidence', selectedProjectId],
|
queryKey: ['evidence', selectedProjectId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (selectedProjectId && selectedProjectId !== 'all') {
|
if (selectedProjectId && selectedProjectId !== 'all') {
|
||||||
@@ -168,6 +176,19 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
|||||||
// UI depends entirely on the API response - no mock or demo data
|
// UI depends entirely on the API response - no mock or demo data
|
||||||
const allEvidence: Evidence[] = rawEvidenceList;
|
const allEvidence: Evidence[] = rawEvidenceList;
|
||||||
|
|
||||||
|
const evidenceExportColumns: ExportColumn<Evidence>[] = [
|
||||||
|
{ header: 'Evidence ID', key: 'id' },
|
||||||
|
{ header: 'File Name', key: 'fileName' },
|
||||||
|
{ header: 'Project ID', key: 'projectId', format: (val) => `Project #${val}` },
|
||||||
|
{ header: 'Milestone ID', key: 'milestoneId', format: (val) => (val ? `Milestone #${val}` : '—') },
|
||||||
|
{ header: 'Activity ID', key: 'activityId', format: (val) => (val ? `Activity #${val}` : '—') },
|
||||||
|
{ header: 'Document Type', key: 'evidenceType', format: (val) => getEvidenceTypeLabel(val) },
|
||||||
|
{ header: 'Submitter', key: 'createdBy', format: (val) => getUserEmail(val) },
|
||||||
|
{ header: 'Submission Date', key: 'createdAt', format: (val) => formatDate(val) },
|
||||||
|
{ header: 'Verification Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label },
|
||||||
|
{ header: 'Review Remarks', key: 'remarks', format: (val) => val || '—' },
|
||||||
|
];
|
||||||
|
|
||||||
// Review evidence mutation: Facilitates Approve & Reject decisions via PUT /v1/api/evidence
|
// Review evidence mutation: Facilitates Approve & Reject decisions via PUT /v1/api/evidence
|
||||||
const reviewMutation = useMutation({
|
const reviewMutation = useMutation({
|
||||||
mutationFn: async ({
|
mutationFn: async ({
|
||||||
@@ -408,14 +429,36 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2.5 shrink-0">
|
<div className="flex items-center gap-2 self-end sm:self-center shrink-0 flex-wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
refetchEvidence();
|
||||||
|
success('Refreshed', 'Latest deliverable evidence records updated.');
|
||||||
|
}}
|
||||||
|
disabled={isFetchingEvidence}
|
||||||
|
className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-slate-300 bg-[#0d121f] hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
|
||||||
|
title="Force refresh evidence from API"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetchingEvidence ? 'animate-spin' : ''}`} />
|
||||||
|
<span>Refresh</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ExportButton
|
||||||
|
title="Deliverable Evidence & Verification Ledger"
|
||||||
|
subtitle="Audited records of submitted milestone deliverables and verification decisions"
|
||||||
|
filename={`pfm-evidence-review-${new Date().toISOString().split('T')[0]}`}
|
||||||
|
columns={evidenceExportColumns}
|
||||||
|
data={filteredEvidence}
|
||||||
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsManualIngestModalOpen(true)}
|
onClick={() => setIsManualIngestModalOpen(true)}
|
||||||
className="inline-flex items-center gap-1.5 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/90 hover:bg-slate-800 border border-slate-700/80 px-3.5 py-2 rounded-xl transition shadow-xs cursor-pointer"
|
className="inline-flex items-center gap-1.5 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 border border-transparent px-3.5 py-2 rounded-xl transition shadow-md shadow-blue-500/20 cursor-pointer"
|
||||||
title="Upload document on behalf of awardee"
|
title="Upload document on behalf of awardee"
|
||||||
>
|
>
|
||||||
<Upload className="w-3.5 h-3.5 text-blue-400" />
|
<Upload className="w-3.5 h-3.5" />
|
||||||
<span>Ingest Evidence</span>
|
<span>Ingest Evidence</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ function drawPfmLogo(doc: jsPDF, x: number, y: number, size: number): void {
|
|||||||
// Cyan/Blue Outer hexagonal shield ring
|
// Cyan/Blue Outer hexagonal shield ring
|
||||||
doc.setDrawColor(59, 130, 246);
|
doc.setDrawColor(59, 130, 246);
|
||||||
doc.setLineWidth(1.2 * scale);
|
doc.setLineWidth(1.2 * scale);
|
||||||
doc.polygon(
|
(doc as any).polygon(
|
||||||
[
|
[
|
||||||
{ x: x + 32 * scale, y: y + 6.5 * scale },
|
{ x: x + 32 * scale, y: y + 6.5 * scale },
|
||||||
{ x: x + 53.5 * scale, y: y + 19 * scale },
|
{ x: x + 53.5 * scale, y: y + 19 * scale },
|
||||||
@@ -160,7 +160,7 @@ function drawPfmLogo(doc: jsPDF, x: number, y: number, size: number): void {
|
|||||||
// Center Plate
|
// Center Plate
|
||||||
doc.setFillColor(15, 23, 42);
|
doc.setFillColor(15, 23, 42);
|
||||||
doc.setDrawColor(51, 65, 85);
|
doc.setDrawColor(51, 65, 85);
|
||||||
doc.polygon(
|
(doc as any).polygon(
|
||||||
[
|
[
|
||||||
{ x: x + 22 * scale, y: y + 28.5 * scale },
|
{ x: x + 22 * scale, y: y + 28.5 * scale },
|
||||||
{ x: x + 32 * scale, y: y + 22.8 * scale },
|
{ x: x + 32 * scale, y: y + 22.8 * scale },
|
||||||
|
|||||||
@@ -2164,7 +2164,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] font-mono font-semibold px-2 py-0.5 rounded-full bg-blue-500/20 text-blue-300 border border-blue-500/30">
|
<span className="text-[10px] font-mono font-semibold px-2 py-0.5 rounded-full bg-blue-500/20 text-blue-300 border border-blue-500/30">
|
||||||
Live API Total
|
Total Value
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2266,7 +2266,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] font-mono font-semibold px-2 py-0.5 rounded-full bg-emerald-500/20 text-emerald-300 border border-emerald-500/30">
|
<span className="text-[10px] font-mono font-semibold px-2 py-0.5 rounded-full bg-emerald-500/20 text-emerald-300 border border-emerald-500/30">
|
||||||
Live API Total
|
Total Value
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ import {
|
|||||||
Link as LinkIcon,
|
Link as LinkIcon,
|
||||||
Edit2,
|
Edit2,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
RotateCcw,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
getAllIndicators,
|
getAllIndicators,
|
||||||
@@ -36,6 +39,8 @@ import {
|
|||||||
import { Modal } from '../components/ui/Modal';
|
import { Modal } from '../components/ui/Modal';
|
||||||
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
||||||
import { useToast } from '../components/ui/Toast';
|
import { useToast } from '../components/ui/Toast';
|
||||||
|
import { ExportButton } from '../components/ui/ExportButton';
|
||||||
|
import { ExportColumn } from '../utils/exportUtils';
|
||||||
import { ContractsManagement } from '../components/monitoring/ContractsManagement';
|
import { ContractsManagement } from '../components/monitoring/ContractsManagement';
|
||||||
import { EvidenceReviewQueue } from '../components/monitoring/EvidenceReviewQueue';
|
import { EvidenceReviewQueue } from '../components/monitoring/EvidenceReviewQueue';
|
||||||
import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix';
|
import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix';
|
||||||
@@ -124,13 +129,34 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
const [selectedIndicatorForActual, setSelectedIndicatorForActual] = useState<Indicator | null>(null);
|
const [selectedIndicatorForActual, setSelectedIndicatorForActual] = useState<Indicator | null>(null);
|
||||||
const [actualInput, setActualInput] = useState<number>(0);
|
const [actualInput, setActualInput] = useState<number>(0);
|
||||||
|
|
||||||
|
// Indicator Filter States
|
||||||
|
const [indicatorSearchTerm, setIndicatorSearchTerm] = useState('');
|
||||||
|
const [indicatorProjectFilter, setIndicatorProjectFilter] = useState<string | number | 'all'>('all');
|
||||||
|
const [indicatorUnitFilter, setIndicatorUnitFilter] = useState<string | number | 'all'>('all');
|
||||||
|
const [indicatorStatusFilter, setIndicatorStatusFilter] = useState<string>('all');
|
||||||
|
|
||||||
|
// Risk Filter States
|
||||||
|
const [riskSearchTerm, setRiskSearchTerm] = useState('');
|
||||||
|
const [riskProjectFilter, setRiskProjectFilter] = useState<string | number | 'all'>('all');
|
||||||
|
const [riskSeverityFilter, setRiskSeverityFilter] = useState<string>('all');
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
const { data: indicators = [], isLoading: loadingIndicators } = useQuery({
|
const {
|
||||||
|
data: indicators = [],
|
||||||
|
isLoading: loadingIndicators,
|
||||||
|
refetch: refetchIndicators,
|
||||||
|
isFetching: isFetchingIndicators,
|
||||||
|
} = useQuery({
|
||||||
queryKey: ['indicators'],
|
queryKey: ['indicators'],
|
||||||
queryFn: () => getAllIndicators(),
|
queryFn: () => getAllIndicators(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: risks = [], isLoading: loadingRisks } = useQuery({
|
const {
|
||||||
|
data: risks = [],
|
||||||
|
isLoading: loadingRisks,
|
||||||
|
refetch: refetchRisks,
|
||||||
|
isFetching: isFetchingRisks,
|
||||||
|
} = useQuery({
|
||||||
queryKey: ['risks'],
|
queryKey: ['risks'],
|
||||||
queryFn: () => getAllRisks(),
|
queryFn: () => getAllRisks(),
|
||||||
});
|
});
|
||||||
@@ -264,21 +290,106 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
return { label: '<70%', badge: 'bg-rose-500/10 text-rose-400 border-rose-500/20' };
|
return { label: '<70%', badge: 'bg-rose-500/10 text-rose-400 border-rose-500/20' };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const indicatorExportColumns: ExportColumn<Indicator>[] = [
|
||||||
|
{ header: 'Indicator ID', key: 'id' },
|
||||||
|
{ header: 'Indicator Statement', key: 'name' },
|
||||||
|
{ header: 'Project ID', key: 'projectId', format: (val) => `Project #${val}` },
|
||||||
|
{ header: 'Unit of Measure', key: 'unitOfMeasure', format: (val) => getUnitOfMeasureLabel(val) },
|
||||||
|
{ header: 'Baseline Value', key: 'baselineAmount' },
|
||||||
|
{ header: 'Target Value', key: 'targetAmount' },
|
||||||
|
{ header: 'Actual Recorded', key: 'actualAmount' },
|
||||||
|
{
|
||||||
|
header: 'Achievement %',
|
||||||
|
key: 'actualAmount',
|
||||||
|
format: (val, item) => (item.targetAmount ? `${Math.round((Number(val) / Number(item.targetAmount)) * 100)}%` : '—'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Status',
|
||||||
|
key: 'actualAmount',
|
||||||
|
format: (val, item) => getIndicatorStatus(Number(val), Number(item.targetAmount)).label,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const filteredIndicators = indicators.filter((ind) => {
|
||||||
|
if (indicatorProjectFilter !== 'all' && String(ind.projectId) !== String(indicatorProjectFilter)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (indicatorUnitFilter !== 'all' && String(ind.unitOfMeasure) !== String(indicatorUnitFilter)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (indicatorStatusFilter !== 'all') {
|
||||||
|
const statusObj = getIndicatorStatus(ind.actualAmount, ind.targetAmount);
|
||||||
|
if (indicatorStatusFilter === 'green' && statusObj.label !== '≥90%') return false;
|
||||||
|
if (indicatorStatusFilter === 'amber' && statusObj.label !== '70–89%') return false;
|
||||||
|
if (indicatorStatusFilter === 'red' && statusObj.label !== '<70%') return false;
|
||||||
|
if (indicatorStatusFilter === 'pending' && statusObj.label !== 'Pending') return false;
|
||||||
|
}
|
||||||
|
if (!indicatorSearchTerm.trim()) return true;
|
||||||
|
const q = indicatorSearchTerm.toLowerCase();
|
||||||
|
const name = (ind.name || '').toLowerCase();
|
||||||
|
const projId = String(ind.projectId || '').toLowerCase();
|
||||||
|
const unit = getUnitOfMeasureLabel(ind.unitOfMeasure).toLowerCase();
|
||||||
|
return name.includes(q) || projId.includes(q) || unit.includes(q);
|
||||||
|
});
|
||||||
|
|
||||||
|
const riskExportColumns: ExportColumn<Risk>[] = [
|
||||||
|
{ header: 'Risk ID', key: 'id' },
|
||||||
|
{ header: 'Risk Title', key: 'name' },
|
||||||
|
{ header: 'Risk Description', key: 'description' },
|
||||||
|
{ header: 'Project ID', key: 'projectId', format: (val) => `Project #${val}` },
|
||||||
|
{ header: 'Likelihood (1–5)', key: 'likelihood' },
|
||||||
|
{ header: 'Impact (1–5)', key: 'impact' },
|
||||||
|
{
|
||||||
|
header: 'Residual Score',
|
||||||
|
key: 'likelihood',
|
||||||
|
format: (_, item) => `${item.likelihood * item.impact} / 25`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Severity Level',
|
||||||
|
key: 'likelihood',
|
||||||
|
format: (_, item) => getRiskScoreColor(item.likelihood, item.impact).level,
|
||||||
|
},
|
||||||
|
{ header: 'Risk Owner', key: 'owner', format: (val) => getUserEmail(val) },
|
||||||
|
{ header: 'Mitigation Action / Controls', key: 'mitigationAction', format: (val) => val || '—' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const filteredRisks = risks.filter((rk) => {
|
||||||
|
if (riskProjectFilter !== 'all' && String(rk.projectId) !== String(riskProjectFilter)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (riskSeverityFilter !== 'all') {
|
||||||
|
const color = getRiskScoreColor(rk.likelihood, rk.impact);
|
||||||
|
if (riskSeverityFilter.toLowerCase() !== color.level.toLowerCase()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!riskSearchTerm.trim()) return true;
|
||||||
|
const q = riskSearchTerm.toLowerCase();
|
||||||
|
const name = (rk.name || '').toLowerCase();
|
||||||
|
const desc = (rk.description || '').toLowerCase();
|
||||||
|
const projId = String(rk.projectId || '').toLowerCase();
|
||||||
|
const owner = getUserEmail(rk.owner).toLowerCase();
|
||||||
|
const mitig = (rk.mitigationAction || '').toLowerCase();
|
||||||
|
return name.includes(q) || desc.includes(q) || projId.includes(q) || owner.includes(q) || mitig.includes(q);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-7xl mx-auto">
|
<div className="space-y-6 max-w-7xl mx-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-[#0f1523]/80 backdrop-blur-md p-6 rounded-2xl border border-slate-800/80 shadow-xl">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-[#0d121f]/90 p-5 rounded-2xl border border-slate-800/80 backdrop-blur-md shadow-xl">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold text-white tracking-tight flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>M&E, Evidence & Risk Management</span>
|
<h2 className="text-xl font-bold text-white tracking-tight">
|
||||||
|
M&E, Evidence & Risk Management
|
||||||
|
</h2>
|
||||||
<HelpTooltip
|
<HelpTooltip
|
||||||
title="Monitoring & Evaluation Hub"
|
title="Monitoring & Evaluation Hub"
|
||||||
content="Track performance indicators, verify tangible deliverable evidence against statutory milestones, and maintain proactive risk mitigation plans."
|
content="Track performance indicators, verify tangible deliverable evidence against statutory milestones, and maintain proactive risk mitigation plans."
|
||||||
placement="right"
|
placement="right"
|
||||||
/>
|
/>
|
||||||
</h2>
|
</div>
|
||||||
<p className="text-xs text-slate-400 mt-1">
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
Verification workflow: Evidence Upload & Review Queue, Indicator Actuals Recording, and Risk Register.
|
Verification workflow: Evidence Upload & Review Queue, Indicator Actuals Recording, and Risk Register.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -286,16 +397,16 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
{activeTab === 'contracts' && (
|
{activeTab === 'contracts' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsCreateContractModalOpen(true)}
|
onClick={() => setIsCreateContractModalOpen(true)}
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition cursor-pointer"
|
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 transition cursor-pointer"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
Create Contract & Upload PDF
|
Create Contract & Upload PDF
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'indicators' && (
|
{activeTab === 'indicators' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsIndicatorModalOpen(true)}
|
onClick={() => setIsIndicatorModalOpen(true)}
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition cursor-pointer"
|
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 transition cursor-pointer"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
Define Indicator
|
Define Indicator
|
||||||
@@ -304,7 +415,7 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
{activeTab === 'risks' && (
|
{activeTab === 'risks' && (
|
||||||
<button
|
<button
|
||||||
onClick={openCreateRisk}
|
onClick={openCreateRisk}
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition cursor-pointer"
|
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 transition cursor-pointer"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
Add Risk to Register
|
Add Risk to Register
|
||||||
@@ -405,63 +516,196 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Tab 2: Indicators Table (M08) */}
|
{/* Tab 2: Indicators Table (M08) */}
|
||||||
{activeTab === 'indicators' && (
|
{activeTab === 'indicators' && (
|
||||||
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
|
<div className="space-y-4">
|
||||||
<div className="overflow-x-auto">
|
{/* Indicators Search and Filter Toolbar */}
|
||||||
<table className="w-full text-left text-xs border-collapse">
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 bg-[#0d121f]/90 p-4 rounded-2xl border border-slate-800/80 backdrop-blur-sm">
|
||||||
<thead>
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 flex-1 flex-wrap">
|
||||||
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
|
{/* Search */}
|
||||||
<th className="py-3.5 px-5">Indicator Name</th>
|
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||||
<th className="py-3.5 px-4">Project ID</th>
|
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-3" />
|
||||||
<th className="py-3.5 px-4">Unit of Measure</th>
|
<input
|
||||||
<th className="py-3.5 px-4 text-right">Baseline</th>
|
type="text"
|
||||||
<th className="py-3.5 px-4 text-right">Target</th>
|
placeholder="Search indicators, statement, project ID..."
|
||||||
<th className="py-3.5 px-4 text-right">Actual Recorded</th>
|
value={indicatorSearchTerm}
|
||||||
<th className="py-3.5 px-4">Status</th>
|
onChange={(e) => setIndicatorSearchTerm(e.target.value)}
|
||||||
<th className="py-3.5 px-5 text-right">Action</th>
|
className="w-full pl-9 pr-4 py-2.5 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-slate-200 placeholder:text-slate-500 transition shadow-xs"
|
||||||
</tr>
|
/>
|
||||||
</thead>
|
</div>
|
||||||
<tbody className="divide-y divide-slate-800/50">
|
|
||||||
{loadingIndicators ? (
|
{/* Project Filter */}
|
||||||
<tr>
|
<div className="flex items-center gap-2">
|
||||||
<td colSpan={8} className="py-8 text-center text-slate-500">
|
<label className="text-xs text-slate-400 whitespace-nowrap font-medium">Project:</label>
|
||||||
Loading indicators...
|
<select
|
||||||
</td>
|
value={indicatorProjectFilter}
|
||||||
|
onChange={(e) => setIndicatorProjectFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))}
|
||||||
|
className="px-3 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900"
|
||||||
|
>
|
||||||
|
<option value="all">All Projects ({projects.length})</option>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name} (#{p.id})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Filter */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-xs text-slate-400 whitespace-nowrap flex items-center gap-1 font-medium">
|
||||||
|
<Filter className="w-3.5 h-3.5 text-slate-400" />
|
||||||
|
Status:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={indicatorStatusFilter}
|
||||||
|
onChange={(e) => setIndicatorStatusFilter(e.target.value)}
|
||||||
|
className="px-3 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900"
|
||||||
|
>
|
||||||
|
<option value="all">All Statuses</option>
|
||||||
|
<option value="green">≥90% Target Achieved</option>
|
||||||
|
<option value="amber">70–89% On Track</option>
|
||||||
|
<option value="red"><70% Underperforming</option>
|
||||||
|
<option value="pending">Pending Actuals</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Unit Filter */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-xs text-slate-400 whitespace-nowrap font-medium">Unit:</label>
|
||||||
|
<select
|
||||||
|
value={indicatorUnitFilter}
|
||||||
|
onChange={(e) => setIndicatorUnitFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))}
|
||||||
|
className="px-3 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900"
|
||||||
|
>
|
||||||
|
<option value="all">All Units</option>
|
||||||
|
{UNIT_OF_MEASURE_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(indicatorSearchTerm || indicatorProjectFilter !== 'all' || indicatorStatusFilter !== 'all' || indicatorUnitFilter !== 'all') && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setIndicatorSearchTerm('');
|
||||||
|
setIndicatorProjectFilter('all');
|
||||||
|
setIndicatorStatusFilter('all');
|
||||||
|
setIndicatorUnitFilter('all');
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1 text-[11px] font-semibold text-slate-400 hover:text-white px-2.5 py-2 bg-slate-800 hover:bg-slate-700 rounded-xl transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3 h-3" />
|
||||||
|
<span>Reset</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons: Refresh & Export */}
|
||||||
|
<div className="flex items-center gap-2 self-end lg:self-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
refetchIndicators();
|
||||||
|
success('Refreshed', 'Latest performance metrics loaded from API.');
|
||||||
|
}}
|
||||||
|
disabled={isFetchingIndicators}
|
||||||
|
className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-slate-300 bg-[#0d121f] hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
|
||||||
|
title="Refresh indicators from API"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetchingIndicators ? 'animate-spin' : ''}`} />
|
||||||
|
<span>Refresh</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ExportButton
|
||||||
|
title="M&E Performance Indicators"
|
||||||
|
subtitle="Baseline and target metrics tracking for monitoring and evaluation"
|
||||||
|
filename={`pfm-indicators-${new Date().toISOString().split('T')[0]}`}
|
||||||
|
columns={indicatorExportColumns}
|
||||||
|
data={filteredIndicators}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Indicators Table Card */}
|
||||||
|
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
|
||||||
|
<div className="p-4 border-b border-slate-800/80 flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||||
|
<TrendingUp className="w-4 h-4 text-blue-400" />
|
||||||
|
Performance Indicators & Actuals Ledger
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs text-slate-400 font-mono">
|
||||||
|
Showing {filteredIndicators.length} of {indicators.length} indicators
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-xs border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
|
||||||
|
<th className="py-3.5 px-5">Indicator Name</th>
|
||||||
|
<th className="py-3.5 px-4">Project ID</th>
|
||||||
|
<th className="py-3.5 px-4">Unit of Measure</th>
|
||||||
|
<th className="py-3.5 px-4 text-right">Baseline</th>
|
||||||
|
<th className="py-3.5 px-4 text-right">Target</th>
|
||||||
|
<th className="py-3.5 px-4 text-right">Actual Recorded</th>
|
||||||
|
<th className="py-3.5 px-4">Status</th>
|
||||||
|
<th className="py-3.5 px-5 text-right">Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
</thead>
|
||||||
indicators.map((ind) => {
|
<tbody className="divide-y divide-slate-800/50">
|
||||||
const statusObj = getIndicatorStatus(ind.actualAmount, ind.targetAmount);
|
{loadingIndicators ? (
|
||||||
return (
|
<tr>
|
||||||
<tr key={ind.id} className="hover:bg-slate-800/40 transition-colors">
|
<td colSpan={8} className="py-12 text-center text-slate-500">
|
||||||
<td className="py-3.5 px-5 font-semibold text-white">{ind.name}</td>
|
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2 text-blue-400" />
|
||||||
<td className="py-3.5 px-4 text-slate-400 font-mono">Project #{ind.projectId}</td>
|
Loading project indicators from API...
|
||||||
<td className="py-3.5 px-4 text-slate-300">{getUnitOfMeasureLabel(ind.unitOfMeasure)}</td>
|
</td>
|
||||||
<td className="py-3.5 px-4 text-right font-mono text-slate-400">{ind.baselineAmount}</td>
|
</tr>
|
||||||
<td className="py-3.5 px-4 text-right font-mono font-bold text-white">{ind.targetAmount}</td>
|
) : filteredIndicators.length === 0 ? (
|
||||||
<td className="py-3.5 px-4 text-right font-mono font-bold text-emerald-400 text-xs">
|
<tr>
|
||||||
{ind.actualAmount}
|
<td colSpan={8} className="py-12 text-center text-slate-500">
|
||||||
</td>
|
<TrendingUp className="w-8 h-8 text-slate-600 mx-auto mb-2" />
|
||||||
<td className="py-3.5 px-4">
|
{indicatorSearchTerm || indicatorProjectFilter !== 'all' || indicatorStatusFilter !== 'all' || indicatorUnitFilter !== 'all'
|
||||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${statusObj.badge}`}>
|
? 'No indicators match your filter criteria.'
|
||||||
{statusObj.label}
|
: 'No performance indicators defined yet. Click "Define Indicator" to begin.'}
|
||||||
</span>
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
<td className="py-3.5 px-5 text-right">
|
) : (
|
||||||
<button
|
filteredIndicators.map((ind) => {
|
||||||
onClick={() => {
|
const statusObj = getIndicatorStatus(ind.actualAmount, ind.targetAmount);
|
||||||
setSelectedIndicatorForActual(ind);
|
return (
|
||||||
setActualInput(ind.actualAmount);
|
<tr key={ind.id} className="hover:bg-slate-800/40 transition-colors">
|
||||||
}}
|
<td className="py-3.5 px-5 font-semibold text-white">{ind.name}</td>
|
||||||
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-300 hover:text-white bg-slate-800/80 hover:bg-slate-700 px-3 py-1.5 rounded-lg border border-slate-700/60 transition"
|
<td className="py-3.5 px-4 text-slate-400 font-mono">Project #{ind.projectId}</td>
|
||||||
>
|
<td className="py-3.5 px-4 text-slate-300">{getUnitOfMeasureLabel(ind.unitOfMeasure)}</td>
|
||||||
Record Actual
|
<td className="py-3.5 px-4 text-right font-mono text-slate-400">{ind.baselineAmount}</td>
|
||||||
</button>
|
<td className="py-3.5 px-4 text-right font-mono font-bold text-white">{ind.targetAmount}</td>
|
||||||
</td>
|
<td className="py-3.5 px-4 text-right font-mono font-bold text-emerald-400 text-xs">
|
||||||
</tr>
|
{ind.actualAmount}
|
||||||
);
|
</td>
|
||||||
})
|
<td className="py-3.5 px-4">
|
||||||
)}
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${statusObj.badge}`}>
|
||||||
</tbody>
|
{statusObj.label}
|
||||||
</table>
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-5 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedIndicatorForActual(ind);
|
||||||
|
setActualInput(ind.actualAmount);
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-300 hover:text-white bg-slate-800/80 hover:bg-slate-700 px-3 py-1.5 rounded-lg border border-slate-700/60 transition cursor-pointer"
|
||||||
|
>
|
||||||
|
Record Actual
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -478,20 +722,112 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
subtitle="ISO 31000 standard matrix — click on any cell to filter and inspect risks or edit mitigation controls"
|
subtitle="ISO 31000 standard matrix — click on any cell to filter and inspect risks or edit mitigation controls"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Risk Register Table */}
|
{/* Risk Search & Filter Toolbar */}
|
||||||
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 bg-[#0d121f]/90 p-4 rounded-2xl border border-slate-800/80 backdrop-blur-sm">
|
||||||
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 flex-1 flex-wrap">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||||
|
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-3" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search risks by title, description, owner, mitigations..."
|
||||||
|
value={riskSearchTerm}
|
||||||
|
onChange={(e) => setRiskSearchTerm(e.target.value)}
|
||||||
|
className="w-full pl-9 pr-4 py-2.5 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-slate-200 placeholder:text-slate-500 transition shadow-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Project Filter */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-xs text-slate-400 whitespace-nowrap font-medium">Project:</label>
|
||||||
|
<select
|
||||||
|
value={riskProjectFilter}
|
||||||
|
onChange={(e) => setRiskProjectFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))}
|
||||||
|
className="px-3 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900"
|
||||||
|
>
|
||||||
|
<option value="all">All Projects ({projects.length})</option>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name} (#{p.id})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Severity Filter */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-xs text-slate-400 whitespace-nowrap flex items-center gap-1 font-medium">
|
||||||
|
<Filter className="w-3.5 h-3.5 text-slate-400" />
|
||||||
|
Severity:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={riskSeverityFilter}
|
||||||
|
onChange={(e) => setRiskSeverityFilter(e.target.value)}
|
||||||
|
className="px-3 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900"
|
||||||
|
>
|
||||||
|
<option value="all">All Severities</option>
|
||||||
|
<option value="critical">Critical (16–25)</option>
|
||||||
|
<option value="high">High (12–15)</option>
|
||||||
|
<option value="medium">Medium (6–10)</option>
|
||||||
|
<option value="low">Low (1–5)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(riskSearchTerm || riskProjectFilter !== 'all' || riskSeverityFilter !== 'all') && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setRiskSearchTerm('');
|
||||||
|
setRiskProjectFilter('all');
|
||||||
|
setRiskSeverityFilter('all');
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1 text-[11px] font-semibold text-slate-400 hover:text-white px-2.5 py-2 bg-slate-800 hover:bg-slate-700 rounded-xl transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3 h-3" />
|
||||||
|
<span>Reset</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons: Refresh & Export */}
|
||||||
|
<div className="flex items-center gap-2 self-end lg:self-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
refetchRisks();
|
||||||
|
success('Refreshed', 'Latest risk evaluations and residual scores loaded.');
|
||||||
|
}}
|
||||||
|
disabled={isFetchingRisks}
|
||||||
|
className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-slate-300 bg-[#0d121f] hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
|
||||||
|
title="Refresh risks from API"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetchingRisks ? 'animate-spin' : ''}`} />
|
||||||
|
<span>Refresh</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ExportButton
|
||||||
|
title="Project Risk Register & Heatmap"
|
||||||
|
subtitle="Itemized audit log of identified risks and mitigation controls"
|
||||||
|
filename={`pfm-risks-${new Date().toISOString().split('T')[0]}`}
|
||||||
|
columns={riskExportColumns}
|
||||||
|
data={filteredRisks}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Risk Register Table Card */}
|
||||||
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
|
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
|
||||||
<div className="p-4 border-b border-slate-800/80 flex items-center justify-between bg-slate-900/60">
|
<div className="p-4 border-b border-slate-800/80 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-bold text-white">Project Risk Register</h4>
|
<h4 className="text-sm font-bold text-white flex items-center gap-2">
|
||||||
|
<ShieldAlert className="w-4 h-4 text-blue-400" />
|
||||||
|
Project Risk Register
|
||||||
|
</h4>
|
||||||
<p className="text-[11px] text-slate-400">Complete itemized audit log of identified risks and residual scores</p>
|
<p className="text-[11px] text-slate-400">Complete itemized audit log of identified risks and residual scores</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<span className="text-xs text-slate-400 font-mono">
|
||||||
onClick={openCreateRisk}
|
Showing {filteredRisks.length} of {risks.length} risks
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl transition shadow-md shadow-blue-500/20"
|
</span>
|
||||||
>
|
|
||||||
<Plus className="w-3.5 h-3.5" />
|
|
||||||
Add Risk
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left text-xs border-collapse">
|
<table className="w-full text-left text-xs border-collapse">
|
||||||
@@ -510,18 +846,22 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
<tbody className="divide-y divide-slate-800/50">
|
<tbody className="divide-y divide-slate-800/50">
|
||||||
{loadingRisks ? (
|
{loadingRisks ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="py-8 text-center text-slate-500">
|
<td colSpan={8} className="py-12 text-center text-slate-500">
|
||||||
Loading risk register...
|
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2 text-blue-400" />
|
||||||
|
Loading risk register from API...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : risks.length === 0 ? (
|
) : filteredRisks.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="py-8 text-center text-slate-500">
|
<td colSpan={8} className="py-12 text-center text-slate-500">
|
||||||
No risks recorded in the register. Click "Add Risk" to log your first risk item.
|
<ShieldAlert className="w-8 h-8 text-slate-600 mx-auto mb-2" />
|
||||||
|
{riskSearchTerm || riskProjectFilter !== 'all' || riskSeverityFilter !== 'all'
|
||||||
|
? 'No risks match your filter criteria.'
|
||||||
|
: 'No risks recorded in the register. Click "Add Risk" to log your first risk item.'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
risks.map((rk) => {
|
filteredRisks.map((rk) => {
|
||||||
const score = rk.likelihood * rk.impact;
|
const score = rk.likelihood * rk.impact;
|
||||||
const color = getRiskScoreColor(rk.likelihood, rk.impact);
|
const color = getRiskScoreColor(rk.likelihood, rk.impact);
|
||||||
return (
|
return (
|
||||||
@@ -545,7 +885,7 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
<td className="py-3.5 px-4 text-right">
|
<td className="py-3.5 px-4 text-right">
|
||||||
<button
|
<button
|
||||||
onClick={() => openEditRisk(rk)}
|
onClick={() => openEditRisk(rk)}
|
||||||
className="p-1.5 text-slate-400 hover:text-white hover:bg-slate-700/60 rounded-lg transition"
|
className="p-1.5 text-slate-400 hover:text-white hover:bg-slate-700/60 rounded-lg transition cursor-pointer"
|
||||||
title="Edit Risk"
|
title="Edit Risk"
|
||||||
>
|
>
|
||||||
<Edit2 className="w-3.5 h-3.5" />
|
<Edit2 className="w-3.5 h-3.5" />
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ import {
|
|||||||
Server,
|
Server,
|
||||||
Database,
|
Database,
|
||||||
Hash,
|
Hash,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
User as UserIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
getUsers,
|
getUsers,
|
||||||
@@ -77,6 +80,7 @@ import {
|
|||||||
getNotificationStatusBadge,
|
getNotificationStatusBadge,
|
||||||
getNotificationPlatformLabel,
|
getNotificationPlatformLabel,
|
||||||
getUserEmail,
|
getUserEmail,
|
||||||
|
registerUserEmails,
|
||||||
getActivityStatusBadge,
|
getActivityStatusBadge,
|
||||||
EMAIL_STATUSES_OPTIONS,
|
EMAIL_STATUSES_OPTIONS,
|
||||||
NOTIFICATION_STATUS_OPTIONS,
|
NOTIFICATION_STATUS_OPTIONS,
|
||||||
@@ -103,11 +107,29 @@ export const SettingsPage: React.FC = () => {
|
|||||||
status: userStatusFilter !== 'all' ? userStatusFilter : undefined,
|
status: userStatusFilter !== 'all' ? userStatusFilter : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { data: usersDirectory = [] } = useQuery({
|
||||||
|
queryKey: ['users-directory'],
|
||||||
|
queryFn: () => getUsers(1, 200),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
const { data: usersList = [], isLoading: loadingUsers, refetch: refetchUsers } = useQuery({
|
const { data: usersList = [], isLoading: loadingUsers, refetch: refetchUsers } = useQuery({
|
||||||
queryKey: ['users', userStatusFilter, userEmailSearch],
|
queryKey: ['users', userStatusFilter, userEmailSearch],
|
||||||
queryFn: () => getUsers(1, 100, userFilter),
|
queryFn: () => getUsers(1, 100, userFilter),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (usersDirectory && usersDirectory.length > 0) {
|
||||||
|
registerUserEmails(usersDirectory);
|
||||||
|
}
|
||||||
|
}, [usersDirectory]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (usersList && usersList.length > 0) {
|
||||||
|
registerUserEmails(usersList);
|
||||||
|
}
|
||||||
|
}, [usersList]);
|
||||||
|
|
||||||
const { data: userFreshDetail, isLoading: loadingUserFreshDetail } = useQuery({
|
const { data: userFreshDetail, isLoading: loadingUserFreshDetail } = useQuery({
|
||||||
queryKey: ['user-canonical', selectedUserForDetail?.id],
|
queryKey: ['user-canonical', selectedUserForDetail?.id],
|
||||||
queryFn: () => (selectedUserForDetail?.id ? getUser(selectedUserForDetail.id) : null),
|
queryFn: () => (selectedUserForDetail?.id ? getUser(selectedUserForDetail.id) : null),
|
||||||
@@ -238,21 +260,25 @@ export const SettingsPage: React.FC = () => {
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
const [emailGatewayStatus, setEmailGatewayStatus] = useState<EmailStatuses>(EmailStatuses.Connected);
|
const [emailGatewayStatus, setEmailGatewayStatus] = useState<EmailStatuses>(EmailStatuses.Connected);
|
||||||
const [notificationSearchTerm, setNotificationSearchTerm] = useState('');
|
const [notificationSearchTerm, setNotificationSearchTerm] = useState('');
|
||||||
const [notificationOrgFilter, setNotificationOrgFilter] = useState<string>('all');
|
const [notificationOrgFilter, setNotificationOrgFilter] = useState<string>('');
|
||||||
const [notificationPlatformFilter, setNotificationPlatformFilter] = useState<string>('all');
|
const [notificationPlatformFilter, setNotificationPlatformFilter] = useState<string>('all');
|
||||||
const [notificationStatusFilter, setNotificationStatusFilter] = useState<string>('all');
|
const [notificationStatusFilter, setNotificationStatusFilter] = useState<string>('all');
|
||||||
const [selectedNotificationForDetail, setSelectedNotificationForDetail] = useState<Notification | null>(null);
|
const [selectedNotificationForDetail, setSelectedNotificationForDetail] = useState<Notification | null>(null);
|
||||||
const [isNotificationDetailModalOpen, setIsNotificationDetailModalOpen] = useState(false);
|
const [isNotificationDetailModalOpen, setIsNotificationDetailModalOpen] = useState(false);
|
||||||
const [copiedNotificationPayload, setCopiedNotificationPayload] = useState(false);
|
const [copiedNotificationPayload, setCopiedNotificationPayload] = useState(false);
|
||||||
|
|
||||||
|
const effectiveNotificationOrgId =
|
||||||
|
notificationOrgFilter || (organisations[0]?.id ? String(organisations[0].id) : '1');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: fetchedNotifications = [],
|
data: fetchedNotifications = [],
|
||||||
isLoading: loadingNotifications,
|
isLoading: loadingNotifications,
|
||||||
refetch: refetchNotifications,
|
refetch: refetchNotifications,
|
||||||
isFetching: isFetchingNotifications,
|
isFetching: isFetchingNotifications,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ['notifications', notificationOrgFilter],
|
queryKey: ['notifications', effectiveNotificationOrgId],
|
||||||
queryFn: () => getNotifications(notificationOrgFilter === 'all' ? undefined : notificationOrgFilter),
|
queryFn: () => getNotifications(effectiveNotificationOrgId),
|
||||||
|
enabled: !!effectiveNotificationOrgId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const notificationExportColumns: ExportColumn<Notification>[] = [
|
const notificationExportColumns: ExportColumn<Notification>[] = [
|
||||||
@@ -270,9 +296,6 @@ export const SettingsPage: React.FC = () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const filteredNotifications = fetchedNotifications.filter((item) => {
|
const filteredNotifications = fetchedNotifications.filter((item) => {
|
||||||
if (notificationOrgFilter !== 'all' && String(item.organisationId) !== String(notificationOrgFilter)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (notificationPlatformFilter !== 'all' && String(item.platform) !== String(notificationPlatformFilter)) {
|
if (notificationPlatformFilter !== 'all' && String(item.platform) !== String(notificationPlatformFilter)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -305,6 +328,28 @@ export const SettingsPage: React.FC = () => {
|
|||||||
const [auditActionFilter, setAuditActionFilter] = useState<string>('all');
|
const [auditActionFilter, setAuditActionFilter] = useState<string>('all');
|
||||||
const [selectedAuditLog, setSelectedAuditLog] = useState<AuditLog | null>(null);
|
const [selectedAuditLog, setSelectedAuditLog] = useState<AuditLog | null>(null);
|
||||||
const [isAuditDetailModalOpen, setIsAuditDetailModalOpen] = useState(false);
|
const [isAuditDetailModalOpen, setIsAuditDetailModalOpen] = useState(false);
|
||||||
|
const [copiedAuditPayload, setCopiedAuditPayload] = useState(false);
|
||||||
|
const [expandedAuditLogIds, setExpandedAuditLogIds] = useState<Record<string | number, boolean>>({});
|
||||||
|
const [copiedUserId, setCopiedUserId] = useState<string | number | null>(null);
|
||||||
|
|
||||||
|
const toggleExpandAuditLog = (id: string | number) => {
|
||||||
|
setExpandedAuditLogIds((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[id]: !prev[id],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyUserId = (userId: string | number, e?: React.MouseEvent) => {
|
||||||
|
if (e) e.stopPropagation();
|
||||||
|
if (!userId) return;
|
||||||
|
const strId = String(userId);
|
||||||
|
navigator.clipboard.writeText(strId);
|
||||||
|
setCopiedUserId(userId);
|
||||||
|
success('User ID Copied', `Copied ${strId} to clipboard.`);
|
||||||
|
setTimeout(() => {
|
||||||
|
setCopiedUserId((curr) => (curr === userId ? null : curr));
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: auditLogs = [],
|
data: auditLogs = [],
|
||||||
@@ -313,62 +358,83 @@ export const SettingsPage: React.FC = () => {
|
|||||||
isFetching: isFetchingAuditLogs,
|
isFetching: isFetchingAuditLogs,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ['audit-logs'],
|
queryKey: ['audit-logs'],
|
||||||
queryFn: () => getAuditLogs(1, 200),
|
queryFn: () => getAuditLogs(1, 100),
|
||||||
});
|
});
|
||||||
|
|
||||||
const auditExportColumns: ExportColumn<AuditLog>[] = [
|
const auditExportColumns: ExportColumn<AuditLog>[] = [
|
||||||
{ header: 'Audit Event ID', key: 'id' },
|
{ header: 'Event ID', key: 'id' },
|
||||||
{ header: 'Timestamp', key: 'createdAt', format: (val) => formatDate(val) },
|
{ header: 'Timestamp', key: 'timestamp', format: (val) => formatDate(val) },
|
||||||
{ header: 'Action Type', key: 'action' },
|
{ header: 'Action', key: 'action', format: (val) => val || '—' },
|
||||||
{ header: 'Entity Type', key: 'entityType' },
|
{ header: 'Entity Name', key: 'entityName', format: (val) => val || '—' },
|
||||||
{ header: 'Entity Identifier', key: 'entityId' },
|
{ header: 'Entity ID', key: 'entityId', format: (val) => val || '—' },
|
||||||
{ header: 'Actor User / System', key: 'userEmail', format: (val) => getUserEmail(val) },
|
{
|
||||||
{ header: 'Audit Details & Payload', key: 'details', format: (val) => val || '—' },
|
header: 'Performed By (Email)',
|
||||||
|
key: 'performedBy',
|
||||||
|
format: (val) => {
|
||||||
|
const email = getUserEmail(val);
|
||||||
|
return email && email !== '—' ? `${email} (${val})` : String(val || 'System');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ header: 'Tenant ID', key: 'tenantId', format: (val) => (val ? `Tenant #${val}` : '—') },
|
||||||
|
{
|
||||||
|
header: 'Changes',
|
||||||
|
key: 'changes',
|
||||||
|
format: (val) => (Array.isArray(val) ? val.join('; ') : val || '—'),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const uniqueAuditEntities = Array.from(
|
||||||
|
new Set(auditLogs.map((l) => l.entityName).filter((e): e is string => Boolean(e)))
|
||||||
|
);
|
||||||
|
const uniqueAuditActions = Array.from(
|
||||||
|
new Set(auditLogs.map((l) => l.action).filter((a): a is string => Boolean(a)))
|
||||||
|
);
|
||||||
|
|
||||||
const filteredAuditLogs = auditLogs.filter((log) => {
|
const filteredAuditLogs = auditLogs.filter((log) => {
|
||||||
if (auditEntityFilter !== 'all' && log.entityType.toLowerCase() !== auditEntityFilter.toLowerCase()) {
|
if (auditEntityFilter !== 'all' && (log.entityName || '').toLowerCase() !== auditEntityFilter.toLowerCase()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (auditActionFilter !== 'all' && log.action.toLowerCase() !== auditActionFilter.toLowerCase()) {
|
if (auditActionFilter !== 'all' && (log.action || '').toLowerCase() !== auditActionFilter.toLowerCase()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!auditSearchTerm.trim()) return true;
|
if (!auditSearchTerm.trim()) return true;
|
||||||
const q = auditSearchTerm.toLowerCase();
|
const q = auditSearchTerm.toLowerCase();
|
||||||
const id = (log.id || '').toLowerCase();
|
const id = String(log.id || '').toLowerCase();
|
||||||
const action = (log.action || '').toLowerCase();
|
const action = (log.action || '').toLowerCase();
|
||||||
const entityType = (log.entityType || '').toLowerCase();
|
const entityName = (log.entityName || '').toLowerCase();
|
||||||
const entityId = String(log.entityId || '').toLowerCase();
|
const entityId = String(log.entityId || '').toLowerCase();
|
||||||
const email = (log.userEmail || '').toLowerCase();
|
const performedBy = (log.performedBy || '').toLowerCase();
|
||||||
const details = (log.details || '').toLowerCase();
|
const tenant = String(log.tenantId || '').toLowerCase();
|
||||||
|
const changes = Array.isArray(log.changes) ? log.changes.join(' ').toLowerCase() : '';
|
||||||
return (
|
return (
|
||||||
id.includes(q) ||
|
id.includes(q) ||
|
||||||
action.includes(q) ||
|
action.includes(q) ||
|
||||||
entityType.includes(q) ||
|
entityName.includes(q) ||
|
||||||
entityId.includes(q) ||
|
entityId.includes(q) ||
|
||||||
email.includes(q) ||
|
performedBy.includes(q) ||
|
||||||
details.includes(q)
|
tenant.includes(q) ||
|
||||||
|
changes.includes(q)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const getActionBadge = (action: string) => {
|
const getActionBadge = (action?: string | null) => {
|
||||||
const act = (action || '').toUpperCase();
|
const act = (action || 'GENERAL_EVENT').toUpperCase();
|
||||||
if (act.includes('APPROV') || act.includes('RELEASE') || act.includes('AUTH') || act.includes('VERIF')) {
|
if (act.includes('APPROV') || act.includes('RELEASE') || act.includes('AUTH') || act.includes('VERIF')) {
|
||||||
return { label: action, className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30' };
|
return { label: action || 'APPROVED', className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30' };
|
||||||
}
|
}
|
||||||
if (act.includes('CREATE') || act.includes('REGISTER') || act.includes('ADD') || act.includes('SCHEDULE')) {
|
if (act.includes('CREATE') || act.includes('REGISTER') || act.includes('ADD') || act.includes('POST') || act.includes('INSERT')) {
|
||||||
return { label: action, className: 'bg-blue-500/10 text-blue-400 border-blue-500/30' };
|
return { label: action || 'CREATED', className: 'bg-blue-500/10 text-blue-400 border-blue-500/30' };
|
||||||
}
|
}
|
||||||
if (act.includes('UPDATE') || act.includes('SYNC') || act.includes('EDIT')) {
|
if (act.includes('UPDATE') || act.includes('SYNC') || act.includes('EDIT') || act.includes('PUT') || act.includes('PATCH')) {
|
||||||
return { label: action, className: 'bg-indigo-500/10 text-indigo-400 border-indigo-500/30' };
|
return { label: action || 'UPDATED', className: 'bg-indigo-500/10 text-indigo-400 border-indigo-500/30' };
|
||||||
}
|
}
|
||||||
if (act.includes('DISPATCH') || act.includes('INVIT') || act.includes('SEND') || act.includes('NOTIF')) {
|
if (act.includes('DISPATCH') || act.includes('INVIT') || act.includes('SEND') || act.includes('NOTIF')) {
|
||||||
return { label: action, className: 'bg-purple-500/10 text-purple-400 border-purple-500/30' };
|
return { label: action || 'DISPATCHED', className: 'bg-purple-500/10 text-purple-400 border-purple-500/30' };
|
||||||
}
|
}
|
||||||
if (act.includes('DELETE') || act.includes('SUSPEND') || act.includes('REJECT') || act.includes('LOCK')) {
|
if (act.includes('DELETE') || act.includes('SUSPEND') || act.includes('REJECT') || act.includes('LOCK')) {
|
||||||
return { label: action, className: 'bg-rose-500/10 text-rose-400 border-rose-500/30' };
|
return { label: action || 'REJECTED', className: 'bg-rose-500/10 text-rose-400 border-rose-500/30' };
|
||||||
}
|
}
|
||||||
return { label: action, className: 'bg-slate-800 text-slate-300 border-slate-700' };
|
return { label: action || 'AUDIT_EVENT', className: 'bg-slate-800 text-slate-300 border-slate-700' };
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -914,18 +980,21 @@ export const SettingsPage: React.FC = () => {
|
|||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Building2 className="w-3.5 h-3.5 text-slate-400" />
|
<span className="text-xs font-semibold text-slate-400 whitespace-nowrap">Target Org:</span>
|
||||||
|
<Building2 className="w-3.5 h-3.5 text-blue-400" />
|
||||||
<select
|
<select
|
||||||
value={notificationOrgFilter}
|
value={notificationOrgFilter}
|
||||||
onChange={(e) => setNotificationOrgFilter(e.target.value)}
|
onChange={(e) => setNotificationOrgFilter(e.target.value)}
|
||||||
className="text-xs bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-2 text-slate-300 focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900 [&>option]:text-white"
|
className="text-xs bg-[#090d16] border border-blue-500/40 rounded-xl px-2.5 py-2 text-white font-medium focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900 [&>option]:text-white"
|
||||||
>
|
>
|
||||||
<option value="all">All Organisations</option>
|
|
||||||
{organisations.map((org) => (
|
{organisations.map((org) => (
|
||||||
<option key={org.id} value={org.id}>
|
<option key={org.id} value={org.id}>
|
||||||
{org.name} (#{org.id})
|
{org.name || `Organisation #${org.id}`} (#{org.id})
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
|
{organisations.length === 0 && (
|
||||||
|
<option value="1">Default Organisation (Org #1)</option>
|
||||||
|
)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -962,13 +1031,11 @@ export const SettingsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(notificationSearchTerm ||
|
{(notificationSearchTerm ||
|
||||||
notificationOrgFilter !== 'all' ||
|
|
||||||
notificationPlatformFilter !== 'all' ||
|
notificationPlatformFilter !== 'all' ||
|
||||||
notificationStatusFilter !== 'all') && (
|
notificationStatusFilter !== 'all') && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setNotificationSearchTerm('');
|
setNotificationSearchTerm('');
|
||||||
setNotificationOrgFilter('all');
|
|
||||||
setNotificationPlatformFilter('all');
|
setNotificationPlatformFilter('all');
|
||||||
setNotificationStatusFilter('all');
|
setNotificationStatusFilter('all');
|
||||||
}}
|
}}
|
||||||
@@ -978,6 +1045,28 @@ export const SettingsPage: React.FC = () => {
|
|||||||
Reset
|
Reset
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
refetchNotifications();
|
||||||
|
success('Refreshed', `Transmissions updated for Organisation #${effectiveNotificationOrgId}.`);
|
||||||
|
}}
|
||||||
|
disabled={isFetchingNotifications}
|
||||||
|
className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-slate-300 bg-[#0d121f] hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
|
||||||
|
title="Force refresh notifications from API"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetchingNotifications ? 'animate-spin' : ''}`} />
|
||||||
|
<span>Refresh</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ExportButton
|
||||||
|
title="Communications & Notifications Ledger"
|
||||||
|
subtitle={`Transmission telemetry report for Organisation #${effectiveNotificationOrgId}`}
|
||||||
|
filename={`pfm-notifications-org-${effectiveNotificationOrgId}-${new Date().toISOString().split('T')[0]}`}
|
||||||
|
columns={notificationExportColumns}
|
||||||
|
data={filteredNotifications}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -997,7 +1086,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[11px] text-slate-400 font-mono">
|
<span className="text-[11px] text-slate-400 font-mono">
|
||||||
GET /v1/api/communications/norifications/{notificationOrgFilter === 'all' ? '{organisationId}' : notificationOrgFilter}
|
GET /v1/api/communications/norifications/{effectiveNotificationOrgId}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1020,14 +1109,14 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="py-12 text-center text-slate-500">
|
<td colSpan={8} className="py-12 text-center text-slate-500">
|
||||||
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2 text-blue-400" />
|
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2 text-blue-400" />
|
||||||
Querying communication microservice endpoints...
|
Querying communication microservice endpoints for Organisation #{effectiveNotificationOrgId}...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : filteredNotifications.length === 0 ? (
|
) : filteredNotifications.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="py-12 text-center text-slate-500">
|
<td colSpan={8} className="py-12 text-center text-slate-500">
|
||||||
<Bell className="w-8 h-8 text-slate-600 mx-auto mb-2" />
|
<Bell className="w-8 h-8 text-slate-600 mx-auto mb-2" />
|
||||||
No notification records returned matching filter criteria.
|
No notification records returned for Organisation #{effectiveNotificationOrgId}.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
@@ -1106,37 +1195,37 @@ export const SettingsPage: React.FC = () => {
|
|||||||
Total Audit Events
|
Total Audit Events
|
||||||
</span>
|
</span>
|
||||||
<div className="text-xl font-bold font-mono text-white">{auditLogs.length}</div>
|
<div className="text-xl font-bold font-mono text-white">{auditLogs.length}</div>
|
||||||
<span className="text-[10px] text-slate-500">Immutable ledger records</span>
|
<span className="text-[10px] text-slate-500">Live API ledger records</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-[#0f1523]/80 backdrop-blur-md p-4 rounded-2xl border border-slate-800/80 shadow-xl space-y-1">
|
<div className="bg-[#0f1523]/80 backdrop-blur-md p-4 rounded-2xl border border-slate-800/80 shadow-xl space-y-1">
|
||||||
<span className="text-[11px] font-semibold text-emerald-400 block uppercase tracking-wider">
|
<span className="text-[11px] font-semibold text-emerald-400 block uppercase tracking-wider">
|
||||||
Financial Operations
|
Audited Entities
|
||||||
</span>
|
</span>
|
||||||
<div className="text-xl font-bold font-mono text-emerald-300">
|
<div className="text-xl font-bold font-mono text-emerald-300">
|
||||||
{auditLogs.filter((l) => ['Award', 'Budget', 'Disbursement'].includes(l.entityType)).length}
|
{uniqueAuditEntities.length}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] text-slate-500">Awards, budgets & disbursements</span>
|
<span className="text-[10px] text-slate-500">Distinct system resources</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-[#0f1523]/80 backdrop-blur-md p-4 rounded-2xl border border-slate-800/80 shadow-xl space-y-1">
|
<div className="bg-[#0f1523]/80 backdrop-blur-md p-4 rounded-2xl border border-slate-800/80 shadow-xl space-y-1">
|
||||||
<span className="text-[11px] font-semibold text-blue-400 block uppercase tracking-wider">
|
<span className="text-[11px] font-semibold text-blue-400 block uppercase tracking-wider">
|
||||||
Security & Auth
|
Active Actors
|
||||||
</span>
|
</span>
|
||||||
<div className="text-xl font-bold font-mono text-blue-300">
|
<div className="text-xl font-bold font-mono text-blue-300">
|
||||||
{auditLogs.filter((l) => ['User', 'Security', 'Invite'].includes(l.entityType)).length}
|
{new Set(auditLogs.map((l) => l.performedBy).filter(Boolean)).size}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] text-slate-500">Logins, roles & access syncs</span>
|
<span className="text-[10px] text-slate-500">Distinct caller UUIDs</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-[#0f1523]/80 backdrop-blur-md p-4 rounded-2xl border border-slate-800/80 shadow-xl space-y-1">
|
<div className="bg-[#0f1523]/80 backdrop-blur-md p-4 rounded-2xl border border-slate-800/80 shadow-xl space-y-1">
|
||||||
<span className="text-[11px] font-semibold text-purple-400 block uppercase tracking-wider">
|
<span className="text-[11px] font-semibold text-purple-400 block uppercase tracking-wider">
|
||||||
Audited Actors
|
Action Categories
|
||||||
</span>
|
</span>
|
||||||
<div className="text-xl font-bold font-mono text-purple-300">
|
<div className="text-xl font-bold font-mono text-purple-300">
|
||||||
{new Set(auditLogs.map((l) => l.userEmail)).size}
|
{uniqueAuditActions.length}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] text-slate-500">Unique authorized principals</span>
|
<span className="text-[10px] text-slate-500">Registered action types</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1148,7 +1237,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
type="text"
|
type="text"
|
||||||
value={auditSearchTerm}
|
value={auditSearchTerm}
|
||||||
onChange={(e) => setAuditSearchTerm(e.target.value)}
|
onChange={(e) => setAuditSearchTerm(e.target.value)}
|
||||||
placeholder="Search audit trail by action, email, entity ID, or details..."
|
placeholder="Search audit trail by action, entity name, ID, performed by UUID..."
|
||||||
className="w-full pl-9 pr-3 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
className="w-full pl-9 pr-3 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1162,14 +1251,20 @@ export const SettingsPage: React.FC = () => {
|
|||||||
className="text-xs bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-white focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900 [&>option]:text-white cursor-pointer"
|
className="text-xs bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-white focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900 [&>option]:text-white cursor-pointer"
|
||||||
>
|
>
|
||||||
<option value="all">All Entities</option>
|
<option value="all">All Entities</option>
|
||||||
<option value="User">User</option>
|
{uniqueAuditEntities.map((ent) => (
|
||||||
<option value="Award">Award</option>
|
<option key={ent} value={ent}>
|
||||||
<option value="Budget">Budget</option>
|
{ent}
|
||||||
<option value="Disbursement">Disbursement</option>
|
</option>
|
||||||
<option value="Risk">Risk</option>
|
))}
|
||||||
<option value="Evidence">Evidence</option>
|
{uniqueAuditEntities.length === 0 && (
|
||||||
<option value="Invite">Invite</option>
|
<>
|
||||||
<option value="Security">Security</option>
|
<option value="User">User</option>
|
||||||
|
<option value="Award">Award</option>
|
||||||
|
<option value="Budget">Budget</option>
|
||||||
|
<option value="Disbursement">Disbursement</option>
|
||||||
|
<option value="Risk">Risk</option>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1181,15 +1276,11 @@ export const SettingsPage: React.FC = () => {
|
|||||||
className="text-xs bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-white focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900 [&>option]:text-white cursor-pointer"
|
className="text-xs bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-white focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900 [&>option]:text-white cursor-pointer"
|
||||||
>
|
>
|
||||||
<option value="all">All Actions</option>
|
<option value="all">All Actions</option>
|
||||||
<option value="USER_AUTHENTICATED">USER_AUTHENTICATED</option>
|
{uniqueAuditActions.map((act) => (
|
||||||
<option value="DISBURSEMENT_APPROVED">DISBURSEMENT_APPROVED</option>
|
<option key={act} value={act}>
|
||||||
<option value="DISBURSEMENT_SCHEDULED">DISBURSEMENT_SCHEDULED</option>
|
{act}
|
||||||
<option value="BUDGET_LINE_CREATED">BUDGET_LINE_CREATED</option>
|
</option>
|
||||||
<option value="AWARD_REGISTERED">AWARD_REGISTERED</option>
|
))}
|
||||||
<option value="RISK_EVALUATED">RISK_EVALUATED</option>
|
|
||||||
<option value="EVIDENCE_VERIFIED">EVIDENCE_VERIFIED</option>
|
|
||||||
<option value="INVITATION_DISPATCHED">INVITATION_DISPATCHED</option>
|
|
||||||
<option value="DIRECTORY_SYNCED">DIRECTORY_SYNCED</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1211,19 +1302,19 @@ export const SettingsPage: React.FC = () => {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
refetchAuditLogs();
|
refetchAuditLogs();
|
||||||
success('Audit Ledger Refreshed', 'Latest audit log entries loaded.');
|
success('Refreshed', 'Latest audit log entries loaded from API.');
|
||||||
}}
|
}}
|
||||||
disabled={isFetchingAuditLogs}
|
disabled={isFetchingAuditLogs}
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-slate-300 bg-[#0d121f] hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
|
className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-slate-300 bg-[#0d121f] hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
|
||||||
title="Refresh audit trail"
|
title="Refresh audit trail from API"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetchingAuditLogs ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetchingAuditLogs ? 'animate-spin' : ''}`} />
|
||||||
<span className="hidden sm:inline">Refresh</span>
|
<span>Refresh</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<ExportButton
|
<ExportButton
|
||||||
title="System Audit & Security Log"
|
title="System Audit & Security Log"
|
||||||
subtitle="Complete audit trail of user access, statutory approvals, and financial transactions"
|
subtitle="Complete audit trail retrieved directly from /v1/api/governance/auditlog"
|
||||||
filename={`pfm-audit-trail-${new Date().toISOString().split('T')[0]}`}
|
filename={`pfm-audit-trail-${new Date().toISOString().split('T')[0]}`}
|
||||||
columns={auditExportColumns}
|
columns={auditExportColumns}
|
||||||
data={filteredAuditLogs}
|
data={filteredAuditLogs}
|
||||||
@@ -1233,74 +1324,235 @@ export const SettingsPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Audit Logs Table */}
|
{/* Audit Logs Table */}
|
||||||
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
|
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
|
||||||
|
<div className="p-4 border-b border-slate-800/80 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<History className="w-4 h-4 text-blue-400" />
|
||||||
|
<h3 className="text-sm font-semibold text-white">
|
||||||
|
Audit Log Ledger ({filteredAuditLogs.length} of {auditLogs.length})
|
||||||
|
</h3>
|
||||||
|
<HelpTooltip
|
||||||
|
title="Audit Trail"
|
||||||
|
content="Authoritative record of state changes retrieved via POST /v1/api/governance/auditlog."
|
||||||
|
size="sm"
|
||||||
|
placement="right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] text-slate-400 font-mono">
|
||||||
|
POST /v1/api/governance/auditlog
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left text-xs border-collapse">
|
<table className="w-full text-left text-xs border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
|
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
|
||||||
<th className="py-3.5 px-5">Timestamp</th>
|
<th className="py-3.5 px-3 w-10 text-center"></th>
|
||||||
|
<th className="py-3.5 px-4">Timestamp</th>
|
||||||
<th className="py-3.5 px-4">Event ID</th>
|
<th className="py-3.5 px-4">Event ID</th>
|
||||||
<th className="py-3.5 px-4">Action</th>
|
<th className="py-3.5 px-4">Action</th>
|
||||||
<th className="py-3.5 px-4">Entity</th>
|
<th className="py-3.5 px-4">Entity</th>
|
||||||
<th className="py-3.5 px-4">Actor</th>
|
<th className="py-3.5 px-4">Performed By</th>
|
||||||
<th className="py-3.5 px-4">Details & Telemetry</th>
|
<th className="py-3.5 px-4">Changes</th>
|
||||||
<th className="py-3.5 px-5 text-right">Inspect</th>
|
<th className="py-3.5 px-5 text-right">Inspect</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800/50">
|
<tbody className="divide-y divide-slate-800/50">
|
||||||
{loadingAuditLogs ? (
|
{loadingAuditLogs ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="py-8 text-center text-slate-500">
|
<td colSpan={8} className="py-12 text-center text-slate-500">
|
||||||
Loading system audit ledger...
|
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2 text-blue-400" />
|
||||||
|
Querying governance audit log endpoints...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : filteredAuditLogs.length === 0 ? (
|
) : filteredAuditLogs.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="py-8 text-center text-slate-500">
|
<td colSpan={8} className="py-12 text-center text-slate-500">
|
||||||
|
<History className="w-8 h-8 text-slate-600 mx-auto mb-2" />
|
||||||
{auditSearchTerm || auditEntityFilter !== 'all' || auditActionFilter !== 'all'
|
{auditSearchTerm || auditEntityFilter !== 'all' || auditActionFilter !== 'all'
|
||||||
? 'No audit log entries match your filter criteria.'
|
? 'No audit log entries match your filter criteria.'
|
||||||
: 'No audit records logged yet.'}
|
: 'No audit records returned from endpoint.'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filteredAuditLogs.map((log) => {
|
filteredAuditLogs.map((log) => {
|
||||||
const badge = getActionBadge(log.action);
|
const badge = getActionBadge(log.action);
|
||||||
|
const isExpanded = !!expandedAuditLogIds[log.id];
|
||||||
|
const changesCount = Array.isArray(log.changes) ? log.changes.length : 0;
|
||||||
|
const performerEmail = getUserEmail(log.performedBy);
|
||||||
|
const hasResolvedEmail = performerEmail && performerEmail !== '—' && !performerEmail.includes('—');
|
||||||
|
const displayEmail = hasResolvedEmail ? performerEmail : (log.performedBy || 'System');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={log.id} className="hover:bg-slate-800/40 transition-colors">
|
<React.Fragment key={log.id}>
|
||||||
<td className="py-3.5 px-5 font-mono text-slate-400 whitespace-nowrap text-[11px]">
|
<tr
|
||||||
{formatDate(log.createdAt)}
|
onClick={() => toggleExpandAuditLog(log.id)}
|
||||||
</td>
|
className={`hover:bg-slate-800/40 transition-colors cursor-pointer ${
|
||||||
<td className="py-3.5 px-4 font-mono font-bold text-white whitespace-nowrap">
|
isExpanded ? 'bg-slate-800/30' : ''
|
||||||
{log.id}
|
}`}
|
||||||
</td>
|
>
|
||||||
<td className="py-3.5 px-4 whitespace-nowrap">
|
<td className="py-3.5 px-3 text-center">
|
||||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${badge.className}`}>
|
<button
|
||||||
{badge.label}
|
type="button"
|
||||||
</span>
|
onClick={(e) => {
|
||||||
</td>
|
e.stopPropagation();
|
||||||
<td className="py-3.5 px-4 whitespace-nowrap">
|
toggleExpandAuditLog(log.id);
|
||||||
<span className="font-semibold text-slate-200 block">{log.entityType}</span>
|
}}
|
||||||
<span className="text-[10px] text-slate-500 font-mono">#{log.entityId}</span>
|
className="p-1 rounded-md hover:bg-slate-700/80 text-slate-400 hover:text-white transition cursor-pointer"
|
||||||
</td>
|
title={isExpanded ? 'Collapse changes shelf' : 'Expand changes shelf'}
|
||||||
<td className="py-3.5 px-4 text-slate-300 font-mono text-[11px]">
|
>
|
||||||
{getUserEmail(log.userEmail)}
|
<ChevronRight
|
||||||
</td>
|
className={`w-3.5 h-3.5 transition-transform duration-200 ${
|
||||||
<td className="py-3.5 px-4 text-slate-400 max-w-md truncate" title={log.details}>
|
isExpanded ? 'rotate-90 text-blue-400' : 'text-slate-500'
|
||||||
{log.details || '—'}
|
}`}
|
||||||
</td>
|
/>
|
||||||
<td className="py-3.5 px-5 text-right whitespace-nowrap">
|
</button>
|
||||||
<button
|
</td>
|
||||||
type="button"
|
<td className="py-3.5 px-4 font-mono text-slate-400 whitespace-nowrap text-[11px]">
|
||||||
onClick={() => {
|
{formatDate(log.timestamp)}
|
||||||
setSelectedAuditLog(log);
|
</td>
|
||||||
setIsAuditDetailModalOpen(true);
|
<td className="py-3.5 px-4 font-mono font-bold text-white whitespace-nowrap">
|
||||||
}}
|
#{log.id}
|
||||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-lg transition cursor-pointer"
|
</td>
|
||||||
|
<td className="py-3.5 px-4 whitespace-nowrap">
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${badge.className}`}>
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 whitespace-nowrap">
|
||||||
|
<span className="font-semibold text-slate-200 block">{log.entityName || 'General'}</span>
|
||||||
|
{log.entityId && (
|
||||||
|
<span className="text-[10px] text-slate-500 font-mono">#{log.entityId}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="py-3.5 px-4"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Eye className="w-3 h-3 text-blue-400" />
|
<div className="flex items-center gap-1.5 min-w-0 max-w-[220px]">
|
||||||
Inspect
|
<span
|
||||||
</button>
|
className="text-slate-200 font-mono text-[11px] truncate block"
|
||||||
</td>
|
title={`${displayEmail}${log.performedBy ? ` (User ID: ${log.performedBy})` : ''}`}
|
||||||
</tr>
|
>
|
||||||
|
{displayEmail}
|
||||||
|
</span>
|
||||||
|
{log.performedBy && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => handleCopyUserId(log.performedBy, e)}
|
||||||
|
className="p-1 rounded-md hover:bg-slate-700/80 text-slate-400 hover:text-white transition cursor-pointer shrink-0"
|
||||||
|
title={`Click to copy User ID (${log.performedBy})`}
|
||||||
|
>
|
||||||
|
{copiedUserId === log.performedBy ? (
|
||||||
|
<Check className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-3.5 h-3.5 hover:text-blue-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleExpandAuditLog(log.id);
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2 py-1 rounded-lg bg-slate-800/90 hover:bg-slate-700 border border-slate-700/80 text-[11px] text-slate-300 hover:text-white transition cursor-pointer"
|
||||||
|
title="Click to toggle changes shelf"
|
||||||
|
>
|
||||||
|
<Layers className="w-3 h-3 text-blue-400" />
|
||||||
|
<span>{changesCount} {changesCount === 1 ? 'change' : 'changes'}</span>
|
||||||
|
<ChevronDown
|
||||||
|
className={`w-3 h-3 text-slate-400 transition-transform duration-200 ${
|
||||||
|
isExpanded ? 'rotate-180 text-blue-400' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-5 text-right whitespace-nowrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedAuditLog(log);
|
||||||
|
setIsAuditDetailModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-lg transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<Eye className="w-3 h-3 text-blue-400" />
|
||||||
|
Inspect
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* Expanding Shelf for Changes Content */}
|
||||||
|
{isExpanded && (
|
||||||
|
<tr className="bg-[#080c14] border-b border-slate-800/80">
|
||||||
|
<td colSpan={8} className="p-4 sm:p-5 bg-gradient-to-r from-[#0d121f] via-[#090d16] to-[#0d121f]">
|
||||||
|
<div className="rounded-xl border border-slate-800/90 bg-[#070a12]/90 p-4 space-y-3 shadow-inner">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-slate-800/80 pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Layers className="w-4 h-4 text-blue-400" />
|
||||||
|
<h4 className="text-xs font-bold text-white uppercase tracking-wider">
|
||||||
|
Recorded Changes Content (Event #{log.id})
|
||||||
|
</h4>
|
||||||
|
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-blue-500/10 text-blue-400 border border-blue-500/20 font-semibold">
|
||||||
|
{changesCount} {changesCount === 1 ? 'Entry' : 'Entries'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-slate-400">
|
||||||
|
<span>
|
||||||
|
Entity: <strong className="text-white">{log.entityName || 'General'}</strong> {log.entityId ? `(#${log.entityId})` : ''}
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
Performer: <strong className="text-slate-200 font-mono text-[11px]">{displayEmail}</strong>
|
||||||
|
{log.performedBy && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => handleCopyUserId(log.performedBy, e)}
|
||||||
|
className="p-1 rounded hover:bg-slate-800 text-slate-400 hover:text-white transition cursor-pointer"
|
||||||
|
title={`Copy User ID: ${log.performedBy}`}
|
||||||
|
>
|
||||||
|
{copiedUserId === log.performedBy ? (
|
||||||
|
<Check className="w-3 h-3 text-emerald-400" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-3 h-3 text-slate-400 hover:text-blue-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Changes List */}
|
||||||
|
{Array.isArray(log.changes) && log.changes.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{log.changes.map((changeItem, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="flex items-start gap-3 p-3 bg-slate-900/90 rounded-xl border border-slate-800/80 text-xs font-mono shadow-xs hover:border-slate-700 transition"
|
||||||
|
>
|
||||||
|
<span className="px-2 py-0.5 rounded-md bg-blue-500/10 text-blue-400 border border-blue-500/20 text-[10px] font-bold shrink-0 mt-0.5">
|
||||||
|
#{idx + 1}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 text-emerald-400 leading-relaxed break-all">
|
||||||
|
{changeItem}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-4 rounded-xl bg-slate-900/50 border border-slate-800/80 text-center text-slate-400 text-xs font-mono">
|
||||||
|
No itemized field changes content recorded for this event.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
@@ -1319,9 +1571,10 @@ export const SettingsPage: React.FC = () => {
|
|||||||
onClose={() => {
|
onClose={() => {
|
||||||
setIsAuditDetailModalOpen(false);
|
setIsAuditDetailModalOpen(false);
|
||||||
setSelectedAuditLog(null);
|
setSelectedAuditLog(null);
|
||||||
|
setCopiedAuditPayload(false);
|
||||||
}}
|
}}
|
||||||
title={`Audit Event: ${selectedAuditLog?.id || ''}`}
|
title={`Audit Event: #${selectedAuditLog?.id || ''}`}
|
||||||
subtitle="Cryptographic verification and authoritative event metadata"
|
subtitle="Authoritative event payload from /v1/api/governance/auditlog"
|
||||||
>
|
>
|
||||||
{selectedAuditLog && (
|
{selectedAuditLog && (
|
||||||
<div className="space-y-4 text-xs">
|
<div className="space-y-4 text-xs">
|
||||||
@@ -1329,37 +1582,112 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<div className="flex justify-between items-center border-b border-slate-800/80 pb-2">
|
<div className="flex justify-between items-center border-b border-slate-800/80 pb-2">
|
||||||
<span className="text-slate-400 font-semibold uppercase text-[11px]">Action Type:</span>
|
<span className="text-slate-400 font-semibold uppercase text-[11px]">Action Type:</span>
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold border ${getActionBadge(selectedAuditLog.action).className}`}>
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold border ${getActionBadge(selectedAuditLog.action).className}`}>
|
||||||
{selectedAuditLog.action}
|
{selectedAuditLog.action || 'EVENT'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Entity Type:</span>
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Entity Name:</span>
|
||||||
<span className="text-white font-semibold">{selectedAuditLog.entityType}</span>
|
<span className="text-white font-semibold">{selectedAuditLog.entityName || '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Entity ID:</span>
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Entity ID:</span>
|
||||||
<span className="font-mono text-slate-300">{selectedAuditLog.entityId}</span>
|
<span className="font-mono text-slate-300">{selectedAuditLog.entityId || '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 border-t border-slate-800/80 pt-2">
|
<div className="grid grid-cols-2 gap-3 border-t border-slate-800/80 pt-2">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Actor / Principal:</span>
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Performed By:</span>
|
||||||
<span className="font-mono text-slate-200 break-all">{selectedAuditLog.userEmail}</span>
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
|
<span className="font-mono text-slate-200 text-[11px] truncate">
|
||||||
|
{getUserEmail(selectedAuditLog.performedBy) !== '—'
|
||||||
|
? getUserEmail(selectedAuditLog.performedBy)
|
||||||
|
: (selectedAuditLog.performedBy || 'System')}
|
||||||
|
</span>
|
||||||
|
{selectedAuditLog.performedBy && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => handleCopyUserId(selectedAuditLog.performedBy, e)}
|
||||||
|
className="p-1 rounded hover:bg-slate-700/80 text-slate-400 hover:text-white transition cursor-pointer shrink-0"
|
||||||
|
title={`Copy User ID: ${selectedAuditLog.performedBy}`}
|
||||||
|
>
|
||||||
|
{copiedUserId === selectedAuditLog.performedBy ? (
|
||||||
|
<Check className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-3.5 h-3.5 hover:text-blue-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selectedAuditLog.performedBy && (
|
||||||
|
<span className="text-[10px] text-slate-500 font-mono block mt-0.5 truncate">
|
||||||
|
ID: {selectedAuditLog.performedBy}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Logged Timestamp:</span>
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Logged Timestamp:</span>
|
||||||
<span className="font-mono text-slate-300">{formatDate(selectedAuditLog.createdAt)}</span>
|
<span className="font-mono text-slate-300 block mt-0.5">{formatDate(selectedAuditLog.timestamp)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-slate-800/80 pt-2">
|
{selectedAuditLog.tenantId && (
|
||||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase mb-1">Details & Audit Trail:</span>
|
<div className="border-t border-slate-800/80 pt-2">
|
||||||
<p className="p-3 rounded-lg bg-slate-900 border border-slate-800 text-slate-300 leading-relaxed font-mono text-[11px]">
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Tenant Scope:</span>
|
||||||
{selectedAuditLog.details || 'No additional payload notes recorded for this transaction.'}
|
<span className="font-mono text-slate-300">Tenant #{selectedAuditLog.tenantId}</span>
|
||||||
</p>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="border-t border-slate-800/80 pt-2 space-y-1">
|
||||||
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase mb-1">State Changes / Delta:</span>
|
||||||
|
{Array.isArray(selectedAuditLog.changes) && selectedAuditLog.changes.length > 0 ? (
|
||||||
|
<ul className="space-y-1 p-2 rounded-lg bg-slate-900 border border-slate-800">
|
||||||
|
{selectedAuditLog.changes.map((ch, idx) => (
|
||||||
|
<li key={idx} className="font-mono text-emerald-400/90 text-[11px] flex items-center gap-1.5">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
|
||||||
|
{ch}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="p-3 rounded-lg bg-slate-900 border border-slate-800 text-slate-400 leading-relaxed font-mono text-[11px]">
|
||||||
|
No granular delta changes specified in audit payload.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Raw JSON Data Viewer */}
|
||||||
|
<div className="border-t border-slate-800/80 pt-2.5 space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-slate-400 font-semibold text-[11px] uppercase">
|
||||||
|
Raw Audit Record (JSON):
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(JSON.stringify(selectedAuditLog, null, 2));
|
||||||
|
setCopiedAuditPayload(true);
|
||||||
|
setTimeout(() => setCopiedAuditPayload(false), 2000);
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1 text-[10px] font-medium text-slate-400 hover:text-white bg-slate-800 px-2 py-1 rounded transition cursor-pointer"
|
||||||
|
>
|
||||||
|
{copiedAuditPayload ? (
|
||||||
|
<>
|
||||||
|
<Check className="w-3 h-3 text-emerald-400" />
|
||||||
|
<span className="text-emerald-400">Copied</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy className="w-3 h-3" />
|
||||||
|
<span>Copy JSON</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre className="p-3 rounded-lg bg-[#070a12] border border-slate-800/80 text-emerald-300/90 font-mono text-[10.5px] overflow-x-auto max-h-48">
|
||||||
|
{JSON.stringify(selectedAuditLog, null, 2)}
|
||||||
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1369,6 +1697,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsAuditDetailModalOpen(false);
|
setIsAuditDetailModalOpen(false);
|
||||||
setSelectedAuditLog(null);
|
setSelectedAuditLog(null);
|
||||||
|
setCopiedAuditPayload(false);
|
||||||
}}
|
}}
|
||||||
className="px-4 py-2 text-xs font-semibold text-white bg-slate-800 hover:bg-slate-700 rounded-xl transition cursor-pointer"
|
className="px-4 py-2 text-xs font-semibold text-white bg-slate-800 hover:bg-slate-700 rounded-xl transition cursor-pointer"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1461,169 +1461,41 @@ export const getApplicantInvites = async (applicantUserId: string): Promise<Invi
|
|||||||
return res.data || [];
|
return res.data || [];
|
||||||
};
|
};
|
||||||
|
|
||||||
const NOTIFICATIONS_STORAGE_KEY = 'pfm_notifications_cache_v1';
|
|
||||||
|
|
||||||
const INITIAL_NOTIFICATIONS: Notification[] = [
|
|
||||||
{
|
|
||||||
id: 1001,
|
|
||||||
tenantId: 1,
|
|
||||||
organisationId: 101,
|
|
||||||
recipient: '550e8400-e29b-41d4-a716-446655440000',
|
|
||||||
platform: NotificationPlatform.Email,
|
|
||||||
status: NotificationStatus.Delivered,
|
|
||||||
subject: 'Grant Award Tranche Dispatched - AW-2026-001',
|
|
||||||
message: 'Statutory notice: Tranche 1 of ZAR 4,200,000.00 has been approved and scheduled for settlement.',
|
|
||||||
destination: 'cfo@soweto-clinics.org.za',
|
|
||||||
createdAt: new Date(Date.now() - 1000 * 60 * 45).toISOString(),
|
|
||||||
sentAt: new Date(Date.now() - 1000 * 60 * 44).toISOString(),
|
|
||||||
updatedAt: new Date(Date.now() - 1000 * 60 * 44).toISOString(),
|
|
||||||
error: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 1002,
|
|
||||||
tenantId: 1,
|
|
||||||
organisationId: 101,
|
|
||||||
recipient: '772b1100-a29b-41d4-b716-112233445566',
|
|
||||||
platform: NotificationPlatform.SMS,
|
|
||||||
status: NotificationStatus.Sent,
|
|
||||||
subject: 'OTP Security Verification',
|
|
||||||
message: 'PFM Portal Security: One-Time Passcode for disbursement release approval is 924-811. Expires in 10 mins.',
|
|
||||||
destination: '+27 82 555 0192',
|
|
||||||
createdAt: new Date(Date.now() - 1000 * 60 * 120).toISOString(),
|
|
||||||
sentAt: new Date(Date.now() - 1000 * 60 * 119).toISOString(),
|
|
||||||
updatedAt: new Date(Date.now() - 1000 * 60 * 119).toISOString(),
|
|
||||||
error: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 1003,
|
|
||||||
tenantId: 1,
|
|
||||||
organisationId: 102,
|
|
||||||
recipient: '331c9900-f12b-41d4-c916-998877665544',
|
|
||||||
platform: NotificationPlatform.InApp,
|
|
||||||
status: NotificationStatus.Read,
|
|
||||||
subject: 'Site Inspection Report Required',
|
|
||||||
message: 'Milestone 2 physical verification evidence pending upload before next tranche release.',
|
|
||||||
destination: 'inapp:compliance_officer_102',
|
|
||||||
createdAt: new Date(Date.now() - 1000 * 60 * 360).toISOString(),
|
|
||||||
sentAt: new Date(Date.now() - 1000 * 60 * 359).toISOString(),
|
|
||||||
updatedAt: new Date(Date.now() - 1000 * 60 * 300).toISOString(),
|
|
||||||
error: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 1004,
|
|
||||||
tenantId: 1,
|
|
||||||
organisationId: 101,
|
|
||||||
recipient: '884d2200-e34b-41d4-d816-334455667788',
|
|
||||||
platform: NotificationPlatform.Webhook,
|
|
||||||
status: NotificationStatus.Delivered,
|
|
||||||
subject: 'Disbursement Webhook Trigger',
|
|
||||||
message: '{"event":"disbursement.approved","disbursementId":402,"amount":1250000.00,"currency":"ZAR"}',
|
|
||||||
destination: 'https://api.treasury.gov.za/webhooks/pfm-settlement',
|
|
||||||
createdAt: new Date(Date.now() - 1000 * 60 * 600).toISOString(),
|
|
||||||
sentAt: new Date(Date.now() - 1000 * 60 * 598).toISOString(),
|
|
||||||
updatedAt: new Date(Date.now() - 1000 * 60 * 598).toISOString(),
|
|
||||||
error: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 1005,
|
|
||||||
tenantId: 1,
|
|
||||||
organisationId: 102,
|
|
||||||
recipient: '661e5500-a98b-41d4-e116-223344556677',
|
|
||||||
platform: NotificationPlatform.Email,
|
|
||||||
status: NotificationStatus.Queued,
|
|
||||||
subject: 'Quarterly Grant Compliance Audit Notice',
|
|
||||||
message: 'Scheduled audit review commencing next Monday. Please review the documentation checklist.',
|
|
||||||
destination: 'director@gauteng-water.gov.za',
|
|
||||||
createdAt: new Date(Date.now() - 1000 * 60 * 25).toISOString(),
|
|
||||||
sentAt: null,
|
|
||||||
updatedAt: new Date(Date.now() - 1000 * 60 * 25).toISOString(),
|
|
||||||
error: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 1006,
|
|
||||||
tenantId: 1,
|
|
||||||
organisationId: 101,
|
|
||||||
recipient: '990f6600-b87b-41d4-f216-112233445500',
|
|
||||||
platform: NotificationPlatform.WhatsApp,
|
|
||||||
status: NotificationStatus.Delivered,
|
|
||||||
subject: 'Contract Execution Confirmation',
|
|
||||||
message: 'Contract CTR-2026-901 has been countersigned by the Accounting Officer. View in M&E portal.',
|
|
||||||
destination: '+27 83 444 8920',
|
|
||||||
createdAt: new Date(Date.now() - 1000 * 60 * 900).toISOString(),
|
|
||||||
sentAt: new Date(Date.now() - 1000 * 60 * 899).toISOString(),
|
|
||||||
updatedAt: new Date(Date.now() - 1000 * 60 * 899).toISOString(),
|
|
||||||
error: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 1007,
|
|
||||||
tenantId: 1,
|
|
||||||
organisationId: 102,
|
|
||||||
recipient: '112a3300-c76b-41d4-a316-001122334455',
|
|
||||||
platform: NotificationPlatform.Email,
|
|
||||||
status: NotificationStatus.Failed,
|
|
||||||
subject: 'Budget Line Reallocation Alert',
|
|
||||||
message: 'Automated warning: Budget line #102-04 variance threshold exceeded 15%.',
|
|
||||||
destination: 'invalid-address@nonexistent-domain.gov.za',
|
|
||||||
createdAt: new Date(Date.now() - 1000 * 60 * 1440).toISOString(),
|
|
||||||
sentAt: new Date(Date.now() - 1000 * 60 * 1438).toISOString(),
|
|
||||||
updatedAt: new Date(Date.now() - 1000 * 60 * 1438).toISOString(),
|
|
||||||
error: 'SMTP 550: Mailbox unavailable or domain could not be resolved',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const getStoredNotifications = (): Notification[] => {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(NOTIFICATIONS_STORAGE_KEY);
|
|
||||||
if (!raw) {
|
|
||||||
localStorage.setItem(NOTIFICATIONS_STORAGE_KEY, JSON.stringify(INITIAL_NOTIFICATIONS));
|
|
||||||
return INITIAL_NOTIFICATIONS;
|
|
||||||
}
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
return Array.isArray(parsed) && parsed.length > 0 ? parsed : INITIAL_NOTIFICATIONS;
|
|
||||||
} catch {
|
|
||||||
return INITIAL_NOTIFICATIONS;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getNotifications = async (organisationId?: string | number): Promise<Notification[]> => {
|
export const getNotifications = async (organisationId?: string | number): Promise<Notification[]> => {
|
||||||
const targetOrgId =
|
const targetOrgId = organisationId !== undefined && organisationId !== '' && organisationId !== null && organisationId !== 'all'
|
||||||
organisationId !== undefined && organisationId !== '' && organisationId !== null && organisationId !== 'all'
|
? organisationId
|
||||||
? organisationId
|
: getCurrentUserTenantId();
|
||||||
: getCurrentUserTenantId();
|
|
||||||
|
|
||||||
if (!targetOrgId || targetOrgId === 'all') {
|
if (!targetOrgId || targetOrgId === 'all') {
|
||||||
return getStoredNotifications();
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const hashedOrgId = await toolsService.resolveOrganisationId(targetOrgId);
|
const hashedOrgId = await toolsService.resolveOrganisationId(targetOrgId);
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.get<Notification[]>(
|
const res = await apiClient.get<Notification[]>(
|
||||||
`/v1/api/communications/norifications/${hashedOrgId}`
|
`/v1/api/communications/norifications/${encodeURIComponent(hashedOrgId)}`
|
||||||
);
|
);
|
||||||
if (res.data && Array.isArray(res.data) && res.data.length > 0) {
|
if (res.data && Array.isArray(res.data)) {
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.response?.status === 404) {
|
if (err?.response?.status === 404) {
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.get<Notification[]>(
|
const fallbackRes = await apiClient.get<Notification[]>(
|
||||||
`/v1/api/communications/notifications/${hashedOrgId}`
|
`/v1/api/communications/notifications/${encodeURIComponent(hashedOrgId)}`
|
||||||
);
|
);
|
||||||
if (res.data && Array.isArray(res.data) && res.data.length > 0) {
|
if (fallbackRes.data && Array.isArray(fallbackRes.data)) {
|
||||||
return res.data;
|
return fallbackRes.data;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (fallbackErr) {
|
||||||
// Fallback to local cache
|
console.warn('[getNotifications] Notification fallback route error:', fallbackErr);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('[getNotifications] API error fetching notifications:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter stored notifications for this organization or return matching items
|
return [];
|
||||||
const allStored = getStoredNotifications();
|
|
||||||
const orgFiltered = allStored.filter(
|
|
||||||
(n) => String(n.organisationId) === String(targetOrgId) || String(n.organisationId) === String(toIntegerId(targetOrgId))
|
|
||||||
);
|
|
||||||
return orgFiltered.length > 0 ? orgFiltered : allStored;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -2082,160 +1954,29 @@ export const updateRule = async (payload: UpdateRuleRequest): Promise<void> => {
|
|||||||
// 12. Audit Logs & System Telemetry APIs
|
// 12. Audit Logs & System Telemetry APIs
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
const AUDIT_STORAGE_KEY = 'pfm_audit_ledger_v1';
|
export const getAuditLogs = async (page = 1, pageSize = 50): Promise<AuditLog[]> => {
|
||||||
|
|
||||||
const INITIAL_AUDIT_LOGS: AuditLog[] = [
|
|
||||||
{
|
|
||||||
id: 'AUD-88391',
|
|
||||||
action: 'USER_AUTHENTICATED',
|
|
||||||
entityType: 'User',
|
|
||||||
entityId: 'USR-KHWEZI',
|
|
||||||
userEmail: 'khwezim@gmail.com',
|
|
||||||
details: 'User authenticated via OIDC Provider. Multi-tenant security context active.',
|
|
||||||
createdAt: new Date(Date.now() - 1000 * 60 * 12).toISOString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'AUD-88390',
|
|
||||||
action: 'DISBURSEMENT_APPROVED',
|
|
||||||
entityType: 'Disbursement',
|
|
||||||
entityId: 'DSB-101',
|
|
||||||
userEmail: 'accounting.officer@treasury.gov.za',
|
|
||||||
details: 'Authorized tranche release of ZAR 2,500,000.00 for Project #401. EFT reference EFT-2026-9031.',
|
|
||||||
createdAt: new Date(Date.now() - 1000 * 60 * 48).toISOString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'AUD-88389',
|
|
||||||
action: 'DISBURSEMENT_SCHEDULED',
|
|
||||||
entityType: 'Disbursement',
|
|
||||||
entityId: 'DSB-101',
|
|
||||||
userEmail: 'grants.officer@dst.gov.za',
|
|
||||||
details: 'Scheduled tranche of ZAR 2,500,000.00 linked to Milestone Deliverable #201.',
|
|
||||||
createdAt: new Date(Date.now() - 1000 * 60 * 95).toISOString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'AUD-88388',
|
|
||||||
action: 'BUDGET_LINE_CREATED',
|
|
||||||
entityType: 'Budget',
|
|
||||||
entityId: 'BDG-401-01',
|
|
||||||
userEmail: 'khwezim@gmail.com',
|
|
||||||
details: 'Established Specialized Equipment & Prototype Probes budget category for ZAR 4,500,000.00 against Project #401.',
|
|
||||||
createdAt: new Date(Date.now() - 1000 * 60 * 180).toISOString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'AUD-88387',
|
|
||||||
action: 'AWARD_REGISTERED',
|
|
||||||
entityType: 'Award',
|
|
||||||
entityId: 'AWD-2026-441',
|
|
||||||
userEmail: 'khwezim@gmail.com',
|
|
||||||
details: 'Registered approved statutory funding award for ZAR 10,000,000.00 (Programme #301, Org #101).',
|
|
||||||
createdAt: new Date(Date.now() - 1000 * 60 * 320).toISOString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'AUD-88386',
|
|
||||||
action: 'RISK_EVALUATED',
|
|
||||||
entityType: 'Risk',
|
|
||||||
entityId: 'RSK-202',
|
|
||||||
userEmail: 'risk.compliance@treasury.gov.za',
|
|
||||||
details: 'Evaluated ISO 31000 Risk: Supply Chain Logistics & Component Delay. Residual risk score set to Medium (9).',
|
|
||||||
createdAt: new Date(Date.now() - 1000 * 60 * 480).toISOString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'AUD-88385',
|
|
||||||
action: 'EVIDENCE_VERIFIED',
|
|
||||||
entityType: 'Evidence',
|
|
||||||
entityId: 'EVD-993',
|
|
||||||
userEmail: 'evaluator@nrf.ac.za',
|
|
||||||
details: 'Verified milestone completion evidence report with cryptographic hash verification.',
|
|
||||||
createdAt: new Date(Date.now() - 1000 * 60 * 720).toISOString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'AUD-88384',
|
|
||||||
action: 'INVITATION_DISPATCHED',
|
|
||||||
entityType: 'Invite',
|
|
||||||
entityId: 'INV-1092',
|
|
||||||
userEmail: 'khwezim@gmail.com',
|
|
||||||
details: 'Dispatched statutory onboarding invitation to reviewer@institution.ac.za via Email SMTP gateway.',
|
|
||||||
createdAt: new Date(Date.now() - 1000 * 60 * 1050).toISOString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'AUD-88383',
|
|
||||||
action: 'DIRECTORY_SYNCED',
|
|
||||||
entityType: 'Security',
|
|
||||||
entityId: 'TENANT-001',
|
|
||||||
userEmail: 'system@treasury.gov.za',
|
|
||||||
details: 'Executed backend synchronization handshake. 12 active users and authorization claims validated.',
|
|
||||||
createdAt: new Date(Date.now() - 1000 * 60 * 1440).toISOString(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const getStoredAuditLogs = (): AuditLog[] => {
|
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(AUDIT_STORAGE_KEY);
|
const res = await apiClient.post<AuditLog[]>('/v1/api/governance/auditlog', {
|
||||||
if (!raw) {
|
index: (page - 1) * pageSize,
|
||||||
localStorage.setItem(AUDIT_STORAGE_KEY, JSON.stringify(INITIAL_AUDIT_LOGS));
|
page,
|
||||||
return INITIAL_AUDIT_LOGS;
|
pageSize,
|
||||||
|
});
|
||||||
|
if (res.data && Array.isArray(res.data)) {
|
||||||
|
return res.data;
|
||||||
}
|
}
|
||||||
const parsed = JSON.parse(raw);
|
return [];
|
||||||
return Array.isArray(parsed) && parsed.length > 0 ? parsed : INITIAL_AUDIT_LOGS;
|
|
||||||
} catch {
|
|
||||||
return INITIAL_AUDIT_LOGS;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const recordAuditLog = (
|
|
||||||
log: Omit<AuditLog, 'id' | 'createdAt'> & { id?: string; createdAt?: string }
|
|
||||||
): AuditLog => {
|
|
||||||
const current = getStoredAuditLogs();
|
|
||||||
const newEntry: AuditLog = {
|
|
||||||
id: log.id || `AUD-${Math.floor(10000 + Math.random() * 90000)}`,
|
|
||||||
action: log.action,
|
|
||||||
entityType: log.entityType,
|
|
||||||
entityId: log.entityId,
|
|
||||||
userEmail: log.userEmail || 'khwezim@gmail.com',
|
|
||||||
details: log.details,
|
|
||||||
createdAt: log.createdAt || new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const updated = [newEntry, ...current];
|
|
||||||
try {
|
|
||||||
localStorage.setItem(AUDIT_STORAGE_KEY, JSON.stringify(updated));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to save audit log to localStorage:', err);
|
console.error('[getAuditLogs] API request failed:', err);
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
return newEntry;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAuditLogs = async (page = 1, pageSize = 100): Promise<AuditLog[]> => {
|
export const getAuditLog = async (auditId: string | number): Promise<AuditLog | null> => {
|
||||||
// Try candidate backend endpoints first
|
|
||||||
const endpoints = [
|
|
||||||
`/v1/api/governance/audit/${page}/${pageSize}`,
|
|
||||||
`/v1/api/governance/audit`,
|
|
||||||
`/v1/api/audit/${page}/${pageSize}`,
|
|
||||||
`/v1/api/audit`,
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const ep of endpoints) {
|
|
||||||
try {
|
|
||||||
const res = await apiClient.get<AuditLog[]>(ep);
|
|
||||||
if (res.data && Array.isArray(res.data) && res.data.length > 0) {
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// fallback to next candidate or stored ledger
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return getStoredAuditLogs();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getAuditLog = async (auditId: string): Promise<AuditLog | null> => {
|
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.get<AuditLog>(`/v1/api/governance/audit/${auditId}`);
|
const logs = await getAuditLogs(1, 100);
|
||||||
if (res.data) return res.data;
|
return logs.find((l) => String(l.id) === String(auditId)) || null;
|
||||||
} catch {
|
} catch {
|
||||||
// fallback
|
return null;
|
||||||
}
|
}
|
||||||
const logs = getStoredAuditLogs();
|
|
||||||
return logs.find((l) => String(l.id) === String(auditId)) || null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -274,13 +274,14 @@ export interface InviteUserRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AuditLog {
|
export interface AuditLog {
|
||||||
id: string;
|
id: number | string;
|
||||||
action: string;
|
tenantId?: number | string | null;
|
||||||
entityType: string;
|
entityName?: string | null;
|
||||||
entityId: string | number;
|
entityId?: string | null;
|
||||||
userEmail: string;
|
action?: string | null;
|
||||||
details?: string;
|
changes?: string[] | null;
|
||||||
createdAt: string;
|
performedBy: string;
|
||||||
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserFilterRequest {
|
export interface UserFilterRequest {
|
||||||
|
|||||||
Reference in New Issue
Block a user