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": { "get": {
"tags": [ "tags": [
"Projects" "Projects"
@@ -1355,7 +1355,7 @@
] ]
} }
}, },
"/v1/api/projects/changerequests/{projectId}": { "/v1/api/projects/{projectId}/changerequests": {
"get": { "get": {
"tags": [ "tags": [
"Projects" "Projects"
@@ -1573,7 +1573,7 @@
] ]
} }
}, },
"/v1/api/projects/issues/{projectId}": { "/v1/api/projects/{projectId}/issues": {
"get": { "get": {
"tags": [ "tags": [
"Projects" "Projects"
@@ -2308,7 +2308,7 @@
] ]
} }
}, },
"/v1/api/projects/sitevisits/{projectId}": { "/v1/api/projects/{projectId}/sitevisits": {
"get": { "get": {
"tags": [ "tags": [
"Projects" "Projects"
@@ -2740,6 +2740,54 @@
"security": [ "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": { "/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}": { "/v1/api/governance/instruments/{programmeId}": {
"get": { "get": {
"tags": [ "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}": { "/v1/api/governance/portfolios/{portfolioId}": {
"get": { "get": {
"tags": [ "tags": [
@@ -3998,23 +4224,23 @@
] ]
} }
}, },
"/v1/api/governance/organisations/{organisationId}/portfolio": { "/v1/api/governance/organisations/portfolios": {
"get": { "post": {
"tags": [ "tags": [
"Governance" "Governance"
], ],
"description": "Get all portfolios", "description": "Get all portfolios",
"operationId": "getportfolios", "operationId": "getportfolios",
"parameters": [ "requestBody": {
{ "content": {
"name": "organisationId", "application/json; version=1": {
"in": "path", "schema": {
"required": true, "$ref": "#/components/schemas/Pagination"
"schema": { }
"type": "string"
} }
} },
], "required": true
},
"responses": { "responses": {
"200": { "200": {
"description": "OK", "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}": { "/v1/api/governance/tenants/{tenantId}": {
"get": { "get": {
"tags": [ "tags": [
@@ -5935,6 +6226,63 @@
"ApprovalStatus": { "ApprovalStatus": {
"type": "integer" "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": { "Award": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -6158,6 +6506,9 @@
"string" "string"
], ],
"format": "double" "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": { "UpdateChangeRequest": {
"required": [ "required": [
"changeRequestId", "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 { Download, FileSpreadsheet, FileText, ChevronDown } from 'lucide-react';
import { ExportColumn, exportToSpreadsheet, exportToPdf } from '../../utils/exportUtils'; import { ExportColumn, exportToSpreadsheet, exportToPdf } from '../../utils/exportUtils';
import { useAuth } from '../../auth/AuthProvider'; import { useAuth } from '../../auth/AuthProvider';
@@ -25,24 +26,46 @@ export function ExportButton<T>({
className = '', className = '',
}: ExportButtonProps<T>) { }: ExportButtonProps<T>) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [menuPosition, setMenuPosition] = useState<{ top: number; right: number } | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const { user, claims, tenantId } = useAuth(); const { user, claims, tenantId } = useAuth();
const isDisabled = !data || data.length === 0; 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(() => { useEffect(() => {
function handleClickOutside(event: MouseEvent) { 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); setIsOpen(false);
} }
} }
if (isOpen) { if (isOpen) {
updatePosition();
document.addEventListener('mousedown', handleClickOutside); document.addEventListener('mousedown', handleClickOutside);
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true);
} }
return () => { return () => {
document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
}; };
}, [isOpen]); }, [isOpen, updatePosition]);
const handleExportSpreadsheet = () => { const handleExportSpreadsheet = () => {
if (isDisabled) return; if (isDisabled) return;
@@ -96,44 +119,56 @@ export function ExportButton<T>({
<ChevronDown className={`w-3 h-3 transition-transform ${isOpen ? 'rotate-180' : ''}`} /> <ChevronDown className={`w-3 h-3 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button> </button>
{isOpen && !isDisabled && ( {isOpen && !isDisabled && menuPosition && typeof document !== 'undefined' &&
<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"> createPortal(
<div className="p-2 border-b border-slate-800/80"> <div
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-400 px-2 block"> ref={menuRef}
Export Format ({data.length} items) style={{
</span> position: 'fixed',
</div> top: `${menuPosition.top}px`,
<div className="p-1 space-y-0.5"> right: `${menuPosition.right}px`,
<button zIndex: 999999,
type="button" }}
onClick={handleExportSpreadsheet} 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"
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="p-2 border-b border-slate-800/80">
<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"> <span className="text-[10px] font-semibold uppercase tracking-wider text-slate-400 px-2 block">
<FileSpreadsheet className="w-3.5 h-3.5 text-emerald-400" /> Export Format ({data.length} items)
</div> </span>
<div> </div>
<span className="font-semibold block leading-tight">Spreadsheet (.xlsx)</span> <div className="p-1 space-y-0.5">
<span className="text-[10px] text-slate-400">Excel / Google Sheets</span> <button
</div> type="button"
</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 <button
type="button" type="button"
onClick={handleExportPdf} 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" 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"> <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" /> <FileText className="w-3.5 h-3.5 text-blue-400" />
</div> </div>
<div> <div>
<span className="font-semibold block leading-tight">PDF Document (.pdf)</span> <span className="font-semibold block leading-tight">PDF Document (.pdf)</span>
<span className="text-[10px] text-slate-400">Landscape with PFM Logo</span> <span className="text-[10px] text-slate-400">Landscape with PFM Logo</span>
</div> </div>
</button> </button>
</div> </div>
</div> </div>,
)} document.body
)
}
</div> </div>
); );
} }
@@ -494,31 +494,31 @@ export const BeneficiariesPage: React.FC = () => {
</button> </button>
</div> </div>
</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"> <div className="overflow-x-auto">
<table className="w-full text-left border-collapse"> <table className="w-full text-left text-xs border-collapse">
<thead> <thead>
<tr className="border-b border-slate-800/80 bg-slate-900/50 text-[11px] font-semibold text-slate-400 uppercase tracking-wider"> <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}> <SortableTh column="name" currentColumn={benSortCol} direction={benSortDir} onSort={handleBenSort} className="py-3.5 px-5 whitespace-nowrap">
Beneficiary / Cohort Beneficiary / Cohort
</SortableTh> </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 Category
</SortableTh> </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 Location
</SortableTh> </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 Target Headcount
</SortableTh> </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 Reached Headcount
</SortableTh> </SortableTh>
<th className="py-3 px-4">Reach Progress</th> <th className="py-3.5 px-4 font-semibold whitespace-nowrap">Reach Progress</th>
<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> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-800/60 text-xs"> <tbody className="divide-y divide-slate-800/60">
{loadingBens ? ( {loadingBens ? (
<tr> <tr>
<td colSpan={7} className="py-12 text-center text-slate-400"> <td colSpan={7} className="py-12 text-center text-slate-400">
@@ -538,27 +538,27 @@ export const BeneficiariesPage: React.FC = () => {
sortedBens.map((b) => { sortedBens.map((b) => {
const prog = b.targetCount && b.targetCount > 0 ? Math.round(((b.reachedCount || 0) / b.targetCount) * 100) : 0; const prog = b.targetCount && b.targetCount > 0 ? Math.round(((b.reachedCount || 0) / b.targetCount) * 100) : 0;
return ( return (
<tr key={b.id} className="hover:bg-slate-800/30 transition"> <tr key={b.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-4 font-semibold text-white"> <td className="py-3.5 px-5 whitespace-nowrap font-semibold text-white">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Users className="w-3.5 h-3.5 text-blue-400 shrink-0" /> <Users className="w-3.5 h-3.5 text-blue-400 shrink-0" />
<span>{b.name}</span> <span>{b.name}</span>
</div> </div>
</td> </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]"> <span className="bg-slate-800/80 px-2 py-0.5 rounded-md border border-slate-700/60 text-[11px]">
{b.category || 'General'} {b.category || 'General'}
</span> </span>
</td> </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"> <div className="flex items-center gap-1.5 text-slate-400">
<MapPin className="w-3 h-3 text-slate-500" /> <MapPin className="w-3 h-3 text-slate-500" />
<span>{b.location || 'Not Specified'}</span> <span>{b.location || 'Not Specified'}</span>
</div> </div>
</td> </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 whitespace-nowrap 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 whitespace-nowrap 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">
<div className="flex items-center gap-2 max-w-[140px]"> <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="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)}%` }} /> <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> <span className="text-[11px] font-mono text-slate-400">{prog}%</span>
</div> </div>
</td> </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 <button
onClick={() => { onClick={() => {
setSelectedBenForDetail(b); setSelectedBenForDetail(b);
@@ -679,27 +679,27 @@ export const BeneficiariesPage: React.FC = () => {
</div> </div>
</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"> <div className="overflow-x-auto">
<table className="w-full text-left border-collapse"> <table className="w-full text-left text-xs border-collapse">
<thead> <thead>
<tr className="border-b border-slate-800/80 bg-slate-900/50 text-[11px] font-semibold text-slate-400 uppercase tracking-wider"> <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}> <SortableTh column="title" currentColumn={compSortCol} direction={compSortDir} onSort={handleCompSort} className="py-3.5 px-5 whitespace-nowrap">
Compliance Title Compliance Title
</SortableTh> </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 Requirements
</SortableTh> </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 Verification Status
</SortableTh> </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 Created
</SortableTh> </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> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-800/60 text-xs"> <tbody className="divide-y divide-slate-800/60">
{loadingComps ? ( {loadingComps ? (
<tr> <tr>
<td colSpan={5} className="py-12 text-center text-slate-400"> <td colSpan={5} className="py-12 text-center text-slate-400">
@@ -717,8 +717,8 @@ export const BeneficiariesPage: React.FC = () => {
</tr> </tr>
) : ( ) : (
sortedComps.map((c) => ( sortedComps.map((c) => (
<tr key={c.id} className="hover:bg-slate-800/30 transition"> <tr key={c.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-4 font-semibold text-white"> <td className="py-3.5 px-5 whitespace-nowrap font-semibold text-white">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ShieldCheck className="w-3.5 h-3.5 text-cyan-400 shrink-0" /> <ShieldCheck className="w-3.5 h-3.5 text-cyan-400 shrink-0" />
<span>{c.title}</span> <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 || ''}> <td className="py-3.5 px-4 text-slate-300 max-w-xs truncate" title={c.requirements || ''}>
{c.requirements || 'None'} {c.requirements || 'None'}
</td> </td>
<td className="py-3.5 px-4">{getApprovalStatusBadge(c.status)}</td> <td className="py-3.5 px-4 whitespace-nowrap">{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 whitespace-nowrap text-slate-400">{formatDate(c.createdAt)}</td>
<td className="py-3.5 px-4 text-right"> <td className="py-3.5 px-5 text-right whitespace-nowrap">
<button <button
onClick={() => handleOpenEditComp(c)} onClick={() => handleOpenEditComp(c)}
className="p-1 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition cursor-pointer" 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> </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"> <div className="overflow-x-auto">
<table className="w-full text-left border-collapse"> <table className="w-full text-left text-xs border-collapse">
<thead> <thead>
<tr className="border-b border-slate-800/80 bg-slate-900/50 text-[11px] font-semibold text-slate-400 uppercase tracking-wider"> <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}> <SortableTh column="title" currentColumn={issueSortCol} direction={issueSortDir} onSort={handleIssueSort} className="py-3.5 px-5 whitespace-nowrap">
Issue Summary Issue Summary
</SortableTh> </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 Priority
</SortableTh> </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 Assigned To
</SortableTh> </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 Status
</SortableTh> </SortableTh>
<th className="py-3 px-4">Resolution Notes</th> <th className="py-3.5 px-4 font-semibold whitespace-nowrap">Resolution Notes</th>
<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> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-800/60 text-xs"> <tbody className="divide-y divide-slate-800/60">
{loadingIssues ? ( {loadingIssues ? (
<tr> <tr>
<td colSpan={6} className="py-12 text-center text-slate-400"> <td colSpan={6} className="py-12 text-center text-slate-400">
@@ -575,8 +575,8 @@ export const ChangeManagementPage: React.FC = () => {
sortedIssues.map((iss) => { sortedIssues.map((iss) => {
const isResolved = iss.status === ActivityStatus.Inactive || !!iss.resolvedAt; const isResolved = iss.status === ActivityStatus.Inactive || !!iss.resolvedAt;
return ( return (
<tr key={iss.id} className="hover:bg-slate-800/30 transition"> <tr key={iss.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-4 font-semibold text-white"> <td className="py-3.5 px-5 font-semibold text-white">
<div className="flex items-start gap-2.5"> <div className="flex items-start gap-2.5">
<AlertOctagon <AlertOctagon
className={`w-4 h-4 mt-0.5 shrink-0 ${isResolved ? 'text-emerald-400' : 'text-rose-400'}`} 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>
</div> </div>
</td> </td>
<td className="py-3.5 px-4">{getPriorityBadge(iss.priority)}</td> <td className="py-3.5 px-4 whitespace-nowrap">{getPriorityBadge(iss.priority)}</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"> <div className="flex items-center gap-1.5">
<User className="w-3.5 h-3.5 text-slate-400" /> <User className="w-3.5 h-3.5 text-slate-400" />
<span className="truncate max-w-[150px]">{iss.assignedTo ? getUserEmail(iss.assignedTo) : 'Unassigned'}</span> <span className="truncate max-w-[150px]">{iss.assignedTo ? getUserEmail(iss.assignedTo) : 'Unassigned'}</span>
</div> </div>
</td> </td>
<td className="py-3.5 px-4"> <td className="py-3.5 px-4 whitespace-nowrap">
{isResolved ? ( {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"> <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 <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"> <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>} {iss.resolution || <span className="italic text-slate-600">Pending resolution...</span>}
</td> </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 <button
onClick={() => openResolveIssue(iss)} 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" 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> </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"> <div className="overflow-x-auto">
<table className="w-full text-left border-collapse"> <table className="w-full text-left text-xs border-collapse">
<thead> <thead>
<tr className="border-b border-slate-800/80 bg-slate-900/50 text-[11px] font-semibold text-slate-400 uppercase tracking-wider"> <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}> <SortableTh column="title" currentColumn={changeSortCol} direction={changeSortDir} onSort={handleChangeSort} className="py-3.5 px-5 whitespace-nowrap">
Change Proposal Change Proposal
</SortableTh> </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 Justification
</SortableTh> </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 Revised Budget
</SortableTh> </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 Governance Status
</SortableTh> </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> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-800/60 text-xs"> <tbody className="divide-y divide-slate-800/60">
{loadingChanges ? ( {loadingChanges ? (
<tr> <tr>
<td colSpan={5} className="py-12 text-center text-slate-400"> <td colSpan={5} className="py-12 text-center text-slate-400">
@@ -771,8 +771,8 @@ export const ChangeManagementPage: React.FC = () => {
</tr> </tr>
) : ( ) : (
sortedChanges.map((cr) => ( sortedChanges.map((cr) => (
<tr key={cr.id} className="hover:bg-slate-800/30 transition"> <tr key={cr.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-4 font-semibold text-white"> <td className="py-3.5 px-5 whitespace-nowrap font-semibold text-white">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<GitPullRequest className="w-4 h-4 text-amber-400 shrink-0" /> <GitPullRequest className="w-4 h-4 text-amber-400 shrink-0" />
<span>{cr.title}</span> <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 || ''}> <td className="py-3.5 px-4 text-slate-300 max-w-xs truncate" title={cr.justification || ''}>
{cr.justification || 'None'} {cr.justification || 'None'}
</td> </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)} {formatZAR(cr.revisedBudget || 0)}
</td> </td>
<td className="py-3.5 px-4">{getApprovalStatusBadge(cr.status)}</td> <td className="py-3.5 px-4 whitespace-nowrap">{getApprovalStatusBadge(cr.status)}</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">
{cr.status === ApprovalStatus.Approved ? ( {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"> <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 Resolved & Enacted
@@ -1268,7 +1268,7 @@ export const SettingsPage: React.FC = () => {
</div> </div>
{/* Filter & Action Toolbar */} {/* 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"> <div className="relative flex-1 max-w-md">
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-3" /> <Search className="w-4 h-4 text-slate-500 absolute left-3 top-3" />
<input <input
@@ -353,27 +353,27 @@ export const SiteVisitsPage: React.FC = () => {
</div> </div>
</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"> <div className="overflow-x-auto">
<table className="w-full text-left border-collapse"> <table className="w-full text-left text-xs border-collapse">
<thead> <thead>
<tr className="border-b border-slate-800/80 bg-slate-900/50 text-[11px] font-semibold text-slate-400 uppercase tracking-wider"> <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}> <SortableTh column="inspectorName" currentColumn={visitSortCol} direction={visitSortDir} onSort={handleVisitSort} className="py-3.5 px-5 whitespace-nowrap">
Inspector Name Inspector Name
</SortableTh> </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 Inspection Date
</SortableTh> </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 Field Observations & Findings
</SortableTh> </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 Verification Status
</SortableTh> </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> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-800/60 text-xs"> <tbody className="divide-y divide-slate-800/60">
{loadingVisits ? ( {loadingVisits ? (
<tr> <tr>
<td colSpan={5} className="py-12 text-center text-slate-400"> <td colSpan={5} className="py-12 text-center text-slate-400">
@@ -391,14 +391,14 @@ export const SiteVisitsPage: React.FC = () => {
</tr> </tr>
) : ( ) : (
sortedVisits.map((v) => ( sortedVisits.map((v) => (
<tr key={v.id} className="hover:bg-slate-800/30 transition"> <tr key={v.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-4 font-semibold text-white"> <td className="py-3.5 px-5 whitespace-nowrap font-semibold text-white">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<User className="w-3.5 h-3.5 text-emerald-400 shrink-0" /> <User className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
<span>{v.inspectorName || 'Field Officer'}</span> <span>{v.inspectorName || 'Field Officer'}</span>
</div> </div>
</td> </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"> <div className="flex items-center gap-1.5 text-slate-300">
<Calendar className="w-3 h-3 text-slate-500" /> <Calendar className="w-3 h-3 text-slate-500" />
<span>{formatDate(v.visitedAt)}</span> <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 || ''}> <td className="py-3.5 px-4 text-slate-300 max-w-sm truncate" title={v.findings || ''}>
{v.findings || 'No findings recorded.'} {v.findings || 'No findings recorded.'}
</td> </td>
<td className="py-3.5 px-4">{getApprovalStatusBadge(v.status)}</td> <td className="py-3.5 px-4 whitespace-nowrap">{getApprovalStatusBadge(v.status)}</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 <button
onClick={() => { onClick={() => {
setSelectedVisitForDetail(v); setSelectedVisitForDetail(v);
@@ -1531,8 +1531,20 @@ export const updateTenant = async (payload: UpdateTenantRequest): Promise<void>
// ========================================== // ==========================================
export const getProjectBeneficiaries = async (projectId: string | number): Promise<Beneficiary[]> => { export const getProjectBeneficiaries = async (projectId: string | number): Promise<Beneficiary[]> => {
const hashedProjectId = await toolsService.resolveProjectId(projectId); const hashedProjectId = await toolsService.resolveProjectId(projectId);
const res = await apiClient.get<Beneficiary[]>(`/v1/api/projects/beneficiaries/${hashedProjectId}`); try {
return res.data || []; 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> => { 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[]> => { export const getProjectChangeRequests = async (projectId: string | number): Promise<ChangeRequest[]> => {
const hashedProjectId = await toolsService.resolveProjectId(projectId); const hashedProjectId = await toolsService.resolveProjectId(projectId);
const res = await apiClient.get<ChangeRequest[]>(`/v1/api/projects/changerequests/${hashedProjectId}`); try {
return res.data || []; 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> => { 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[]> => { export const getProjectIssues = async (projectId: string | number): Promise<Issue[]> => {
const hashedProjectId = await toolsService.resolveProjectId(projectId); const hashedProjectId = await toolsService.resolveProjectId(projectId);
const res = await apiClient.get<Issue[]>(`/v1/api/projects/issues/${hashedProjectId}`); try {
return res.data || []; 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> => { 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[]> => { export const getProjectSiteVisits = async (projectId: string | number): Promise<SiteVisit[]> => {
const hashedProjectId = await toolsService.resolveProjectId(projectId); const hashedProjectId = await toolsService.resolveProjectId(projectId);
const res = await apiClient.get<SiteVisit[]>(`/v1/api/projects/sitevisits/${hashedProjectId}`); try {
return res.data || []; 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> => { export const getSiteVisit = async (siteVisitId: string | number): Promise<SiteVisit> => {
@@ -108,12 +108,15 @@ export const SortableTh: React.FC<SortableThProps> = ({
...props ...props
}) => { }) => {
const isCurrent = currentColumn === column && direction !== null; 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 ( return (
<th <th
{...props} {...props}
onClick={() => onSort(column)} 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)`} title={`Click to sort (Ascending, Descending, Clear)`}
> >
<div <div