Implemented data export

This commit is contained in:
2026-09-26 13:45:42 +02:00
parent 15a8d8f449
commit 2949d6218b
12 changed files with 10939 additions and 359 deletions
+299 -144
View File
@@ -20,6 +20,8 @@ import {
CreateProgrammeRequest,
UpdateProgrammeRequest,
Instrument,
CreateInstrumentRequest,
UpdateInstrumentRequest,
Instruction,
CreateInstructionRequest,
UpdateInstructionRequest,
@@ -225,7 +227,7 @@ export const updateOrganisation = async (payload: UpdateOrganisationRequest): Pr
return res.data;
};
export const getPortfolios = async (organisationId?: string | number): Promise<Portfolio[]> => {
export const getOrganisationPortfolios = async (organisationId?: string | number): Promise<Portfolio[]> => {
const targetOrgId =
organisationId !== undefined && organisationId !== '' && organisationId !== null
? organisationId
@@ -235,8 +237,31 @@ export const getPortfolios = async (organisationId?: string | number): Promise<P
return [];
}
const hashedOrgId = await toolsService.resolveOrganisationId(targetOrgId);
const res = await apiClient.get<Portfolio[]>(`/v1/api/governance/organisations/${hashedOrgId}/portfolio`);
return res.data || [];
try {
const res = await apiClient.get<Portfolio[]>(`/v1/api/governance/organisations/${hashedOrgId}/portfolio`);
return res.data || [];
} catch (err) {
// If hashed ID fails or is not recognized, attempt with raw targetOrgId
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> => {
@@ -245,23 +270,42 @@ export const getPortfolio = async (portfolioId: string | number): Promise<Portfo
return Array.isArray(res.data) ? res.data[0] : res.data;
};
export const getAllPortfolios = async (): Promise<Portfolio[]> => {
export const getAllPortfolios = async (
pagination: Pagination = { page: 1, pageSize: 100, index: 0 }
): Promise<Portfolio[]> => {
try {
const orgs = await getOrganisations(1, 10);
if (orgs.length > 0) {
const results = await Promise.allSettled(
orgs.map((org) => getPortfolios(org.id))
);
const combined = results
.filter((r): r is PromiseFulfilledResult<Portfolio[]> => r.status === 'fulfilled')
.flatMap((r) => r.value);
if (combined.length > 0) return combined;
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 {
// Fallback to iterating organisations if bulk endpoint is unavailable
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;
}
}
const res = await apiClient.get<Portfolio[]>('/v1/api/governance/organisations/portfolio');
return res.data || [];
} catch {
return [];
}
return [];
};
export const getPortfolioProgrammes = async (portfolioId: string | number): Promise<Programme[]> => {
@@ -314,7 +358,30 @@ export const getProgrammes = async (portfolioId: string | number): Promise<Progr
const LOCAL_INSTRUCTIONS_KEY = 'pfm_local_instructions';
export const getStoredInstructions = (): Instruction[] => {
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);
@@ -324,11 +391,14 @@ export const getStoredInstructions = (): Instruction[] => {
}
};
export const saveStoredInstruction = (inst: Instruction): void => {
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));
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 {
@@ -340,31 +410,35 @@ export const saveStoredInstruction = (inst: Instruction): void => {
}
};
export const getInstructions = async (programmeId: string | number): Promise<Instruction[]> => {
let apiItems: Instruction[] = [];
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<Instruction[]>(`/v1/api/governance/instruments/${hashedProgId}`);
apiItems = res.data || [];
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(`[getInstructions] Could not fetch instruments from API for prog ${programmeId}:`, err);
console.warn(`[getInstruments] Could not fetch instruments from API for prog ${programmeId}:`, err);
}
if (apiSucceeded) {
purgeStoredInstructionDuplicates(apiItems);
return apiItems;
}
// Fallback to local items only when API failed
const localItems = getStoredInstructions().filter(
(item) => String(item.programmeId) === String(programmeId)
);
const map = new Map<string, Instruction>();
apiItems.forEach((item) => map.set(String(item.id), item));
localItems.forEach((item) => map.set(String(item.id), item));
return Array.from(map.values());
return localItems;
};
export const getInstruments = getInstructions;
export const getInstructions = getInstruments;
export const getAllInstructions = async (programmesList?: Programme[]): Promise<Instruction[]> => {
const localItems = getStoredInstructions();
export const getAllInstruments = async (programmesList?: Programme[]): Promise<Instrument[]> => {
let progList = programmesList;
if (!progList || progList.length === 0) {
try {
@@ -374,66 +448,103 @@ export const getAllInstructions = async (programmesList?: Programme[]): Promise<
}
}
const map = new Map<string, Instruction>();
localItems.forEach((item) => map.set(String(item.id), item));
const map = new Map<string, Instrument>();
let hasApiData = false;
if (progList && progList.length > 0) {
const results = await Promise.allSettled(
progList.map((pr) => getInstructions(pr.id))
progList.map((pr) => getInstruments(pr.id))
);
results.forEach((res) => {
if (res.status === 'fulfilled' && Array.isArray(res.value)) {
if (res.status === 'fulfilled' && Array.isArray(res.value) && res.value.length > 0) {
hasApiData = true;
res.value.forEach((inst) => map.set(String(inst.id), inst));
}
});
}
return Array.from(map.values());
if (hasApiData || map.size > 0) {
const allApiInstruments = Array.from(map.values());
purgeStoredInstructionDuplicates(allApiInstruments);
return allApiInstruments;
}
return getStoredInstructions();
};
export const createInstruction = async (payload: CreateInstructionRequest): Promise<Instruction> => {
const newId = Date.now();
const newInstruction: Instruction = {
id: newId,
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),
code: payload.code || `INST-${String(newId).slice(-4)}`,
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;
}
// If API returned 200 without full body, return formatted item without saving duplicate to localStorage
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(),
};
try {
await apiClient.post('/v1/api/governance/instruments', {
programmeId: toIntegerId(payload.programmeId),
code: newInstruction.code,
name: newInstruction.name,
description: newInstruction.description,
status: newInstruction.status,
});
} catch (err: any) {
try {
await apiClient.post('/v1/api/governance/instructions', {
programmeId: toIntegerId(payload.programmeId),
code: newInstruction.code,
name: newInstruction.name,
description: newInstruction.description,
status: newInstruction.status,
});
} catch (e) {
console.warn('[createInstruction] API create failed, persisted locally:', e);
}
}
saveStoredInstruction(newInstruction);
return newInstruction;
};
export const updateInstruction = async (payload: UpdateInstructionRequest): Promise<Instruction> => {
const existing = getStoredInstructions().find((i) => String(i.id) === String(payload.instructionId));
const updatedInstruction: Instruction = {
id: payload.instructionId,
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,
@@ -445,35 +556,14 @@ export const updateInstruction = async (payload: UpdateInstructionRequest): Prom
updatedAt: new Date().toISOString(),
};
try {
await apiClient.put('/v1/api/governance/instruments', {
instrumentId: toIntegerId(payload.instructionId),
instructionId: toIntegerId(payload.instructionId),
programmeId: payload.programmeId ? toIntegerId(payload.programmeId) : undefined,
code: payload.code,
name: payload.name,
description: payload.description,
status: Number(payload.status),
});
} catch (err) {
try {
await apiClient.put('/v1/api/governance/instructions', {
instructionId: toIntegerId(payload.instructionId),
programmeId: payload.programmeId ? toIntegerId(payload.programmeId) : undefined,
code: payload.code,
name: payload.name,
description: payload.description,
status: Number(payload.status),
});
} catch (e) {
console.warn('[updateInstruction] API update failed, updated locally:', e);
}
if (existing) {
saveStoredInstruction(updatedInstrument);
}
saveStoredInstruction(updatedInstruction);
return updatedInstruction;
return updatedInstrument;
};
export const updateInstruction = updateInstrument;
// ==========================================
// 3. Project & Milestone Management APIs
// ==========================================
@@ -1610,6 +1700,29 @@ export const updateSiteVisit = async (payload: UpdateSiteVisitRequest): Promise<
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 {
@@ -1624,7 +1737,10 @@ 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));
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 {
@@ -1640,15 +1756,28 @@ export const saveStoredRule = (rule: Rule): void => {
// 15. Execution: Programme Rules APIs
// ==========================================
export const getRule = async (ruleId: string | number): Promise<Rule> => {
const localRule = getStoredRules().find((r) => String(r.id) === String(ruleId));
try {
const hashedRuleId = await toolsService.encodeId(ruleId);
const res = await apiClient.get<Rule>(`/v1/api/execution/rules/${hashedRuleId}`);
return res.data || localRule!;
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 (
@@ -1656,23 +1785,33 @@ export const getRules = async (
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,
});
apiRules = res.data || [];
if (Array.isArray(res.data)) {
apiRules = res.data;
apiSucceeded = true;
}
} catch (err) {
console.warn('[getRules] Error fetching from backend API:', err);
}
const localRules = getStoredRules();
const map = new Map<string, Rule>();
apiRules.forEach((r) => map.set(String(r.id), r));
localRules.forEach((r) => map.set(String(r.id), r));
if (apiSucceeded) {
purgeStoredRuleDuplicates(apiRules);
let result = apiRules;
if (programmeId != null && programmeId !== '' && programmeId !== 'all') {
result = result.filter((r) => String(r.programmeId) === String(programmeId));
}
return result;
}
let combined = Array.from(map.values());
// Fallback to local storage only if API call failed
const localRules = getStoredRules();
let combined = localRules;
if (programmeId != null && programmeId !== '' && programmeId !== 'all') {
combined = combined.filter((r) => String(r.programmeId) === String(programmeId));
}
@@ -1680,60 +1819,76 @@ export const getRules = async (
};
export const createRule = async (payload: CreateRuleRequest): Promise<Rule> => {
const newId = Date.now();
const newRule: Rule = {
id: newId,
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: payload.userId,
createdBy: validUserId,
createdAt: new Date().toISOString(),
};
try {
await apiClient.post('/v1/api/execution/rules', {
programmeId: toIntegerId(payload.programmeId),
userId: payload.userId,
name: payload.name,
description: payload.description,
});
} catch (err) {
console.warn('[createRule] API create failed, persisted locally:', err);
}
saveStoredRule(newRule);
return newRule;
};
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: payload.userId,
userId: validUserId,
name: payload.name,
description: payload.description,
status: Number(payload.status),
version: Number(payload.version),
version: Number(payload.version || 1),
...(payload.programmeId ? { programmeId: toIntegerId(payload.programmeId) } : {}),
});
} catch (err) {
console.warn('[updateRule] API update failed, updated locally:', 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);
}
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),
createdBy: existing?.createdBy || payload.userId,
createdAt: existing?.createdAt || new Date().toISOString(),
updatedBy: payload.userId,
updatedAt: new Date().toISOString(),
};
saveStoredRule(updatedRule);
};