439 lines
17 KiB
JavaScript
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>
|
|
);
|
|
}
|