dev1/src/components/Admin/RosterUpload.js
Josh c0a68eb81c
Some checks failed
ci/woodpecker/manual/woodpecker Pipeline failed
Big one - admin portal and DOB COPPA compliance
2025-10-30 10:28:38 +00:00

439 lines
17 KiB
JavaScript

import { useState, useEffect } from 'react';
import axios from 'axios';
import { Upload, FileText, AlertCircle, CheckCircle, Download, History } from 'lucide-react';
import AdminLayout from './AdminLayout.js';
export default function RosterUpload() {
const [file, setFile] = useState(null);
const [preview, setPreview] = useState([]);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(null);
const [history, setHistory] = useState([]);
const [loadingHistory, setLoadingHistory] = useState(true);
const [activeTab, setActiveTab] = useState('upload'); // 'upload' | 'history'
const [orgType, setOrgType] = useState(null);
const [loadingOrgType, setLoadingOrgType] = useState(true);
useEffect(() => {
fetchHistory();
fetchOrgType();
}, []);
const fetchHistory = async () => {
try {
const { data } = await axios.get('/api/admin/roster/history', {
withCredentials: true
});
setHistory(data);
} catch (err) {
console.error('Failed to load roster history:', err);
} finally {
setLoadingHistory(false);
}
};
const fetchOrgType = async () => {
try {
const { data } = await axios.get('/api/admin/organization/profile', {
withCredentials: true
});
setOrgType(data.organization_type);
} catch (err) {
console.error('Failed to load organization type:', err);
setOrgType(''); // Default to empty if can't fetch
} finally {
setLoadingOrgType(false);
}
};
const handleFileChange = (e) => {
const selectedFile = e.target.files[0];
if (!selectedFile) return;
if (!selectedFile.name.endsWith('.csv')) {
setError('Please select a CSV file');
return;
}
setFile(selectedFile);
setError(null);
setSuccess(null);
// Parse CSV for preview
const reader = new FileReader();
reader.onload = (event) => {
const text = event.target.result;
const lines = text.split('\n').filter(line => line.trim());
if (lines.length === 0) {
setError('CSV file is empty');
setPreview([]);
return;
}
// Parse header
const headers = lines[0].split(',').map(h => h.trim().toLowerCase());
// Validate required columns (grade_level required for K-12 schools only)
const requiredCols = orgType === 'K-12 School'
? ['email', 'firstname', 'lastname', 'grade_level']
: ['email', 'firstname', 'lastname'];
const missing = requiredCols.filter(col => !headers.includes(col));
if (missing.length > 0) {
setError(`Missing required columns: ${missing.join(', ')}`);
setPreview([]);
return;
}
// Parse rows (limit preview to first 10)
const rows = [];
for (let i = 1; i < Math.min(lines.length, 11); i++) {
const values = lines[i].split(',').map(v => v.trim());
const row = {};
headers.forEach((header, idx) => {
row[header] = values[idx] || '';
});
// Basic validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
let valid = emailRegex.test(row.email) && row.firstname && row.lastname;
// For K-12 schools, grade_level is required and must be 9-12
if (orgType === 'K-12 School') {
if (row.grade_level && row.grade_level.trim()) {
const gradeLevel = parseInt(row.grade_level);
valid = valid && !isNaN(gradeLevel) && gradeLevel >= 9 && gradeLevel <= 12;
} else {
valid = false; // Missing required grade_level
}
} else {
// For non-K12, grade_level is optional but if provided must be 9-12
if (row.grade_level && row.grade_level.trim()) {
const gradeLevel = parseInt(row.grade_level);
valid = valid && !isNaN(gradeLevel) && gradeLevel >= 9 && gradeLevel <= 12;
}
}
row.valid = valid;
rows.push(row);
}
setPreview(rows);
if (lines.length > 11) {
setError(null);
}
};
reader.readAsText(selectedFile);
};
const handleUpload = async () => {
if (!file || preview.length === 0) {
setError('Please select a file first');
return;
}
setUploading(true);
setError(null);
setSuccess(null);
try {
// Use the already-parsed preview data but parse entire file
const text = await file.text();
const lines = text.split('\n').filter(line => line.trim());
const headers = lines[0].split(',').map(h => h.trim().toLowerCase());
// Parse ALL rows (not just preview)
const students = [];
for (let i = 1; i < lines.length; i++) {
const values = lines[i].split(',').map(v => v.trim());
const student = {};
headers.forEach((header, idx) => {
student[header] = values[idx] || '';
});
// Only include if has required fields
if (student.email && student.firstname && student.lastname) {
const studentData = {
email: student.email,
firstname: student.firstname,
lastname: student.lastname,
status: student.status || 'active'
};
// Include grade_level only if provided and valid
if (student.grade_level && student.grade_level.trim()) {
const gradeLevel = parseInt(student.grade_level);
if (!isNaN(gradeLevel) && gradeLevel >= 9 && gradeLevel <= 12) {
studentData.grade_level = gradeLevel;
}
}
students.push(studentData);
}
}
if (students.length === 0) {
setError('No valid students found in CSV');
setUploading(false);
return;
}
// Send parsed JSON to backend
const { data } = await axios.post('/api/admin/roster/upload',
{ students },
{ withCredentials: true }
);
setSuccess(`Successfully added ${data.results.added} students, updated ${data.results.updated} existing students${data.results.errors.length > 0 ? `, ${data.results.errors.length} errors` : ''}`);
setFile(null);
setPreview([]);
// Refresh history
fetchHistory();
// Reset file input
const fileInput = document.getElementById('roster-file-input');
if (fileInput) fileInput.value = '';
} catch (err) {
setError(err.response?.data?.error || 'Failed to upload roster');
} finally {
setUploading(false);
}
};
const downloadTemplate = () => {
const csv = orgType === 'K-12 School'
? 'email,firstname,lastname,grade_level\nstudent1@example.com,John,Doe,11\nstudent2@example.com,Jane,Smith,12'
: 'email,firstname,lastname\nstudent1@example.com,John,Doe\nstudent2@example.com,Jane,Smith';
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'roster_template.csv';
a.click();
URL.revokeObjectURL(url);
};
return (
<AdminLayout>
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Roster Management</h1>
<p className="mt-1 text-sm text-gray-500">
Upload student rosters and view upload history
</p>
</div>
{/* Tabs */}
<div className="border-b border-gray-200">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setActiveTab('upload')}
className={`${
activeTab === 'upload'
? 'border-aptiva text-aptiva'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
} whitespace-nowrap border-b-2 py-4 px-1 text-sm font-medium`}
>
<Upload className="inline-block w-4 h-4 mr-2" />
Upload Roster
</button>
<button
onClick={() => setActiveTab('history')}
className={`${
activeTab === 'history'
? 'border-aptiva text-aptiva'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
} whitespace-nowrap border-b-2 py-4 px-1 text-sm font-medium`}
>
<History className="inline-block w-4 h-4 mr-2" />
Upload History
</button>
</nav>
</div>
{/* Upload Tab */}
{activeTab === 'upload' && (
<div className="space-y-6">
{/* Instructions */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 className="text-sm font-medium text-blue-900 mb-2">CSV Format Requirements</h3>
<ul className="text-sm text-blue-800 space-y-1">
<li> Required columns: <code className="bg-blue-100 px-1 rounded">email</code>, <code className="bg-blue-100 px-1 rounded">firstname</code>, <code className="bg-blue-100 px-1 rounded">lastname</code>{orgType === 'K-12 School' && <>, <code className="bg-blue-100 px-1 rounded">grade_level</code></>}</li>
{orgType === 'K-12 School' ? (
<li> Grade level must be 9-12 (9th-12th grade)</li>
) : (
<li> Optional column: <code className="bg-blue-100 px-1 rounded">grade_level</code> (if provided, must be 9-12)</li>
)}
<li> Header row must be included</li>
<li> Email addresses must be valid format</li>
<li> Duplicate emails will be skipped automatically</li>
</ul>
<button
onClick={downloadTemplate}
className="mt-3 inline-flex items-center text-sm text-aptiva hover:text-aptiva-dark font-medium"
>
<Download className="w-4 h-4 mr-1" />
Download CSV Template
</button>
</div>
{/* File Upload */}
<div className="bg-white shadow rounded-lg p-6">
<label
htmlFor="roster-file-input"
className="block w-full border-2 border-dashed border-gray-300 rounded-lg p-12 text-center hover:border-gray-400 cursor-pointer transition-colors"
>
<FileText className="mx-auto h-12 w-12 text-gray-400" />
<p className="mt-2 text-sm font-medium text-gray-900">
{file ? file.name : 'Click to select CSV file'}
</p>
<p className="mt-1 text-xs text-gray-500">or drag and drop</p>
<input
id="roster-file-input"
type="file"
accept=".csv"
onChange={handleFileChange}
className="hidden"
/>
</label>
{error && (
<div className="mt-4 bg-red-50 border border-red-200 rounded-lg p-4 flex items-start">
<AlertCircle className="h-5 w-5 text-red-600 mr-3 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-800">{error}</p>
</div>
)}
{success && (
<div className="mt-4 bg-green-50 border border-green-200 rounded-lg p-4 flex items-start">
<CheckCircle className="h-5 w-5 text-green-600 mr-3 flex-shrink-0 mt-0.5" />
<p className="text-sm text-green-800">{success}</p>
</div>
)}
{preview.length > 0 && (
<div className="mt-6">
<h3 className="text-sm font-medium text-gray-900 mb-3">
Preview ({preview.length} rows shown)
</h3>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Email</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">First Name</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Last Name</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Grade</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{preview.map((row, idx) => (
<tr key={idx}>
<td className="px-3 py-2 text-sm text-gray-900">{row.email}</td>
<td className="px-3 py-2 text-sm text-gray-900">{row.firstname}</td>
<td className="px-3 py-2 text-sm text-gray-900">{row.lastname}</td>
<td className="px-3 py-2 text-sm text-gray-900">{row.grade_level}</td>
<td className="px-3 py-2 text-sm">
{row.valid ? (
<span className="text-green-600 font-medium">Valid</span>
) : (
<span className="text-red-600 font-medium">Invalid</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
onClick={handleUpload}
disabled={uploading || preview.some(r => !r.valid)}
className="mt-4 w-full bg-aptiva text-white py-2 px-4 rounded-lg hover:bg-aptiva-dark disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
>
{uploading ? 'Uploading...' : 'Upload Roster'}
</button>
</div>
)}
</div>
</div>
)}
{/* History Tab */}
{activeTab === 'history' && (
<div className="bg-white shadow rounded-lg">
{loadingHistory ? (
<div className="p-12 text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-aptiva mx-auto"></div>
<p className="mt-2 text-sm text-gray-500">Loading history...</p>
</div>
) : history.length === 0 ? (
<div className="p-12 text-center">
<History className="mx-auto h-12 w-12 text-gray-400" />
<p className="mt-2 text-sm text-gray-500">No roster uploads yet</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Upload Date
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Students Added
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Already Existed
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Total Roster Size
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Change %
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{history.map((upload) => (
<tr key={upload.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{new Date(upload.uploaded_at).toLocaleString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{upload.students_added}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{upload.students_existing}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{upload.total_students_after}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<span className={`font-medium ${
upload.change_percentage > 0 ? 'text-green-600' : 'text-gray-400'
}`}>
{upload.change_percentage > 0 ? '+' : ''}{upload.change_percentage}%
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
</AdminLayout>
);
}