416 lines
18 KiB
TypeScript
416 lines
18 KiB
TypeScript
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<EvidenceUploadModalProps> = ({
|
|
isOpen,
|
|
onClose,
|
|
project,
|
|
milestone,
|
|
activity,
|
|
onEvidenceUploaded,
|
|
}) => {
|
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
|
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);
|
|
|
|
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]) {
|
|
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(
|
|
<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" />
|
|
|
|
{/* Modal Header */}
|
|
<div className="flex items-center justify-between pb-4 border-b border-white/10">
|
|
<div className="space-y-1">
|
|
<div className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-md bg-emerald-500/10 border border-emerald-500/20 text-[11px] font-mono text-emerald-400">
|
|
<Sparkles className="w-3 h-3" />
|
|
<span>Activity Evidence Submission</span>
|
|
</div>
|
|
<h3 className="text-base font-bold text-white">Upload Activity Deliverable</h3>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="p-1.5 rounded-xl hover:bg-white/10 text-slate-400 hover:text-white transition-colors cursor-pointer"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Context Summary */}
|
|
<div className="my-4 p-3 rounded-2xl bg-slate-950/60 border border-white/5 space-y-1.5 text-xs">
|
|
<div className="text-slate-400">
|
|
Project: <span className="text-slate-200 font-semibold">{project.name || `Project #${project.id}`}</span>
|
|
</div>
|
|
{milestone?.name && (
|
|
<div className="text-slate-400">
|
|
Milestone: <span className="text-emerald-300 font-medium">{milestone.name}</span>
|
|
</div>
|
|
)}
|
|
<div className="text-slate-400">
|
|
Activity: <span className="text-cyan-300 font-medium">{activity.title || `Activity #${activity.id}`}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Error Alerts */}
|
|
{errorMessages.length > 0 && (
|
|
<div className="mb-4 p-3.5 rounded-2xl bg-rose-950/60 border border-rose-500/30 text-rose-200 text-xs space-y-1">
|
|
<div className="flex items-center gap-1.5 font-bold text-rose-300">
|
|
<AlertTriangle className="w-4 h-4 text-rose-400 shrink-0" />
|
|
<span>Upload Notice ({errorMessages.length} issue{errorMessages.length > 1 ? 's' : ''}):</span>
|
|
</div>
|
|
<ul className="list-disc pl-5 space-y-0.5 text-[11px] text-rose-200/90 font-mono">
|
|
{errorMessages.map((msg, idx) => (
|
|
<li key={idx}>{msg}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{/* Success Alert */}
|
|
{successMessage && (
|
|
<div className="mb-4 p-3.5 rounded-2xl bg-emerald-950/70 border border-emerald-500/40 text-emerald-200 text-xs flex items-center gap-2">
|
|
<CheckCircle2 className="w-4 h-4 text-emerald-400 shrink-0" />
|
|
<span>{successMessage}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Form */}
|
|
<form onSubmit={handleSubmit} className="space-y-4 text-xs">
|
|
{/* File Dropzone */}
|
|
<div>
|
|
<label className="block text-slate-300 font-medium mb-1.5">
|
|
Evidence File (Document, Invoice, Certificate, Photo) <span className="text-emerald-400">*</span>
|
|
</label>
|
|
<div className="relative border-2 border-dashed border-white/15 hover:border-emerald-500/50 rounded-2xl p-4 text-center bg-white/[0.02] hover:bg-emerald-500/[0.02] transition-colors cursor-pointer">
|
|
<input
|
|
type="file"
|
|
required
|
|
onChange={handleFileChange}
|
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
|
/>
|
|
<div className="flex flex-col items-center gap-2">
|
|
<div className="p-2.5 rounded-xl bg-emerald-500/10 text-emerald-400">
|
|
<UploadCloud className="w-5 h-5" />
|
|
</div>
|
|
{selectedFile ? (
|
|
<div className="text-center">
|
|
<div className="font-semibold text-emerald-300">{selectedFile.name}</div>
|
|
<div className="text-[11px] text-slate-400">
|
|
{(selectedFile.size / 1024).toFixed(1)} KB
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<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>
|
|
</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>
|
|
<label className="block text-slate-300 font-medium mb-1">Evidence Title</label>
|
|
<input
|
|
type="text"
|
|
placeholder="e.g. Activity Verification Proof"
|
|
value={title}
|
|
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>
|
|
<div>
|
|
<label className="block text-slate-300 font-medium mb-1">Evidence Type</label>
|
|
<select
|
|
value={evidenceType}
|
|
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 => (
|
|
<option key={opt.value} value={opt.value} className="bg-slate-900 text-slate-100">
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Description / Notes */}
|
|
<div>
|
|
<label className="block text-slate-300 font-medium mb-1">Deliverable Notes (Optional)</label>
|
|
<textarea
|
|
rows={2}
|
|
placeholder="Provide verification notes or audit references..."
|
|
value={description}
|
|
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>
|
|
|
|
{/* Modal Actions */}
|
|
<div className="flex items-center justify-end gap-3 pt-3 border-t border-white/10">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
disabled={isSubmitting}
|
|
className="px-4 py-2 rounded-xl border border-white/10 hover:bg-white/5 text-slate-300 font-medium transition-colors cursor-pointer"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={isSubmitting}
|
|
className="flex items-center gap-1.5 px-5 py-2 rounded-xl bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-400 hover:to-teal-400 text-slate-950 font-bold transition-all shadow-[0_4px_16px_rgba(16,185,129,0.3)] hover:shadow-[0_6px_20px_rgba(16,185,129,0.4)] disabled:opacity-50 cursor-pointer active:scale-95"
|
|
>
|
|
{isSubmitting ? (
|
|
<>
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
<span>Uploading...</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Plus className="w-4 h-4" />
|
|
<span>Upload & Record Actual</span>
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
);
|
|
};
|