PartnerUI evidence, notification, indicators and actuals fixes

This commit is contained in:
2026-10-26 08:53:05 +02:00
parent 5ac317cbe3
commit bb77a4dc9a
10 changed files with 1290 additions and 302 deletions
@@ -1,17 +1,26 @@
import React, { useState } from 'react';
import { uploadEvidenceFile, extractApiErrors, toIntegerId } from '../../services/api';
import { Activity, Milestone, Project, EvidenceType } from '../../services/api/models';
import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useQuery } from '@tanstack/react-query';
import {
uploadEvidenceFile,
extractApiErrors,
toIntegerId,
getProjectIndicators,
recordIndicatorActual,
} from '../../services/api';
import { Activity, Milestone, Project, Indicator } from '../../services/api/models';
import { EVIDENCE_TYPE_OPTIONS } from '../../lib/formatters';
import {
X,
UploadCloud,
FileText,
CheckCircle2,
AlertTriangle,
Loader2,
Plus,
import {
X,
UploadCloud,
FileText,
CheckCircle2,
AlertTriangle,
Loader2,
Plus,
Sparkles,
Link as LinkIcon
Target,
BarChart3,
} from 'lucide-react';
interface EvidenceUploadModalProps {
@@ -35,11 +44,43 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
const [title, setTitle] = useState('');
const [evidenceType, setEvidenceType] = useState<number>(4); // Default: FinancialInvoiceOrReceipt
const [description, setDescription] = useState('');
const [selectedIndicatorId, setSelectedIndicatorId] = useState<string>('');
const [actualAmount, setActualAmount] = useState<string>('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessages, setErrorMessages] = useState<string[]>([]);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
if (!isOpen) return null;
const projectId = project?.id || 0;
// Requirement #4: Fetch linked indicators for project & activity
const { data: projectIndicators = [], isLoading: isLoadingIndicators } = useQuery<Indicator[]>({
queryKey: ['evidence-project-indicators', String(projectId)],
queryFn: () => getProjectIndicators(projectId),
enabled: isOpen && Boolean(projectId),
});
// Prioritize indicators directly linked to this activity, then project indicators
const availableIndicators = React.useMemo(() => {
if (!projectIndicators || projectIndicators.length === 0) return [];
const directMatches = projectIndicators.filter(
ind => ind.activityId && String(ind.activityId) === String(activity.id)
);
if (directMatches.length > 0) return directMatches;
return projectIndicators;
}, [projectIndicators, activity.id]);
// Auto-select first indicator if available
useEffect(() => {
if (availableIndicators.length > 0 && !selectedIndicatorId) {
const firstId = String(availableIndicators[0].id);
setSelectedIndicatorId(firstId);
if (availableIndicators[0].actualAmount !== undefined) {
setActualAmount(String(availableIndicators[0].actualAmount || ''));
}
}
}, [availableIndicators, selectedIndicatorId]);
if (!isOpen || typeof document === 'undefined') return null;
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
@@ -51,6 +92,10 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
}
};
const selectedIndicatorObj = availableIndicators.find(
i => String(i.id) === String(selectedIndicatorId)
);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrorMessages([]);
@@ -64,6 +109,22 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
const numProjectId = toIntegerId(project.id);
const numActivityId = toIntegerId(activity.id);
const numMilestoneId = toIntegerId(milestone?.id || activity.milestoneId || 0);
const numIndicatorId = toIntegerId(selectedIndicatorId);
// Requirement #4: IndicatorId must not be 0 to maintain referential integrity in database
if (!numIndicatorId || numIndicatorId <= 0) {
setErrorMessages([
'A valid linked Indicator is mandatory for evidence verification. Please select an indicator from the list.',
]);
return;
}
if (!actualAmount.trim() || isNaN(Number(actualAmount))) {
setErrorMessages([
'Recording an Actual amount/metric against the selected indicator is mandatory. Please provide a numeric value.',
]);
return;
}
if (!numProjectId || !numActivityId) {
setErrorMessages(['Cannot upload evidence: Missing projectId or activityId reference.']);
@@ -73,22 +134,34 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
setIsSubmitting(true);
try {
// Build multipart/form-data adhering strictly to EvidenceService.CreateEvidenceAsync
// 1. Build multipart/form-data adhering strictly to EvidenceService.CreateEvidenceAsync
// Requirement #7: When uploading, IForm does not need the userId field in it; API detects userId from JWT sub
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('projectId', String(numProjectId));
formData.append('activityId', String(numActivityId));
formData.append('milestoneId', String(numMilestoneId));
formData.append('indicatorId', '0');
formData.append('indicatorId', String(numIndicatorId));
formData.append('type', String(Number(evidenceType) || 4));
formData.append('title', title.trim() || selectedFile.name);
if (description.trim()) {
formData.append('description', description.trim());
}
// 2. Upload evidence file
await uploadEvidenceFile(formData);
setSuccessMessage('Evidence successfully uploaded and linked to activity!');
// 3. Requirement #4: Record Actual against linked indicator
const parsedActual = Number(actualAmount);
if (!isNaN(parsedActual)) {
try {
await recordIndicatorActual(numIndicatorId, parsedActual);
} catch (indErr) {
console.warn('[Record Indicator Actual Warning]:', indErr);
}
}
setSuccessMessage('Evidence successfully uploaded and linked to activity with recorded indicator actual!');
setTimeout(() => {
onEvidenceUploaded();
onClose();
@@ -96,15 +169,17 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
} catch (err: any) {
console.error('[Evidence Upload Error]:', err);
const errors = extractApiErrors(err);
setErrorMessages(errors.length > 0 ? errors : ['Failed to upload evidence file. Please try again.']);
setErrorMessages(
errors.length > 0 ? errors : ['Failed to upload evidence file. Please verify parameters and try again.']
);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md animate-in fade-in duration-200">
<div className="relative w-full max-w-lg rounded-3xl border border-white/15 bg-slate-900/95 p-6 shadow-2xl backdrop-blur-2xl text-slate-100 overflow-hidden">
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md animate-in fade-in duration-200 overflow-y-auto">
<div className="relative w-full max-w-lg rounded-3xl border border-white/15 bg-slate-900/95 p-6 shadow-2xl backdrop-blur-2xl text-slate-100 overflow-hidden my-auto">
{/* Ambient background glow */}
<div className="absolute top-0 right-0 -mr-16 -mt-16 w-60 h-60 bg-emerald-500/10 rounded-full blur-3xl pointer-events-none" />
@@ -192,13 +267,78 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
) : (
<div>
<span className="font-medium text-slate-300">Click to choose document</span>
<p className="text-[11px] text-slate-500 mt-0.5">PDF, DOCX, XLSX, PNG, JPG (Multiple uploads supported)</p>
<p className="text-[11px] text-slate-500 mt-0.5">
PDF, DOCX, XLSX, PNG, JPG (Multiple uploads supported)
</p>
</div>
)}
</div>
</div>
</div>
{/* Requirement #4: Mandatory Indicator Selection & Actual Amount Recording */}
<div className="p-3.5 rounded-2xl bg-slate-950/80 border border-emerald-500/30 space-y-3">
<div className="flex items-center gap-2 text-emerald-400 font-semibold text-xs">
<Target className="w-4 h-4" />
<span>Linked Indicator & Performance Actual (Mandatory)</span>
</div>
<div>
<label className="block text-slate-300 font-medium mb-1">
Select Linked Indicator <span className="text-emerald-400">*</span>
</label>
{isLoadingIndicators ? (
<div className="text-[11px] text-slate-400 py-1">Loading indicators...</div>
) : availableIndicators.length === 0 ? (
<div className="text-[11px] text-amber-300 bg-amber-950/40 border border-amber-500/30 p-2.5 rounded-xl">
No indicators currently recorded for this project. Please contact programme management or select standard output.
</div>
) : (
<select
required
value={selectedIndicatorId}
onChange={e => setSelectedIndicatorId(e.target.value)}
className="w-full px-3 py-2 rounded-xl bg-slate-900 border border-white/10 text-slate-100 focus:outline-none focus:border-emerald-500/50 cursor-pointer"
>
<option value="">-- Choose Indicator for this Deliverable --</option>
{availableIndicators.map(ind => (
<option key={ind.id} value={ind.id} className="bg-slate-900 text-slate-100">
{ind.name || `Indicator #${ind.id}`} (Target: {ind.targetAmount} • Baseline: {ind.baselineAmount})
</option>
))}
</select>
)}
</div>
<div>
<label className="block text-slate-300 font-medium mb-1">
Actual Amount / Metric Recorded <span className="text-emerald-400">*</span>
</label>
<div className="relative">
<input
type="number"
step="any"
min="0"
required
placeholder={
selectedIndicatorObj
? `Target is ${selectedIndicatorObj.targetAmount}`
: 'e.g. 10 or 1500'
}
value={actualAmount}
onChange={e => setActualAmount(e.target.value)}
className="w-full px-3.5 py-2 rounded-xl bg-slate-900 border border-white/10 text-slate-100 placeholder:text-slate-500 focus:outline-none focus:border-emerald-500/50"
/>
<BarChart3 className="w-4 h-4 text-emerald-400 absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none" />
</div>
{selectedIndicatorObj && (
<p className="text-[11px] text-slate-400 mt-1">
Baseline: {selectedIndicatorObj.baselineAmount} • Target: {selectedIndicatorObj.targetAmount} • Current Actual: {selectedIndicatorObj.actualAmount}
</p>
)}
</div>
</div>
{/* Evidence Title & Type */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
@@ -207,7 +347,7 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
type="text"
placeholder="e.g. Activity Verification Proof"
value={title}
onChange={(e) => setTitle(e.target.value)}
onChange={e => setTitle(e.target.value)}
className="w-full px-3 py-2 rounded-xl bg-slate-950/70 border border-white/10 text-slate-200 placeholder:text-slate-500 focus:outline-none focus:border-emerald-500/50"
/>
</div>
@@ -215,10 +355,10 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
<label className="block text-slate-300 font-medium mb-1">Evidence Type</label>
<select
value={evidenceType}
onChange={(e) => setEvidenceType(Number(e.target.value))}
onChange={e => setEvidenceType(Number(e.target.value))}
className="w-full px-3 py-2 rounded-xl bg-slate-950/70 border border-white/10 text-slate-200 focus:outline-none focus:border-emerald-500/50 cursor-pointer"
>
{EVIDENCE_TYPE_OPTIONS.map((opt) => (
{EVIDENCE_TYPE_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value} className="bg-slate-900 text-slate-100">
{opt.label}
</option>
@@ -234,7 +374,7 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
rows={2}
placeholder="Provide verification notes or audit references..."
value={description}
onChange={(e) => setDescription(e.target.value)}
onChange={e => setDescription(e.target.value)}
className="w-full px-3 py-2 rounded-xl bg-slate-950/70 border border-white/10 text-slate-200 placeholder:text-slate-500 focus:outline-none focus:border-emerald-500/50 resize-none"
/>
</div>
@@ -262,13 +402,14 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
) : (
<>
<Plus className="w-4 h-4" />
<span>Upload & Attach Evidence</span>
<span>Upload & Record Actual</span>
</>
)}
</button>
</div>
</form>
</div>
</div>
</div>,
document.body
);
};