Merge pull request 'Updated api usage, corrected table designs' (#109) from finishline into main

Reviewed-on: #109
This commit was merged in pull request #109.
This commit is contained in:
2026-09-27 13:32:01 +02:00
11 changed files with 13413 additions and 3582 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1193,7 +1193,7 @@
]
}
},
"/v1/api/projects/beneficiaries/{projectId}": {
"/v1/api/projects/{projectId}/beneficiaries": {
"get": {
"tags": [
"Projects"
@@ -1355,7 +1355,7 @@
]
}
},
"/v1/api/projects/changerequests/{projectId}": {
"/v1/api/projects/{projectId}/changerequests": {
"get": {
"tags": [
"Projects"
@@ -1573,7 +1573,7 @@
]
}
},
"/v1/api/projects/issues/{projectId}": {
"/v1/api/projects/{projectId}/issues": {
"get": {
"tags": [
"Projects"
@@ -2308,7 +2308,7 @@
]
}
},
"/v1/api/projects/sitevisits/{projectId}": {
"/v1/api/projects/{projectId}/sitevisits": {
"get": {
"tags": [
"Projects"
@@ -2740,6 +2740,54 @@
"security": [
{}
]
},
"put": {
"tags": [
"Governance"
],
"description": "Updates a specific budget",
"operationId": "updatebudget",
"requestBody": {
"content": {
"application/json; version=1": {
"schema": {
"$ref": "#/components/schemas/UpdateBudgetRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK"
},
"401": {
"description": "Unauthorized"
},
"400": {
"description": "Bad Request",
"content": {
"application/json; version=1": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json; version=1": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
},
"security": [
{}
]
}
},
"/v1/api/grants/contracts": {
@@ -3770,6 +3818,66 @@
]
}
},
"/v1/api/governance/auditlog": {
"post": {
"tags": [
"Governance"
],
"description": "Retrieves audit logs based on pagination parameters",
"operationId": "getauditlogs",
"requestBody": {
"content": {
"application/json; version=1": {
"schema": {
"$ref": "#/components/schemas/Pagination"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json; version=1": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AuditLog"
}
}
}
}
},
"401": {
"description": "Unauthorized"
},
"400": {
"description": "Bad Request",
"content": {
"application/json; version=1": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json; version=1": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
},
"security": [
{}
]
}
},
"/v1/api/governance/instruments/{programmeId}": {
"get": {
"tags": [
@@ -3876,6 +3984,124 @@
]
}
},
"/v1/api/governance/organisations/{organisationId}/portfolio": {
"get": {
"tags": [
"Governance"
],
"description": "Get all portfolios belonging to a specific organisation",
"operationId": "getorganisationportfolios",
"parameters": [
{
"name": "organisationId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json; version=1": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Portfolio"
}
}
}
}
},
"403": {
"description": "Forbidden"
},
"401": {
"description": "Unauthorized"
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json; version=1": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
},
"security": [
{}
]
}
},
"/v1/api/governance/portfolios/{portfolioId}/value": {
"get": {
"tags": [
"Governance"
],
"description": "Calculates and returns a portfolio's total project's budget value",
"operationId": "getporfoliovalue",
"parameters": [
{
"name": "portfolioId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json; version=1": {
"schema": {
"pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$",
"type": [
"number",
"string"
],
"format": "double"
}
}
}
},
"403": {
"description": "Forbidden"
},
"401": {
"description": "Unauthorized"
},
"400": {
"description": "Bad Request",
"content": {
"application/json; version=1": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json; version=1": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
},
"security": [
{}
]
}
},
"/v1/api/governance/portfolios/{portfolioId}": {
"get": {
"tags": [
@@ -3998,23 +4224,23 @@
]
}
},
"/v1/api/governance/organisations/{organisationId}/portfolio": {
"get": {
"/v1/api/governance/organisations/portfolios": {
"post": {
"tags": [
"Governance"
],
"description": "Get all portfolios",
"operationId": "getportfolios",
"parameters": [
{
"name": "organisationId",
"in": "path",
"required": true,
"schema": {
"type": "string"
"requestBody": {
"content": {
"application/json; version=1": {
"schema": {
"$ref": "#/components/schemas/Pagination"
}
}
}
],
},
"required": true
},
"responses": {
"200": {
"description": "OK",
@@ -4104,6 +4330,71 @@
]
}
},
"/v1/api/governance/programmes/{programmeId}/value": {
"get": {
"tags": [
"Governance"
],
"description": "Calculates and returns a programme's total project budget value",
"operationId": "getprogrammevalue",
"parameters": [
{
"name": "programmeId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json; version=1": {
"schema": {
"pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$",
"type": [
"number",
"string"
],
"format": "double"
}
}
}
},
"403": {
"description": "Forbidden"
},
"401": {
"description": "Unauthorized"
},
"400": {
"description": "Bad Request",
"content": {
"application/json; version=1": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json; version=1": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
},
"security": [
{}
]
}
},
"/v1/api/governance/tenants/{tenantId}": {
"get": {
"tags": [
@@ -5935,6 +6226,63 @@
"ApprovalStatus": {
"type": "integer"
},
"AuditLog": {
"type": "object",
"properties": {
"id": {
"pattern": "^-?(?:0|[1-9]\\d*)$",
"type": [
"integer",
"string"
],
"format": "int64"
},
"tenantId": {
"pattern": "^-?(?:0|[1-9]\\d*)$",
"type": [
"null",
"integer",
"string"
],
"format": "int64"
},
"entityName": {
"type": [
"null",
"string"
]
},
"entityId": {
"type": [
"null",
"string"
]
},
"action": {
"type": [
"null",
"string"
]
},
"changes": {
"type": [
"null",
"array"
],
"items": {
"type": "string"
}
},
"performedBy": {
"type": "string",
"format": "uuid"
},
"timestamp": {
"type": "string",
"format": "date-time"
}
}
},
"Award": {
"type": "object",
"properties": {
@@ -6158,6 +6506,9 @@
"string"
],
"format": "double"
},
"status": {
"$ref": "#/components/schemas/ApprovalStatus"
}
}
},
@@ -8481,6 +8832,26 @@
}
}
},
"UpdateBudgetRequest": {
"required": [
"budgetId",
"status"
],
"type": "object",
"properties": {
"budgetId": {
"pattern": "^-?(?:0|[1-9]\\d*)$",
"type": [
"integer",
"string"
],
"format": "int64"
},
"status": {
"$ref": "#/components/schemas/ApprovalStatus"
}
}
},
"UpdateChangeRequest": {
"required": [
"changeRequestId",
@@ -1,4 +1,5 @@
import React, { useState, useRef, useEffect } from 'react';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { Download, FileSpreadsheet, FileText, ChevronDown } from 'lucide-react';
import { ExportColumn, exportToSpreadsheet, exportToPdf } from '../../utils/exportUtils';
import { useAuth } from '../../auth/AuthProvider';
@@ -25,24 +26,46 @@ export function ExportButton<T>({
className = '',
}: ExportButtonProps<T>) {
const [isOpen, setIsOpen] = useState(false);
const [menuPosition, setMenuPosition] = useState<{ top: number; right: number } | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const { user, claims, tenantId } = useAuth();
const isDisabled = !data || data.length === 0;
const updatePosition = useCallback(() => {
if (!dropdownRef.current) return;
const rect = dropdownRef.current.getBoundingClientRect();
setMenuPosition({
top: rect.bottom + 8,
right: Math.max(16, window.innerWidth - rect.right),
});
}, []);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
const target = event.target as Node;
if (
dropdownRef.current &&
!dropdownRef.current.contains(target) &&
(!menuRef.current || !menuRef.current.contains(target))
) {
setIsOpen(false);
}
}
if (isOpen) {
updatePosition();
document.addEventListener('mousedown', handleClickOutside);
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
};
}, [isOpen]);
}, [isOpen, updatePosition]);
const handleExportSpreadsheet = () => {
if (isDisabled) return;
@@ -96,44 +119,56 @@ export function ExportButton<T>({
<ChevronDown className={`w-3 h-3 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && !isDisabled && (
<div className="absolute right-0 mt-2 w-56 bg-[#0d121f] border border-slate-700/90 rounded-xl shadow-2xl z-[99999] overflow-hidden backdrop-blur-md animate-in fade-in-50 zoom-in-95 ring-1 ring-black/40">
<div className="p-2 border-b border-slate-800/80">
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-400 px-2 block">
Export Format ({data.length} items)
</span>
</div>
<div className="p-1 space-y-0.5">
<button
type="button"
onClick={handleExportSpreadsheet}
className="w-full flex items-center gap-2.5 px-3 py-2 text-xs text-left text-slate-200 hover:text-white hover:bg-emerald-500/15 rounded-lg transition-colors cursor-pointer group"
>
<div className="w-6 h-6 rounded-md bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center shrink-0 group-hover:bg-emerald-500/20">
<FileSpreadsheet className="w-3.5 h-3.5 text-emerald-400" />
</div>
<div>
<span className="font-semibold block leading-tight">Spreadsheet (.xlsx)</span>
<span className="text-[10px] text-slate-400">Excel / Google Sheets</span>
</div>
</button>
{isOpen && !isDisabled && menuPosition && typeof document !== 'undefined' &&
createPortal(
<div
ref={menuRef}
style={{
position: 'fixed',
top: `${menuPosition.top}px`,
right: `${menuPosition.right}px`,
zIndex: 999999,
}}
className="w-56 bg-[#0d121f] border border-slate-700/90 rounded-xl shadow-2xl overflow-hidden backdrop-blur-md animate-in fade-in-50 zoom-in-95 ring-1 ring-black/40"
>
<div className="p-2 border-b border-slate-800/80">
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-400 px-2 block">
Export Format ({data.length} items)
</span>
</div>
<div className="p-1 space-y-0.5">
<button
type="button"
onClick={handleExportSpreadsheet}
className="w-full flex items-center gap-2.5 px-3 py-2 text-xs text-left text-slate-200 hover:text-white hover:bg-emerald-500/15 rounded-lg transition-colors cursor-pointer group"
>
<div className="w-6 h-6 rounded-md bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center shrink-0 group-hover:bg-emerald-500/20">
<FileSpreadsheet className="w-3.5 h-3.5 text-emerald-400" />
</div>
<div>
<span className="font-semibold block leading-tight">Spreadsheet (.xlsx)</span>
<span className="text-[10px] text-slate-400">Excel / Google Sheets</span>
</div>
</button>
<button
type="button"
onClick={handleExportPdf}
className="w-full flex items-center gap-2.5 px-3 py-2 text-xs text-left text-slate-200 hover:text-white hover:bg-blue-500/15 rounded-lg transition-colors cursor-pointer group"
>
<div className="w-6 h-6 rounded-md bg-blue-500/10 border border-blue-500/20 flex items-center justify-center shrink-0 group-hover:bg-blue-500/20">
<FileText className="w-3.5 h-3.5 text-blue-400" />
</div>
<div>
<span className="font-semibold block leading-tight">PDF Document (.pdf)</span>
<span className="text-[10px] text-slate-400">Landscape with PFM Logo</span>
</div>
</button>
</div>
</div>
)}
<button
type="button"
onClick={handleExportPdf}
className="w-full flex items-center gap-2.5 px-3 py-2 text-xs text-left text-slate-200 hover:text-white hover:bg-blue-500/15 rounded-lg transition-colors cursor-pointer group"
>
<div className="w-6 h-6 rounded-md bg-blue-500/10 border border-blue-500/20 flex items-center justify-center shrink-0 group-hover:bg-blue-500/20">
<FileText className="w-3.5 h-3.5 text-blue-400" />
</div>
<div>
<span className="font-semibold block leading-tight">PDF Document (.pdf)</span>
<span className="text-[10px] text-slate-400">Landscape with PFM Logo</span>
</div>
</button>
</div>
</div>,
document.body
)
}
</div>
);
}
@@ -494,31 +494,31 @@ export const BeneficiariesPage: React.FC = () => {
</button>
</div>
</div>
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
<div className="bg-[#0f1523]/80 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="border-b border-slate-800/80 bg-slate-900/50 text-[11px] font-semibold text-slate-400 uppercase tracking-wider">
<SortableTh column="name" currentColumn={benSortCol} direction={benSortDir} onSort={handleBenSort}>
<tr className="bg-[#0d121f]/95 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold text-[11px]">
<SortableTh column="name" currentColumn={benSortCol} direction={benSortDir} onSort={handleBenSort} className="py-3.5 px-5 whitespace-nowrap">
Beneficiary / Cohort
</SortableTh>
<SortableTh column="category" currentColumn={benSortCol} direction={benSortDir} onSort={handleBenSort}>
<SortableTh column="category" currentColumn={benSortCol} direction={benSortDir} onSort={handleBenSort} className="py-3.5 px-4 whitespace-nowrap">
Category
</SortableTh>
<SortableTh column="location" currentColumn={benSortCol} direction={benSortDir} onSort={handleBenSort}>
<SortableTh column="location" currentColumn={benSortCol} direction={benSortDir} onSort={handleBenSort} className="py-3.5 px-4 whitespace-nowrap">
Location
</SortableTh>
<SortableTh column="targetCount" currentColumn={benSortCol} direction={benSortDir} onSort={handleBenSort}>
<SortableTh column="targetCount" currentColumn={benSortCol} direction={benSortDir} onSort={handleBenSort} className="py-3.5 px-4 whitespace-nowrap">
Target Headcount
</SortableTh>
<SortableTh column="reachedCount" currentColumn={benSortCol} direction={benSortDir} onSort={handleBenSort}>
<SortableTh column="reachedCount" currentColumn={benSortCol} direction={benSortDir} onSort={handleBenSort} className="py-3.5 px-4 whitespace-nowrap">
Reached Headcount
</SortableTh>
<th className="py-3 px-4">Reach Progress</th>
<th className="py-3 px-4 text-right">Actions</th>
<th className="py-3.5 px-4 font-semibold whitespace-nowrap">Reach Progress</th>
<th className="py-3.5 px-5 text-right font-medium whitespace-nowrap">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60 text-xs">
<tbody className="divide-y divide-slate-800/60">
{loadingBens ? (
<tr>
<td colSpan={7} className="py-12 text-center text-slate-400">
@@ -538,27 +538,27 @@ export const BeneficiariesPage: React.FC = () => {
sortedBens.map((b) => {
const prog = b.targetCount && b.targetCount > 0 ? Math.round(((b.reachedCount || 0) / b.targetCount) * 100) : 0;
return (
<tr key={b.id} className="hover:bg-slate-800/30 transition">
<td className="py-3.5 px-4 font-semibold text-white">
<tr key={b.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-5 whitespace-nowrap font-semibold text-white">
<div className="flex items-center gap-2">
<Users className="w-3.5 h-3.5 text-blue-400 shrink-0" />
<span>{b.name}</span>
</div>
</td>
<td className="py-3.5 px-4 text-slate-300">
<td className="py-3.5 px-4 whitespace-nowrap text-slate-300">
<span className="bg-slate-800/80 px-2 py-0.5 rounded-md border border-slate-700/60 text-[11px]">
{b.category || 'General'}
</span>
</td>
<td className="py-3.5 px-4 text-slate-300">
<td className="py-3.5 px-4 whitespace-nowrap text-slate-300">
<div className="flex items-center gap-1.5 text-slate-400">
<MapPin className="w-3 h-3 text-slate-500" />
<span>{b.location || 'Not Specified'}</span>
</div>
</td>
<td className="py-3.5 px-4 font-medium text-slate-200">{(b.targetCount || 0).toLocaleString()}</td>
<td className="py-3.5 px-4 font-bold text-emerald-400">{(b.reachedCount || 0).toLocaleString()}</td>
<td className="py-3.5 px-4">
<td className="py-3.5 px-4 whitespace-nowrap font-medium text-slate-200">{(b.targetCount || 0).toLocaleString()}</td>
<td className="py-3.5 px-4 whitespace-nowrap font-bold text-emerald-400">{(b.reachedCount || 0).toLocaleString()}</td>
<td className="py-3.5 px-4 whitespace-nowrap">
<div className="flex items-center gap-2 max-w-[140px]">
<div className="flex-1 bg-slate-800 h-1.5 rounded-full overflow-hidden">
<div className="bg-emerald-500 h-full rounded-full" style={{ width: `${Math.min(100, prog)}%` }} />
@@ -566,7 +566,7 @@ export const BeneficiariesPage: React.FC = () => {
<span className="text-[11px] font-mono text-slate-400">{prog}%</span>
</div>
</td>
<td className="py-3.5 px-4 text-right space-x-2">
<td className="py-3.5 px-5 text-right whitespace-nowrap space-x-2">
<button
onClick={() => {
setSelectedBenForDetail(b);
@@ -679,27 +679,27 @@ export const BeneficiariesPage: React.FC = () => {
</div>
</div>
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
<div className="bg-[#0f1523]/80 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="border-b border-slate-800/80 bg-slate-900/50 text-[11px] font-semibold text-slate-400 uppercase tracking-wider">
<SortableTh column="title" currentColumn={compSortCol} direction={compSortDir} onSort={handleCompSort}>
<tr className="bg-[#0d121f]/95 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold text-[11px]">
<SortableTh column="title" currentColumn={compSortCol} direction={compSortDir} onSort={handleCompSort} className="py-3.5 px-5 whitespace-nowrap">
Compliance Title
</SortableTh>
<SortableTh column="requirements" currentColumn={compSortCol} direction={compSortDir} onSort={handleCompSort}>
<SortableTh column="requirements" currentColumn={compSortCol} direction={compSortDir} onSort={handleCompSort} className="py-3.5 px-4 whitespace-nowrap">
Requirements
</SortableTh>
<SortableTh column="status" currentColumn={compSortCol} direction={compSortDir} onSort={handleCompSort}>
<SortableTh column="status" currentColumn={compSortCol} direction={compSortDir} onSort={handleCompSort} className="py-3.5 px-4 whitespace-nowrap">
Verification Status
</SortableTh>
<SortableTh column="createdAt" currentColumn={compSortCol} direction={compSortDir} onSort={handleCompSort}>
<SortableTh column="createdAt" currentColumn={compSortCol} direction={compSortDir} onSort={handleCompSort} className="py-3.5 px-4 whitespace-nowrap">
Created
</SortableTh>
<th className="py-3 px-4 text-right">Actions</th>
<th className="py-3.5 px-5 text-right font-medium whitespace-nowrap">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60 text-xs">
<tbody className="divide-y divide-slate-800/60">
{loadingComps ? (
<tr>
<td colSpan={5} className="py-12 text-center text-slate-400">
@@ -717,8 +717,8 @@ export const BeneficiariesPage: React.FC = () => {
</tr>
) : (
sortedComps.map((c) => (
<tr key={c.id} className="hover:bg-slate-800/30 transition">
<td className="py-3.5 px-4 font-semibold text-white">
<tr key={c.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-5 whitespace-nowrap font-semibold text-white">
<div className="flex items-center gap-2">
<ShieldCheck className="w-3.5 h-3.5 text-cyan-400 shrink-0" />
<span>{c.title}</span>
@@ -727,9 +727,9 @@ export const BeneficiariesPage: React.FC = () => {
<td className="py-3.5 px-4 text-slate-300 max-w-xs truncate" title={c.requirements || ''}>
{c.requirements || 'None'}
</td>
<td className="py-3.5 px-4">{getApprovalStatusBadge(c.status)}</td>
<td className="py-3.5 px-4 text-slate-400">{formatDate(c.createdAt)}</td>
<td className="py-3.5 px-4 text-right">
<td className="py-3.5 px-4 whitespace-nowrap">{getApprovalStatusBadge(c.status)}</td>
<td className="py-3.5 px-4 whitespace-nowrap text-slate-400">{formatDate(c.createdAt)}</td>
<td className="py-3.5 px-5 text-right whitespace-nowrap">
<button
onClick={() => handleOpenEditComp(c)}
className="p-1 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition cursor-pointer"
@@ -534,28 +534,28 @@ export const ChangeManagementPage: React.FC = () => {
</div>
</div>
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
<div className="bg-[#0f1523]/80 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="border-b border-slate-800/80 bg-slate-900/50 text-[11px] font-semibold text-slate-400 uppercase tracking-wider">
<SortableTh column="title" currentColumn={issueSortCol} direction={issueSortDir} onSort={handleIssueSort}>
<tr className="bg-[#0d121f]/95 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold text-[11px]">
<SortableTh column="title" currentColumn={issueSortCol} direction={issueSortDir} onSort={handleIssueSort} className="py-3.5 px-5 whitespace-nowrap">
Issue Summary
</SortableTh>
<SortableTh column="priority" currentColumn={issueSortCol} direction={issueSortDir} onSort={handleIssueSort}>
<SortableTh column="priority" currentColumn={issueSortCol} direction={issueSortDir} onSort={handleIssueSort} className="py-3.5 px-4 whitespace-nowrap">
Priority
</SortableTh>
<SortableTh column="assignedTo" currentColumn={issueSortCol} direction={issueSortDir} onSort={handleIssueSort}>
<SortableTh column="assignedTo" currentColumn={issueSortCol} direction={issueSortDir} onSort={handleIssueSort} className="py-3.5 px-4 whitespace-nowrap">
Assigned To
</SortableTh>
<SortableTh column="status" currentColumn={issueSortCol} direction={issueSortDir} onSort={handleIssueSort}>
<SortableTh column="status" currentColumn={issueSortCol} direction={issueSortDir} onSort={handleIssueSort} className="py-3.5 px-4 whitespace-nowrap">
Status
</SortableTh>
<th className="py-3 px-4">Resolution Notes</th>
<th className="py-3 px-4 text-right">Actions</th>
<th className="py-3.5 px-4 font-semibold whitespace-nowrap">Resolution Notes</th>
<th className="py-3.5 px-5 text-right font-medium whitespace-nowrap">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60 text-xs">
<tbody className="divide-y divide-slate-800/60">
{loadingIssues ? (
<tr>
<td colSpan={6} className="py-12 text-center text-slate-400">
@@ -575,8 +575,8 @@ export const ChangeManagementPage: React.FC = () => {
sortedIssues.map((iss) => {
const isResolved = iss.status === ActivityStatus.Inactive || !!iss.resolvedAt;
return (
<tr key={iss.id} className="hover:bg-slate-800/30 transition">
<td className="py-3.5 px-4 font-semibold text-white">
<tr key={iss.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-5 font-semibold text-white">
<div className="flex items-start gap-2.5">
<AlertOctagon
className={`w-4 h-4 mt-0.5 shrink-0 ${isResolved ? 'text-emerald-400' : 'text-rose-400'}`}
@@ -587,14 +587,14 @@ export const ChangeManagementPage: React.FC = () => {
</div>
</div>
</td>
<td className="py-3.5 px-4">{getPriorityBadge(iss.priority)}</td>
<td className="py-3.5 px-4 text-slate-300">
<td className="py-3.5 px-4 whitespace-nowrap">{getPriorityBadge(iss.priority)}</td>
<td className="py-3.5 px-4 whitespace-nowrap text-slate-300">
<div className="flex items-center gap-1.5">
<User className="w-3.5 h-3.5 text-slate-400" />
<span className="truncate max-w-[150px]">{iss.assignedTo ? getUserEmail(iss.assignedTo) : 'Unassigned'}</span>
</div>
</td>
<td className="py-3.5 px-4">
<td className="py-3.5 px-4 whitespace-nowrap">
{isResolved ? (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-semibold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
<CheckCircle2 className="w-3 h-3" /> Resolved
@@ -608,7 +608,7 @@ export const ChangeManagementPage: React.FC = () => {
<td className="py-3.5 px-4 text-slate-400 max-w-xs truncate">
{iss.resolution || <span className="italic text-slate-600">Pending resolution...</span>}
</td>
<td className="py-3.5 px-4 text-right space-x-2">
<td className="py-3.5 px-5 text-right whitespace-nowrap space-x-2">
<button
onClick={() => openResolveIssue(iss)}
className="px-2.5 py-1 text-xs font-medium text-amber-400 hover:text-amber-300 bg-amber-950/40 hover:bg-amber-900/40 rounded-lg border border-amber-800/40 transition cursor-pointer"
@@ -733,27 +733,27 @@ export const ChangeManagementPage: React.FC = () => {
</div>
</div>
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
<div className="bg-[#0f1523]/80 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="border-b border-slate-800/80 bg-slate-900/50 text-[11px] font-semibold text-slate-400 uppercase tracking-wider">
<SortableTh column="title" currentColumn={changeSortCol} direction={changeSortDir} onSort={handleChangeSort}>
<tr className="bg-[#0d121f]/95 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold text-[11px]">
<SortableTh column="title" currentColumn={changeSortCol} direction={changeSortDir} onSort={handleChangeSort} className="py-3.5 px-5 whitespace-nowrap">
Change Proposal
</SortableTh>
<SortableTh column="justification" currentColumn={changeSortCol} direction={changeSortDir} onSort={handleChangeSort}>
<SortableTh column="justification" currentColumn={changeSortCol} direction={changeSortDir} onSort={handleChangeSort} className="py-3.5 px-4 whitespace-nowrap">
Justification
</SortableTh>
<SortableTh column="revisedBudget" currentColumn={changeSortCol} direction={changeSortDir} onSort={handleChangeSort}>
<SortableTh column="revisedBudget" currentColumn={changeSortCol} direction={changeSortDir} onSort={handleChangeSort} className="py-3.5 px-4 whitespace-nowrap">
Revised Budget
</SortableTh>
<SortableTh column="status" currentColumn={changeSortCol} direction={changeSortDir} onSort={handleChangeSort}>
<SortableTh column="status" currentColumn={changeSortCol} direction={changeSortDir} onSort={handleChangeSort} className="py-3.5 px-4 whitespace-nowrap">
Governance Status
</SortableTh>
<th className="py-3 px-4 text-right">In-Place Decision</th>
<th className="py-3.5 px-5 text-right font-medium whitespace-nowrap">In-Place Decision</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60 text-xs">
<tbody className="divide-y divide-slate-800/60">
{loadingChanges ? (
<tr>
<td colSpan={5} className="py-12 text-center text-slate-400">
@@ -771,8 +771,8 @@ export const ChangeManagementPage: React.FC = () => {
</tr>
) : (
sortedChanges.map((cr) => (
<tr key={cr.id} className="hover:bg-slate-800/30 transition">
<td className="py-3.5 px-4 font-semibold text-white">
<tr key={cr.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-5 whitespace-nowrap font-semibold text-white">
<div className="flex items-center gap-2">
<GitPullRequest className="w-4 h-4 text-amber-400 shrink-0" />
<span>{cr.title}</span>
@@ -781,11 +781,11 @@ export const ChangeManagementPage: React.FC = () => {
<td className="py-3.5 px-4 text-slate-300 max-w-xs truncate" title={cr.justification || ''}>
{cr.justification || 'None'}
</td>
<td className="py-3.5 px-4 font-mono font-semibold text-slate-200">
<td className="py-3.5 px-4 whitespace-nowrap font-mono font-semibold text-slate-200">
{formatZAR(cr.revisedBudget || 0)}
</td>
<td className="py-3.5 px-4">{getApprovalStatusBadge(cr.status)}</td>
<td className="py-3.5 px-4 text-right space-x-2">
<td className="py-3.5 px-4 whitespace-nowrap">{getApprovalStatusBadge(cr.status)}</td>
<td className="py-3.5 px-5 text-right whitespace-nowrap space-x-2">
{cr.status === ApprovalStatus.Approved ? (
<span className="text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20">
Resolved & Enacted
@@ -1268,7 +1268,7 @@ export const SettingsPage: React.FC = () => {
</div>
{/* Filter & Action Toolbar */}
<div className="bg-[#0f1523]/80 backdrop-blur-md p-4 rounded-2xl border border-slate-800/80 shadow-xl flex flex-col lg:flex-row items-stretch lg:items-center justify-between gap-3">
<div className="relative z-30 bg-[#0f1523]/80 backdrop-blur-md p-4 rounded-2xl border border-slate-800/80 shadow-xl flex flex-col lg:flex-row items-stretch lg:items-center justify-between gap-3">
<div className="relative flex-1 max-w-md">
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-3" />
<input
@@ -353,27 +353,27 @@ export const SiteVisitsPage: React.FC = () => {
</div>
</div>
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
<div className="bg-[#0f1523]/80 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="border-b border-slate-800/80 bg-slate-900/50 text-[11px] font-semibold text-slate-400 uppercase tracking-wider">
<SortableTh column="inspectorName" currentColumn={visitSortCol} direction={visitSortDir} onSort={handleVisitSort}>
<tr className="bg-[#0d121f]/95 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold text-[11px]">
<SortableTh column="inspectorName" currentColumn={visitSortCol} direction={visitSortDir} onSort={handleVisitSort} className="py-3.5 px-5 whitespace-nowrap">
Inspector Name
</SortableTh>
<SortableTh column="visitedAt" currentColumn={visitSortCol} direction={visitSortDir} onSort={handleVisitSort}>
<SortableTh column="visitedAt" currentColumn={visitSortCol} direction={visitSortDir} onSort={handleVisitSort} className="py-3.5 px-4 whitespace-nowrap">
Inspection Date
</SortableTh>
<SortableTh column="findings" currentColumn={visitSortCol} direction={visitSortDir} onSort={handleVisitSort}>
<SortableTh column="findings" currentColumn={visitSortCol} direction={visitSortDir} onSort={handleVisitSort} className="py-3.5 px-4 whitespace-nowrap">
Field Observations & Findings
</SortableTh>
<SortableTh column="status" currentColumn={visitSortCol} direction={visitSortDir} onSort={handleVisitSort}>
<SortableTh column="status" currentColumn={visitSortCol} direction={visitSortDir} onSort={handleVisitSort} className="py-3.5 px-4 whitespace-nowrap">
Verification Status
</SortableTh>
<th className="py-3 px-4 text-right">Actions</th>
<th className="py-3.5 px-5 text-right font-medium whitespace-nowrap">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60 text-xs">
<tbody className="divide-y divide-slate-800/60">
{loadingVisits ? (
<tr>
<td colSpan={5} className="py-12 text-center text-slate-400">
@@ -391,14 +391,14 @@ export const SiteVisitsPage: React.FC = () => {
</tr>
) : (
sortedVisits.map((v) => (
<tr key={v.id} className="hover:bg-slate-800/30 transition">
<td className="py-3.5 px-4 font-semibold text-white">
<tr key={v.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-5 whitespace-nowrap font-semibold text-white">
<div className="flex items-center gap-2">
<User className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
<span>{v.inspectorName || 'Field Officer'}</span>
</div>
</td>
<td className="py-3.5 px-4 text-slate-300 font-medium">
<td className="py-3.5 px-4 whitespace-nowrap text-slate-300 font-medium">
<div className="flex items-center gap-1.5 text-slate-300">
<Calendar className="w-3 h-3 text-slate-500" />
<span>{formatDate(v.visitedAt)}</span>
@@ -407,8 +407,8 @@ export const SiteVisitsPage: React.FC = () => {
<td className="py-3.5 px-4 text-slate-300 max-w-sm truncate" title={v.findings || ''}>
{v.findings || 'No findings recorded.'}
</td>
<td className="py-3.5 px-4">{getApprovalStatusBadge(v.status)}</td>
<td className="py-3.5 px-4 text-right space-x-2">
<td className="py-3.5 px-4 whitespace-nowrap">{getApprovalStatusBadge(v.status)}</td>
<td className="py-3.5 px-5 text-right whitespace-nowrap space-x-2">
<button
onClick={() => {
setSelectedVisitForDetail(v);
@@ -1531,8 +1531,20 @@ export const updateTenant = async (payload: UpdateTenantRequest): Promise<void>
// ==========================================
export const getProjectBeneficiaries = async (projectId: string | number): Promise<Beneficiary[]> => {
const hashedProjectId = await toolsService.resolveProjectId(projectId);
const res = await apiClient.get<Beneficiary[]>(`/v1/api/projects/beneficiaries/${hashedProjectId}`);
return res.data || [];
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
}
}
throw err;
}
};
export const getBeneficiary = async (beneficiaryId: string | number): Promise<Beneficiary> => {
@@ -1605,8 +1617,20 @@ export const updateOutcome = async (payload: UpdateOutcomeRequest): Promise<void
// ==========================================
export const getProjectChangeRequests = async (projectId: string | number): Promise<ChangeRequest[]> => {
const hashedProjectId = await toolsService.resolveProjectId(projectId);
const res = await apiClient.get<ChangeRequest[]>(`/v1/api/projects/changerequests/${hashedProjectId}`);
return res.data || [];
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
}
}
throw err;
}
};
export const getChangeRequest = async (changeRequestId: string | number): Promise<ChangeRequest> => {
@@ -1676,8 +1700,20 @@ export const updateComplianceItem = async (payload: UpdateComplianceItemRequest)
// ==========================================
export const getProjectIssues = async (projectId: string | number): Promise<Issue[]> => {
const hashedProjectId = await toolsService.resolveProjectId(projectId);
const res = await apiClient.get<Issue[]>(`/v1/api/projects/issues/${hashedProjectId}`);
return res.data || [];
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
}
}
throw err;
}
};
export const getIssue = async (issueId: string | number): Promise<Issue> => {
@@ -1718,8 +1754,20 @@ export const updateIssue = async (payload: UpdateIssueRequest): Promise<void> =>
// ==========================================
export const getProjectSiteVisits = async (projectId: string | number): Promise<SiteVisit[]> => {
const hashedProjectId = await toolsService.resolveProjectId(projectId);
const res = await apiClient.get<SiteVisit[]>(`/v1/api/projects/sitevisits/${hashedProjectId}`);
return res.data || [];
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
}
}
throw err;
}
};
export const getSiteVisit = async (siteVisitId: string | number): Promise<SiteVisit> => {
@@ -108,12 +108,15 @@ export const SortableTh: React.FC<SortableThProps> = ({
...props
}) => {
const isCurrent = currentColumn === column && direction !== null;
const hasPadding = className.includes('p-') || className.includes('py-') || className.includes('px-');
const defaultPadding = hasPadding ? '' : 'py-3.5 px-4';
const defaultWhitespace = className.includes('whitespace-') ? '' : 'whitespace-nowrap';
return (
<th
{...props}
onClick={() => onSort(column)}
className={`cursor-pointer select-none group transition-colors hover:text-white ${className}`}
className={`cursor-pointer select-none group transition-colors hover:text-white ${defaultPadding} ${defaultWhitespace} ${className}`.trim()}
title={`Click to sort (Ascending, Descending, Clear)`}
>
<div