Revised all UIs

This commit is contained in:
2026-09-26 16:36:45 +02:00
parent ed6e57ffa9
commit 6e6419ab51
7 changed files with 2477 additions and 1035 deletions
@@ -44,6 +44,7 @@ import {
ExecuteContractRequest,
Budget,
CreateBudgetRequest,
UpdateBudgetRequest,
Disbursement,
CreateDisbursementRequest,
UpdateDisbursementRequest,
@@ -88,6 +89,8 @@ import {
UpdateRuleRequest,
Pagination,
Notification,
NotificationPlatform,
NotificationStatus,
} from '../types';
import {
@@ -356,6 +359,44 @@ export const getProgrammes = async (portfolioId: string | number): Promise<Progr
return res.data || [];
};
export const getPortfolioValue = async (portfolioId: string | number): Promise<number> => {
const hashedPortId = await toolsService.resolvePortfolioId(portfolioId);
try {
const res = await apiClient.get<number>(`/v1/api/governance/portfolios/${hashedPortId}/value`);
return typeof res.data === 'number' ? res.data : Number(res.data) || 0;
} catch (err) {
if (String(portfolioId) !== String(hashedPortId)) {
try {
const fallbackRes = await apiClient.get<number>(`/v1/api/governance/portfolios/${portfolioId}/value`);
return typeof fallbackRes.data === 'number' ? fallbackRes.data : Number(fallbackRes.data) || 0;
} catch {
// ignore
}
}
console.warn('[getPortfolioValue] Failed to get portfolio value:', err);
return 0;
}
};
export const getProgrammeValue = async (programmeId: string | number): Promise<number> => {
const hashedProgId = await toolsService.resolveProgrammeId(programmeId);
try {
const res = await apiClient.get<number>(`/v1/api/governance/programmes/${hashedProgId}/value`);
return typeof res.data === 'number' ? res.data : Number(res.data) || 0;
} catch (err) {
if (String(programmeId) !== String(hashedProgId)) {
try {
const fallbackRes = await apiClient.get<number>(`/v1/api/governance/programmes/${programmeId}/value`);
return typeof fallbackRes.data === 'number' ? fallbackRes.data : Number(fallbackRes.data) || 0;
} catch {
// ignore
}
}
console.warn('[getProgrammeValue] Failed to get programme value:', err);
return 0;
}
};
const LOCAL_INSTRUCTIONS_KEY = 'pfm_local_instructions';
export const purgeStoredInstructionDuplicates = (apiItems: Instrument[]): void => {
@@ -996,6 +1037,13 @@ export const createBudget = async (payload: CreateBudgetRequest): Promise<Budget
return res.data;
};
export const updateBudget = async (payload: UpdateBudgetRequest): Promise<void> => {
await apiClient.put('/v1/api/grants/budgets', {
budgetId: toIntegerId(payload.budgetId),
status: Number(payload.status),
});
};
// ==========================================
// 5. Execution (Disbursements & Risks) APIs
// ==========================================
@@ -1413,32 +1461,169 @@ export const getApplicantInvites = async (applicantUserId: string): Promise<Invi
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[]> => {
const targetOrgId =
organisationId !== undefined && organisationId !== '' && organisationId !== null
organisationId !== undefined && organisationId !== '' && organisationId !== null && organisationId !== 'all'
? organisationId
: getCurrentUserTenantId();
if (!targetOrgId) return [];
if (!targetOrgId || targetOrgId === 'all') {
return getStoredNotifications();
}
const hashedOrgId = await toolsService.resolveOrganisationId(targetOrgId);
try {
const res = await apiClient.get<Notification[]>(
`/v1/api/communications/norifications/${hashedOrgId}`
);
return res.data || [];
if (res.data && Array.isArray(res.data) && res.data.length > 0) {
return res.data;
}
} catch (err: any) {
if (err?.response?.status === 404) {
try {
const res = await apiClient.get<Notification[]>(
`/v1/api/communications/notifications/${hashedOrgId}`
);
return res.data || [];
if (res.data && Array.isArray(res.data) && res.data.length > 0) {
return res.data;
}
} catch {
return [];
// Fallback to local cache
}
}
return [];
}
// Filter stored notifications for this organization or return matching items
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;
};
// ==========================================
@@ -1892,3 +2077,165 @@ export const updateRule = async (payload: UpdateRuleRequest): Promise<void> => {
saveStoredRule(updatedRule);
}
};
// ==========================================
// 12. Audit Logs & System Telemetry APIs
// ==========================================
const AUDIT_STORAGE_KEY = 'pfm_audit_ledger_v1';
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 {
const raw = localStorage.getItem(AUDIT_STORAGE_KEY);
if (!raw) {
localStorage.setItem(AUDIT_STORAGE_KEY, JSON.stringify(INITIAL_AUDIT_LOGS));
return INITIAL_AUDIT_LOGS;
}
const parsed = JSON.parse(raw);
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) {
console.warn('Failed to save audit log to localStorage:', err);
}
return newEntry;
};
export const getAuditLogs = async (page = 1, pageSize = 100): Promise<AuditLog[]> => {
// 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 {
const res = await apiClient.get<AuditLog>(`/v1/api/governance/audit/${auditId}`);
if (res.data) return res.data;
} catch {
// fallback
}
const logs = getStoredAuditLogs();
return logs.find((l) => String(l.id) === String(auditId)) || null;
};