374 lines
11 KiB
TypeScript
374 lines
11 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import supabase from '@/lib/db';
|
|
import * as XLSX from 'xlsx';
|
|
|
|
interface NilaiMahasiswaUpload {
|
|
nim: string;
|
|
kode_mk: string;
|
|
semester: number;
|
|
nilai_huruf: 'A' | 'B+' | 'B' | 'C+' | 'C' | 'D+' | 'D' | 'E';
|
|
nilai_angka: number;
|
|
}
|
|
|
|
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
const formData = await request.formData();
|
|
const file = formData.get('file') as File;
|
|
|
|
if (!file) {
|
|
return NextResponse.json(
|
|
{ message: 'No file uploaded' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Validate file type
|
|
const validTypes = [
|
|
'application/vnd.ms-excel',
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
'text/csv'
|
|
];
|
|
|
|
if (!validTypes.includes(file.type)) {
|
|
return NextResponse.json(
|
|
{ message: 'Invalid file type. Please upload Excel (.xlsx, .xls) or CSV (.csv) file' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Validate file size (10MB max)
|
|
const maxSize = 10 * 1024 * 1024; // 10MB
|
|
if (file.size > maxSize) {
|
|
return NextResponse.json(
|
|
{ message: 'File size too large. Maximum size is 10MB' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Read file content
|
|
const buffer = await file.arrayBuffer();
|
|
let data: any[] = [];
|
|
|
|
if (file.type === 'text/csv') {
|
|
// Handle CSV file
|
|
const text = new TextDecoder().decode(buffer);
|
|
const lines = text.split('\n').filter(line => line.trim());
|
|
|
|
if (lines.length < 2) {
|
|
return NextResponse.json(
|
|
{ message: 'File must contain at least header and one data row' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
|
|
|
|
for (let i = 1; i < lines.length; i++) {
|
|
const values = lines[i].split(',').map(v => v.trim().replace(/"/g, ''));
|
|
const row: any = {};
|
|
headers.forEach((header, index) => {
|
|
row[header] = values[index] || '';
|
|
});
|
|
data.push(row);
|
|
}
|
|
} else {
|
|
// Handle Excel file
|
|
const workbook = XLSX.read(buffer, { type: 'buffer' });
|
|
const sheetName = workbook.SheetNames[0];
|
|
const worksheet = workbook.Sheets[sheetName];
|
|
data = XLSX.utils.sheet_to_json(worksheet);
|
|
}
|
|
|
|
if (data.length === 0) {
|
|
return NextResponse.json(
|
|
{ message: 'No data found in file' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Normalize column names and validate required columns
|
|
const normalizedData = data.map(row => {
|
|
const normalizedRow: any = {};
|
|
Object.keys(row).forEach(key => {
|
|
const normalizedKey = key.toLowerCase()
|
|
.replace(/\s+/g, '_')
|
|
.replace(/[^a-z0-9_]/g, '');
|
|
normalizedRow[normalizedKey] = row[key];
|
|
});
|
|
return normalizedRow;
|
|
});
|
|
|
|
// Map common column variations to standard names
|
|
const columnMappings = {
|
|
'nim': ['nim', 'NIM', 'Nim'],
|
|
'kode_mk': ['kode_mk', 'Kode MK', 'kode mk', 'kodemk', 'kodeMK'],
|
|
'semester': ['semester', 'Semester', 'sem'],
|
|
'nilai_huruf': ['nilai_huruf', 'Nilai Huruf', 'nilai huruf', 'nilaihuruf', 'nilaiHuruf', 'grade'],
|
|
'nilai_angka': ['nilai_angka', 'Nilai Angka', 'nilai angka', 'nilaiangka', 'nilaiAngka', 'score']
|
|
};
|
|
|
|
// Further normalize data with column mappings
|
|
const finalData = normalizedData.map(row => {
|
|
const finalRow: any = {};
|
|
|
|
Object.keys(columnMappings).forEach(standardKey => {
|
|
const variations = columnMappings[standardKey as keyof typeof columnMappings];
|
|
let value = null;
|
|
|
|
// Try to find the value using different variations
|
|
for (const variation of variations) {
|
|
const normalizedVariation = variation.toLowerCase()
|
|
.replace(/\s+/g, '_')
|
|
.replace(/[^a-z0-9_]/g, '');
|
|
|
|
if (row[normalizedVariation] !== undefined && row[normalizedVariation] !== null && row[normalizedVariation] !== '') {
|
|
value = row[normalizedVariation];
|
|
break;
|
|
}
|
|
}
|
|
|
|
finalRow[standardKey] = value;
|
|
});
|
|
|
|
return finalRow;
|
|
});
|
|
|
|
// Validate required columns
|
|
const requiredColumns = ['nim', 'kode_mk', 'semester', 'nilai_huruf', 'nilai_angka'];
|
|
const firstRow = finalData[0];
|
|
const missingColumns = requiredColumns.filter(col =>
|
|
firstRow[col] === undefined || firstRow[col] === null || firstRow[col] === ''
|
|
);
|
|
|
|
if (missingColumns.length > 0) {
|
|
return NextResponse.json(
|
|
{ message: `Missing required columns: ${missingColumns.join(', ')}. Please check your Excel headers.` },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Use the normalized data for processing
|
|
data = finalData;
|
|
|
|
// Process and validate data
|
|
const processedData: NilaiMahasiswaUpload[] = [];
|
|
const errors: string[] = [];
|
|
|
|
for (let i = 0; i < data.length; i++) {
|
|
const row = data[i];
|
|
const rowNum = i + 2; // +2 because Excel rows start from 1 and we skip header
|
|
|
|
try {
|
|
// Validate and convert data types
|
|
const semester = parseInt(row.semester);
|
|
|
|
// Handle decimal comma (Indonesian format) and convert to dot
|
|
const nilaiAngkaStr = row.nilai_angka.toString().replace(',', '.');
|
|
const nilai_angka = parseFloat(nilaiAngkaStr);
|
|
|
|
if (isNaN(semester) || semester <= 0 || semester > 8) {
|
|
errors.push(`Row ${rowNum}: Semester must be a number between 1-8`);
|
|
continue;
|
|
}
|
|
|
|
if (isNaN(nilai_angka) || nilai_angka < 0 || nilai_angka > 4) {
|
|
errors.push(`Row ${rowNum}: Nilai angka must be a number between 0-4 (current: ${row.nilai_angka})`);
|
|
continue;
|
|
}
|
|
|
|
// Validate nilai_huruf
|
|
const validNilaiHuruf = ['A', 'B+', 'B', 'C+', 'C', 'D+', 'D', 'E'];
|
|
if (!validNilaiHuruf.includes(row.nilai_huruf)) {
|
|
errors.push(`Row ${rowNum}: nilai_huruf must be one of: ${validNilaiHuruf.join(', ')}`);
|
|
continue;
|
|
}
|
|
|
|
// Validate required fields
|
|
if (!row.nim || !row.kode_mk) {
|
|
errors.push(`Row ${rowNum}: nim and kode_mk are required`);
|
|
continue;
|
|
}
|
|
|
|
// Keep kode_mk as is - don't normalize it
|
|
let kodeMK = row.kode_mk.toString().trim();
|
|
|
|
processedData.push({
|
|
nim: row.nim.toString().trim(),
|
|
kode_mk: kodeMK,
|
|
semester,
|
|
nilai_huruf: row.nilai_huruf as 'A' | 'B+' | 'B' | 'C+' | 'C' | 'D+' | 'D' | 'E',
|
|
nilai_angka
|
|
});
|
|
} catch (error) {
|
|
errors.push(`Row ${rowNum}: Invalid data format`);
|
|
}
|
|
}
|
|
|
|
if (errors.length > 0) {
|
|
return NextResponse.json(
|
|
{
|
|
message: 'Validation errors found',
|
|
errors: errors.slice(0, 10) // Limit to first 10 errors
|
|
},
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Get existing mahasiswa and mata kuliah for validation
|
|
const { data: existingMahasiswa, error: fetchMahasiswaError } = await supabase
|
|
.from('mahasiswa')
|
|
.select('id_mahasiswa, nim');
|
|
|
|
if (fetchMahasiswaError) {
|
|
return NextResponse.json(
|
|
{ message: 'Failed to validate mahasiswa data' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
|
|
const { data: existingMataKuliah, error: fetchMKError } = await supabase
|
|
.from('mata_kuliah')
|
|
.select('id_mk, kode_mk');
|
|
|
|
if (fetchMKError) {
|
|
return NextResponse.json(
|
|
{ message: 'Failed to validate mata kuliah data' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
|
|
const nimToIdMap = new Map(existingMahasiswa?.map(m => [m.nim, m.id_mahasiswa]) || []);
|
|
const kodeMKToIdMap = new Map(existingMataKuliah?.map(mk => [mk.kode_mk, mk.id_mk]) || []);
|
|
|
|
// Process data for insertion/update
|
|
const insertData: any[] = [];
|
|
const validationErrors: string[] = [];
|
|
|
|
for (const nilai of processedData) {
|
|
// Check if mahasiswa exists
|
|
const id_mahasiswa = nimToIdMap.get(nilai.nim);
|
|
if (!id_mahasiswa) {
|
|
validationErrors.push(`NIM ${nilai.nim} not found in database`);
|
|
continue;
|
|
}
|
|
|
|
// Check if mata kuliah exists
|
|
const id_mk = kodeMKToIdMap.get(nilai.kode_mk);
|
|
if (!id_mk) {
|
|
validationErrors.push(`Kode MK ${nilai.kode_mk} not found in database`);
|
|
continue;
|
|
}
|
|
|
|
insertData.push({
|
|
id_mahasiswa,
|
|
id_mk,
|
|
nilai_huruf: nilai.nilai_huruf,
|
|
nilai_angka: nilai.nilai_angka,
|
|
semester: nilai.semester
|
|
});
|
|
}
|
|
|
|
if (validationErrors.length > 0) {
|
|
return NextResponse.json(
|
|
{
|
|
message: 'Data validation errors',
|
|
errors: validationErrors.slice(0, 10)
|
|
},
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Fetch existing nilai_mahasiswa to determine which to update vs insert
|
|
const { data: existingNilai, error: fetchNilaiError } = await supabase
|
|
.from('nilai_mahasiswa')
|
|
.select('id_nilai, id_mahasiswa, id_mk');
|
|
|
|
if (fetchNilaiError) {
|
|
return NextResponse.json(
|
|
{ message: 'Failed to fetch existing nilai data' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
|
|
// Create map of existing nilai: "id_mahasiswa-id_mk" -> id_nilai
|
|
const existingNilaiMap = new Map(
|
|
(existingNilai || []).map(n => [`${n.id_mahasiswa}-${n.id_mk}`, n.id_nilai])
|
|
);
|
|
|
|
// Separate data into updates and inserts
|
|
const dataToUpdate: any[] = [];
|
|
const dataToInsert: any[] = [];
|
|
|
|
for (const data of insertData) {
|
|
const key = `${data.id_mahasiswa}-${data.id_mk}`;
|
|
const existingId = existingNilaiMap.get(key);
|
|
|
|
if (existingId) {
|
|
// Data exists, prepare for update
|
|
dataToUpdate.push({
|
|
id_nilai: existingId,
|
|
...data,
|
|
updated_at: new Date().toISOString()
|
|
});
|
|
} else {
|
|
// Data doesn't exist, prepare for insert
|
|
dataToInsert.push(data);
|
|
}
|
|
}
|
|
|
|
let insertCount = 0;
|
|
let updateCount = 0;
|
|
|
|
// Perform batch insert
|
|
if (dataToInsert.length > 0) {
|
|
const { error: insertError } = await supabase
|
|
.from('nilai_mahasiswa')
|
|
.insert(dataToInsert);
|
|
|
|
if (insertError) {
|
|
console.error('Insert error:', insertError);
|
|
return NextResponse.json(
|
|
{ message: 'Failed to insert data to database', error: insertError.message },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
|
|
insertCount = dataToInsert.length;
|
|
}
|
|
|
|
// Perform batch update (one by one because Supabase doesn't support batch update easily)
|
|
if (dataToUpdate.length > 0) {
|
|
for (const data of dataToUpdate) {
|
|
const { id_nilai, ...updateFields } = data;
|
|
|
|
const { error: updateError } = await supabase
|
|
.from('nilai_mahasiswa')
|
|
.update(updateFields)
|
|
.eq('id_nilai', id_nilai);
|
|
|
|
if (updateError) {
|
|
console.error('Update error:', updateError);
|
|
// Continue with other updates even if one fails
|
|
} else {
|
|
updateCount++;
|
|
}
|
|
}
|
|
}
|
|
|
|
return NextResponse.json({
|
|
message: 'Upload completed',
|
|
inserted: insertCount,
|
|
updated: updateCount,
|
|
totalProcessed: insertCount + updateCount,
|
|
totalRows: processedData.length
|
|
});
|
|
|
|
} catch (error) {
|
|
return NextResponse.json(
|
|
{ message: 'Internal server error' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|