2725 lines
94 KiB
TypeScript
2725 lines
94 KiB
TypeScript
import axios from 'axios';
|
|
import { apiClient } from '../httpClient';
|
|
import { toolsService } from '../toolsService';
|
|
import {
|
|
notifyTenantManagersAndExecutivesOnRecordStatusChange,
|
|
notifyInviteApproved,
|
|
notifyNewUserCreated,
|
|
isApprovedOrActiveStatus,
|
|
} from '../notificationRulesService';
|
|
|
|
export * from './models';
|
|
export * from './errorHelper';
|
|
export * from './endpoints';
|
|
|
|
export {
|
|
notifyTenantManagersAndExecutivesOnRecordStatusChange,
|
|
notifyInviteApproved,
|
|
notifyNewUserCreated,
|
|
isApprovedOrActiveStatus,
|
|
};
|
|
|
|
import {
|
|
getCurrentUserSub,
|
|
getCurrentUserTenantId,
|
|
getCurrentUserHashedTenantId,
|
|
} from '../../auth/AuthProvider';
|
|
|
|
export { getCurrentUserTenantId, getCurrentUserHashedTenantId };
|
|
|
|
import {
|
|
Dashboard,
|
|
Organisation,
|
|
CreateOrganisationRequest,
|
|
UpdateOrganisationRequest,
|
|
Portfolio,
|
|
CreatePortfolioRequest,
|
|
UpdatePortfolioRequest,
|
|
Programme,
|
|
CreateProgrammeRequest,
|
|
UpdateProgrammeRequest,
|
|
Instrument,
|
|
CreateInstrumentRequest,
|
|
UpdateInstrumentRequest,
|
|
Project,
|
|
CreateProjectRequest,
|
|
UpdateProjectRequest,
|
|
UpdateProjectStatusRequest,
|
|
Activity,
|
|
CreateActivityRequest,
|
|
UpdateActivityRequest,
|
|
Milestone,
|
|
CreateMilestoneRequest,
|
|
UpdateMilestoneRequest,
|
|
Award,
|
|
CreateAwardRequest,
|
|
UpdateAwardRequest,
|
|
Contract,
|
|
ContractStatus,
|
|
CreateContractRequest,
|
|
UpdateContractRequest,
|
|
ExecuteContractRequest,
|
|
Budget,
|
|
CreateBudgetRequest,
|
|
UpdateBudgetRequest,
|
|
Disbursement,
|
|
CreateDisbursementRequest,
|
|
UpdateDisbursementRequest,
|
|
Indicator,
|
|
CreateIndicatorRequest,
|
|
Evidence,
|
|
UpdateEvidenceRequest,
|
|
Risk,
|
|
CreateRiskRequest,
|
|
UpdateRiskRequest,
|
|
User,
|
|
UserFilterRequest,
|
|
Invite,
|
|
InviteRequest,
|
|
ApplicantData,
|
|
AuditLog,
|
|
ProblemDetails,
|
|
ActivityStatus,
|
|
ApprovalStatus,
|
|
Tenant,
|
|
TenantOrganisation,
|
|
TenantUser,
|
|
CreateTenantRequest,
|
|
UpdateTenantRequest,
|
|
Beneficiary,
|
|
CreateBeneficiaryRequest,
|
|
UpdateBeneficiaryRequest,
|
|
Outcome,
|
|
CreateOutcomeRequest,
|
|
UpdateOutcomeRequest,
|
|
ChangeRequest,
|
|
CreateChangeRequest,
|
|
UpdateChangeRequest,
|
|
ComplianceItem,
|
|
CreateComplianceItemRequest,
|
|
UpdateComplianceItemRequest,
|
|
Issue,
|
|
CreateIssueRequest,
|
|
UpdateIssueRequest,
|
|
SiteVisit,
|
|
CreateSiteVisitRequest,
|
|
UpdateSiteVisitRequest,
|
|
Rule,
|
|
CreateRuleRequest,
|
|
UpdateRuleRequest,
|
|
Pagination,
|
|
Notification,
|
|
NotificationStatus,
|
|
Role,
|
|
Claim,
|
|
CreateUserRequest,
|
|
AddUserClaimRequest,
|
|
CreateNotificationRequest,
|
|
UpdateNotificationRequest,
|
|
} from '../../types';
|
|
|
|
import {
|
|
encodeIdNumberHex,
|
|
encodeEntityId,
|
|
toSafeInt64String,
|
|
} from '../../utils/hashids';
|
|
|
|
import {
|
|
parseFriendlyError,
|
|
FriendlyError,
|
|
inspectErrorResponseObject,
|
|
parseCommaDelimitedErrors,
|
|
} from '../../utils/apiErrorParser';
|
|
import { registerUserEmail, registerUserEmails, getUserIdByEmail } from '../../lib/formatters';
|
|
|
|
export function parseApiError(error: unknown): ProblemDetails {
|
|
const returnObj = inspectErrorResponseObject(error);
|
|
if (returnObj) {
|
|
return {
|
|
type: returnObj.type || 'https://tools.ietf.org/html/rfc9110#section-15.5.1',
|
|
title: returnObj.title || 'Error',
|
|
status: returnObj.status || (axios.isAxiosError(error) ? error.response?.status : 400),
|
|
detail: returnObj.detail || null,
|
|
instance: returnObj.instance || null,
|
|
};
|
|
}
|
|
|
|
const friendly = parseFriendlyError(error);
|
|
return {
|
|
type: 'https://tools.ietf.org/html/rfc9110#section-15.5.1',
|
|
title: friendly.title,
|
|
detail: friendly.message,
|
|
status: friendly.statusCode || 500,
|
|
};
|
|
}
|
|
|
|
export { parseFriendlyError, inspectErrorResponseObject, parseCommaDelimitedErrors };
|
|
export type { FriendlyError };
|
|
|
|
// ==========================================
|
|
// 1. Executive Dashboard APIs
|
|
// ==========================================
|
|
export const getDashboard = async (): Promise<Dashboard> => {
|
|
const res = await apiClient.get<Dashboard>('/v1/api/governance/dashboard');
|
|
return res.data;
|
|
};
|
|
|
|
// ==========================================
|
|
// 2. Governance & Structure APIs
|
|
// ==========================================
|
|
export const toIntegerId = (id: number | string | null | undefined): number => {
|
|
return toolsService.decodeToNumberSync(id);
|
|
};
|
|
|
|
export const toNullableIntegerId = (id: number | string | null | undefined): number | null => {
|
|
if (id === null || id === undefined || id === '') return null;
|
|
return toolsService.decodeToNumberSync(id);
|
|
};
|
|
|
|
export const normalizeOrganisation = (raw: any): Organisation => {
|
|
if (!raw) return raw;
|
|
const status =
|
|
raw.status ??
|
|
raw.Status ??
|
|
raw.activityStatus ??
|
|
raw.ActivityStatus ??
|
|
raw.approvalStatus ??
|
|
raw.ApprovalStatus ??
|
|
raw.statusId ??
|
|
raw.StatusId ??
|
|
raw.state ??
|
|
raw.State ??
|
|
(raw.isActive !== undefined ? (raw.isActive ? 1 : 2) : undefined) ??
|
|
(raw.IsActive !== undefined ? (raw.IsActive ? 1 : 2) : undefined) ??
|
|
ActivityStatus.Active;
|
|
|
|
return {
|
|
...raw,
|
|
id: raw.id ?? raw.Id ?? raw.organisationId ?? raw.OrganisationId,
|
|
tenantId: raw.tenantId ?? raw.TenantId ?? raw.tenant_id ?? null,
|
|
name: raw.name ?? raw.Name ?? null,
|
|
registrationNo: raw.registrationNo ?? raw.RegistrationNo ?? raw.regNo ?? raw.RegNo ?? null,
|
|
email: raw.email ?? raw.Email ?? null,
|
|
userId: raw.userId ?? raw.UserId ?? raw.organisationUser ?? raw.OrganisationUser ?? raw.user_id ?? raw.linkedUser ?? raw.LinkedUser ?? null,
|
|
idNumber: raw.idNumber ?? raw.IdNumber ?? raw.id_number ?? null,
|
|
type: raw.type ?? raw.Type ?? raw.organisationType ?? raw.OrganisationType ?? 5,
|
|
status,
|
|
createdAt: raw.createdAt ?? raw.CreatedAt ?? new Date().toISOString(),
|
|
createdBy: raw.createdBy ?? raw.CreatedBy ?? '',
|
|
};
|
|
};
|
|
|
|
export const getOrganisations = async (page = 1, pageSize = 100): Promise<Organisation[]> => {
|
|
const res = await apiClient.get<any[]>('/v1/api/governance/organisations', {
|
|
params: { page, pageSize },
|
|
});
|
|
const list = res.data || [];
|
|
return list.map(normalizeOrganisation);
|
|
};
|
|
|
|
export const getOrganisation = async (organisationId?: string | number): Promise<Organisation> => {
|
|
const targetOrgId =
|
|
organisationId !== undefined && organisationId !== '' && organisationId !== null
|
|
? organisationId
|
|
: getCurrentUserTenantId();
|
|
|
|
if (targetOrgId === undefined || targetOrgId === null || targetOrgId === '') {
|
|
throw new Error('Organisation ID is required');
|
|
}
|
|
const hashedOrgId = await toolsService.resolveOrganisationId(targetOrgId);
|
|
const res = await apiClient.get<any>(`/v1/api/governance/organisations/${hashedOrgId}`);
|
|
return normalizeOrganisation(res.data);
|
|
};
|
|
|
|
export const createOrganisation = async (payload: CreateOrganisationRequest): Promise<Organisation> => {
|
|
const res = await apiClient.post('/v1/api/governance/organisations', {
|
|
name: payload.name,
|
|
registrationNo: payload.registrationNo,
|
|
email: payload.email,
|
|
idNumber: payload.idNumber || null,
|
|
organisationUser: payload.organisationUser,
|
|
type: payload.type !== undefined ? Number(payload.type) : 5,
|
|
});
|
|
return res.data;
|
|
};
|
|
|
|
export const updateOrganisation = async (payload: UpdateOrganisationRequest): Promise<Organisation> => {
|
|
const orgStatus = payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active;
|
|
const res = await apiClient.put('/v1/api/governance/organisations', {
|
|
organisationId: toIntegerId(payload.organisationId),
|
|
name: payload.name,
|
|
registrationNo: payload.registrationNo,
|
|
email: payload.email,
|
|
idNumber: payload.idNumber,
|
|
organisationUser: payload.organisationUser,
|
|
type: Number(payload.type),
|
|
status: orgStatus,
|
|
});
|
|
if (isApprovedOrActiveStatus(orgStatus)) {
|
|
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
|
recordType: 'Organisation',
|
|
recordId: payload.organisationId,
|
|
newStatus: orgStatus,
|
|
title: payload.name,
|
|
}).catch(console.warn);
|
|
}
|
|
return res.data;
|
|
};
|
|
|
|
export const getOrganisationPortfolios = async (organisationId?: string | number): Promise<Portfolio[]> => {
|
|
const targetOrgId =
|
|
organisationId !== undefined && organisationId !== '' && organisationId !== null
|
|
? organisationId
|
|
: getCurrentUserTenantId();
|
|
|
|
if (targetOrgId === undefined || targetOrgId === null || targetOrgId === '') {
|
|
return [];
|
|
}
|
|
const hashedOrgId = await toolsService.resolveOrganisationId(targetOrgId);
|
|
try {
|
|
const res = await apiClient.get<Portfolio[]>(`/v1/api/governance/organisations/${hashedOrgId}/portfolio`);
|
|
return res.data || [];
|
|
} catch (err) {
|
|
if (String(targetOrgId) !== String(hashedOrgId)) {
|
|
try {
|
|
const fallbackRes = await apiClient.get<Portfolio[]>(`/v1/api/governance/organisations/${targetOrgId}/portfolio`);
|
|
return fallbackRes.data || [];
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
console.warn('[getOrganisationPortfolios] Failed to retrieve portfolios for organisation:', err);
|
|
return [];
|
|
}
|
|
};
|
|
|
|
export const getPortfolios = async (
|
|
arg?: string | number | Pagination
|
|
): Promise<Portfolio[]> => {
|
|
if (arg === undefined || arg === null || typeof arg === 'object') {
|
|
return getAllPortfolios(typeof arg === 'object' ? arg : undefined);
|
|
}
|
|
return getOrganisationPortfolios(arg);
|
|
};
|
|
|
|
export const getPortfolio = async (portfolioId: string | number): Promise<Portfolio> => {
|
|
const hashedPortId = await toolsService.resolvePortfolioId(portfolioId);
|
|
const res = await apiClient.get<Portfolio | Portfolio[]>(`/v1/api/governance/portfolios/${hashedPortId}`);
|
|
return Array.isArray(res.data) ? res.data[0] : res.data;
|
|
};
|
|
|
|
export const getAllPortfolios = async (
|
|
pagination: Pagination = { page: 1, pageSize: 100, index: 0 }
|
|
): Promise<Portfolio[]> => {
|
|
try {
|
|
const res = await apiClient.post<Portfolio[]>('/v1/api/governance/organisations/portfolios', {
|
|
index: pagination.index ? toIntegerId(pagination.index) : 0,
|
|
page: pagination.page || 1,
|
|
pageSize: pagination.pageSize || 100,
|
|
});
|
|
if (Array.isArray(res.data)) {
|
|
return res.data;
|
|
}
|
|
} catch (err) {
|
|
console.warn('[getAllPortfolios] POST /v1/api/governance/organisations/portfolios failed, attempting fallback:', err);
|
|
try {
|
|
const getRes = await apiClient.get<Portfolio[]>('/v1/api/governance/organisations/portfolios', {
|
|
params: { page: pagination.page || 1, pageSize: pagination.pageSize || 100 },
|
|
});
|
|
if (Array.isArray(getRes.data)) {
|
|
return getRes.data;
|
|
}
|
|
} catch {
|
|
const orgs = await getOrganisations(1, 10);
|
|
if (orgs.length > 0) {
|
|
const results = await Promise.allSettled(
|
|
orgs.map((org) => getOrganisationPortfolios(org.id))
|
|
);
|
|
const combined = results
|
|
.filter((r): r is PromiseFulfilledResult<Portfolio[]> => r.status === 'fulfilled')
|
|
.flatMap((r) => r.value);
|
|
if (combined.length > 0) return combined;
|
|
}
|
|
}
|
|
}
|
|
return [];
|
|
};
|
|
|
|
export const getPortfolioProgrammes = async (portfolioId: string | number): Promise<Programme[]> => {
|
|
const hashedPortId = await toolsService.resolvePortfolioId(portfolioId);
|
|
const res = await apiClient.get<Programme[]>(`/v1/api/governance/portfolios/${hashedPortId}/programmes`);
|
|
return res.data || [];
|
|
};
|
|
|
|
export const createPortfolio = async (payload: CreatePortfolioRequest): Promise<Portfolio> => {
|
|
const userSub = getCurrentUserSub();
|
|
let ownerId = payload.ownerId;
|
|
if (ownerId && typeof ownerId === 'string' && ownerId.includes('@')) {
|
|
ownerId = getUserIdByEmail(ownerId) || userSub || ownerId;
|
|
}
|
|
const tenantId = getCurrentUserTenantId();
|
|
const orgId =
|
|
payload.organisationId !== undefined && payload.organisationId !== '' && payload.organisationId !== null
|
|
? toIntegerId(payload.organisationId)
|
|
: (tenantId !== null ? tenantId : 0);
|
|
|
|
const res = await apiClient.post('/v1/api/governance/organisations/portfolio', {
|
|
name: payload.name,
|
|
description: payload.description,
|
|
organisationId: orgId,
|
|
ownerId: ownerId || userSub || '00000000-0000-0000-0000-000000000000',
|
|
});
|
|
return res.data;
|
|
};
|
|
|
|
export const updatePortfolio = async (payload: UpdatePortfolioRequest): Promise<Portfolio> => {
|
|
const userSub = getCurrentUserSub();
|
|
let ownerId = payload.ownerId;
|
|
if (ownerId && typeof ownerId === 'string' && ownerId.includes('@')) {
|
|
ownerId = getUserIdByEmail(ownerId) || userSub || ownerId;
|
|
}
|
|
const res = await apiClient.put('/v1/api/governance/organisations/portfolio', {
|
|
portfolioId: toIntegerId(payload.portfolioId),
|
|
name: payload.name,
|
|
description: payload.description,
|
|
ownerId: ownerId || userSub || '00000000-0000-0000-0000-000000000000',
|
|
});
|
|
return res.data;
|
|
};
|
|
|
|
export const getProgrammes = async (portfolioId: string | number): Promise<Programme[]> => {
|
|
const hashedPortId = await toolsService.resolvePortfolioId(portfolioId);
|
|
const res = await apiClient.get<Programme[]>(`/v1/api/governance/programmes/${hashedPortId}`);
|
|
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 => {
|
|
if (typeof window === 'undefined' || !Array.isArray(apiItems) || apiItems.length === 0) return;
|
|
try {
|
|
const raw = localStorage.getItem(LOCAL_INSTRUCTIONS_KEY);
|
|
if (!raw) return;
|
|
const local: Instrument[] = JSON.parse(raw);
|
|
if (!Array.isArray(local) || local.length === 0) return;
|
|
|
|
const apiIds = new Set(apiItems.map((i) => String(i.id)));
|
|
const apiKeys = new Set(apiItems.map((i) => `${i.programmeId}::${(i.name || '').toLowerCase().trim()}`));
|
|
|
|
const remaining = local.filter(
|
|
(l) => !apiIds.has(String(l.id)) && !apiKeys.has(`${l.programmeId}::${(l.name || '').toLowerCase().trim()}`)
|
|
);
|
|
|
|
if (remaining.length !== local.length) {
|
|
localStorage.setItem(LOCAL_INSTRUCTIONS_KEY, JSON.stringify(remaining));
|
|
}
|
|
} catch (e) {
|
|
console.warn('Failed to purge stored instruction duplicates', e);
|
|
}
|
|
};
|
|
|
|
export const getStoredInstructions = (): Instrument[] => {
|
|
if (typeof window === 'undefined') return [];
|
|
try {
|
|
const raw = localStorage.getItem(LOCAL_INSTRUCTIONS_KEY);
|
|
return raw ? JSON.parse(raw) : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
};
|
|
|
|
export const saveStoredInstruction = (inst: Instrument): void => {
|
|
if (typeof window === 'undefined') return;
|
|
try {
|
|
const current = getStoredInstructions();
|
|
const idx = current.findIndex(
|
|
(i) => String(i.id) === String(inst.id) ||
|
|
(String(i.programmeId) === String(inst.programmeId) && (i.name || '').trim().toLowerCase() === (inst.name || '').trim().toLowerCase())
|
|
);
|
|
if (idx >= 0) {
|
|
current[idx] = { ...current[idx], ...inst, updatedAt: new Date().toISOString() };
|
|
} else {
|
|
current.unshift(inst);
|
|
}
|
|
localStorage.setItem(LOCAL_INSTRUCTIONS_KEY, JSON.stringify(current));
|
|
} catch (e) {
|
|
console.warn('Failed to save stored instruction', e);
|
|
}
|
|
};
|
|
|
|
export const getInstruments = async (programmeId: string | number): Promise<Instrument[]> => {
|
|
let apiItems: Instrument[] = [];
|
|
let apiSucceeded = false;
|
|
try {
|
|
const hashedProgId = await toolsService.resolveProgrammeId(programmeId);
|
|
const res = await apiClient.get<Instrument[]>(`/v1/api/governance/instruments/${hashedProgId}`);
|
|
if (Array.isArray(res.data)) {
|
|
apiItems = res.data;
|
|
apiSucceeded = true;
|
|
}
|
|
} catch (err) {
|
|
console.warn(`[getInstruments] Could not fetch instruments from API for prog ${programmeId}:`, err);
|
|
}
|
|
|
|
if (apiSucceeded) {
|
|
purgeStoredInstructionDuplicates(apiItems);
|
|
return apiItems;
|
|
}
|
|
|
|
const localItems = getStoredInstructions().filter(
|
|
(item) => String(item.programmeId) === String(programmeId)
|
|
);
|
|
return localItems;
|
|
};
|
|
|
|
export const getInstructions = getInstruments;
|
|
|
|
export const getAllInstruments = async (programmesList?: Programme[]): Promise<Instrument[]> => {
|
|
let progList = programmesList;
|
|
if (!progList || progList.length === 0) {
|
|
try {
|
|
progList = await getAllProgrammes(1, 100);
|
|
} catch {
|
|
progList = [];
|
|
}
|
|
}
|
|
|
|
const map = new Map<string, Instrument>();
|
|
let hasApiData = false;
|
|
|
|
if (progList && progList.length > 0) {
|
|
const results = await Promise.allSettled(
|
|
progList.map((pr) => getInstruments(pr.id))
|
|
);
|
|
results.forEach((res) => {
|
|
if (res.status === 'fulfilled' && Array.isArray(res.value) && res.value.length > 0) {
|
|
hasApiData = true;
|
|
res.value.forEach((inst) => map.set(String(inst.id), inst));
|
|
}
|
|
});
|
|
}
|
|
|
|
if (hasApiData || map.size > 0) {
|
|
const allApiInstruments = Array.from(map.values());
|
|
purgeStoredInstructionDuplicates(allApiInstruments);
|
|
return allApiInstruments;
|
|
}
|
|
|
|
return getStoredInstructions();
|
|
};
|
|
|
|
export const getAllInstructions = getAllInstruments;
|
|
|
|
export const createInstrument = async (payload: CreateInstrumentRequest): Promise<Instrument> => {
|
|
const code = payload.code || `INST-${Date.now().toString().slice(-4)}`;
|
|
const body = {
|
|
programmeId: toIntegerId(payload.programmeId),
|
|
name: payload.name,
|
|
code: code,
|
|
description: payload.description,
|
|
};
|
|
|
|
try {
|
|
const res = await apiClient.post<Instrument>('/v1/api/governance/instruments', body);
|
|
if (res.data && res.data.id) {
|
|
purgeStoredInstructionDuplicates([res.data]);
|
|
return res.data;
|
|
}
|
|
} catch (err: any) {
|
|
console.warn('[createInstrument] API create failed, falling back to local storage:', err);
|
|
const newId = Date.now();
|
|
const fallbackInstruction: Instrument = {
|
|
id: newId,
|
|
programmeId: toIntegerId(payload.programmeId),
|
|
code: code,
|
|
name: payload.name,
|
|
description: payload.description,
|
|
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
|
|
createdBy: payload.userId || 'system',
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
saveStoredInstruction(fallbackInstruction);
|
|
return fallbackInstruction;
|
|
}
|
|
|
|
return {
|
|
id: Date.now(),
|
|
programmeId: toIntegerId(payload.programmeId),
|
|
code: code,
|
|
name: payload.name,
|
|
description: payload.description,
|
|
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
|
|
createdBy: payload.userId || 'system',
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
};
|
|
|
|
export const createInstruction = createInstrument;
|
|
|
|
export const updateInstrument = async (payload: UpdateInstrumentRequest): Promise<Instrument> => {
|
|
const targetId = payload.instructionId ?? payload.instrumentId ?? payload.id;
|
|
const numId = toIntegerId(targetId);
|
|
|
|
const body = {
|
|
instructionId: numId,
|
|
code: payload.code || '',
|
|
name: payload.name,
|
|
description: payload.description,
|
|
status: Number(payload.status),
|
|
};
|
|
|
|
try {
|
|
const res = await apiClient.put<Instrument>('/v1/api/governance/instruments', body);
|
|
if (res.data && res.data.id) {
|
|
return res.data;
|
|
}
|
|
} catch (err) {
|
|
console.warn('[updateInstrument] API update failed:', err);
|
|
}
|
|
|
|
const existing = getStoredInstructions().find((i) => String(i.id) === String(targetId));
|
|
const updatedInstrument: Instrument = {
|
|
id: targetId!,
|
|
programmeId: payload.programmeId ? toIntegerId(payload.programmeId) : (existing?.programmeId || 0),
|
|
code: payload.code || existing?.code || null,
|
|
name: payload.name,
|
|
description: payload.description,
|
|
status: Number(payload.status),
|
|
createdBy: existing?.createdBy || 'system',
|
|
createdAt: existing?.createdAt || new Date().toISOString(),
|
|
updatedBy: payload.userId || 'system',
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
if (existing) {
|
|
saveStoredInstruction(updatedInstrument);
|
|
}
|
|
if (isApprovedOrActiveStatus(payload.status)) {
|
|
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
|
recordType: 'Funding Instrument',
|
|
recordId: targetId!,
|
|
newStatus: payload.status,
|
|
title: payload.name,
|
|
}).catch(console.warn);
|
|
}
|
|
return updatedInstrument;
|
|
};
|
|
|
|
export const updateInstruction = updateInstrument;
|
|
|
|
// ==========================================
|
|
// 3. Project & Milestone Management APIs
|
|
// ==========================================
|
|
export const getAllProgrammes = async (page = 1, pageSize = 100): Promise<Programme[]> => {
|
|
const res = await apiClient.get<Programme[]>(`/v1/api/projects/programme/${page}/${pageSize}`);
|
|
return res.data || [];
|
|
};
|
|
|
|
export const getProgramme = async (programmeId: string | number): Promise<Programme> => {
|
|
const hashedProgId = await toolsService.resolveProgrammeId(programmeId);
|
|
const res = await apiClient.get<Programme>(`/v1/api/projects/programme/${hashedProgId}`);
|
|
return res.data;
|
|
};
|
|
|
|
export const getProgrammeProjects = async (programmeId: string | number): Promise<Project[]> => {
|
|
const hashedProgId = await toolsService.resolveProgrammeId(programmeId);
|
|
const res = await apiClient.get<Project[]>(`/v1/api/projects/${hashedProgId}/programme`);
|
|
return res.data || [];
|
|
};
|
|
|
|
export const createProgramme = async (payload: CreateProgrammeRequest): Promise<Programme> => {
|
|
const userSub = getCurrentUserSub();
|
|
let ownerId = payload.ownerId;
|
|
if (ownerId && typeof ownerId === 'string' && ownerId.includes('@')) {
|
|
ownerId = getUserIdByEmail(ownerId) || userSub || ownerId;
|
|
}
|
|
const res = await apiClient.post('/v1/api/projects/programme', {
|
|
portfolioId: toIntegerId(payload.portfolioId),
|
|
name: payload.name,
|
|
ownerId: ownerId || userSub || '00000000-0000-0000-0000-000000000000',
|
|
});
|
|
return res.data;
|
|
};
|
|
|
|
export const updateProgramme = async (payload: UpdateProgrammeRequest): Promise<Programme> => {
|
|
const userSub = getCurrentUserSub();
|
|
let ownerId = payload.ownerId;
|
|
if (ownerId && typeof ownerId === 'string' && ownerId.includes('@')) {
|
|
ownerId = getUserIdByEmail(ownerId) || userSub || ownerId;
|
|
}
|
|
const progStatus = Number(payload.status) || 1;
|
|
const res = await apiClient.put('/v1/api/projects/programme', {
|
|
programmeId: toIntegerId(payload.programmeId),
|
|
name: payload.name,
|
|
ownerId: ownerId || userSub || '00000000-0000-0000-0000-000000000000',
|
|
status: progStatus,
|
|
});
|
|
if (isApprovedOrActiveStatus(progStatus)) {
|
|
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
|
recordType: 'Programme',
|
|
recordId: payload.programmeId,
|
|
newStatus: progStatus,
|
|
title: payload.name,
|
|
}).catch(console.warn);
|
|
}
|
|
return res.data;
|
|
};
|
|
|
|
export const getProjects = async (page = 1, pageSize = 100): Promise<Project[]> => {
|
|
const res = await apiClient.get<Project[]>('/v1/api/projects', {
|
|
params: { page, pageSize },
|
|
});
|
|
return res.data || [];
|
|
};
|
|
|
|
export const getProject = async (projectId: string | number): Promise<Project> => {
|
|
const hashedProjectId = await toolsService.resolveProjectId(projectId);
|
|
const res = await apiClient.get<Project | Project[]>(`/v1/api/projects/${hashedProjectId}`);
|
|
return Array.isArray(res.data) ? res.data[0] : res.data;
|
|
};
|
|
|
|
export const createProject = async (payload: CreateProjectRequest): Promise<Project> => {
|
|
const res = await apiClient.post('/v1/api/projects', {
|
|
programmeId: toIntegerId(payload.programmeId),
|
|
name: payload.name,
|
|
description: payload.description,
|
|
sector: Number(payload.sector),
|
|
projectType: Number(payload.projectType),
|
|
});
|
|
return res.data;
|
|
};
|
|
|
|
export const updateProject = async (payload: UpdateProjectRequest): Promise<Project> => {
|
|
const startedAt = payload.startedAt
|
|
? (payload.startedAt.includes('T') ? payload.startedAt : new Date(payload.startedAt).toISOString())
|
|
: null;
|
|
const endedAt = payload.endedAt
|
|
? (payload.endedAt.includes('T') ? payload.endedAt : new Date(payload.endedAt).toISOString())
|
|
: null;
|
|
|
|
const res = await apiClient.put('/v1/api/projects', {
|
|
projectId: toIntegerId(payload.projectId),
|
|
name: payload.name,
|
|
description: payload.description,
|
|
startedAt,
|
|
endedAt,
|
|
sector: Number(payload.sector),
|
|
projectType: Number(payload.projectType),
|
|
});
|
|
return res.data;
|
|
};
|
|
|
|
export const updateProjectStatus = async (payload: UpdateProjectStatusRequest): Promise<void> => {
|
|
await apiClient.put('/v1/api/projects/status', {
|
|
projectId: toIntegerId(payload.projectId),
|
|
status: Number(payload.status),
|
|
});
|
|
if (isApprovedOrActiveStatus(payload.status)) {
|
|
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
|
recordType: 'Project',
|
|
recordId: payload.projectId,
|
|
newStatus: payload.status,
|
|
}).catch(console.warn);
|
|
}
|
|
};
|
|
|
|
export const getActivities = async (projectId: string | number): Promise<Activity[]> => {
|
|
const hashedProjectId = await toolsService.resolveProjectId(projectId);
|
|
try {
|
|
const res = await apiClient.get<Activity[]>(`/v1/api/projects/${hashedProjectId}/activities`);
|
|
return res.data || [];
|
|
} catch (err: any) {
|
|
if (String(projectId) !== String(hashedProjectId)) {
|
|
try {
|
|
const rawRes = await apiClient.get<Activity[]>(`/v1/api/projects/${projectId}/activities`);
|
|
return rawRes.data || [];
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
if (err?.response?.status === 404) return [];
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
export const getActivity = async (activityId: string | number): Promise<Activity> => {
|
|
const hashedActivityId = await toolsService.encodeId(activityId);
|
|
const res = await apiClient.get<Activity>(`/v1/api/projects/activities/${hashedActivityId}`);
|
|
return res.data;
|
|
};
|
|
|
|
export const createActivity = async (payload: CreateActivityRequest): Promise<Activity> => {
|
|
const performedAt = payload.performedAt
|
|
? (payload.performedAt.includes('T') ? payload.performedAt : new Date(payload.performedAt).toISOString())
|
|
: null;
|
|
|
|
const body = {
|
|
projectId: toIntegerId(payload.projectId),
|
|
milestoneId: toNullableIntegerId(payload.milestoneId),
|
|
category: payload.category || 'Operations',
|
|
amount: Number(payload.amount) || 0,
|
|
title: payload.title,
|
|
description: payload.description || '',
|
|
performedAt,
|
|
};
|
|
const res = await apiClient.post('/v1/api/projects/activities', body);
|
|
return res.data;
|
|
};
|
|
|
|
export const updateActivity = async (payload: UpdateActivityRequest): Promise<Activity> => {
|
|
const performedAt = payload.performedAt
|
|
? (payload.performedAt.includes('T') ? payload.performedAt : new Date(payload.performedAt).toISOString())
|
|
: null;
|
|
|
|
const body = {
|
|
activityId: toIntegerId(payload.activityId),
|
|
projectId: toIntegerId(payload.projectId),
|
|
milestoneId: toNullableIntegerId(payload.milestoneId),
|
|
category: payload.category || 'Operations',
|
|
amount: Number(payload.amount) || 0,
|
|
title: payload.title,
|
|
description: payload.description || '',
|
|
performedAt,
|
|
};
|
|
try {
|
|
const res = await apiClient.put('/v1/api/projects/activities', body);
|
|
return res.data;
|
|
} catch (err: any) {
|
|
if (err?.response?.status === 404 || err?.response?.status === 405) {
|
|
const res = await apiClient.put('/v1/api/projects/activity', body);
|
|
return res.data;
|
|
}
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
|
|
export const submitActivityForApproval = async (
|
|
activity: Activity,
|
|
projectId: number | string,
|
|
evidences?: Evidence[]
|
|
): Promise<void> => {
|
|
if (evidences && evidences.length > 0) {
|
|
for (const ev of evidences) {
|
|
if (Number(ev.status) === ApprovalStatus.Pending) {
|
|
try {
|
|
await updateEvidence({
|
|
evidenceId: ev.id,
|
|
version: ev.version || 1,
|
|
evidenceType: (ev as any).evidenceType ?? ev.type,
|
|
status: ApprovalStatus.Review,
|
|
});
|
|
} catch (e) {
|
|
console.warn('Could not update evidence status', ev.id, e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
try {
|
|
await updateActivity({
|
|
activityId: activity.id,
|
|
projectId,
|
|
milestoneId: activity.milestoneId,
|
|
category: activity.category || 'Operations',
|
|
amount: activity.amount || 0,
|
|
title: activity.title || 'Activity',
|
|
description: activity.description || '',
|
|
performedAt: new Date().toISOString(),
|
|
status: ApprovalStatus.Review,
|
|
});
|
|
} catch (e) {
|
|
console.warn('Could not update activity fulfillment record', e);
|
|
}
|
|
};
|
|
|
|
export const getMilestones = async (projectId: string | number): Promise<Milestone[]> => {
|
|
const hashedProjectId = await toolsService.resolveProjectId(projectId);
|
|
try {
|
|
const res = await apiClient.get<Milestone[]>(`/v1/api/projects/${hashedProjectId}/milestones`);
|
|
return res.data || [];
|
|
} catch (err: any) {
|
|
if (String(projectId) !== String(hashedProjectId)) {
|
|
try {
|
|
const rawRes = await apiClient.get<Milestone[]>(`/v1/api/projects/${projectId}/milestones`);
|
|
return rawRes.data || [];
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
if (err?.response?.status === 404) return [];
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
export const getAllMilestones = async (page = 1, pageSize = 100): Promise<Milestone[]> => {
|
|
const res = await apiClient.get<Milestone[]>(`/v1/api/projects/milestones/${page}/${pageSize}`);
|
|
return res.data || [];
|
|
};
|
|
|
|
export const getMilestone = async (milestoneId: string | number): Promise<Milestone> => {
|
|
const hashedMilestoneId = await toolsService.encodeId(milestoneId);
|
|
const res = await apiClient.get<Milestone>(`/v1/api/projects/milestones/${hashedMilestoneId}`);
|
|
return res.data;
|
|
};
|
|
|
|
export const createMilestone = async (payload: CreateMilestoneRequest): Promise<Milestone> => {
|
|
const dueAtIso = payload.dueAt.includes('T')
|
|
? payload.dueAt
|
|
: new Date(payload.dueAt).toISOString();
|
|
|
|
const res = await apiClient.post('/v1/api/evidence/milestones', {
|
|
projectId: toIntegerId(payload.projectId),
|
|
dueAt: dueAtIso,
|
|
name: payload.name,
|
|
priority: Number(payload.priority) || 1,
|
|
});
|
|
return res.data;
|
|
};
|
|
|
|
export const updateMilestone = async (payload: UpdateMilestoneRequest): Promise<void> => {
|
|
const dueAtIso = payload.dueAt.includes('T')
|
|
? payload.dueAt
|
|
: new Date(payload.dueAt).toISOString();
|
|
|
|
await apiClient.put('/v1/api/projects/milestone', {
|
|
milestoneId: toIntegerId(payload.milestoneId),
|
|
dueAt: dueAtIso,
|
|
name: payload.name,
|
|
priority: Number(payload.priority) || 1,
|
|
status: Number(payload.status) || 1,
|
|
});
|
|
if (isApprovedOrActiveStatus(payload.status)) {
|
|
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
|
recordType: 'Milestone',
|
|
recordId: payload.milestoneId,
|
|
newStatus: payload.status,
|
|
title: payload.name,
|
|
}).catch(console.warn);
|
|
}
|
|
};
|
|
|
|
export const getProjectIndicators = async (projectId: string | number): Promise<Indicator[]> => {
|
|
const hashedProjectId = await toolsService.resolveProjectId(projectId);
|
|
try {
|
|
const res = await apiClient.get<Indicator[]>(`/v1/api/projects/indicators/project/${hashedProjectId}`);
|
|
return res.data || [];
|
|
} catch (err: any) {
|
|
if (err?.response?.status === 404) return [];
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
export const getProjectRisks = async (projectId: string | number): Promise<Risk[]> => {
|
|
const hashedProjectId = await toolsService.resolveProjectId(projectId);
|
|
try {
|
|
const res = await apiClient.get<any[]>(`/v1/api/projects/${hashedProjectId}/risks`);
|
|
const list = res.data || [];
|
|
return list.map(normalizeRisk);
|
|
} catch (err: any) {
|
|
if (err?.response?.status === 404) return [];
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
// ==========================================
|
|
// 4. Grant & Financial Management APIs
|
|
// ==========================================
|
|
export const getAwards = async (page = 1, pageSize = 100): Promise<Award[]> => {
|
|
const res = await apiClient.get<Award[]>('/v1/api/grants/awards', {
|
|
params: { page, pageSize },
|
|
});
|
|
return res.data || [];
|
|
};
|
|
|
|
export const getAward = async (awardId: string | number): Promise<Award> => {
|
|
const hashedAwardId = await toolsService.resolveAwardId(awardId);
|
|
const res = await apiClient.get<Award>(`/v1/api/grants/awards/${hashedAwardId}`);
|
|
return res.data;
|
|
};
|
|
|
|
export const createAward = async (payload: CreateAwardRequest): Promise<Award> => {
|
|
const tenantId = getCurrentUserTenantId();
|
|
const orgId =
|
|
payload.organisationId !== undefined && payload.organisationId !== '' && payload.organisationId !== null
|
|
? toIntegerId(payload.organisationId)
|
|
: (tenantId !== null ? tenantId : 0);
|
|
|
|
const res = await apiClient.post('/v1/api/grants/awards', {
|
|
organisationId: orgId,
|
|
programmeId: toIntegerId(payload.programmeId),
|
|
amount: Number(payload.amount),
|
|
});
|
|
return res.data;
|
|
};
|
|
|
|
export const updateAward = async (payload: UpdateAwardRequest): Promise<void> => {
|
|
const numericAwardId = toIntegerId(payload.awardId);
|
|
const numericOrgId = toIntegerId(payload.organisationId);
|
|
const numericProgId = toIntegerId(payload.programmeId);
|
|
|
|
await apiClient.put('/v1/api/grants/awards', {
|
|
awardId: numericAwardId,
|
|
organisationId: numericOrgId,
|
|
programmeId: numericProgId,
|
|
amount: Number(payload.amount),
|
|
...(payload.status !== undefined ? { status: Number(payload.status) } : {}),
|
|
});
|
|
if (payload.status === undefined || isApprovedOrActiveStatus(payload.status)) {
|
|
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
|
recordType: 'Award',
|
|
recordId: payload.awardId,
|
|
newStatus: payload.status !== undefined ? payload.status : ApprovalStatus.Approved,
|
|
}).catch(console.warn);
|
|
}
|
|
};
|
|
|
|
export const fetchHashId = async (id64: number | string): Promise<string> => {
|
|
try {
|
|
const res = await apiClient.get<string>(`/v1/api/tools/hashid/${id64}`);
|
|
if (res.data && typeof res.data === 'string') {
|
|
return res.data;
|
|
}
|
|
} catch {
|
|
// Fall back to client-side deterministic Hashids
|
|
}
|
|
return toolsService.encodeIdSync(id64);
|
|
};
|
|
|
|
export const getContracts = async (page = 1, pageSize = 100): Promise<Contract[]> => {
|
|
const res = await apiClient.get<Contract[]>('/v1/api/grants/contracts', {
|
|
params: { page, pageSize },
|
|
});
|
|
return res.data || [];
|
|
};
|
|
|
|
export const getContract = async (contractId: string | number): Promise<Contract> => {
|
|
const hashedContractId = await toolsService.resolveContractId(contractId);
|
|
const res = await apiClient.get<Contract>(`/v1/api/grants/contracts/${hashedContractId}`);
|
|
return res.data;
|
|
};
|
|
|
|
export const createContract = async (payload: CreateContractRequest): Promise<Contract> => {
|
|
let numericAwardId: number = Number(payload.awardId);
|
|
if (isNaN(numericAwardId) || !/^-?\d+$/.test(String(payload.awardId))) {
|
|
const decoded = toolsService.decodeIdSync(String(payload.awardId));
|
|
if (decoded && /^-?\d+$/.test(String(decoded))) {
|
|
numericAwardId = Number(decoded);
|
|
}
|
|
}
|
|
|
|
const effectiveAtIso = payload.effectiveAt.includes('T')
|
|
? payload.effectiveAt
|
|
: new Date(payload.effectiveAt).toISOString();
|
|
const expiresAtIso = payload.expiresAt.includes('T')
|
|
? payload.expiresAt
|
|
: new Date(payload.expiresAt).toISOString();
|
|
|
|
const res = await apiClient.post('/v1/api/grants/contracts', {
|
|
awardId: !isNaN(numericAwardId) ? numericAwardId : payload.awardId,
|
|
totalValue: Number(payload.totalValue),
|
|
effectiveAt: effectiveAtIso,
|
|
expiresAt: expiresAtIso,
|
|
});
|
|
|
|
const locationHeader = res.headers?.['location'] || res.headers?.['Location'];
|
|
let headerId: string | undefined;
|
|
if (locationHeader && typeof locationHeader === 'string') {
|
|
const parts = locationHeader.split('/');
|
|
headerId = parts[parts.length - 1];
|
|
}
|
|
|
|
const hashedAwardId = toolsService.encodeIdSync(numericAwardId || payload.awardId);
|
|
|
|
if (typeof res.data === 'string' && res.data.trim().length > 0) {
|
|
const rawStr = res.data.trim();
|
|
return {
|
|
id: rawStr,
|
|
contractId: rawStr,
|
|
awardId: hashedAwardId,
|
|
totalValue: Number(payload.totalValue),
|
|
effectiveAt: effectiveAtIso,
|
|
expiresAt: expiresAtIso,
|
|
status: ContractStatus.Draft,
|
|
} as unknown as Contract;
|
|
}
|
|
|
|
if (res.data && typeof res.data === 'object') {
|
|
const candidateId =
|
|
res.data.id ??
|
|
res.data.contractId ??
|
|
res.data.Id ??
|
|
res.data.ContractId ??
|
|
headerId;
|
|
if (candidateId) {
|
|
return {
|
|
...res.data,
|
|
id: candidateId,
|
|
contractId: candidateId,
|
|
awardId: res.data.awardId || hashedAwardId,
|
|
};
|
|
}
|
|
}
|
|
|
|
if (headerId) {
|
|
return {
|
|
id: headerId,
|
|
contractId: headerId,
|
|
awardId: hashedAwardId,
|
|
totalValue: Number(payload.totalValue),
|
|
effectiveAt: effectiveAtIso,
|
|
expiresAt: expiresAtIso,
|
|
status: ContractStatus.Draft,
|
|
} as unknown as Contract;
|
|
}
|
|
|
|
try {
|
|
const existing = await getContracts(1, 10);
|
|
const matched = existing.find(
|
|
(c) =>
|
|
String(c.awardId) === String(hashedAwardId) ||
|
|
String(c.awardId) === String(numericAwardId) ||
|
|
String(c.awardId) === String(payload.awardId)
|
|
);
|
|
if (matched) return matched;
|
|
} catch {}
|
|
|
|
return (res.data || {
|
|
id: hashedAwardId,
|
|
contractId: hashedAwardId,
|
|
awardId: hashedAwardId,
|
|
totalValue: Number(payload.totalValue),
|
|
effectiveAt: effectiveAtIso,
|
|
expiresAt: expiresAtIso,
|
|
status: ContractStatus.Draft,
|
|
}) as unknown as Contract;
|
|
};
|
|
|
|
export const updateContract = async (payload: UpdateContractRequest): Promise<void> => {
|
|
await apiClient.put('/v1/api/grants/contracts', {
|
|
contractId: toIntegerId(payload.contractId),
|
|
status: Number(payload.status),
|
|
});
|
|
if (isApprovedOrActiveStatus(payload.status)) {
|
|
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
|
recordType: 'Contract',
|
|
recordId: payload.contractId,
|
|
newStatus: payload.status,
|
|
}).catch(console.warn);
|
|
}
|
|
};
|
|
|
|
export const uploadContractFile = async (
|
|
contractId: string | number,
|
|
file: File | FormData
|
|
): Promise<void> => {
|
|
const hashedContractId = await toolsService.resolveContractId(contractId);
|
|
const formData = file instanceof FormData ? file : new FormData();
|
|
if (file instanceof File) {
|
|
formData.append('file', file);
|
|
}
|
|
await apiClient.post(`/v1/api/grants/contracts/${hashedContractId}`, formData, {
|
|
headers: { 'Content-Type': 'multipart/form-data' },
|
|
});
|
|
};
|
|
|
|
export const getContractSecureLink = async (contractId: string | number): Promise<string> => {
|
|
const hashedContractId = await toolsService.resolveContractId(contractId);
|
|
const res = await apiClient.get<string | { url?: string; link?: string }>(
|
|
`/v1/api/grants/contracts/${hashedContractId}/securelink`
|
|
);
|
|
if (typeof res.data === 'string') return res.data;
|
|
return (res.data as any)?.url || (res.data as any)?.link || String(res.data);
|
|
};
|
|
|
|
export const executeContract = async (payload: ExecuteContractRequest): Promise<void> => {
|
|
await apiClient.post('/v1/api/grants/contracts/execute', {
|
|
contractId: toIntegerId(payload.contractId),
|
|
externalSignatureId: payload.externalSignatureId,
|
|
signedDocumentUrl: payload.signedDocumentUrl,
|
|
});
|
|
};
|
|
|
|
export const getBudgets = async (page = 1, pageSize = 100): Promise<Budget[]> => {
|
|
const res = await apiClient.get<Budget[]>('/v1/api/grants/budgets', {
|
|
params: { page, pageSize },
|
|
});
|
|
return res.data || [];
|
|
};
|
|
|
|
export const getBudget = async (budgetId: string | number): Promise<Budget> => {
|
|
const hashedBudgetId = await toolsService.encodeId(budgetId);
|
|
const res = await apiClient.get<Budget>(`/v1/api/grants/budgets/${hashedBudgetId}`);
|
|
return res.data;
|
|
};
|
|
|
|
export const createBudget = async (payload: CreateBudgetRequest): Promise<Budget> => {
|
|
const res = await apiClient.post('/v1/api/grants/budgets', {
|
|
projectId: toIntegerId(payload.projectId),
|
|
name: payload.name,
|
|
amount: Number(payload.amount),
|
|
});
|
|
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),
|
|
});
|
|
if (isApprovedOrActiveStatus(payload.status)) {
|
|
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
|
recordType: 'Budget',
|
|
recordId: payload.budgetId,
|
|
newStatus: payload.status,
|
|
}).catch(console.warn);
|
|
}
|
|
};
|
|
|
|
// ==========================================
|
|
// 5. Execution (Disbursements & Risks) APIs
|
|
// ==========================================
|
|
export const getDisbursements = async (page = 1, pageSize = 100): Promise<Disbursement[]> => {
|
|
try {
|
|
const res = await apiClient.get<Disbursement[]>('/v1/api/execution/disbursements', {
|
|
params: { page, pageSize },
|
|
});
|
|
return res.data || [];
|
|
} catch (err: any) {
|
|
if (err?.response?.status === 404) return [];
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
export const getDisbursement = async (disbursementId: string | number): Promise<Disbursement> => {
|
|
const hashedDisbId = await toolsService.encodeId(disbursementId);
|
|
const res = await apiClient.get<Disbursement>(`/v1/api/execution/disbursements/${hashedDisbId}`);
|
|
return res.data;
|
|
};
|
|
|
|
export const createDisbursement = async (payload: CreateDisbursementRequest): Promise<Disbursement> => {
|
|
const res = await apiClient.post('/v1/api/execution/disbursements', {
|
|
projectId: toIntegerId(payload.projectId),
|
|
milestoneId: toNullableIntegerId(payload.milestoneId),
|
|
amount: Number(payload.amount),
|
|
});
|
|
return res.data;
|
|
};
|
|
|
|
export const updateDisbursement = async (payload: UpdateDisbursementRequest): Promise<void> => {
|
|
await apiClient.put('/v1/api/execution/disbursements', {
|
|
disbursementId: toIntegerId(payload.disbursementId),
|
|
milestoneId: toNullableIntegerId(payload.milestoneId),
|
|
amount: Number(payload.amount),
|
|
});
|
|
};
|
|
|
|
export const approveDisbursement = async (disbursementId: string | number): Promise<void> => {
|
|
const hashedDisbId = await toolsService.encodeId(disbursementId);
|
|
await apiClient.post(`/v1/api/execution/disbursements/${hashedDisbId}/approve`, {});
|
|
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
|
recordType: 'Disbursement',
|
|
recordId: disbursementId,
|
|
newStatus: ApprovalStatus.Approved,
|
|
}).catch(console.warn);
|
|
};
|
|
|
|
export const normalizeRisk = (raw: any): Risk => {
|
|
if (!raw) return raw;
|
|
|
|
const rawLik = raw.likelihood ?? raw.Likelihood ?? raw.riskLikelihood ?? raw.RiskLikelihood;
|
|
const rawImp = raw.impact ?? raw.Impact ?? raw.riskImpact ?? raw.RiskImpact;
|
|
|
|
const parseScore = (val: any, defaultVal: number): number => {
|
|
if (typeof val === 'number' && !isNaN(val)) {
|
|
return Math.min(5, Math.max(1, Math.round(val)));
|
|
}
|
|
if (typeof val === 'string') {
|
|
const parsed = parseInt(val, 10);
|
|
if (!isNaN(parsed) && parsed >= 1 && parsed <= 5) {
|
|
return parsed;
|
|
}
|
|
const lower = val.toLowerCase().trim();
|
|
if (lower.includes('rare') || lower.includes('insignificant') || lower.includes('trivial') || lower === '1') return 1;
|
|
if (lower.includes('unlikely') || lower.includes('minor') || lower === '2') return 2;
|
|
if (lower.includes('possible') || lower.includes('moderate') || lower.includes('medium') || lower === '3') return 3;
|
|
if (lower.includes('likely') || lower.includes('major') || lower.includes('high') || lower === '4') return 4;
|
|
if (lower.includes('almost') || lower.includes('certain') || lower.includes('severe') || lower.includes('critical') || lower.includes('catastrophic') || lower === '5') return 5;
|
|
}
|
|
return defaultVal;
|
|
};
|
|
|
|
const likelihood = parseScore(rawLik, 3);
|
|
const impact = parseScore(rawImp, 2);
|
|
|
|
return {
|
|
...raw,
|
|
id: raw.id ?? raw.Id ?? raw.riskId ?? raw.RiskId,
|
|
projectId: raw.projectId ?? raw.ProjectId,
|
|
name: raw.name ?? raw.Name ?? null,
|
|
description: raw.description ?? raw.Description ?? null,
|
|
likelihood,
|
|
impact,
|
|
createdBy: raw.createdBy ?? raw.CreatedBy ?? '',
|
|
createdAt: raw.createdAt ?? raw.CreatedAt ?? new Date().toISOString(),
|
|
owner: raw.owner ?? raw.Owner ?? raw.riskOwner ?? raw.RiskOwner ?? raw.createdBy ?? raw.CreatedBy ?? '',
|
|
mitigationAction: raw.mitigationAction ?? raw.MitigationAction ?? raw.mitigation ?? raw.Mitigation ?? '',
|
|
status: raw.status ?? raw.Status,
|
|
};
|
|
};
|
|
|
|
export const getAllRisks = async (page = 1, pageSize = 100): Promise<Risk[]> => {
|
|
const res = await apiClient.get<any[]>('/v1/api/execution/risks', {
|
|
params: { page, pageSize },
|
|
});
|
|
const list = res.data || [];
|
|
return list.map(normalizeRisk);
|
|
};
|
|
|
|
export const getRisk = async (riskId: string | number): Promise<Risk> => {
|
|
const hashedRiskId = await toolsService.encodeId(riskId);
|
|
const res = await apiClient.get<any>(`/v1/api/execution/risks/${hashedRiskId}`);
|
|
return normalizeRisk(res.data);
|
|
};
|
|
|
|
export const createRisk = async (payload: CreateRiskRequest): Promise<Risk> => {
|
|
const res = await apiClient.post('/v1/api/execution/risks', {
|
|
projectId: toIntegerId(payload.projectId),
|
|
likelihood: Number(payload.likelihood),
|
|
impact: Number(payload.impact),
|
|
name: payload.name,
|
|
description: payload.description || '',
|
|
});
|
|
return normalizeRisk(res.data);
|
|
};
|
|
|
|
export const updateRisk = async (payload: UpdateRiskRequest): Promise<void> => {
|
|
await apiClient.put('/v1/api/execution/risks', {
|
|
riskId: toIntegerId(payload.riskId),
|
|
likelihood: Number(payload.likelihood),
|
|
impact: Number(payload.impact),
|
|
name: payload.name,
|
|
description: payload.description || '',
|
|
});
|
|
};
|
|
|
|
// ==========================================
|
|
// 6. Evidence & M&E APIs
|
|
// ==========================================
|
|
export const getAllIndicators = async (page = 1, pageSize = 100): Promise<Indicator[]> => {
|
|
const res = await apiClient.get<Indicator[]>(`/v1/api/evidence/indicators/${page}/${pageSize}`);
|
|
return res.data || [];
|
|
};
|
|
|
|
export const getIndicator = async (indicatorId: string | number): Promise<Indicator> => {
|
|
const hashedIndId = await toolsService.encodeId(indicatorId);
|
|
const res = await apiClient.get<Indicator>(`/v1/api/evidence/indicators/${hashedIndId}`);
|
|
return res.data;
|
|
};
|
|
|
|
export const createIndicator = async (payload: CreateIndicatorRequest): Promise<Indicator> => {
|
|
const res = await apiClient.post('/v1/api/evidence/indicators', {
|
|
projectId: toIntegerId(payload.projectId),
|
|
activityId: toNullableIntegerId(payload.activityId),
|
|
name: payload.name,
|
|
unitOfMeasure: Number(payload.unitOfMeasure) || 1,
|
|
baselineAmount: Number(payload.baselineAmount) || 0,
|
|
targetAmount: Number(payload.targetAmount) || 0,
|
|
targetCost: Number(payload.targetCost) || 0,
|
|
});
|
|
return res.data;
|
|
};
|
|
|
|
export const recordIndicatorActual = async (indicatorId: string | number, actualAmount: number): Promise<void> => {
|
|
const hashedIndId = await toolsService.encodeId(indicatorId);
|
|
await apiClient.post(`/v1/api/evidence/indicators/${hashedIndId}/actuals/${actualAmount}`, {});
|
|
};
|
|
|
|
export const getEvidenceFiles = async (page = 1, pageSize = 100): Promise<Evidence[]> => {
|
|
try {
|
|
const res = await apiClient.get<Evidence[]>(`/v1/api/evidence/files/${page}/${pageSize}`);
|
|
return res.data || [];
|
|
} catch (err: any) {
|
|
if (err?.response?.status === 404) {
|
|
return [];
|
|
}
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
export const getAllEvidence = async (page = 1, pageSize = 100): Promise<Evidence[]> => {
|
|
return getEvidenceFiles(page, pageSize);
|
|
};
|
|
|
|
export const getEvidence = async (evidenceId: string | number): Promise<Evidence> => {
|
|
const hashedEvId = await toolsService.encodeId(evidenceId);
|
|
const res = await apiClient.get<Evidence>(`/v1/api/evidence/${hashedEvId}`);
|
|
return res.data;
|
|
};
|
|
|
|
export const getEvidenceSecureLink = async (evidenceId: string | number): Promise<string> => {
|
|
const hashedEvId = await toolsService.encodeId(evidenceId);
|
|
const res = await apiClient.get<string | { url?: string; link?: string }>(`/v1/api/evidence/${hashedEvId}/securelink`);
|
|
if (typeof res.data === 'string') return res.data;
|
|
return (res.data as any)?.url || (res.data as any)?.link || String(res.data);
|
|
};
|
|
|
|
export const getMilestoneEvidence = async (milestoneId: string | number): Promise<Evidence[]> => {
|
|
try {
|
|
const hashedMId = await toolsService.encodeId(milestoneId);
|
|
const res = await apiClient.get<Evidence[]>(`/v1/api/evidence/milestone/${hashedMId}`);
|
|
return res.data || [];
|
|
} catch (err: any) {
|
|
if (err?.response?.status === 404) {
|
|
return [];
|
|
}
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
export const getProjectEvidence = async (projectId: string | number): Promise<Evidence[]> => {
|
|
try {
|
|
const hashedProjectId = await toolsService.resolveProjectId(projectId);
|
|
const res = await apiClient.get<Evidence[]>(`/v1/api/evidence/projects/${hashedProjectId}`);
|
|
return res.data || [];
|
|
} catch (err: any) {
|
|
if ((err?.response?.status === 404 || err?.response?.status === 400) && String(projectId) !== '') {
|
|
try {
|
|
const rawRes = await apiClient.get<Evidence[]>(`/v1/api/evidence/projects/${projectId}`);
|
|
return rawRes.data || [];
|
|
} catch (fallbackErr: any) {
|
|
if (fallbackErr?.response?.status === 404) {
|
|
return [];
|
|
}
|
|
}
|
|
}
|
|
if (err?.response?.status === 404) {
|
|
return [];
|
|
}
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
export const uploadEvidenceFile = async (formData: FormData): Promise<Evidence> => {
|
|
const userSub = getCurrentUserSub();
|
|
if (userSub && !formData.get('userId')) {
|
|
formData.append('userId', userSub);
|
|
}
|
|
const res = await apiClient.put<Evidence>('/v1/api/evidence/file', formData, {
|
|
headers: { 'Content-Type': 'multipart/form-data' },
|
|
});
|
|
return res.data;
|
|
};
|
|
|
|
export const updateEvidence = async (payload: UpdateEvidenceRequest): Promise<void> => {
|
|
await apiClient.put('/v1/api/evidence', {
|
|
evidenceId: toIntegerId(payload.evidenceId),
|
|
version: Number(payload.version) || 1,
|
|
evidenceType: Number(payload.evidenceType),
|
|
status: Number(payload.status),
|
|
});
|
|
if (isApprovedOrActiveStatus(payload.status)) {
|
|
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
|
recordType: 'Evidence',
|
|
recordId: payload.evidenceId,
|
|
newStatus: payload.status,
|
|
}).catch(console.warn);
|
|
}
|
|
};
|
|
|
|
// ==========================================
|
|
// 7. Users & Background Integration APIs
|
|
// ==========================================
|
|
export const getUsers = async (
|
|
page = 1,
|
|
pageSize = 100,
|
|
filter: UserFilterRequest = {}
|
|
): Promise<User[]> => {
|
|
const tenantId = getCurrentUserTenantId();
|
|
const orgId =
|
|
filter.organisationId !== undefined
|
|
? (filter.organisationId !== null && filter.organisationId !== ''
|
|
? toIntegerId(filter.organisationId)
|
|
: null)
|
|
: tenantId;
|
|
|
|
const requestBody: Record<string, any> = {
|
|
...filter,
|
|
};
|
|
if (orgId !== null && orgId !== undefined) {
|
|
requestBody.organisationId = orgId;
|
|
}
|
|
|
|
const queryParams: Record<string, any> = {};
|
|
if (orgId !== null && orgId !== undefined) {
|
|
const hashedOrgId = await toolsService.encodeId(orgId);
|
|
if (hashedOrgId) {
|
|
queryParams.organisationId = hashedOrgId;
|
|
}
|
|
}
|
|
|
|
const res = await apiClient.post<User[]>(
|
|
`/v1/api/users/${page}/${pageSize}`,
|
|
requestBody,
|
|
{ params: Object.keys(queryParams).length > 0 ? queryParams : undefined }
|
|
);
|
|
const list = res.data || [];
|
|
registerUserEmails(list);
|
|
return list;
|
|
};
|
|
|
|
export const getUser = async (userId: string): Promise<User> => {
|
|
const res = await apiClient.get<User>(`/v1/api/users/${userId}`);
|
|
if (res.data?.id && res.data?.email) {
|
|
registerUserEmail(res.data.id, res.data.email);
|
|
}
|
|
return res.data;
|
|
};
|
|
|
|
export const getCurrentUser = async (): Promise<User> => {
|
|
const userSub = getCurrentUserSub();
|
|
if (userSub) {
|
|
try {
|
|
const u = await getUser(userSub);
|
|
if (u?.id && u?.email) {
|
|
registerUserEmail(u.id, u.email);
|
|
}
|
|
return u;
|
|
} catch {
|
|
// Fallback
|
|
}
|
|
}
|
|
return {
|
|
id: userSub || 'user-1',
|
|
email: null,
|
|
status: ActivityStatus.Active,
|
|
lastLoginAt: new Date().toISOString(),
|
|
};
|
|
};
|
|
|
|
export const updateUserStatus = async (userId: string, status: ActivityStatus | number): Promise<void> => {
|
|
await apiClient.get(`/v1/api/users/${userId}/status/${status}`);
|
|
};
|
|
|
|
export const createUser = async (
|
|
tenantId: number | string,
|
|
idNumber: string,
|
|
payload: CreateUserRequest
|
|
): Promise<void> => {
|
|
const encodedTenant = encodeEntityId(tenantId) || String(tenantId);
|
|
const encodedIdNumber = encodeIdNumberHex(idNumber);
|
|
|
|
const rawEmail = payload.email?.trim().toLowerCase() || '';
|
|
const rawUserName = payload.userName?.trim();
|
|
let sanitizedUserName = rawUserName
|
|
? rawUserName.toLowerCase().replace(/\s+/g, '.').replace(/[^a-z0-9_.\-@+]/g, '')
|
|
: rawEmail.split('@')[0];
|
|
|
|
if (!sanitizedUserName) {
|
|
sanitizedUserName = rawEmail;
|
|
}
|
|
|
|
await apiClient.post(`/v1/api/users/${encodeURIComponent(encodedTenant)}/${encodeURIComponent(encodedIdNumber)}`, {
|
|
id: payload.id || null,
|
|
userName: sanitizedUserName,
|
|
email: rawEmail || null,
|
|
emailConfirmed: payload.emailConfirmed ?? true,
|
|
phoneNumber: payload.phoneNumber || null,
|
|
phoneNumberConfirmed: payload.phoneNumberConfirmed ?? false,
|
|
LockoutEnabled: payload.LockoutEnabled ?? false,
|
|
twoFactorEnabled: payload.twoFactorEnabled ?? false,
|
|
accessFailedCount: payload.accessFailedCount !== undefined ? Number(payload.accessFailedCount) : 0,
|
|
lockoutEnd: payload.lockoutEnd || null,
|
|
});
|
|
};
|
|
|
|
import {
|
|
fetchAndCacheSystemRoles,
|
|
fetchAndCacheRoleClaims,
|
|
getSystemClaims,
|
|
getCachedSystemRoles,
|
|
getCachedRoleClaims,
|
|
preloadAndCacheAllRoleClaims,
|
|
} from '../roleClaimsCache';
|
|
|
|
export {
|
|
getSystemClaims,
|
|
getCachedSystemRoles,
|
|
getCachedRoleClaims,
|
|
preloadAndCacheAllRoleClaims,
|
|
};
|
|
|
|
export const getSystemRoles = async (): Promise<Role[]> => {
|
|
return await fetchAndCacheSystemRoles();
|
|
};
|
|
|
|
export const getUserRoles = async (userId: string): Promise<Role[]> => {
|
|
const res = await apiClient.get<Role[]>(`/v1/api/users/${userId}/roles`);
|
|
return res.data || [];
|
|
};
|
|
|
|
export const assignUserRole = async (userId: string, roleId: string): Promise<void> => {
|
|
await apiClient.patch(`/v1/api/users/${userId}/roles/${roleId}`);
|
|
};
|
|
|
|
export const removeUserRole = async (userId: string, roleId: string): Promise<void> => {
|
|
await apiClient.delete(`/v1/api/users/${userId}/roles/${roleId}`);
|
|
};
|
|
|
|
export const getRoleClaims = async (roleId: string, roleName?: string): Promise<Claim[]> => {
|
|
return await fetchAndCacheRoleClaims(roleId, roleName);
|
|
};
|
|
|
|
export const getUserClaims = async (userId: string): Promise<Claim[]> => {
|
|
const res = await apiClient.get<Claim[]>(`/v1/api/users/${userId}/claims`);
|
|
return res.data || [];
|
|
};
|
|
|
|
export const addUserClaim = async (payload: AddUserClaimRequest): Promise<void> => {
|
|
await apiClient.patch('/v1/api/users/user/claims', {
|
|
userId: payload.userId,
|
|
claimId: Number(payload.claimId),
|
|
claimType: payload.claimType,
|
|
claimValue: payload.claimValue,
|
|
});
|
|
};
|
|
|
|
export const deleteUserClaim = async (targetUserId: string, claimId: string | number): Promise<void> => {
|
|
await apiClient.delete(`/v1/api/users/${targetUserId}/claims/${claimId}`);
|
|
};
|
|
|
|
// Administrative functions (forceLogoutUser, setUserLockout) removed from PartnerUI
|
|
|
|
import {
|
|
syncUserAccount,
|
|
ensureUserSynced,
|
|
isUserSyncedInBrowser,
|
|
saveUserSyncInBrowser,
|
|
clearUserSyncInBrowser,
|
|
getUserSyncRecord,
|
|
isActivationPending,
|
|
setActivationPending,
|
|
getActivationPendingReason,
|
|
} from '../userSyncService';
|
|
|
|
export {
|
|
syncUserAccount,
|
|
ensureUserSynced,
|
|
isUserSyncedInBrowser,
|
|
saveUserSyncInBrowser,
|
|
clearUserSyncInBrowser,
|
|
getUserSyncRecord,
|
|
isActivationPending,
|
|
setActivationPending,
|
|
getActivationPendingReason,
|
|
};
|
|
|
|
export const syncUser = async (
|
|
userData?: {
|
|
id?: string;
|
|
sub?: string;
|
|
email?: string | null;
|
|
name?: string | null;
|
|
role?: string | null;
|
|
tenant?: string | null;
|
|
},
|
|
force = false
|
|
): Promise<boolean> => {
|
|
return ensureUserSynced(userData?.sub || userData?.id, undefined, userData, force);
|
|
};
|
|
|
|
export const syncIntegration = async (force = true): Promise<void> => {
|
|
try {
|
|
await apiClient.post('/v1/api/integration/sync', {});
|
|
} catch {
|
|
await ensureUserSynced(undefined, undefined, undefined, force);
|
|
}
|
|
};
|
|
|
|
// ==========================================
|
|
// 8. Communications & Invites APIs (incl. Primary Partner Endpoint GetIApplicantDataEndpoint)
|
|
// ==========================================
|
|
export const sendInvite = async (payload: InviteRequest): Promise<Invite> => {
|
|
const tenantId = getCurrentUserTenantId();
|
|
const hostOrgId =
|
|
payload.organisationId !== undefined && payload.organisationId !== '' && payload.organisationId !== null
|
|
? toSafeInt64String(payload.organisationId)
|
|
: (tenantId !== null ? String(tenantId) : '0');
|
|
|
|
const body = {
|
|
organisationId: hostOrgId,
|
|
invitedOrganisationId: toSafeInt64String(payload.invitedOrganisationId),
|
|
awardId: toSafeInt64String(payload.awardId),
|
|
contractId: toSafeInt64String(payload.contractId),
|
|
recipient: payload.recipient,
|
|
message: payload.message,
|
|
platform: payload.platform !== undefined ? Number(payload.platform) : 1,
|
|
};
|
|
|
|
const res = await apiClient.post<Invite>('/v1/api/communications/invites', body);
|
|
return res.data;
|
|
};
|
|
|
|
export const getInvites = async (page = 1, pageSize = 100): Promise<Invite[]> => {
|
|
const res = await apiClient.get<Invite[]>(`/v1/api/communications/invites/${page}/${pageSize}`);
|
|
return res.data || [];
|
|
};
|
|
|
|
/**
|
|
* Normalizes a single ApplicantData item returned by GetIApplicantDataEndpoint
|
|
* (`GET /v1/api/communications/applicants/{applicantUserId}/all`).
|
|
*/
|
|
export const normalizeApplicantData = (raw: any): ApplicantData => {
|
|
if (!raw || typeof raw !== 'object') {
|
|
return {
|
|
invite: null,
|
|
contract: null,
|
|
fundingOrganisation: null,
|
|
invitedOrganisation: null,
|
|
awards: [],
|
|
projects: [],
|
|
};
|
|
}
|
|
|
|
const rawInvite = raw.invite ?? raw.Invite ?? null;
|
|
const rawContract = raw.contract ?? raw.Contract ?? null;
|
|
const rawFundingOrg = raw.fundingOrganisation ?? raw.FundingOrganisation ?? null;
|
|
const rawInvitedOrg = raw.invitedOrganisation ?? raw.InvitedOrganisation ?? null;
|
|
const rawAwards = raw.awards ?? raw.Awards ?? [];
|
|
const rawProjects = raw.projects ?? raw.Projects ?? [];
|
|
|
|
return {
|
|
invite: rawInvite
|
|
? {
|
|
...rawInvite,
|
|
id: rawInvite.id ?? rawInvite.Id,
|
|
organisationId: rawInvite.organisationId ?? rawInvite.OrganisationId,
|
|
invitedOrganisationId: rawInvite.invitedOrganisationId ?? rawInvite.InvitedOrganisationId,
|
|
awardId: rawInvite.awardId ?? rawInvite.AwardId,
|
|
contractId: rawInvite.contractId ?? rawInvite.ContractId,
|
|
recipient: rawInvite.recipient ?? rawInvite.Recipient ?? null,
|
|
platform: rawInvite.platform ?? rawInvite.Platform ?? 1,
|
|
status: rawInvite.status ?? rawInvite.Status ?? 1,
|
|
message: rawInvite.message ?? rawInvite.Message ?? null,
|
|
sentAt: rawInvite.sentAt ?? rawInvite.SentAt ?? null,
|
|
error: rawInvite.error ?? rawInvite.Error ?? null,
|
|
createdAt: rawInvite.createdAt ?? rawInvite.CreatedAt ?? new Date().toISOString(),
|
|
}
|
|
: null,
|
|
contract: rawContract
|
|
? {
|
|
...rawContract,
|
|
id: rawContract.id ?? rawContract.Id ?? rawContract.contractId ?? rawContract.ContractId,
|
|
contractId: rawContract.contractId ?? rawContract.ContractId ?? rawContract.id ?? rawContract.Id,
|
|
awardId: rawContract.awardId ?? rawContract.AwardId,
|
|
version: rawContract.version ?? rawContract.Version ?? 1,
|
|
status: rawContract.status ?? rawContract.Status ?? ContractStatus.Draft,
|
|
totalValue: Number(rawContract.totalValue ?? rawContract.TotalValue ?? 0),
|
|
signedDocumentUrl: rawContract.signedDocumentUrl ?? rawContract.SignedDocumentUrl ?? null,
|
|
externalSignatureId: rawContract.externalSignatureId ?? rawContract.ExternalSignatureId ?? null,
|
|
executedBy: rawContract.executedBy ?? rawContract.ExecutedBy ?? null,
|
|
effectiveAt: rawContract.effectiveAt ?? rawContract.EffectiveAt ?? new Date().toISOString(),
|
|
expiresAt: rawContract.expiresAt ?? rawContract.ExpiresAt ?? new Date().toISOString(),
|
|
executedAt: rawContract.executedAt ?? rawContract.ExecutedAt ?? null,
|
|
createdBy: rawContract.createdBy ?? rawContract.CreatedBy ?? '',
|
|
createdAt: rawContract.createdAt ?? rawContract.CreatedAt ?? new Date().toISOString(),
|
|
}
|
|
: null,
|
|
fundingOrganisation: rawFundingOrg ? normalizeOrganisation(rawFundingOrg) : null,
|
|
invitedOrganisation: rawInvitedOrg ? normalizeOrganisation(rawInvitedOrg) : null,
|
|
awards: Array.isArray(rawAwards)
|
|
? rawAwards.map((a: any) => ({
|
|
...a,
|
|
id: a.id ?? a.Id ?? a.awardId ?? a.AwardId,
|
|
awardId: a.awardId ?? a.AwardId ?? a.id ?? a.Id,
|
|
programmeId: a.programmeId ?? a.ProgrammeId,
|
|
organisationId: a.organisationId ?? a.OrganisationId,
|
|
amount: Number(a.amount ?? a.Amount ?? 0),
|
|
status: a.status ?? a.Status ?? ApprovalStatus.Approved,
|
|
approvedBy: a.approvedBy ?? a.ApprovedBy ?? '',
|
|
approvedAt: a.approvedAt ?? a.ApprovedAt ?? a.createdAt ?? a.CreatedAt ?? new Date().toISOString(),
|
|
createdBy: a.createdBy ?? a.CreatedBy ?? '',
|
|
createdAt: a.createdAt ?? a.CreatedAt ?? new Date().toISOString(),
|
|
}))
|
|
: [],
|
|
projects: Array.isArray(rawProjects)
|
|
? rawProjects.map((p: any) => ({
|
|
...p,
|
|
id: p.id ?? p.Id ?? p.projectId ?? p.ProjectId,
|
|
programmeId: p.programmeId ?? p.ProgrammeId,
|
|
name: p.name ?? p.Name ?? 'Untitled Project',
|
|
description: p.description ?? p.Description ?? null,
|
|
sector: Number(p.sector ?? p.Sector ?? 1),
|
|
projectType: Number(p.projectType ?? p.ProjectType ?? 1),
|
|
status: Number(p.status ?? p.Status ?? 3),
|
|
startedAt: p.startedAt ?? p.StartedAt ?? null,
|
|
endedAt: p.endedAt ?? p.EndedAt ?? null,
|
|
createdBy: p.createdBy ?? p.CreatedBy ?? '',
|
|
createdAt: p.createdAt ?? p.CreatedAt ?? new Date().toISOString(),
|
|
updatedBy: p.updatedBy ?? p.UpdatedBy ?? null,
|
|
updatedAt: p.updatedAt ?? p.UpdatedAt ?? null,
|
|
}))
|
|
: [],
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Primary starting API call for PartnerUI (`GetIApplicantDataEndpoint`):
|
|
* `GET /v1/api/communications/applicants/{applicantUserId:Guid}/all`
|
|
* Returns all ApplicantData records belonging to this partner.
|
|
*/
|
|
export const getApplicantData = async (applicantUserId: string): Promise<ApplicantData[]> => {
|
|
if (!applicantUserId) return [];
|
|
const res = await apiClient.get<any>(`/v1/api/communications/applicants/${encodeURIComponent(applicantUserId)}/all`);
|
|
const payload = res.data;
|
|
if (!payload) return [];
|
|
if (Array.isArray(payload)) {
|
|
return payload.map(normalizeApplicantData);
|
|
}
|
|
if (Array.isArray(payload.data)) {
|
|
return payload.data.map(normalizeApplicantData);
|
|
}
|
|
if (Array.isArray(payload.items)) {
|
|
return payload.items.map(normalizeApplicantData);
|
|
}
|
|
if (typeof payload === 'object') {
|
|
return [normalizeApplicantData(payload)];
|
|
}
|
|
return [];
|
|
};
|
|
|
|
export const getApplicantInvite = async (applicantUserId: string): Promise<Invite> => {
|
|
try {
|
|
const res = await apiClient.get<Invite>(`/v1/api/communications/invites/applicants/${applicantUserId}`);
|
|
return res.data;
|
|
} catch (err: any) {
|
|
if (err?.response?.status === 404) {
|
|
const res = await apiClient.get<Invite>(`/v1/api/communications/invites/applicant/${applicantUserId}`);
|
|
return res.data;
|
|
}
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
export const getApplicantInvites = async (applicantUserId: string): Promise<Invite[]> => {
|
|
const res = await apiClient.get<Invite[]>(`/v1/api/communications/invites/${applicantUserId}`);
|
|
return res.data || [];
|
|
};
|
|
|
|
export const getNotifications = async (organisationId?: string | number): Promise<Notification[]> => {
|
|
const targetOrgId =
|
|
organisationId !== undefined && organisationId !== '' && organisationId !== null && organisationId !== 'all'
|
|
? organisationId
|
|
: getCurrentUserTenantId();
|
|
|
|
if (!targetOrgId || targetOrgId === 'all') {
|
|
return [];
|
|
}
|
|
|
|
const hashedOrgId = await toolsService.resolveOrganisationId(targetOrgId);
|
|
try {
|
|
const res = await apiClient.get<Notification[]>(
|
|
`/v1/api/communications/notifications/${encodeURIComponent(hashedOrgId)}`
|
|
);
|
|
if (res.data && Array.isArray(res.data)) {
|
|
return res.data;
|
|
}
|
|
} catch (err: any) {
|
|
console.warn('[getNotifications] API error fetching notifications:', err);
|
|
}
|
|
|
|
return [];
|
|
};
|
|
|
|
export const getUserNotifications = async (userId?: string): Promise<Notification[]> => {
|
|
try {
|
|
const url = userId
|
|
? `/v1/api/communications/notifications/user/${encodeURIComponent(userId)}`
|
|
: `/v1/api/communications/notifications/inbox`;
|
|
const res = await apiClient.get<Notification[]>(url);
|
|
if (res.data && Array.isArray(res.data)) {
|
|
return res.data;
|
|
}
|
|
} catch (err: any) {
|
|
console.warn('[getUserNotifications] API error:', err);
|
|
}
|
|
return [];
|
|
};
|
|
|
|
export const createNotification = async (payload: CreateNotificationRequest): Promise<void> => {
|
|
const tenantId = getCurrentUserTenantId();
|
|
const orgId =
|
|
payload.organisationId !== undefined && payload.organisationId !== '' && payload.organisationId !== null
|
|
? toIntegerId(payload.organisationId)
|
|
: (tenantId !== null ? tenantId : 1);
|
|
|
|
await apiClient.post('/v1/api/communications/notifications', {
|
|
organisationId: orgId,
|
|
recipient: payload.recipient,
|
|
platform: payload.platform,
|
|
subject: payload.subject || null,
|
|
destination: payload.destination || 'In-App User Inbox',
|
|
message: payload.message,
|
|
status: payload.status !== undefined ? payload.status : undefined,
|
|
});
|
|
};
|
|
|
|
export const updateNotification = async (payload: UpdateNotificationRequest): Promise<void> => {
|
|
await apiClient.put('/v1/api/communications/notifications', {
|
|
notificationId: toIntegerId(payload.notificationId),
|
|
destination: payload.destination || undefined,
|
|
status: payload.status,
|
|
sentAt: payload.sentAt || new Date().toISOString(),
|
|
error: payload.error || null,
|
|
});
|
|
};
|
|
|
|
export const markNotificationAsRead = async (notificationId: number | string): Promise<void> => {
|
|
await updateNotification({
|
|
notificationId: toIntegerId(notificationId),
|
|
status: NotificationStatus.Read,
|
|
});
|
|
};
|
|
|
|
// ==========================================
|
|
// 9. Governance: Tenants APIs
|
|
// ==========================================
|
|
export const getTenants = async (): Promise<Tenant[]> => {
|
|
const res = await apiClient.get<Tenant[]>('/v1/api/governance/tenants');
|
|
return res.data || [];
|
|
};
|
|
|
|
export const getTenant = async (tenantId: string | number): Promise<Tenant> => {
|
|
const hashedTenantId = await toolsService.encodeId(tenantId);
|
|
const res = await apiClient.get<Tenant>(`/v1/api/governance/tenants/${encodeURIComponent(hashedTenantId)}`);
|
|
return res.data;
|
|
};
|
|
|
|
export const createTenant = async (payload: CreateTenantRequest): Promise<void> => {
|
|
await apiClient.post('/v1/api/governance/tenants', {
|
|
name: payload.name,
|
|
});
|
|
};
|
|
|
|
export const updateTenant = async (payload: UpdateTenantRequest): Promise<void> => {
|
|
await apiClient.put('/v1/api/governance/tenants', {
|
|
tenantId: toIntegerId(payload.tenantId),
|
|
name: payload.name,
|
|
status: Number(payload.status),
|
|
});
|
|
if (isApprovedOrActiveStatus(payload.status)) {
|
|
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
|
recordType: 'Tenant',
|
|
recordId: payload.tenantId,
|
|
newStatus: payload.status,
|
|
title: payload.name,
|
|
}).catch(console.warn);
|
|
}
|
|
};
|
|
|
|
export const getTenantOrganisations = async (tenantId: string | number): Promise<TenantOrganisation[]> => {
|
|
const hashedTenantId = await toolsService.encodeId(tenantId);
|
|
try {
|
|
const res = await apiClient.get<TenantOrganisation[]>(
|
|
`/v1/api/governance/tenants/${encodeURIComponent(hashedTenantId)}/organisations`
|
|
);
|
|
return res.data || [];
|
|
} catch (err: any) {
|
|
if (String(tenantId) !== String(hashedTenantId)) {
|
|
try {
|
|
const fallbackRes = await apiClient.get<TenantOrganisation[]>(
|
|
`/v1/api/governance/tenants/${encodeURIComponent(String(tenantId))}/organisations`
|
|
);
|
|
return fallbackRes.data || [];
|
|
} catch {}
|
|
}
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
export const getTenantUsers = async (tenantId: string | number): Promise<TenantUser[]> => {
|
|
const hashedTenantId = await toolsService.encodeId(tenantId);
|
|
try {
|
|
const res = await apiClient.get<TenantUser[]>(
|
|
`/v1/api/governance/tenants/${encodeURIComponent(hashedTenantId)}/users`
|
|
);
|
|
return res.data || [];
|
|
} catch (err: any) {
|
|
if (String(tenantId) !== String(hashedTenantId)) {
|
|
try {
|
|
const fallbackRes = await apiClient.get<TenantUser[]>(
|
|
`/v1/api/governance/tenants/${encodeURIComponent(String(tenantId))}/users`
|
|
);
|
|
return fallbackRes.data || [];
|
|
} catch {}
|
|
}
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
// ==========================================
|
|
/**
|
|
* Resolves a recipient user GUID by email address across TenantUsers and Directory.
|
|
* Automatically caches any found user emails and IDs in local memory.
|
|
*/
|
|
export const resolveUserIdByEmail = async (
|
|
email: string,
|
|
hintTenantOrOrgId?: number | string
|
|
): Promise<string | undefined> => {
|
|
if (!email || typeof email !== 'string' || !email.includes('@')) {
|
|
return undefined;
|
|
}
|
|
const cleanEmail = email.trim().toLowerCase();
|
|
|
|
// 1. Fast cache check
|
|
const cachedId = getUserIdByEmail(cleanEmail);
|
|
if (cachedId && cachedId !== '00000000-0000-0000-0000-000000000000') {
|
|
return cachedId;
|
|
}
|
|
|
|
const matchInTenantUsers = (tenantUsers: TenantUser[]): string | undefined => {
|
|
if (!Array.isArray(tenantUsers)) return undefined;
|
|
let match: string | undefined = undefined;
|
|
for (const tu of tenantUsers) {
|
|
const uId = tu.userId || tu.user?.id;
|
|
const uEmail = tu.user?.email || (tu as any)?.email;
|
|
if (uId && uEmail) {
|
|
registerUserEmail(uId, uEmail);
|
|
if (String(uEmail).trim().toLowerCase() === cleanEmail) {
|
|
match = String(uId).trim();
|
|
}
|
|
}
|
|
}
|
|
return match;
|
|
};
|
|
|
|
// 2. Query tenant users with hintTenantOrOrgId
|
|
if (hintTenantOrOrgId) {
|
|
try {
|
|
const users = await getTenantUsers(hintTenantOrOrgId);
|
|
const match = matchInTenantUsers(users);
|
|
if (match) return match;
|
|
} catch {}
|
|
}
|
|
|
|
// 3. Query all tenants from getTenants and their users
|
|
try {
|
|
const tenants = await getTenants();
|
|
for (const t of tenants) {
|
|
if (hintTenantOrOrgId && String(t.id) === String(hintTenantOrOrgId)) continue;
|
|
try {
|
|
const users = await getTenantUsers(t.id);
|
|
const match = matchInTenantUsers(users);
|
|
if (match) return match;
|
|
} catch {}
|
|
}
|
|
} catch {}
|
|
|
|
// 4. Try standard fallback tenant IDs
|
|
for (const tid of [1, 2, 3]) {
|
|
if (hintTenantOrOrgId && String(tid) === String(hintTenantOrOrgId)) continue;
|
|
try {
|
|
const users = await getTenantUsers(tid);
|
|
const match = matchInTenantUsers(users);
|
|
if (match) return match;
|
|
} catch {}
|
|
}
|
|
|
|
// 5. Query user directory fallback
|
|
try {
|
|
const dirUsers = await apiClient.post<User[]>('/v1/api/users/1/200', {});
|
|
if (Array.isArray(dirUsers.data)) {
|
|
for (const u of dirUsers.data) {
|
|
if (u && u.id && u.email) {
|
|
registerUserEmail(u.id, u.email);
|
|
if (u.email.trim().toLowerCase() === cleanEmail) {
|
|
return String(u.id).trim();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch {}
|
|
|
|
return undefined;
|
|
};
|
|
|
|
// 10. Projects: Beneficiaries & Outcomes APIs
|
|
// ==========================================
|
|
export const getProjectBeneficiaries = async (projectId: string | number): Promise<Beneficiary[]> => {
|
|
const hashedProjectId = await toolsService.resolveProjectId(projectId);
|
|
try {
|
|
const res = await apiClient.get<Beneficiary[]>(`/v1/api/projects/${hashedProjectId}/beneficiaries`);
|
|
return res.data || [];
|
|
} catch (err: any) {
|
|
if (String(projectId) !== String(hashedProjectId)) {
|
|
try {
|
|
const fallbackRes = await apiClient.get<Beneficiary[]>(`/v1/api/projects/${projectId}/beneficiaries`);
|
|
return fallbackRes.data || [];
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
if (err?.response?.status === 404) return [];
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
export const getBeneficiary = async (beneficiaryId: string | number): Promise<Beneficiary> => {
|
|
const hashedBeneficiaryId = await toolsService.encodeId(beneficiaryId);
|
|
try {
|
|
const res = await apiClient.get<Beneficiary>(`/v1/api/projects/beneficiaries/${hashedBeneficiaryId}`);
|
|
return res.data;
|
|
} catch (err: any) {
|
|
if (String(beneficiaryId) !== String(hashedBeneficiaryId)) {
|
|
try {
|
|
const fallbackRes = await apiClient.get<Beneficiary>(`/v1/api/projects/beneficiaries/${beneficiaryId}`);
|
|
return fallbackRes.data;
|
|
} catch {}
|
|
}
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
export const createBeneficiary = async (payload: CreateBeneficiaryRequest): Promise<void> => {
|
|
await apiClient.post('/v1/api/projects/beneficiaries', {
|
|
projectId: toIntegerId(payload.projectId),
|
|
name: payload.name,
|
|
category: payload.category,
|
|
location: payload.location,
|
|
targetCount: Number(payload.targetCount),
|
|
reachedCount: Number(payload.reachedCount),
|
|
});
|
|
};
|
|
|
|
export const updateBeneficiary = async (payload: UpdateBeneficiaryRequest): Promise<void> => {
|
|
await apiClient.put('/v1/api/projects/beneficiaries', {
|
|
beneficiaryId: toIntegerId(payload.beneficiaryId),
|
|
name: payload.name,
|
|
category: payload.category,
|
|
location: payload.location,
|
|
targetCount: Number(payload.targetCount),
|
|
reachedCount: Number(payload.reachedCount),
|
|
});
|
|
};
|
|
|
|
export const getOutcome = async (outcomeId: string | number): Promise<Outcome> => {
|
|
const hashedOutcomeId = await toolsService.encodeId(outcomeId);
|
|
try {
|
|
const res = await apiClient.get<Outcome>(`/v1/api/projects/outcome/${hashedOutcomeId}`);
|
|
return res.data;
|
|
} catch {
|
|
const res = await apiClient.get<Outcome>(`/v1/api/projects/outcomes/single/${hashedOutcomeId}`);
|
|
return res.data;
|
|
}
|
|
};
|
|
|
|
export const getBeneficiaryOutcomes = async (beneficiaryId: string | number): Promise<Outcome[]> => {
|
|
const hashedBeneficiaryId = await toolsService.encodeId(beneficiaryId);
|
|
try {
|
|
const res = await apiClient.get<Outcome[]>(`/v1/api/projects/beneficiaries/${hashedBeneficiaryId}/outcomes`);
|
|
return Array.isArray(res.data) ? res.data : (res.data ? [res.data] : []);
|
|
} catch (err: any) {
|
|
if (err?.response?.status === 404) {
|
|
try {
|
|
const res = await apiClient.get<Outcome[]>(`/v1/api/projects/outcomes/${hashedBeneficiaryId}`);
|
|
return Array.isArray(res.data) ? res.data : (res.data ? [res.data] : []);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
return [];
|
|
}
|
|
};
|
|
|
|
export const getProjectOutcomes = async (projectId: string | number): Promise<Outcome[]> => {
|
|
const hashedProjectId = await toolsService.resolveProjectId(projectId);
|
|
try {
|
|
const res = await apiClient.get<Outcome[]>(`/v1/api/projects/${hashedProjectId}/outcomes`);
|
|
return Array.isArray(res.data) ? res.data : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
};
|
|
|
|
export const createOutcome = async (payload: CreateOutcomeRequest): Promise<void> => {
|
|
const body = {
|
|
beneficiaryId: toIntegerId(payload.beneficiaryId),
|
|
name: payload.name,
|
|
description: payload.description,
|
|
status: payload.status !== undefined ? Number(payload.status) : 1,
|
|
};
|
|
try {
|
|
await apiClient.post('/v1/api/projects/beneficiaries/outcomes', body);
|
|
} catch (err: any) {
|
|
if (err?.response?.status === 404) {
|
|
await apiClient.post('/v1/api/projects/beneficiaris/outcomes', body);
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
};
|
|
|
|
export const updateOutcome = async (payload: UpdateOutcomeRequest): Promise<void> => {
|
|
await apiClient.put('/v1/api/projects/outcomes', {
|
|
outcomeId: toIntegerId(payload.outcomeId),
|
|
name: payload.name,
|
|
description: payload.description,
|
|
status: Number(payload.status),
|
|
});
|
|
if (isApprovedOrActiveStatus(payload.status)) {
|
|
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
|
recordType: 'Outcome',
|
|
recordId: payload.outcomeId,
|
|
newStatus: payload.status,
|
|
title: payload.name,
|
|
}).catch(console.warn);
|
|
}
|
|
};
|
|
|
|
// ==========================================
|
|
// 11. Projects: Change Requests APIs
|
|
// ==========================================
|
|
export const getProjectChangeRequests = async (projectId: string | number): Promise<ChangeRequest[]> => {
|
|
const hashedProjectId = await toolsService.resolveProjectId(projectId);
|
|
try {
|
|
const res = await apiClient.get<ChangeRequest[]>(`/v1/api/projects/${hashedProjectId}/changerequests`);
|
|
return res.data || [];
|
|
} catch (err: any) {
|
|
if (String(projectId) !== String(hashedProjectId)) {
|
|
try {
|
|
const fallbackRes = await apiClient.get<ChangeRequest[]>(`/v1/api/projects/${projectId}/changerequests`);
|
|
return fallbackRes.data || [];
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
if (err?.response?.status === 404) return [];
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
export const getChangeRequest = async (changeRequestId: string | number): Promise<ChangeRequest> => {
|
|
const hashedChangeReqId = await toolsService.encodeId(changeRequestId);
|
|
try {
|
|
const res = await apiClient.get<ChangeRequest>(`/v1/api/projects/changerequests/${hashedChangeReqId}`);
|
|
return res.data;
|
|
} catch (err: any) {
|
|
if (String(changeRequestId) !== String(hashedChangeReqId)) {
|
|
try {
|
|
const fallbackRes = await apiClient.get<ChangeRequest>(`/v1/api/projects/changerequests/${changeRequestId}`);
|
|
return fallbackRes.data;
|
|
} catch {}
|
|
}
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
export const createChangeRequest = async (payload: CreateChangeRequest): Promise<void> => {
|
|
const revisionDateIso = payload.revisionDate.includes('T')
|
|
? payload.revisionDate
|
|
: new Date(payload.revisionDate).toISOString();
|
|
|
|
await apiClient.post('/v1/api/projects/changerequests', {
|
|
projectId: toIntegerId(payload.projectId),
|
|
title: payload.title,
|
|
justification: payload.justification,
|
|
revisedBudget: Number(payload.revisedBudget),
|
|
revisionDate: revisionDateIso,
|
|
});
|
|
};
|
|
|
|
export const updateChangeRequest = async (payload: UpdateChangeRequest): Promise<void> => {
|
|
await apiClient.put('/v1/api/projects/changerequests', {
|
|
changeRequestId: toIntegerId(payload.changeRequestId),
|
|
title: payload.title,
|
|
justification: payload.justification,
|
|
revisedBudget: Number(payload.revisedBudget),
|
|
status: Number(payload.status),
|
|
});
|
|
if (isApprovedOrActiveStatus(payload.status)) {
|
|
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
|
recordType: 'Change Request',
|
|
recordId: payload.changeRequestId,
|
|
newStatus: payload.status,
|
|
title: payload.title,
|
|
}).catch(console.warn);
|
|
}
|
|
};
|
|
|
|
// ==========================================
|
|
// 12. Projects: Compliance Items APIs
|
|
// ==========================================
|
|
export const getProjectComplianceItems = async (projectId: string | number): Promise<ComplianceItem[]> => {
|
|
const hashedProjectId = await toolsService.resolveProjectId(projectId);
|
|
try {
|
|
const res = await apiClient.get<ComplianceItem[]>(`/v1/api/projects/${hashedProjectId}/complianceitems`);
|
|
return res.data || [];
|
|
} catch (err: any) {
|
|
if (err?.response?.status === 404 || err?.response?.status === 400) {
|
|
try {
|
|
const fallbackRes = await apiClient.get<ComplianceItem[]>(`/api/projects/${hashedProjectId}/complianceitems`);
|
|
return fallbackRes.data || [];
|
|
} catch {
|
|
try {
|
|
const rawIdRes = await apiClient.get<ComplianceItem[]>(`/v1/api/projects/${projectId}/complianceitems`);
|
|
return rawIdRes.data || [];
|
|
} catch {
|
|
try {
|
|
const rawApiRes = await apiClient.get<ComplianceItem[]>(`/api/projects/${projectId}/complianceitems`);
|
|
return rawApiRes.data || [];
|
|
} catch {
|
|
if (err?.response?.status === 404) return [];
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
export const getComplianceItem = async (complianceItemId: string | number): Promise<ComplianceItem> => {
|
|
const hashedItemId = await toolsService.encodeId(complianceItemId);
|
|
const res = await apiClient.get<ComplianceItem>(`/v1/api/projects/complianceitems/${hashedItemId}`);
|
|
return res.data;
|
|
};
|
|
|
|
export const createComplianceItem = async (payload: CreateComplianceItemRequest): Promise<void> => {
|
|
await apiClient.post('/v1/api/projects/complianceitems', {
|
|
projectId: toIntegerId(payload.projectId),
|
|
title: payload.title,
|
|
requirements: payload.requirements,
|
|
status: payload.status !== undefined ? Number(payload.status) : 1,
|
|
beneficiaryId: payload.beneficiaryId ? toIntegerId(payload.beneficiaryId) : null,
|
|
});
|
|
};
|
|
|
|
export const updateComplianceItem = async (payload: UpdateComplianceItemRequest): Promise<void> => {
|
|
await apiClient.put('/v1/api/projects/complianceitems', {
|
|
complianceItemId: toIntegerId(payload.complianceItemId),
|
|
title: payload.title,
|
|
requirements: payload.requirements,
|
|
status: Number(payload.status),
|
|
beneficiaryId: payload.beneficiaryId ? toIntegerId(payload.beneficiaryId) : null,
|
|
});
|
|
if (isApprovedOrActiveStatus(payload.status)) {
|
|
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
|
recordType: 'Compliance Item',
|
|
recordId: payload.complianceItemId,
|
|
newStatus: payload.status,
|
|
title: payload.title,
|
|
}).catch(console.warn);
|
|
}
|
|
};
|
|
|
|
// ==========================================
|
|
// 13. Projects: Issues APIs
|
|
// ==========================================
|
|
export const getProjectIssues = async (projectId: string | number): Promise<Issue[]> => {
|
|
const hashedProjectId = await toolsService.resolveProjectId(projectId);
|
|
try {
|
|
const res = await apiClient.get<Issue[]>(`/v1/api/projects/${hashedProjectId}/issues`);
|
|
return res.data || [];
|
|
} catch (err: any) {
|
|
if (String(projectId) !== String(hashedProjectId)) {
|
|
try {
|
|
const fallbackRes = await apiClient.get<Issue[]>(`/v1/api/projects/${projectId}/issues`);
|
|
return fallbackRes.data || [];
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
if (err?.response?.status === 404) return [];
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
export const getIssue = async (issueId: string | number): Promise<Issue> => {
|
|
const hashedIssueId = await toolsService.encodeId(issueId);
|
|
const res = await apiClient.get<Issue>(`/v1/api/projects/issues/${hashedIssueId}`);
|
|
return res.data;
|
|
};
|
|
|
|
export const createIssue = async (payload: CreateIssueRequest): Promise<void> => {
|
|
await apiClient.post('/v1/api/projects/issues', {
|
|
projectId: toIntegerId(payload.projectId),
|
|
title: payload.title,
|
|
description: payload.description,
|
|
priority: Number(payload.priority),
|
|
assignedTo: payload.assignedTo,
|
|
});
|
|
};
|
|
|
|
export const updateIssue = async (payload: UpdateIssueRequest): Promise<void> => {
|
|
let resolvedAtIso: string | null = null;
|
|
if (payload.resolvedAt) {
|
|
try {
|
|
resolvedAtIso = payload.resolvedAt.includes('T') && payload.resolvedAt.endsWith('Z')
|
|
? payload.resolvedAt
|
|
: new Date(payload.resolvedAt).toISOString();
|
|
} catch {
|
|
resolvedAtIso = new Date().toISOString();
|
|
}
|
|
} else {
|
|
resolvedAtIso = new Date().toISOString();
|
|
}
|
|
|
|
const finalResolution = payload.resolution && payload.resolution.trim() !== ''
|
|
? payload.resolution
|
|
: (Number(payload.status) === ActivityStatus.Inactive ? 'Resolved' : 'Under active investigation');
|
|
|
|
await apiClient.put('/v1/api/projects/issues', {
|
|
issueId: toIntegerId(payload.issueId),
|
|
title: payload.title,
|
|
description: payload.description,
|
|
resolution: finalResolution,
|
|
resolvedAt: resolvedAtIso,
|
|
priority: Number(payload.priority),
|
|
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
|
|
assignedTo: payload.assignedTo,
|
|
});
|
|
const issueStatus = payload.status !== undefined ? payload.status : ActivityStatus.Active;
|
|
if (isApprovedOrActiveStatus(issueStatus)) {
|
|
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
|
recordType: 'Issue',
|
|
recordId: payload.issueId,
|
|
newStatus: issueStatus,
|
|
title: payload.title,
|
|
}).catch(console.warn);
|
|
}
|
|
};
|
|
|
|
// ==========================================
|
|
// 14. Projects: Site Visits APIs
|
|
// ==========================================
|
|
export const getProjectSiteVisits = async (projectId: string | number): Promise<SiteVisit[]> => {
|
|
const hashedProjectId = await toolsService.resolveProjectId(projectId);
|
|
try {
|
|
const res = await apiClient.get<SiteVisit[]>(`/v1/api/projects/${hashedProjectId}/sitevisits`);
|
|
return res.data || [];
|
|
} catch (err: any) {
|
|
if (String(projectId) !== String(hashedProjectId)) {
|
|
try {
|
|
const fallbackRes = await apiClient.get<SiteVisit[]>(`/v1/api/projects/${projectId}/sitevisits`);
|
|
return fallbackRes.data || [];
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
if (err?.response?.status === 404) return [];
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
export const getSiteVisit = async (siteVisitId: string | number): Promise<SiteVisit> => {
|
|
const hashedVisitId = await toolsService.encodeId(siteVisitId);
|
|
try {
|
|
const res = await apiClient.get<SiteVisit>(`/v1/api/projects/sitevisits/${hashedVisitId}`);
|
|
return res.data;
|
|
} catch (err: any) {
|
|
if (String(siteVisitId) !== String(hashedVisitId)) {
|
|
try {
|
|
const fallbackRes = await apiClient.get<SiteVisit>(`/v1/api/projects/sitevisits/${siteVisitId}`);
|
|
return fallbackRes.data;
|
|
} catch {}
|
|
}
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
export const createSiteVisit = async (payload: CreateSiteVisitRequest): Promise<void> => {
|
|
const visitedAtIso = payload.visitedAt.includes('T')
|
|
? payload.visitedAt
|
|
: new Date(payload.visitedAt).toISOString();
|
|
|
|
await apiClient.post('/v1/api/projects/sitevisits', {
|
|
projectId: toIntegerId(payload.projectId),
|
|
inspectorName: payload.inspectorName,
|
|
findings: payload.findings,
|
|
visitedAt: visitedAtIso,
|
|
});
|
|
};
|
|
|
|
export const updateSiteVisit = async (payload: UpdateSiteVisitRequest): Promise<void> => {
|
|
const visitedAtIso = payload.visitedAt.includes('T')
|
|
? payload.visitedAt
|
|
: new Date(payload.visitedAt).toISOString();
|
|
|
|
await apiClient.put('/v1/api/projects/sitevisits', {
|
|
siteVisitId: toIntegerId(payload.siteVisitId),
|
|
inspectorName: payload.inspectorName,
|
|
findings: payload.findings,
|
|
status: Number(payload.status),
|
|
visitedAt: visitedAtIso,
|
|
});
|
|
if (isApprovedOrActiveStatus(payload.status)) {
|
|
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
|
recordType: 'Site Visit',
|
|
recordId: payload.siteVisitId,
|
|
newStatus: payload.status,
|
|
title: payload.inspectorName,
|
|
}).catch(console.warn);
|
|
}
|
|
};
|
|
|
|
const LOCAL_RULES_KEY = 'pfm_local_rules';
|
|
|
|
export const purgeStoredRuleDuplicates = (apiRules: Rule[]): void => {
|
|
if (typeof window === 'undefined' || !Array.isArray(apiRules) || apiRules.length === 0) return;
|
|
try {
|
|
const raw = localStorage.getItem(LOCAL_RULES_KEY);
|
|
if (!raw) return;
|
|
const local: Rule[] = JSON.parse(raw);
|
|
if (!Array.isArray(local) || local.length === 0) return;
|
|
|
|
const apiIds = new Set(apiRules.map((r) => String(r.id)));
|
|
const apiKeys = new Set(apiRules.map((r) => `${r.programmeId}::${(r.name || '').toLowerCase().trim()}`));
|
|
|
|
const remaining = local.filter(
|
|
(l) => !apiIds.has(String(l.id)) && !apiKeys.has(`${l.programmeId}::${(l.name || '').toLowerCase().trim()}`)
|
|
);
|
|
|
|
if (remaining.length !== local.length) {
|
|
localStorage.setItem(LOCAL_RULES_KEY, JSON.stringify(remaining));
|
|
}
|
|
} catch (e) {
|
|
console.warn('Failed to purge stored rule duplicates', e);
|
|
}
|
|
};
|
|
|
|
export const getStoredRules = (): Rule[] => {
|
|
if (typeof window === 'undefined') return [];
|
|
try {
|
|
const raw = localStorage.getItem(LOCAL_RULES_KEY);
|
|
return raw ? JSON.parse(raw) : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
};
|
|
|
|
export const saveStoredRule = (rule: Rule): void => {
|
|
if (typeof window === 'undefined') return;
|
|
try {
|
|
const current = getStoredRules();
|
|
const idx = current.findIndex(
|
|
(r) => String(r.id) === String(rule.id) ||
|
|
(String(r.programmeId) === String(rule.programmeId) && (r.name || '').trim().toLowerCase() === (rule.name || '').trim().toLowerCase())
|
|
);
|
|
if (idx >= 0) {
|
|
current[idx] = { ...current[idx], ...rule, updatedAt: new Date().toISOString() };
|
|
} else {
|
|
current.unshift(rule);
|
|
}
|
|
localStorage.setItem(LOCAL_RULES_KEY, JSON.stringify(current));
|
|
} catch (e) {
|
|
console.warn('Failed to save stored rule', e);
|
|
}
|
|
};
|
|
|
|
// ==========================================
|
|
// 15. Execution: Programme Rules APIs
|
|
// ==========================================
|
|
export const getRule = async (ruleId: string | number): Promise<Rule> => {
|
|
try {
|
|
const hashedRuleId = await toolsService.encodeId(ruleId);
|
|
const res = await apiClient.get<Rule>(`/v1/api/execution/rules/${hashedRuleId}`);
|
|
if (res.data) {
|
|
purgeStoredRuleDuplicates([res.data]);
|
|
return res.data;
|
|
}
|
|
} catch {
|
|
const localRule = getStoredRules().find((r) => String(r.id) === String(ruleId));
|
|
if (localRule) return localRule;
|
|
throw new Error('Rule not found');
|
|
}
|
|
throw new Error('Rule not found');
|
|
};
|
|
|
|
const toValidUuid = (userId?: string | null): string => {
|
|
if (!userId) return '00000000-0000-0000-0000-000000000000';
|
|
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
if (uuidPattern.test(userId)) return userId;
|
|
const userSub = getCurrentUserSub();
|
|
if (userSub && uuidPattern.test(userSub)) return userSub;
|
|
return '00000000-0000-0000-0000-000000000000';
|
|
};
|
|
|
|
export const getRules = async (
|
|
pagination: Pagination = { page: 1, pageSize: 100 },
|
|
programmeId?: string | number
|
|
): Promise<Rule[]> => {
|
|
let apiRules: Rule[] = [];
|
|
let apiSucceeded = false;
|
|
try {
|
|
const res = await apiClient.post<Rule[]>('/v1/api/execution/programmes/rules', {
|
|
index: pagination.index ? toIntegerId(pagination.index) : 0,
|
|
page: pagination.page || 1,
|
|
pageSize: pagination.pageSize || 100,
|
|
});
|
|
if (Array.isArray(res.data)) {
|
|
apiRules = res.data;
|
|
apiSucceeded = true;
|
|
}
|
|
} catch (err) {
|
|
console.warn('[getRules] Error fetching from backend API:', err);
|
|
}
|
|
|
|
if (apiSucceeded) {
|
|
purgeStoredRuleDuplicates(apiRules);
|
|
let result = apiRules;
|
|
if (programmeId != null && programmeId !== '' && programmeId !== 'all') {
|
|
result = result.filter((r) => String(r.programmeId) === String(programmeId));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const localRules = getStoredRules();
|
|
let combined = localRules;
|
|
if (programmeId != null && programmeId !== '' && programmeId !== 'all') {
|
|
combined = combined.filter((r) => String(r.programmeId) === String(programmeId));
|
|
}
|
|
return combined;
|
|
};
|
|
|
|
export const createRule = async (payload: CreateRuleRequest): Promise<Rule> => {
|
|
const validUserId = toValidUuid(payload.userId);
|
|
const body = {
|
|
programmeId: toIntegerId(payload.programmeId),
|
|
userId: validUserId,
|
|
name: payload.name,
|
|
description: payload.description,
|
|
};
|
|
|
|
try {
|
|
const res = await apiClient.post<Rule>('/v1/api/execution/rules', body);
|
|
if (res.data && res.data.id) {
|
|
purgeStoredRuleDuplicates([res.data]);
|
|
return res.data;
|
|
}
|
|
} catch (err) {
|
|
console.warn('[createRule] API create failed, falling back to local storage:', err);
|
|
const newId = Date.now();
|
|
const fallbackRule: Rule = {
|
|
id: newId,
|
|
programmeId: toIntegerId(payload.programmeId),
|
|
name: payload.name,
|
|
description: payload.description,
|
|
status: ActivityStatus.Active,
|
|
version: 1,
|
|
createdBy: validUserId,
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
saveStoredRule(fallbackRule);
|
|
return fallbackRule;
|
|
}
|
|
|
|
return {
|
|
id: Date.now(),
|
|
programmeId: toIntegerId(payload.programmeId),
|
|
name: payload.name,
|
|
description: payload.description,
|
|
status: ActivityStatus.Active,
|
|
version: 1,
|
|
createdBy: validUserId,
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
};
|
|
|
|
export const updateRule = async (payload: UpdateRuleRequest): Promise<void> => {
|
|
const validUserId = toValidUuid(payload.userId);
|
|
try {
|
|
await apiClient.put('/v1/api/execution/rules', {
|
|
ruleId: toIntegerId(payload.ruleId),
|
|
userId: validUserId,
|
|
name: payload.name,
|
|
description: payload.description,
|
|
status: Number(payload.status),
|
|
version: Number(payload.version || 1),
|
|
...(payload.programmeId ? { programmeId: toIntegerId(payload.programmeId) } : {}),
|
|
});
|
|
} catch (err) {
|
|
console.warn('[updateRule] API update failed:', err);
|
|
const existing = getStoredRules().find((r) => String(r.id) === String(payload.ruleId));
|
|
const updatedRule: Rule = {
|
|
id: payload.ruleId,
|
|
programmeId: payload.programmeId ? toIntegerId(payload.programmeId) : (existing?.programmeId || 0),
|
|
name: payload.name,
|
|
description: payload.description,
|
|
status: Number(payload.status),
|
|
version: Number(payload.version || 1),
|
|
createdBy: existing?.createdBy || validUserId,
|
|
createdAt: existing?.createdAt || new Date().toISOString(),
|
|
updatedBy: validUserId,
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
saveStoredRule(updatedRule);
|
|
}
|
|
};
|
|
|
|
// ==========================================
|
|
// 16. Audit Logs & System Telemetry APIs
|
|
// ==========================================
|
|
export const getAuditLogs = async (page = 1, pageSize = 50): Promise<AuditLog[]> => {
|
|
try {
|
|
const res = await apiClient.post<AuditLog[]>('/v1/api/governance/auditlog', {
|
|
index: (page - 1) * pageSize,
|
|
page,
|
|
pageSize,
|
|
});
|
|
if (res.data && Array.isArray(res.data)) {
|
|
return res.data;
|
|
}
|
|
return [];
|
|
} catch (err) {
|
|
console.error('[getAuditLogs] API request failed:', err);
|
|
return [];
|
|
}
|
|
};
|
|
|
|
export const getAuditLog = async (auditId: string | number): Promise<AuditLog | null> => {
|
|
try {
|
|
const logs = await getAuditLogs(1, 100);
|
|
return logs.find((l) => String(l.id) === String(auditId)) || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|