revisi ini
This commit is contained in:
@@ -78,11 +78,6 @@ export async function GET(request: NextRequest) {
|
|||||||
mata_kuliah!inner(kode_mk, nama_mk)
|
mata_kuliah!inner(kode_mk, nama_mk)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Add search condition if provided
|
|
||||||
if (search) {
|
|
||||||
query = query.or(`mahasiswa.nim.ilike.%${search}%,mahasiswa.nama.ilike.%${search}%,mata_kuliah.kode_mk.ilike.%${search}%,mata_kuliah.nama_mk.ilike.%${search}%`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add semester filter if provided
|
// Add semester filter if provided
|
||||||
if (semester && semester !== 'all') {
|
if (semester && semester !== 'all') {
|
||||||
query = query.eq('semester', parseInt(semester));
|
query = query.eq('semester', parseInt(semester));
|
||||||
@@ -94,7 +89,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add order by
|
// Add order by
|
||||||
query = query.order('semester', { ascending: true }).order('mahasiswa(nim)', { ascending: true });
|
query = query.order('semester', { ascending: true });
|
||||||
|
|
||||||
const { data, error } = await query;
|
const { data, error } = await query;
|
||||||
|
|
||||||
@@ -104,7 +99,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Transformasi data untuk meratakan field yang di-join
|
// Transformasi data untuk meratakan field yang di-join
|
||||||
const transformedData = data.map((item: any) => ({
|
let transformedData = data.map((item: any) => ({
|
||||||
...item,
|
...item,
|
||||||
nim: item.mahasiswa?.nim || '',
|
nim: item.mahasiswa?.nim || '',
|
||||||
nama: item.mahasiswa?.nama || '',
|
nama: item.mahasiswa?.nama || '',
|
||||||
@@ -115,6 +110,27 @@ export async function GET(request: NextRequest) {
|
|||||||
return rest;
|
return rest;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Client-side search filtering (untuk joined tables)
|
||||||
|
if (search) {
|
||||||
|
const searchLower = search.toLowerCase();
|
||||||
|
transformedData = transformedData.filter((item: any) => {
|
||||||
|
return (
|
||||||
|
item.nim?.toLowerCase().includes(searchLower) ||
|
||||||
|
item.nama?.toLowerCase().includes(searchLower) ||
|
||||||
|
item.kode_mk?.toLowerCase().includes(searchLower) ||
|
||||||
|
item.nama_mk?.toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by nim after filtering
|
||||||
|
transformedData.sort((a: any, b: any) => {
|
||||||
|
if (a.semester !== b.semester) {
|
||||||
|
return a.semester - b.semester;
|
||||||
|
}
|
||||||
|
return a.nim.localeCompare(b.nim);
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json(transformedData);
|
return NextResponse.json(transformedData);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -230,7 +246,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
message: 'Nilai mahasiswa berhasil ditambahkan',
|
message: 'Nilai mahasiswa berhasil ditambahkan',
|
||||||
id: data.id_nilai,
|
id: data.id_nilai,
|
||||||
mahasiswa: mahasiswa.nama,
|
mahasiswa: mahasiswa.nama,
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export async function POST(request: NextRequest) {
|
|||||||
// Handle CSV file
|
// Handle CSV file
|
||||||
const text = new TextDecoder().decode(buffer);
|
const text = new TextDecoder().decode(buffer);
|
||||||
const lines = text.split('\n').filter(line => line.trim());
|
const lines = text.split('\n').filter(line => line.trim());
|
||||||
|
|
||||||
if (lines.length < 2) {
|
if (lines.length < 2) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ message: 'File must contain at least header and one data row' },
|
{ message: 'File must contain at least header and one data row' },
|
||||||
@@ -63,7 +63,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
|
const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
|
||||||
|
|
||||||
for (let i = 1; i < lines.length; i++) {
|
for (let i = 1; i < lines.length; i++) {
|
||||||
const values = lines[i].split(',').map(v => v.trim().replace(/"/g, ''));
|
const values = lines[i].split(',').map(v => v.trim().replace(/"/g, ''));
|
||||||
const row: any = {};
|
const row: any = {};
|
||||||
@@ -111,33 +111,33 @@ export async function POST(request: NextRequest) {
|
|||||||
// Further normalize data with column mappings
|
// Further normalize data with column mappings
|
||||||
const finalData = normalizedData.map(row => {
|
const finalData = normalizedData.map(row => {
|
||||||
const finalRow: any = {};
|
const finalRow: any = {};
|
||||||
|
|
||||||
Object.keys(columnMappings).forEach(standardKey => {
|
Object.keys(columnMappings).forEach(standardKey => {
|
||||||
const variations = columnMappings[standardKey as keyof typeof columnMappings];
|
const variations = columnMappings[standardKey as keyof typeof columnMappings];
|
||||||
let value = null;
|
let value = null;
|
||||||
|
|
||||||
// Try to find the value using different variations
|
// Try to find the value using different variations
|
||||||
for (const variation of variations) {
|
for (const variation of variations) {
|
||||||
const normalizedVariation = variation.toLowerCase()
|
const normalizedVariation = variation.toLowerCase()
|
||||||
.replace(/\s+/g, '_')
|
.replace(/\s+/g, '_')
|
||||||
.replace(/[^a-z0-9_]/g, '');
|
.replace(/[^a-z0-9_]/g, '');
|
||||||
|
|
||||||
if (row[normalizedVariation] !== undefined && row[normalizedVariation] !== null && row[normalizedVariation] !== '') {
|
if (row[normalizedVariation] !== undefined && row[normalizedVariation] !== null && row[normalizedVariation] !== '') {
|
||||||
value = row[normalizedVariation];
|
value = row[normalizedVariation];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
finalRow[standardKey] = value;
|
finalRow[standardKey] = value;
|
||||||
});
|
});
|
||||||
|
|
||||||
return finalRow;
|
return finalRow;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Validate required columns
|
// Validate required columns
|
||||||
const requiredColumns = ['nim', 'kode_mk', 'semester', 'nilai_huruf', 'nilai_angka'];
|
const requiredColumns = ['nim', 'kode_mk', 'semester', 'nilai_huruf', 'nilai_angka'];
|
||||||
const firstRow = finalData[0];
|
const firstRow = finalData[0];
|
||||||
const missingColumns = requiredColumns.filter(col =>
|
const missingColumns = requiredColumns.filter(col =>
|
||||||
firstRow[col] === undefined || firstRow[col] === null || firstRow[col] === ''
|
firstRow[col] === undefined || firstRow[col] === null || firstRow[col] === ''
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -162,7 +162,7 @@ export async function POST(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
// Validate and convert data types
|
// Validate and convert data types
|
||||||
const semester = parseInt(row.semester);
|
const semester = parseInt(row.semester);
|
||||||
|
|
||||||
// Handle decimal comma (Indonesian format) and convert to dot
|
// Handle decimal comma (Indonesian format) and convert to dot
|
||||||
const nilaiAngkaStr = row.nilai_angka.toString().replace(',', '.');
|
const nilaiAngkaStr = row.nilai_angka.toString().replace(',', '.');
|
||||||
const nilai_angka = parseFloat(nilaiAngkaStr);
|
const nilai_angka = parseFloat(nilaiAngkaStr);
|
||||||
@@ -207,7 +207,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
message: 'Validation errors found',
|
message: 'Validation errors found',
|
||||||
errors: errors.slice(0, 10) // Limit to first 10 errors
|
errors: errors.slice(0, 10) // Limit to first 10 errors
|
||||||
},
|
},
|
||||||
@@ -241,7 +241,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const nimToIdMap = new Map(existingMahasiswa?.map(m => [m.nim, m.id_mahasiswa]) || []);
|
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]) || []);
|
const kodeMKToIdMap = new Map(existingMataKuliah?.map(mk => [mk.kode_mk, mk.id_mk]) || []);
|
||||||
|
|
||||||
// Process data for insertion
|
// Process data for insertion/update
|
||||||
const insertData: any[] = [];
|
const insertData: any[] = [];
|
||||||
const validationErrors: string[] = [];
|
const validationErrors: string[] = [];
|
||||||
|
|
||||||
@@ -271,7 +271,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
if (validationErrors.length > 0) {
|
if (validationErrors.length > 0) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
message: 'Data validation errors',
|
message: 'Data validation errors',
|
||||||
errors: validationErrors.slice(0, 10)
|
errors: validationErrors.slice(0, 10)
|
||||||
},
|
},
|
||||||
@@ -279,26 +279,88 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert data to database
|
// Fetch existing nilai_mahasiswa to determine which to update vs insert
|
||||||
let successCount = 0;
|
const { data: existingNilai, error: fetchNilaiError } = await supabase
|
||||||
if (insertData.length > 0) {
|
.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
|
const { error: insertError } = await supabase
|
||||||
.from('nilai_mahasiswa')
|
.from('nilai_mahasiswa')
|
||||||
.insert(insertData);
|
.insert(dataToInsert);
|
||||||
|
|
||||||
if (insertError) {
|
if (insertError) {
|
||||||
|
console.error('Insert error:', insertError);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ message: 'Failed to insert data to database' },
|
{ message: 'Failed to insert data to database', error: insertError.message },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
successCount = insertData.length;
|
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({
|
return NextResponse.json({
|
||||||
message: 'Upload completed',
|
message: 'Upload completed',
|
||||||
successCount,
|
inserted: insertCount,
|
||||||
|
updated: updateCount,
|
||||||
|
totalProcessed: insertCount + updateCount,
|
||||||
totalRows: processedData.length
|
totalRows: processedData.length
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogClose
|
DialogClose
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
@@ -34,12 +34,12 @@ export default function UploadFileNilaiMahasiswa({ onUploadSuccess }: UploadFile
|
|||||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
'text/csv'
|
'text/csv'
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!validTypes.includes(file.type)) {
|
if (!validTypes.includes(file.type)) {
|
||||||
showError("Error!", "File harus berformat Excel (.xlsx, .xls) atau CSV (.csv)");
|
showError("Error!", "File harus berformat Excel (.xlsx, .xls) atau CSV (.csv)");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelectedFile(file);
|
setSelectedFile(file);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -53,17 +53,17 @@ export default function UploadFileNilaiMahasiswa({ onUploadSuccess }: UploadFile
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', selectedFile);
|
formData.append('file', selectedFile);
|
||||||
|
|
||||||
const response = await fetch('/api/keloladata/data-nilai-mahasiswa/upload', {
|
const response = await fetch('/api/keloladata/data-nilai-mahasiswa/upload', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let errorMessage = result.message || 'Upload failed';
|
let errorMessage = result.message || 'Upload failed';
|
||||||
if (result.errors && result.errors.length > 0) {
|
if (result.errors && result.errors.length > 0) {
|
||||||
@@ -71,12 +71,26 @@ export default function UploadFileNilaiMahasiswa({ onUploadSuccess }: UploadFile
|
|||||||
}
|
}
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
showSuccess("Berhasil!", `${result.successCount} nilai mahasiswa berhasil diimport`);
|
// Create success message based on insert and update counts
|
||||||
|
const inserted = result.inserted || 0;
|
||||||
|
const updated = result.updated || 0;
|
||||||
|
const total = inserted + updated;
|
||||||
|
|
||||||
|
let successMessage = `${total} nilai mahasiswa berhasil diproses`;
|
||||||
|
if (inserted > 0 && updated > 0) {
|
||||||
|
successMessage += ` (${inserted} ditambahkan, ${updated} diperbarui)`;
|
||||||
|
} else if (inserted > 0) {
|
||||||
|
successMessage += ` (${inserted} ditambahkan)`;
|
||||||
|
} else if (updated > 0) {
|
||||||
|
successMessage += ` (${updated} diperbarui)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
showSuccess("Berhasil!", successMessage);
|
||||||
onUploadSuccess();
|
onUploadSuccess();
|
||||||
setIsDialogOpen(false);
|
setIsDialogOpen(false);
|
||||||
setSelectedFile(null);
|
setSelectedFile(null);
|
||||||
|
|
||||||
// Reset file input
|
// Reset file input
|
||||||
const fileInput = document.getElementById('file-upload-nilai') as HTMLInputElement;
|
const fileInput = document.getElementById('file-upload-nilai') as HTMLInputElement;
|
||||||
if (fileInput) {
|
if (fileInput) {
|
||||||
@@ -105,7 +119,7 @@ export default function UploadFileNilaiMahasiswa({ onUploadSuccess }: UploadFile
|
|||||||
Import Data Nilai Mahasiswa
|
Import Data Nilai Mahasiswa
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="file-upload-nilai" className="text-sm font-medium">
|
<label htmlFor="file-upload-nilai" className="text-sm font-medium">
|
||||||
@@ -122,7 +136,7 @@ export default function UploadFileNilaiMahasiswa({ onUploadSuccess }: UploadFile
|
|||||||
Format yang didukung: .xlsx, .xls, .csv (Max: 10MB)
|
Format yang didukung: .xlsx, .xls, .csv (Max: 10MB)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedFile && (
|
{selectedFile && (
|
||||||
<div className="p-3 bg-muted rounded-md">
|
<div className="p-3 bg-muted rounded-md">
|
||||||
<p className="text-sm font-medium">File terpilih:</p>
|
<p className="text-sm font-medium">File terpilih:</p>
|
||||||
@@ -132,7 +146,7 @@ export default function UploadFileNilaiMahasiswa({ onUploadSuccess }: UploadFile
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="border-t pt-4">
|
<div className="border-t pt-4">
|
||||||
<h4 className="text-sm font-medium mb-2">Format File:</h4>
|
<h4 className="text-sm font-medium mb-2">Format File:</h4>
|
||||||
<div className="text-xs text-muted-foreground space-y-1">
|
<div className="text-xs text-muted-foreground space-y-1">
|
||||||
@@ -145,15 +159,15 @@ export default function UploadFileNilaiMahasiswa({ onUploadSuccess }: UploadFile
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<DialogClose asChild>
|
<DialogClose asChild>
|
||||||
<Button variant="outline">
|
<Button variant="outline">
|
||||||
Batal
|
Batal
|
||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleUpload}
|
onClick={handleUpload}
|
||||||
disabled={!selectedFile || isUploading}
|
disabled={!selectedFile || isUploading}
|
||||||
>
|
>
|
||||||
{isUploading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{isUploading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
|||||||
Reference in New Issue
Block a user