Refactored dashboard
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
import React, { useState } from 'react';
|
||||
import { api, extractApiErrors } from '../../services/api';
|
||||
import { Activity, Milestone, Project, EvidenceType } from '../../services/api/models';
|
||||
import {
|
||||
X,
|
||||
UploadCloud,
|
||||
FileText,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
Plus,
|
||||
Sparkles,
|
||||
Link as LinkIcon
|
||||
} from 'lucide-react';
|
||||
|
||||
interface EvidenceUploadModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
project: Project;
|
||||
milestone?: Milestone;
|
||||
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 [documentUrl, setDocumentUrl] = useState('');
|
||||
const [evidenceType, setEvidenceType] = useState<EvidenceType>(1); // Invoice / Document
|
||||
const [description, setDescription] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errorMessages, setErrorMessages] = useState<string[]>([]);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
if (!isOpen) 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 handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErrorMessages([]);
|
||||
setSuccessMessage(null);
|
||||
|
||||
if (!title.trim() && !selectedFile && !documentUrl.trim()) {
|
||||
setErrorMessages(['Please provide an evidence title, file upload, or document URL.']);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// Build upload payload
|
||||
const formData = new FormData();
|
||||
if (selectedFile) {
|
||||
formData.append('file', selectedFile);
|
||||
}
|
||||
formData.append('title', title.trim() || selectedFile?.name || 'Evidence Deliverable');
|
||||
formData.append('projectId', String(project.id || ''));
|
||||
if (milestone?.id) formData.append('milestoneId', String(milestone.id));
|
||||
if (activity?.id) formData.append('activityId', String(activity.id));
|
||||
formData.append('type', String(evidenceType));
|
||||
if (documentUrl.trim()) formData.append('documentUrl', documentUrl.trim());
|
||||
if (description.trim()) formData.append('description', description.trim());
|
||||
|
||||
// Also support JSON payload fallback if server expects JSON body
|
||||
try {
|
||||
await api.evidence.createEvidenceFile(formData);
|
||||
} catch (err: any) {
|
||||
// Fallback: Attempt JSON payload if multipart failed or route expects JSON
|
||||
const jsonPayload = {
|
||||
projectId: project.id,
|
||||
milestoneId: milestone?.id,
|
||||
activityId: activity.id,
|
||||
title: title.trim() || selectedFile?.name || 'Evidence Deliverable',
|
||||
type: evidenceType,
|
||||
documentUrl: documentUrl.trim() || (selectedFile ? `s3://evidence/${selectedFile.name}` : undefined),
|
||||
description: description.trim(),
|
||||
};
|
||||
await api.evidence.createEvidenceFile(jsonPayload);
|
||||
}
|
||||
|
||||
setSuccessMessage('Evidence successfully uploaded and linked to activity!');
|
||||
setTimeout(() => {
|
||||
onEvidenceUploaded();
|
||||
onClose();
|
||||
}, 1000);
|
||||
} catch (err: any) {
|
||||
console.error('[Evidence Upload Error]:', err);
|
||||
const errors = extractApiErrors(err);
|
||||
setErrorMessages(errors);
|
||||
} 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">
|
||||
{/* 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
|
||||
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 (Comma-delimited error list from ProblemDetails) */}
|
||||
{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>Validation 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, Receipt, Report)</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"
|
||||
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 upload file</span>
|
||||
<p className="text-[11px] text-slate-500 mt-0.5">PDF, DOCX, XLSX, PNG, JPG (Max 25MB)</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</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. Audit Signoff Dossier"
|
||||
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) as EvidenceType)}
|
||||
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"
|
||||
>
|
||||
<option value={0}>Document</option>
|
||||
<option value={1}>Invoice</option>
|
||||
<option value={2}>Receipt</option>
|
||||
<option value={3}>Site Photo</option>
|
||||
<option value={4}>Verification Report</option>
|
||||
<option value={5}>Other Deliverable</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* External Secure URL (Optional) */}
|
||||
<div>
|
||||
<label className="block text-slate-300 font-medium mb-1">Document Link / S3 URI (Optional)</label>
|
||||
<div className="relative">
|
||||
<LinkIcon className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://... or s3://..."
|
||||
value={documentUrl}
|
||||
onChange={(e) => setDocumentUrl(e.target.value)}
|
||||
className="w-full pl-8 pr-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>
|
||||
|
||||
{/* Description / Notes */}
|
||||
<div>
|
||||
<label className="block text-slate-300 font-medium mb-1">Deliverable Notes</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 & Attach Evidence</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user