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, Sparkles, Target, BarChart3, } from 'lucide-react'; interface EvidenceUploadModalProps { isOpen: boolean; onClose: () => void; project: Project; milestone?: Milestone | null; activity: Activity; onEvidenceUploaded: () => void; } export const EvidenceUploadModal: React.FC = ({ isOpen, onClose, project, milestone, activity, onEvidenceUploaded, }) => { const [selectedFile, setSelectedFile] = useState(null); const [title, setTitle] = useState(''); const [evidenceType, setEvidenceType] = useState(4); // Default: FinancialInvoiceOrReceipt const [description, setDescription] = useState(''); const [selectedIndicatorId, setSelectedIndicatorId] = useState(''); const [actualAmount, setActualAmount] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); const [errorMessages, setErrorMessages] = useState([]); const [successMessage, setSuccessMessage] = useState(null); const projectId = project?.id || 0; // Requirement #4: Fetch linked indicators for project & activity const { data: projectIndicators = [], isLoading: isLoadingIndicators } = useQuery({ 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) => { if (e.target.files && e.target.files[0]) { const file = e.target.files[0]; setSelectedFile(file); if (!title) { setTitle(file.name.replace(/\.[^/.]+$/, '')); } } }; const selectedIndicatorObj = availableIndicators.find( i => String(i.id) === String(selectedIndicatorId) ); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setErrorMessages([]); setSuccessMessage(null); if (!selectedFile) { setErrorMessages(['A supporting document file is required. Please choose a file to upload.']); return; } 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.']); return; } setIsSubmitting(true); try { // 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', 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); // 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(); }, 800); } catch (err: any) { console.error('[Evidence Upload Error]:', err); const errors = extractApiErrors(err); setErrorMessages( errors.length > 0 ? errors : ['Failed to upload evidence file. Please verify parameters and try again.'] ); } finally { setIsSubmitting(false); } }; return createPortal(
{/* Ambient background glow */}
{/* Modal Header */}
Activity Evidence Submission

Upload Activity Deliverable

{/* Context Summary */}
Project: {project.name || `Project #${project.id}`}
{milestone?.name && (
Milestone: {milestone.name}
)}
Activity: {activity.title || `Activity #${activity.id}`}
{/* Error Alerts */} {errorMessages.length > 0 && (
Upload Notice ({errorMessages.length} issue{errorMessages.length > 1 ? 's' : ''}):
    {errorMessages.map((msg, idx) => (
  • {msg}
  • ))}
)} {/* Success Alert */} {successMessage && (
{successMessage}
)} {/* Form */}
{/* File Dropzone */}
{selectedFile ? (
{selectedFile.name}
{(selectedFile.size / 1024).toFixed(1)} KB
) : (
Click to choose document

PDF, DOCX, XLSX, PNG, JPG (Multiple uploads supported)

)}
{/* Requirement #4: Mandatory Indicator Selection & Actual Amount Recording */}
Linked Indicator & Performance Actual (Mandatory)
{isLoadingIndicators ? (
Loading indicators...
) : availableIndicators.length === 0 ? (
No indicators currently recorded for this project. Please contact programme management or select standard output.
) : ( )}
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" />
{selectedIndicatorObj && (

Baseline: {selectedIndicatorObj.baselineAmount} • Target: {selectedIndicatorObj.targetAmount} • Current Actual: {selectedIndicatorObj.actualAmount}

)}
{/* Evidence Title & Type */}
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" />
{/* Description / Notes */}