perbaikan

This commit is contained in:
mhilya 2026-05-25 07:06:09 +07:00
parent 51fc725c41
commit ce3ee0d1a3
18 changed files with 1252 additions and 438 deletions

38
.gitignore vendored Normal file
View File

@ -0,0 +1,38 @@
# Python / FastAPI Core
__pycache__/
*.py[cod]
*$py.class
*.so
# Python Environments
.venv/
.env/
env/
venv/
ENV/
env.bak/
# Python Testing, Logs, & Coverage
.pytest_cache/
.coverage
htmlcov/
*.log
fastapi/ml_assets/tmp/
# Environments & Secrets (Global & FastAPI)
.env
.env.*
!.env.example
# OS & Editor / IDE (Untuk level Root)
.vscode/
.idea/
.DS_Store
Thumbs.db
*.swp
*.bak
# Model / Data (Jika di-generate/download lokal)
fastapi/ml_assets/*.pkl
fastapi/ml_assets/*.h5
fastapi/ml_assets/*.pt

1
fastapi/.env.example Normal file
View File

@ -0,0 +1 @@
FASTAPI_SECRET_KEY=

View File

@ -1,4 +1,5 @@
from fastapi import FastAPI, UploadFile, File, HTTPException, BackgroundTasks from fastapi import FastAPI, UploadFile, File, HTTPException, BackgroundTasks, Depends, Security
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
import pandas as pd import pandas as pd
import numpy as np import numpy as np
@ -10,6 +11,7 @@ import uuid
import shutil import shutil
import subprocess import subprocess
import sys import sys
import os
import logging import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
@ -69,6 +71,18 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="Tracer Study Classification Worker", lifespan=lifespan) app = FastAPI(title="Tracer Study Classification Worker", lifespan=lifespan)
# --- SECURITY SETUP ---
security = HTTPBearer()
EXPECTED_TOKEN = os.getenv("FASTAPI_SECRET_KEY", "")
def verify_token(credentials: HTTPAuthorizationCredentials = Security(security)):
if not EXPECTED_TOKEN:
return credentials.credentials
if credentials.credentials != EXPECTED_TOKEN:
raise HTTPException(status_code=401, detail="Unauthorized - Invalid Token")
return credentials.credentials
# ----------------------
def clean_text(text: str) -> str: def clean_text(text: str) -> str:
if pd.isna(text) or not isinstance(text, str): if pd.isna(text) or not isinstance(text, str):
return "" return ""
@ -102,7 +116,7 @@ def safe_get(row: pd.Series, df_columns: list, code: str, default: str = "") ->
def classify_rule(job_text: str) -> dict: def classify_rule(job_text: str) -> dict:
clean = clean_text(job_text) clean = clean_text(job_text)
if not clean or len(clean) < 3: if not clean or len(clean) < 3:
return {"profile": "Non-IT", "confidence": 0.65, "method": "rule_based_fallback"} return {"profile": "Tidak Diketahui", "confidence": 0.65, "method": "rule_based_fallback"}
for profile, keywords in KEYWORD_RULES.items(): for profile, keywords in KEYWORD_RULES.items():
if any(kw in clean for kw in keywords): if any(kw in clean for kw in keywords):
@ -112,7 +126,7 @@ def classify_rule(job_text: str) -> dict:
def classify_ml(job_text: str, pipeline) -> dict: def classify_ml(job_text: str, pipeline) -> dict:
clean = clean_text(job_text) clean = clean_text(job_text)
if not clean: if not clean:
return {"profile": "Non-IT", "confidence": 0.0, "method": "ml_empty_input"} return {"profile": "Tidak Diketahui", "confidence": 0.0, "method": "ml_empty_input"}
try: try:
proba = pipeline.predict_proba([clean])[0] proba = pipeline.predict_proba([clean])[0]
max_conf = float(np.max(proba)) max_conf = float(np.max(proba))
@ -163,7 +177,7 @@ def health_check():
# RE-TRAINING ENDPOINTS # RE-TRAINING ENDPOINTS
@app.post("/api/v1/retrain") @app.post("/api/v1/retrain", dependencies=[Depends(verify_token)])
async def trigger_retrain(file: UploadFile = File(None)): async def trigger_retrain(file: UploadFile = File(None)):
if STATUS_PATH.exists(): if STATUS_PATH.exists():
try: try:
@ -214,7 +228,7 @@ async def trigger_retrain(file: UploadFile = File(None)):
return JSONResponse(content={"status": "started", "message": "Re-training dimulai di background."}) return JSONResponse(content={"status": "started", "message": "Re-training dimulai di background."})
@app.get("/api/v1/retrain/status") @app.get("/api/v1/retrain/status", dependencies=[Depends(verify_token)])
def retrain_status(): def retrain_status():
if not STATUS_PATH.exists(): if not STATUS_PATH.exists():
return JSONResponse(content={"stage": "idle", "message": "Belum ada proses re-training."}) return JSONResponse(content={"stage": "idle", "message": "Belum ada proses re-training."})
@ -237,7 +251,7 @@ def retrain_status():
return JSONResponse(content=data) return JSONResponse(content=data)
@app.post("/api/v1/retrain/reload") @app.post("/api/v1/retrain/reload", dependencies=[Depends(verify_token)])
def reload_model(): def reload_model():
try: try:
app.state.pipeline = joblib.load(PIPELINE_PATH) app.state.pipeline = joblib.load(PIPELINE_PATH)
@ -246,7 +260,7 @@ def reload_model():
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"Gagal reload model: {str(e)}") raise HTTPException(status_code=500, detail=f"Gagal reload model: {str(e)}")
@app.post("/api/v1/classify") @app.post("/api/v1/classify", dependencies=[Depends(verify_token)])
async def classify_tracer(file: UploadFile = File(...)): async def classify_tracer(file: UploadFile = File(...)):
if not file.filename: if not file.filename:
raise HTTPException(status_code=400, detail="No file provided") raise HTTPException(status_code=400, detail="No file provided")
@ -348,7 +362,7 @@ async def classify_tracer(file: UploadFile = File(...)):
rule_res = classify_rule(job_text) rule_res = classify_rule(job_text)
res = rule_res or (classify_ml(job_text, pipeline) if pipeline else {"profile": "Non-IT", "confidence": 0.0, "method": "ml_unavailable"}) res = rule_res or (classify_ml(job_text, pipeline) if pipeline else {"profile": "Non-IT", "confidence": 0.0, "method": "ml_unavailable"})
status = "auto_classified" if res["method"] in ["rule_based", "ml_fallback"] else "needs_review" status = "auto_classified" if res["method"] in ["rule_based", "ml_fallback"] or res["profile"] == "Tidak Diketahui" else "needs_review"
raw_data_payload = {str(k): safe_str(v) for k, v in row.to_dict().items()} raw_data_payload = {str(k): safe_str(v) for k, v in row.to_dict().items()}

View File

@ -0,0 +1,10 @@
{
"stage": "rolled_back",
"message": "Model lama dipertahankan. Model baru lebih buruk secara signifikan (-12.92% weighted F1)",
"timestamp": "2026-05-25T06:51:07.653672",
"result": "rolled_back",
"reason": "Model baru lebih buruk secara signifikan (-12.92% weighted F1)",
"old_f1": 0.7804040674617057,
"new_f1": 0.6512,
"_reloaded": true
}

View File

@ -1,371 +0,0 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\ClassificationResult;
use App\Models\KemendikRawData;
use App\Services\FastApiWorkerService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Log;
class ClassificationController extends Controller
{
public function __construct(protected FastApiWorkerService $fastApi) {}
public function upload(Request $request) {
$request->validate(['file' => 'required|file|mimes:csv,xlsx,xls|max:20480']);
$result = $this->fastApi->classifyFile($request->file('file')->getRealPath(), $request->file('file')->getClientOriginalName());
if (isset($result['error'])) return back()->with('error', $result['error']);
DB::transaction(function () use ($result) {
foreach ($result['results'] as $row) {
if ($row['source_type'] === 'kemendik') {
$raw = $row['raw_data'] ?? [];
KemendikRawData::updateOrCreate(
['nimhsmsmh' => $row['nim'], 'source_type' => 'kemendik'],
[
'nmmhsmsmh' => $row['nama'], 'tahun_lulus' => $row['tahun_lulus'] ?? null,
'f5c_jabatan_kode' => $raw['F5c'] ?? null, 'f5c_jabatan_text' => $this->mapF5c($raw['F5c'] ?? null),
'f1101_jenis_instansi_kode' => $raw['F1101'] ?? null, 'f1101_jenis_instansi_text' => $this->mapF1101($raw['F1101'] ?? null),
'f5a1_provinsi' => $raw['F5a1'] ?? null, 'raw_data' => $raw
]
);
} else {
ClassificationResult::updateOrCreate(
['nim' => $row['nim'], 'source_type' => 'internal_mif'],
[
'nama' => $row['nama'], 'tahun_lulus' => $row['tahun_lulus'] ?? null,
'job_text_raw' => $row['job_text_raw'], 'predicted_profile' => $row['predicted_profile'],
'confidence_score' => $row['confidence_score'], 'classification_method' => $row['classification_method'],
'status' => $row['status'], 'error_detail' => $row['error_detail'] ?? null
]
);
if (isset($row['raw_data'])) {
\App\Models\InternalRawData::updateOrCreate(
['nim' => $row['nim']],
['raw_data' => $row['raw_data']]
);
}
}
}
});
if (empty($result['results'])) {
return back()->with('success', "Selesai. 0 data berhasil diproses.");
}
$isKemendik = $result['results'][0]['source_type'] === 'kemendik';
if ($isKemendik) {
$count = collect($result['results'])->where('source_type','kemendik')->count();
return back()->with('success', "Selesai. {$result['processed_rows']} baris diproses. {$count} data Kemendik berhasil masuk.");
} else {
$classified = collect($result['results'])->where('source_type','internal_mif')->where('status','auto_classified')->count();
return back()->with('success', "Selesai. {$result['processed_rows']} data diproses. {$classified} diklasifikasi otomatis (Internal MIF).");
}
}
private function mapF5c(?string $c): ?string { return ['1'=>'Founder','2'=>'Co-Founder','3'=>'Staff','4'=>'Freelance'][$c] ?? null; }
private function mapF1101(?string $c): ?string { return ['1'=>'Pemerintah','2'=>'Non-profit','3'=>'Swasta','4'=>'Wiraswasta','6'=>'BUMN','7'=>'Multilateral'][$c] ?? null; }
public function dashboard(Request $request)
{
$stats = [
'total_internal' => \App\Models\ClassificationResult::where('source_type', 'internal_mif')->count(),
'auto_classified' => \App\Models\ClassificationResult::where('source_type', 'internal_mif')->where('status', 'auto_classified')->count(),
'needs_review' => \App\Models\ClassificationResult::where('source_type', 'internal_mif')->where('status', 'needs_review')->count(),
];
$query = \App\Models\ClassificationResult::where('source_type', 'internal_mif');
// Always put needs_review first unless overridden by a very specific custom sort requirement (we'll just prepend it to orders)
$query->orderByRaw("FIELD(status, 'needs_review', 'manual_override', 'auto_classified', 'failed')");
if ($request->has('sort')) {
$allowedSorts = ['nim', 'nama', 'job_text_raw', 'predicted_profile', 'status'];
if (in_array($request->sort, $allowedSorts)) {
$query->orderBy($request->sort, $request->direction === 'desc' ? 'desc' : 'asc');
}
} else {
$query->latest();
}
// Charts Data Aggregation
$allResults = \App\Models\ClassificationResult::where('source_type', 'internal_mif')->get();
$allRaws = \App\Models\InternalRawData::all();
$chartProfile = $allResults->whereNotNull('predicted_profile')->groupBy('predicted_profile')->map->count();
$chartMethod = $allResults->whereNotNull('classification_method')->groupBy('classification_method')->map->count();
$waktuTunggu = [];
foreach ($allRaws as $r) {
$tahun = $r->raw_data['tahun_lulus'] ?? null;
$tunggu = intval($r->raw_data['total_masa_tunggu'] ?? 0);
if (!empty($tahun) && $tunggu > 0) {
$waktuTunggu[$tahun][] = $tunggu;
}
}
$chartWaktuTunggu = collect($waktuTunggu)->map(fn($arr) => round(collect($arr)->average(), 1))->sortKeys();
$chartFunnel = [
'Lamaran Dikirim' => round((float)$allRaws->map(fn($r) => intval($r->raw_data['jumlah_lamaran_dikirim'] ?? 0))->average(), 1),
'Respons' => round((float)$allRaws->map(fn($r) => intval($r->raw_data['jumlah_respons_lamaran'] ?? 0))->average(), 1),
'Wawancara' => round((float)$allRaws->map(fn($r) => intval($r->raw_data['jumlah_undangan_wawancara'] ?? 0))->average(), 1),
];
$chartLokasi = $allRaws->map(fn($r) => $r->raw_data['lokasi_perusahaan'] ?? null)
->map(fn($i) => trim($i))
->filter(fn($i) => !empty($i))
->groupBy(fn($i) => $i)->map->count();
$chartTopLokasi = $chartLokasi->sortDesc()->take(5);
$charts = [
'profile' => $chartProfile,
'method' => $chartMethod,
'waktu_tunggu' => $chartWaktuTunggu,
'funnel' => $chartFunnel,
'lokasi' => $chartTopLokasi,
'map_lokasi' => $chartLokasi,
];
// Load ML metrics untuk panel re-training
$metricsPath = base_path('../fastapi/ml_assets/metrics_internal_only.json');
$mlMetrics = null;
if (file_exists($metricsPath)) {
$mlMetrics = json_decode(file_get_contents($metricsPath), true);
}
$manualOverrideCount = \App\Models\ClassificationResult::where('source_type', 'internal_mif')
->where('status', 'manual_override')
->whereNotNull('job_text_raw')
->count();
return view('dashboard', [
'stats' => $stats,
'charts' => $charts,
'internal_data' => $query->paginate(10)->appends($request->query()),
'ml_metrics' => $mlMetrics,
'manual_override_count' => $manualOverrideCount,
]);
}
public function update(Request $request, $id)
{
$request->validate([
'predicted_profile' => 'required|in:Programmer,Data Analyst,Wirausaha Informatika,Non-IT',
]);
$record = \App\Models\ClassificationResult::findOrFail($id);
$record->update([
'predicted_profile' => $request->predicted_profile,
'status' => 'manual_override'
]);
return back()->with('success', 'Data berhasil diperbarui!');
}
public function destroy($id)
{
$record = \App\Models\ClassificationResult::findOrFail($id);
$record->delete();
return back()->with('success', 'Data berhasil dihapus!');
}
public function bulkDestroy(Request $request)
{
$request->validate([
'ids' => 'required|array',
'ids.*' => 'exists:classification_results,id',
]);
\App\Models\ClassificationResult::whereIn('id', $request->ids)->delete();
return back()->with('success', count($request->ids) . ' data berhasil dihapus!');
}
public function bulkUpdate(Request $request)
{
$request->validate([
'ids' => 'required|array',
'ids.*' => 'exists:classification_results,id',
'predicted_profile' => 'required|in:Programmer,Data Analyst,Wirausaha Informatika,Non-IT',
]);
\App\Models\ClassificationResult::whereIn('id', $request->ids)->update([
'predicted_profile' => $request->predicted_profile,
'status' => 'manual_override'
]);
return back()->with('success', count($request->ids) . ' data berhasil diubah profilnya!');
}
public function kemendik()
{
$stats = [
'total_kemendik' => \App\Models\KemendikRawData::count(),
];
$keteranganPath = base_path('../data/keterangan_ts_kemendiktisaintek.csv');
$keterangan = [];
if (file_exists($keteranganPath)) {
$lines = file($keteranganPath);
if (count($lines) > 0) {
$rawHeaders = str_getcsv(trim($lines[0]), ';');
$headers = array_map('trim', $rawHeaders);
for ($i = 1; $i < count($lines); $i++) {
$row = str_getcsv(trim($lines[$i]), ';');
foreach ($headers as $index => $header) {
$val = trim($row[$index] ?? '');
if ($val !== '') {
if (preg_match('/^(\d+)\s*-\s*(.*)/', $val, $matches)) {
$keterangan[$header][$matches[1]] = $val;
} else {
$keterangan[$header][$val] = $val;
}
}
}
}
}
}
$allRaws = \App\Models\KemendikRawData::all();
// Helper to count occurrences
$countBy = function($key) use ($allRaws, $keterangan) {
return $allRaws->map(function($r) use ($key, $keterangan) {
$val = trim($r->raw_data[$key] ?? '');
return $val !== '' ? ($keterangan[$key][$val] ?? $val) : null;
})->filter()->groupBy(fn($i) => $i)->map->count();
};
$chartStatus = $countBy('F8 (Jelaskan status Anda saat ini?)');
$chartInstansi = $countBy('F1101 (Apa jenis perusahaan/instansi/institusi tempat anda bekerja sekarang?)');
$chartKesesuaian = $countBy('F14 (Seberapa erat hubungan bidang studi dengan pekerjaan Anda?)');
$chartLevel = $countBy('F5d (Apa tingkatan tempat kerja Anda?)');
// Pendapatan F505
$chartPendapatanRaw = $allRaws->map(fn($r) => floatval($r->raw_data['F505 (Berapa rata-rata pendapatan Anda per bulan?)'] ?? 0))->filter(fn($v) => $v > 0);
$chartPendapatan = [
'< 2 Juta' => $chartPendapatanRaw->filter(fn($v) => $v < 2000000)->count(),
'2 - 4 Juta' => $chartPendapatanRaw->filter(fn($v) => $v >= 2000000 && $v <= 4000000)->count(),
'4 - 6 Juta' => $chartPendapatanRaw->filter(fn($v) => $v > 4000000 && $v <= 6000000)->count(),
'> 6 Juta' => $chartPendapatanRaw->filter(fn($v) => $v > 6000000)->count(),
];
// Map Lokasi
$mapLokasi = $allRaws->map(fn($r) => $r->raw_data['F5a2 (Dimana lokasi kabupaten/kota tempat Anda bekerja?)'] ?? null)->filter()->groupBy(fn($i) => $i)->map->count();
$coordsPath = base_path('../data/kabupaten_coords.json');
$kabupatenCoords = file_exists($coordsPath) ? json_decode(file_get_contents($coordsPath), true) : [];
$charts = [
'status' => $chartStatus,
'instansi' => $chartInstansi,
'kesesuaian' => $chartKesesuaian,
'pendapatan' => array_filter($chartPendapatan), // remove zero values
'level' => $chartLevel,
'map_lokasi' => $mapLokasi,
'coords' => $kabupatenCoords
];
return view('kemendik', [
'stats' => $stats,
'charts' => $charts,
'kemendik_data' => \App\Models\KemendikRawData::orderBy('imported_at', 'desc')->paginate(10),
'keterangan' => $keterangan
]);
}
public function downloadPdf(Request $request)
{
$query = \App\Models\ClassificationResult::where('source_type', 'internal_mif');
$query->orderByRaw("FIELD(status, 'needs_review', 'manual_override', 'auto_classified', 'failed')");
if ($request->has('sort')) {
$allowedSorts = ['nim', 'nama', 'job_text_raw', 'predicted_profile', 'status'];
if (in_array($request->sort, $allowedSorts)) {
$query->orderBy($request->sort, $request->direction === 'desc' ? 'desc' : 'asc');
}
} else {
$query->latest();
}
$internal_data = $query->get();
$pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('pdf.classification_results', compact('internal_data'));
return $pdf->download('Hasil_Klasifikasi_Internal_MIF.pdf');
}
/**
* Ekspor data manual_override ke CSV sementara, lalu trigger re-training via FastAPI.
* Returns JSON untuk AJAX.
*/
public function retrain(Request $request)
{
// Ambil semua data manual_override yang memiliki job_text_raw
$overrideData = ClassificationResult::where('source_type', 'internal_mif')
->where('status', 'manual_override')
->whereNotNull('job_text_raw')
->whereNotNull('predicted_profile')
->get(['job_text_raw', 'predicted_profile']);
$csvPath = null;
if ($overrideData->count() > 0) {
// Buat CSV sementara di storage/app/temp/
$csvLines = ['job_text_raw;label'];
foreach ($overrideData as $row) {
$text = str_replace([';', "\n", "\r"], [',', ' ', ' '], $row->job_text_raw);
$label = $row->predicted_profile;
$csvLines[] = "{$text};{$label}";
}
$tmpDir = storage_path('app/temp');
if (!is_dir($tmpDir)) {
mkdir($tmpDir, 0755, true);
}
$csvPath = $tmpDir . '/manual_override_' . time() . '.csv';
file_put_contents($csvPath, implode("\n", $csvLines));
Log::info("Retrain: Exported {$overrideData->count()} manual_override rows to {$csvPath}");
} else {
Log::info('Retrain: Tidak ada data manual_override, training hanya dengan corpus asli.');
}
// Trigger FastAPI
$result = $this->fastApi->triggerRetrain($csvPath);
// Bersihkan CSV temp
if ($csvPath && file_exists($csvPath)) {
@unlink($csvPath);
}
if (isset($result['error'])) {
return response()->json(['success' => false, 'message' => $result['error']], 422);
}
return response()->json([
'success' => true,
'message' => 'Re-training dimulai di background.',
'override_count' => $overrideData->count(),
]);
}
/**
* Proxy polling status re-training dari FastAPI ke frontend.
*/
public function retrainStatus(Request $request)
{
$status = $this->fastApi->getRetrainStatus();
return response()->json($status);
}
}

View File

@ -0,0 +1,195 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\ClassificationResult;
use App\Models\KemendikRawData;
use App\Models\InternalRawData;
use App\Services\FastApiWorkerService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class ClassificationTaskController extends Controller
{
public function __construct(protected FastApiWorkerService $fastApi) {}
public function upload(Request $request) {
$request->validate(['file' => 'required|file|mimes:csv,xlsx,xls|max:20480']);
$result = $this->fastApi->classifyFile($request->file('file')->getRealPath(), $request->file('file')->getClientOriginalName());
if (!isset($result['results']) && isset($result['error'])) {
return back()->with('error', $result['error']);
}
if (empty($result['results'])) {
return back()->with('success', "Selesai. 0 data berhasil diproses.");
}
DB::transaction(function () use ($result) {
$kemendikUpsertData = [];
$internalResultsUpsertData = [];
$internalRawUpsertData = [];
foreach ($result['results'] as $row) {
if ($row['source_type'] === 'kemendik') {
$raw = $row['raw_data'] ?? [];
$kemendikUpsertData[] = [
'nimhsmsmh' => $row['nim'],
'source_type' => 'kemendik',
'nmmhsmsmh' => $row['nama'],
'tahun_lulus' => $row['tahun_lulus'] ?? null,
'f5c_jabatan_kode' => $raw['F5c'] ?? null,
'f5c_jabatan_text' => $this->mapF5c($raw['F5c'] ?? null),
'f1101_jenis_instansi_kode' => $raw['F1101'] ?? null,
'f1101_jenis_instansi_text' => $this->mapF1101($raw['F1101'] ?? null),
'f5a1_provinsi' => $raw['F5a1'] ?? null,
'raw_data' => json_encode($raw)
];
} else {
$internalResultsUpsertData[] = [
'nim' => $row['nim'],
'source_type' => 'internal_mif',
'nama' => $row['nama'],
'tahun_lulus' => $row['tahun_lulus'] ?? null,
'job_text_raw' => $row['job_text_raw'] ?? '',
'predicted_profile' => $row['predicted_profile'],
'confidence_score' => $row['confidence_score'] ?? 0,
'classification_method' => $row['classification_method'],
'status' => $row['status'],
'error_detail' => $row['error_detail'] ?? null
];
if (isset($row['raw_data'])) {
$mapped = ['nim' => $row['nim']];
$cols = [
'nama_lengkap', 'email', 'no_telepon', 'alamat_domisili', 'jurusan',
'program_studi', 'tahun_masuk', 'tahun_lulus', 'status_pekerjaan',
'klasifikasi_pekerjaan', 'nama_perusahaan', 'jenis_perusahaan', 'jabatan',
'lokasi_perusahaan', 'alamat_perusahaan', 'deskripsi_pekerjaan',
'tahun_mulai_kerja', 'masa_kerja_bulan', 'jumlah_lamaran_dikirim',
'jumlah_respons_lamaran', 'jumlah_undangan_wawancara', 'masa_tunggu_pra_lulus',
'masa_tunggu_pasca_lulus', 'total_masa_tunggu', 'instansi_terupdate',
'jabatan_terupdate', 'tahun_bekerja', 'status_pekerjaan_terupdate',
'linkedin_profile', 'sosmed_ig'
];
foreach ($cols as $col) {
$val = $row['raw_data'][$col] ?? null;
$mapped[$col] = $val === '' ? null : $val;
}
$internalRawUpsertData[] = $mapped;
}
}
}
if (!empty($kemendikUpsertData)) {
foreach (array_chunk($kemendikUpsertData, 500) as $chunk) {
KemendikRawData::upsert(
$chunk,
['nimhsmsmh', 'source_type'],
['nmmhsmsmh', 'tahun_lulus', 'f5c_jabatan_kode', 'f5c_jabatan_text', 'f1101_jenis_instansi_kode', 'f1101_jenis_instansi_text', 'f5a1_provinsi', 'raw_data']
);
}
}
if (!empty($internalResultsUpsertData)) {
foreach (array_chunk($internalResultsUpsertData, 500) as $chunk) {
ClassificationResult::upsert(
$chunk,
['nim', 'source_type'],
['nama', 'tahun_lulus', 'job_text_raw', 'predicted_profile', 'confidence_score', 'classification_method', 'status', 'error_detail']
);
}
}
if (!empty($internalRawUpsertData)) {
$cols = [
'nama_lengkap', 'email', 'no_telepon', 'alamat_domisili', 'jurusan',
'program_studi', 'tahun_masuk', 'tahun_lulus', 'status_pekerjaan',
'klasifikasi_pekerjaan', 'nama_perusahaan', 'jenis_perusahaan', 'jabatan',
'lokasi_perusahaan', 'alamat_perusahaan', 'deskripsi_pekerjaan',
'tahun_mulai_kerja', 'masa_kerja_bulan', 'jumlah_lamaran_dikirim',
'jumlah_respons_lamaran', 'jumlah_undangan_wawancara', 'masa_tunggu_pra_lulus',
'masa_tunggu_pasca_lulus', 'total_masa_tunggu', 'instansi_terupdate',
'jabatan_terupdate', 'tahun_bekerja', 'status_pekerjaan_terupdate',
'linkedin_profile', 'sosmed_ig'
];
foreach (array_chunk($internalRawUpsertData, 500) as $chunk) {
InternalRawData::upsert($chunk, ['nim'], $cols);
}
}
});
$isKemendik = $result['results'][0]['source_type'] === 'kemendik';
if ($isKemendik) {
$count = collect($result['results'])->where('source_type','kemendik')->count();
return back()->with('success', "Selesai. {$result['processed_rows']} baris diproses. {$count} data Kemendik berhasil masuk.");
} else {
$classified = collect($result['results'])->where('source_type','internal_mif')->where('status','auto_classified')->count();
return back()->with('success', "Selesai. {$result['processed_rows']} data diproses. {$classified} diklasifikasi otomatis (Internal MIF).");
}
}
private function mapF5c(?string $c): ?string { return ['1'=>'Founder','2'=>'Co-Founder','3'=>'Staff','4'=>'Freelance'][$c] ?? null; }
private function mapF1101(?string $c): ?string { return ['1'=>'Pemerintah','2'=>'Non-profit','3'=>'Swasta','4'=>'Wiraswasta','6'=>'BUMN','7'=>'Multilateral'][$c] ?? null; }
public function retrain(Request $request)
{
$overrideData = ClassificationResult::where('source_type', 'internal_mif')
->where('status', 'manual_override')
->whereNotNull('job_text_raw')
->whereNotNull('predicted_profile')
->get(['job_text_raw', 'predicted_profile']);
$csvPath = null;
if ($overrideData->count() > 0) {
$csvLines = ['job_text_raw;label'];
foreach ($overrideData as $row) {
$text = str_replace([';', "\n", "\r"], [',', ' ', ' '], $row->job_text_raw);
$label = $row->predicted_profile;
$csvLines[] = "{$text};{$label}";
}
$tmpDir = storage_path('app/temp');
if (!is_dir($tmpDir)) {
mkdir($tmpDir, 0755, true);
}
$csvPath = $tmpDir . '/manual_override_' . Str::uuid() . '.csv';
file_put_contents($csvPath, implode("\n", $csvLines));
Log::info("Retrain: Exported {$overrideData->count()} manual_override rows to {$csvPath}");
} else {
Log::info('Retrain: Tidak ada data manual_override, training hanya dengan corpus asli.');
}
$result = $this->fastApi->triggerRetrain($csvPath);
if ($csvPath && file_exists($csvPath)) {
try {
unlink($csvPath);
} catch (\Exception $e) {
Log::warning("Gagal menghapus temp CSV: " . $e->getMessage());
}
}
if (isset($result['error'])) {
return response()->json(['success' => false, 'message' => $result['error']], 422);
}
return response()->json([
'success' => true,
'message' => 'Re-training dimulai di background.',
'override_count' => $overrideData->count(),
]);
}
public function retrainStatus(Request $request)
{
$status = $this->fastApi->getRetrainStatus();
return response()->json($status);
}
}

View File

@ -0,0 +1,179 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\ClassificationResult;
use App\Models\InternalRawData;
use Illuminate\Support\Facades\DB;
class InternalDataController extends Controller
{
public function index(Request $request)
{
$stats = [
'total_internal' => ClassificationResult::where('source_type', 'internal_mif')->count(),
'auto_classified' => ClassificationResult::where('source_type', 'internal_mif')->where('status', 'auto_classified')->count(),
'needs_review' => ClassificationResult::where('source_type', 'internal_mif')->where('status', 'needs_review')->count(),
];
$query = ClassificationResult::where('source_type', 'internal_mif');
$query->orderByRaw("FIELD(status, 'needs_review', 'manual_override', 'auto_classified', 'failed')");
if ($request->has('sort')) {
$allowedSorts = ['nim', 'nama', 'job_text_raw', 'predicted_profile', 'status'];
if (in_array($request->sort, $allowedSorts)) {
$query->orderBy($request->sort, $request->direction === 'desc' ? 'desc' : 'asc');
}
} else {
$query->latest();
}
// --- Optimasi OOM: Hitung menggunakan database level, bukan php level mapping ---
$chartProfile = ClassificationResult::where('source_type', 'internal_mif')
->whereNotNull('predicted_profile')
->select('predicted_profile', DB::raw('count(*) as total'))
->groupBy('predicted_profile')
->pluck('total', 'predicted_profile');
$chartMethod = ClassificationResult::where('source_type', 'internal_mif')
->whereNotNull('classification_method')
->select('classification_method', DB::raw('count(*) as total'))
->groupBy('classification_method')
->pluck('total', 'classification_method');
// Memakai cursor untuk menghemat memori
$waktuTunggu = [];
$lamaranDikirim = [];
$respons = [];
$wawancara = [];
$lokasiCounts = [];
foreach (InternalRawData::cursor() as $r) {
$tahun = $r->tahun_lulus;
$tunggu = intval($r->total_masa_tunggu ?? 0);
if (!empty($tahun) && $tunggu > 0) {
$waktuTunggu[$tahun][] = $tunggu;
}
$lamaranDikirim[] = intval($r->jumlah_lamaran_dikirim ?? 0);
$respons[] = intval($r->jumlah_respons_lamaran ?? 0);
$wawancara[] = intval($r->jumlah_undangan_wawancara ?? 0);
$lokasi = trim($r->lokasi_perusahaan ?? '');
if (!empty($lokasi)) {
$lokasiCounts[$lokasi] = ($lokasiCounts[$lokasi] ?? 0) + 1;
}
}
$chartWaktuTunggu = collect($waktuTunggu)->map(fn($arr) => round(collect($arr)->average(), 1))->sortKeys();
$chartFunnel = [
'Lamaran Dikirim' => count($lamaranDikirim) > 0 ? round(collect($lamaranDikirim)->average(), 1) : 0,
'Respons' => count($respons) > 0 ? round(collect($respons)->average(), 1) : 0,
'Wawancara' => count($wawancara) > 0 ? round(collect($wawancara)->average(), 1) : 0,
];
$chartLokasi = collect($lokasiCounts);
$chartTopLokasi = $chartLokasi->sortDesc()->take(5);
$charts = [
'profile' => $chartProfile,
'method' => $chartMethod,
'waktu_tunggu' => $chartWaktuTunggu,
'funnel' => $chartFunnel,
'lokasi' => $chartTopLokasi,
'map_lokasi' => $chartLokasi,
];
$metricsPath = base_path('../fastapi/ml_assets/metrics_internal_only.json');
$mlMetrics = null;
if (file_exists($metricsPath)) {
$mlMetrics = json_decode(file_get_contents($metricsPath), true);
}
$manualOverrideCount = ClassificationResult::where('source_type', 'internal_mif')
->where('status', 'manual_override')
->whereNotNull('job_text_raw')
->count();
return view('dashboard', [
'stats' => $stats,
'charts' => $charts,
'internal_data' => $query->paginate(10)->appends($request->query()),
'ml_metrics' => $mlMetrics,
'manual_override_count' => $manualOverrideCount,
]);
}
public function update(Request $request, $id)
{
$request->validate([
'predicted_profile' => 'required|in:Programmer,Data Analyst,Wirausaha Informatika,Non-IT,Tidak Diketahui',
]);
$record = ClassificationResult::findOrFail($id);
$record->update([
'predicted_profile' => $request->predicted_profile,
'status' => 'manual_override'
]);
return back()->with('success', 'Data berhasil diperbarui!');
}
public function destroy($id)
{
$record = ClassificationResult::findOrFail($id);
$record->delete();
return back()->with('success', 'Data berhasil dihapus!');
}
public function bulkDestroy(Request $request)
{
$request->validate([
'ids' => 'required|array',
'ids.*' => 'exists:classification_results,id',
]);
ClassificationResult::whereIn('id', $request->ids)->delete();
return back()->with('success', count($request->ids) . ' data berhasil dihapus!');
}
public function bulkUpdate(Request $request)
{
$request->validate([
'ids' => 'required|array',
'ids.*' => 'exists:classification_results,id',
'predicted_profile' => 'required|in:Programmer,Data Analyst,Wirausaha Informatika,Non-IT,Tidak Diketahui',
]);
ClassificationResult::whereIn('id', $request->ids)->update([
'predicted_profile' => $request->predicted_profile,
'status' => 'manual_override'
]);
return back()->with('success', count($request->ids) . ' data berhasil diubah profilnya!');
}
public function exportPdf(Request $request)
{
$query = ClassificationResult::where('source_type', 'internal_mif');
$query->orderByRaw("FIELD(status, 'needs_review', 'manual_override', 'auto_classified', 'failed')");
if ($request->has('sort')) {
$allowedSorts = ['nim', 'nama', 'job_text_raw', 'predicted_profile', 'status'];
if (in_array($request->sort, $allowedSorts)) {
$query->orderBy($request->sort, $request->direction === 'desc' ? 'desc' : 'asc');
}
} else {
$query->latest();
}
$internal_data = $query->get();
$pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('pdf.classification_results', compact('internal_data'));
return $pdf->download('Hasil_Klasifikasi_Internal_MIF.pdf');
}
}

View File

@ -0,0 +1,107 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\KemendikRawData;
use Illuminate\Support\Facades\DB;
class KemendikDataController extends Controller
{
public function index()
{
$stats = [
'total_kemendik' => KemendikRawData::count(),
];
$keteranganPath = base_path('../data/keterangan_ts_kemendiktisaintek.csv');
$keterangan = [];
if (file_exists($keteranganPath)) {
$lines = file($keteranganPath);
if (count($lines) > 0) {
$rawHeaders = str_getcsv(trim($lines[0]), ';');
$headers = array_map('trim', $rawHeaders);
for ($i = 1; $i < count($lines); $i++) {
$row = str_getcsv(trim($lines[$i]), ';');
foreach ($headers as $index => $header) {
$val = trim($row[$index] ?? '');
if ($val !== '') {
if (preg_match('/^(\d+)\s*-\s*(.*)/', $val, $matches)) {
$keterangan[$header][$matches[1]] = $val;
} else {
$keterangan[$header][$val] = $val;
}
}
}
}
}
}
// Optimasi OOM: Gunakan cursor() alih-alih all()
$statusCounts = [];
$instansiCounts = [];
$kesesuaianCounts = [];
$levelCounts = [];
$pendapatanRaw = [];
$mapLokasi = [];
foreach (KemendikRawData::cursor() as $r) {
$raw = $r->raw_data;
if (!$raw) continue;
$this->incrementCount($statusCounts, $raw, 'F8 (Jelaskan status Anda saat ini?)', $keterangan);
$this->incrementCount($instansiCounts, $raw, 'F1101 (Apa jenis perusahaan/instansi/institusi tempat anda bekerja sekarang?)', $keterangan);
$this->incrementCount($kesesuaianCounts, $raw, 'F14 (Seberapa erat hubungan bidang studi dengan pekerjaan Anda?)', $keterangan);
$this->incrementCount($levelCounts, $raw, 'F5d (Apa tingkatan tempat kerja Anda?)', $keterangan);
$pendapatanVal = floatval($raw['F505 (Berapa rata-rata pendapatan Anda per bulan?)'] ?? 0);
if ($pendapatanVal > 0) {
$pendapatanRaw[] = $pendapatanVal;
}
$lokasi = trim($raw['F5a2 (Dimana lokasi kabupaten/kota tempat Anda bekerja?)'] ?? '');
if ($lokasi) {
$mapLokasi[$lokasi] = ($mapLokasi[$lokasi] ?? 0) + 1;
}
}
$chartPendapatan = collect($pendapatanRaw)->reduce(function($carry, $v) {
if ($v < 2000000) $carry['< 2 Juta']++;
elseif ($v <= 4000000) $carry['2 - 4 Juta']++;
elseif ($v <= 6000000) $carry['4 - 6 Juta']++;
else $carry['> 6 Juta']++;
return $carry;
}, ['< 2 Juta' => 0, '2 - 4 Juta' => 0, '4 - 6 Juta' => 0, '> 6 Juta' => 0]);
$chartPendapatan = array_filter($chartPendapatan);
$coordsPath = base_path('../data/kabupaten_coords.json');
$kabupatenCoords = file_exists($coordsPath) ? json_decode(file_get_contents($coordsPath), true) : [];
$charts = [
'status' => collect($statusCounts),
'instansi' => collect($instansiCounts),
'kesesuaian' => collect($kesesuaianCounts),
'pendapatan' => $chartPendapatan,
'level' => collect($levelCounts),
'map_lokasi' => collect($mapLokasi),
'coords' => $kabupatenCoords
];
return view('kemendik', [
'stats' => $stats,
'charts' => $charts,
'kemendik_data' => KemendikRawData::orderBy('imported_at', 'desc')->paginate(10),
'keterangan' => $keterangan
]);
}
private function incrementCount(&$countsArray, $raw, $key, $keterangan)
{
$val = trim($raw[$key] ?? '');
if ($val !== '') {
$mapped = $keterangan[$key][$val] ?? $val;
$countsArray[$mapped] = ($countsArray[$mapped] ?? 0) + 1;
}
}
}

View File

@ -7,9 +7,5 @@
class InternalRawData extends Model class InternalRawData extends Model
{ {
protected $guarded = ['id']; protected $guarded = ['id'];
protected $casts = [
'raw_data' => 'array',
];
} }

View File

@ -4,21 +4,43 @@
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Http\Client\ConnectionException;
class FastApiWorkerService class FastApiWorkerService
{ {
private string $baseUrl; private string $baseUrl;
private string $secretKey;
public function __construct() public function __construct()
{ {
$this->baseUrl = config('services.fastapi.worker_url', 'http://127.0.0.1:8001'); $this->baseUrl = config('services.fastapi.worker_url', 'http://127.0.0.1:8001');
$this->secretKey = config('services.fastapi.secret_key', '');
}
/**
* Helper to get HTTP client with common config
*/
private function getClient(int $timeout = 15)
{
$client = Http::timeout($timeout);
if (!empty($this->secretKey)) {
$client = $client->withToken($this->secretKey);
}
return $client;
} }
public function classifyFile(string $filePath, string $filename): array public function classifyFile(string $filePath, string $filename): array
{ {
$fileStream = null;
try { try {
$response = Http::timeout(config('services.fastapi.timeout', 120)) // Fix OOM: Gunakan fopen untuk stream file, bukan file_get_contents
->attach('file', file_get_contents($filePath), $filename) $fileStream = fopen($filePath, 'r');
if (!$fileStream) {
throw new \Exception("Tidak dapat membaca file: {$filePath}");
}
$response = $this->getClient(config('services.fastapi.timeout', 120))
->attach('file', $fileStream, $filename)
->post($this->baseUrl . '/api/v1/classify'); ->post($this->baseUrl . '/api/v1/classify');
if ($response->failed()) { if ($response->failed()) {
@ -26,9 +48,16 @@ public function classifyFile(string $filePath, string $filename): array
return ['error' => "Gagal menghubungi FastAPI: {$response->status()}"]; return ['error' => "Gagal menghubungi FastAPI: {$response->status()}"];
} }
return $response->json(); return $response->json();
} catch (ConnectionException $e) {
Log::error("FastAPI Connection Timeout/Refused: {$e->getMessage()}");
return ['error' => 'Server AI sedang tidak dapat dijangkau (Timeout/Refused).'];
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error("FastAPI Connection Failed: {$e->getMessage()}"); Log::error("FastAPI Internal System Error: {$e->getMessage()}");
return ['error' => "Koneksi ke FastAPI gagal: {$e->getMessage()}"]; return ['error' => "Kesalahan sistem internal: {$e->getMessage()}"];
} finally {
if (is_resource($fileStream)) {
fclose($fileStream);
}
} }
} }
@ -38,15 +67,16 @@ public function classifyFile(string $filePath, string $filename): array
*/ */
public function triggerRetrain(?string $csvPath = null): array public function triggerRetrain(?string $csvPath = null): array
{ {
$fileStream = null;
try { try {
$request = Http::timeout(30); $request = $this->getClient(30);
if ($csvPath && file_exists($csvPath)) { if ($csvPath && file_exists($csvPath)) {
$request = $request->attach( $fileStream = fopen($csvPath, 'r');
'file', if (!$fileStream) {
file_get_contents($csvPath), throw new \Exception("Tidak dapat membaca temp CSV: {$csvPath}");
'manual_override.csv' }
); $request = $request->attach('file', $fileStream, 'manual_override.csv');
} }
$response = $request->post($this->baseUrl . '/api/v1/retrain'); $response = $request->post($this->baseUrl . '/api/v1/retrain');
@ -61,9 +91,16 @@ public function triggerRetrain(?string $csvPath = null): array
} }
return $response->json(); return $response->json();
} catch (ConnectionException $e) {
Log::error("FastAPI Retrain Connection Timeout/Refused: {$e->getMessage()}");
return ['error' => 'Server AI sedang tidak dapat dijangkau (Timeout/Refused).'];
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error("FastAPI Retrain Failed: {$e->getMessage()}"); Log::error("FastAPI Retrain Internal System Error: {$e->getMessage()}");
return ['error' => "Koneksi ke FastAPI gagal: {$e->getMessage()}"]; return ['error' => "Kesalahan sistem internal: {$e->getMessage()}"];
} finally {
if (is_resource($fileStream)) {
fclose($fileStream);
}
} }
} }
@ -73,16 +110,19 @@ public function triggerRetrain(?string $csvPath = null): array
public function getRetrainStatus(): array public function getRetrainStatus(): array
{ {
try { try {
$response = Http::timeout(10)->get($this->baseUrl . '/api/v1/retrain/status'); $response = $this->getClient(10)->get($this->baseUrl . '/api/v1/retrain/status');
if ($response->failed()) { if ($response->failed()) {
return ['stage' => 'unknown', 'message' => 'Tidak dapat membaca status dari FastAPI.']; return ['stage' => 'unknown', 'message' => 'Tidak dapat membaca status dari FastAPI.'];
} }
return $response->json(); return $response->json();
} catch (ConnectionException $e) {
Log::warning("FastAPI Status Check Connection Refused/Timeout: {$e->getMessage()}");
return ['stage' => 'unknown', 'message' => 'Server AI tidak merespons (Timeout).'];
} catch (\Exception $e) { } catch (\Exception $e) {
Log::warning("FastAPI Status Check Failed: {$e->getMessage()}"); Log::warning("FastAPI Status Check System Error: {$e->getMessage()}");
return ['stage' => 'unknown', 'message' => "Koneksi gagal: {$e->getMessage()}"]; return ['stage' => 'unknown', 'message' => "Kesalahan sistem internal: {$e->getMessage()}"];
} }
} }
@ -92,16 +132,19 @@ public function getRetrainStatus(): array
public function reloadModel(): array public function reloadModel(): array
{ {
try { try {
$response = Http::timeout(15)->post($this->baseUrl . '/api/v1/retrain/reload'); $response = $this->getClient(15)->post($this->baseUrl . '/api/v1/retrain/reload');
if ($response->failed()) { if ($response->failed()) {
return ['error' => "Gagal reload model: HTTP {$response->status()}"]; return ['error' => "Gagal reload model: HTTP {$response->status()}"];
} }
return $response->json(); return $response->json();
} catch (ConnectionException $e) {
Log::error("FastAPI Reload Connection Timeout/Refused: {$e->getMessage()}");
return ['error' => 'Server AI sedang tidak dapat dijangkau (Timeout/Refused).'];
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error("FastAPI Reload Failed: {$e->getMessage()}"); Log::error("FastAPI Reload System Error: {$e->getMessage()}");
return ['error' => "Koneksi ke FastAPI gagal: {$e->getMessage()}"]; return ['error' => "Kesalahan sistem internal: {$e->getMessage()}"];
} }
} }
} }

View File

@ -7,6 +7,8 @@
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.3", "php": "^8.3",
"barryvdh/laravel-dompdf": "^3.1",
"guzzlehttp/guzzle": "^7.10",
"laravel/framework": "^13.8", "laravel/framework": "^13.8",
"laravel/tinker": "^3.0" "laravel/tinker": "^3.0"
}, },

524
sista_mif/composer.lock generated
View File

@ -4,8 +4,85 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "efc013d7b1ca50dea035fa69d50022c4", "content-hash": "dd902983f1193a2e136efdb6349c690e",
"packages": [ "packages": [
{
"name": "barryvdh/laravel-dompdf",
"version": "v3.1.2",
"source": {
"type": "git",
"url": "https://github.com/barryvdh/laravel-dompdf.git",
"reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/ee3b72b19ccdf57d0243116ecb2b90261344dedc",
"reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc",
"shasum": ""
},
"require": {
"dompdf/dompdf": "^3.0",
"illuminate/support": "^9|^10|^11|^12|^13.0",
"php": "^8.1"
},
"require-dev": {
"larastan/larastan": "^2.7|^3.0",
"orchestra/testbench": "^7|^8|^9.16|^10|^11.0",
"phpro/grumphp": "^2.5",
"squizlabs/php_codesniffer": "^3.5"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"PDF": "Barryvdh\\DomPDF\\Facade\\Pdf",
"Pdf": "Barryvdh\\DomPDF\\Facade\\Pdf"
},
"providers": [
"Barryvdh\\DomPDF\\ServiceProvider"
]
},
"branch-alias": {
"dev-master": "3.0-dev"
}
},
"autoload": {
"psr-4": {
"Barryvdh\\DomPDF\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Barry vd. Heuvel",
"email": "barryvdh@gmail.com"
}
],
"description": "A DOMPDF Wrapper for Laravel",
"keywords": [
"dompdf",
"laravel",
"pdf"
],
"support": {
"issues": "https://github.com/barryvdh/laravel-dompdf/issues",
"source": "https://github.com/barryvdh/laravel-dompdf/tree/v3.1.2"
},
"funding": [
{
"url": "https://fruitcake.nl",
"type": "custom"
},
{
"url": "https://github.com/barryvdh",
"type": "github"
}
],
"time": "2026-02-21T08:51:10+00:00"
},
{ {
"name": "brick/math", "name": "brick/math",
"version": "0.14.8", "version": "0.14.8",
@ -377,6 +454,161 @@
], ],
"time": "2024-02-05T11:56:58+00:00" "time": "2024-02-05T11:56:58+00:00"
}, },
{
"name": "dompdf/dompdf",
"version": "v3.1.5",
"source": {
"type": "git",
"url": "https://github.com/dompdf/dompdf.git",
"reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496",
"reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496",
"shasum": ""
},
"require": {
"dompdf/php-font-lib": "^1.0.0",
"dompdf/php-svg-lib": "^1.0.0",
"ext-dom": "*",
"ext-mbstring": "*",
"masterminds/html5": "^2.0",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"ext-gd": "*",
"ext-json": "*",
"ext-zip": "*",
"mockery/mockery": "^1.3",
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11",
"squizlabs/php_codesniffer": "^3.5",
"symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0"
},
"suggest": {
"ext-gd": "Needed to process images",
"ext-gmagick": "Improves image processing performance",
"ext-imagick": "Improves image processing performance",
"ext-zlib": "Needed for pdf stream compression"
},
"type": "library",
"autoload": {
"psr-4": {
"Dompdf\\": "src/"
},
"classmap": [
"lib/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1"
],
"authors": [
{
"name": "The Dompdf Community",
"homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md"
}
],
"description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter",
"homepage": "https://github.com/dompdf/dompdf",
"support": {
"issues": "https://github.com/dompdf/dompdf/issues",
"source": "https://github.com/dompdf/dompdf/tree/v3.1.5"
},
"time": "2026-03-03T13:54:37+00:00"
},
{
"name": "dompdf/php-font-lib",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-font-lib.git",
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a",
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12"
},
"type": "library",
"autoload": {
"psr-4": {
"FontLib\\": "src/FontLib"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1-or-later"
],
"authors": [
{
"name": "The FontLib Community",
"homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md"
}
],
"description": "A library to read, parse, export and make subsets of different types of font files.",
"homepage": "https://github.com/dompdf/php-font-lib",
"support": {
"issues": "https://github.com/dompdf/php-font-lib/issues",
"source": "https://github.com/dompdf/php-font-lib/tree/1.0.2"
},
"time": "2026-01-20T14:10:26+00:00"
},
{
"name": "dompdf/php-svg-lib",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-svg-lib.git",
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1",
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0",
"sabberworm/php-css-parser": "^8.4 || ^9.0"
},
"require-dev": {
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11"
},
"type": "library",
"autoload": {
"psr-4": {
"Svg\\": "src/Svg"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-3.0-or-later"
],
"authors": [
{
"name": "The SvgLib Community",
"homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md"
}
],
"description": "A library to read, parse and export to PDF SVG files.",
"homepage": "https://github.com/dompdf/php-svg-lib",
"support": {
"issues": "https://github.com/dompdf/php-svg-lib/issues",
"source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2"
},
"time": "2026-01-02T16:01:13+00:00"
},
{ {
"name": "dragonmantank/cron-expression", "name": "dragonmantank/cron-expression",
"version": "v3.6.0", "version": "v3.6.0",
@ -2026,6 +2258,73 @@
], ],
"time": "2026-03-08T20:05:35+00:00" "time": "2026-03-08T20:05:35+00:00"
}, },
{
"name": "masterminds/html5",
"version": "2.10.0",
"source": {
"type": "git",
"url": "https://github.com/Masterminds/html5-php.git",
"reference": "fcf91eb64359852f00d921887b219479b4f21251"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251",
"reference": "fcf91eb64359852f00d921887b219479b4f21251",
"shasum": ""
},
"require": {
"ext-dom": "*",
"php": ">=5.3.0"
},
"require-dev": {
"phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.7-dev"
}
},
"autoload": {
"psr-4": {
"Masterminds\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Matt Butcher",
"email": "technosophos@gmail.com"
},
{
"name": "Matt Farina",
"email": "matt@mattfarina.com"
},
{
"name": "Asmir Mustafic",
"email": "goetas@gmail.com"
}
],
"description": "An HTML5 parser and serializer.",
"homepage": "http://masterminds.github.io/html5-php",
"keywords": [
"HTML5",
"dom",
"html",
"parser",
"querypath",
"serializer",
"xml"
],
"support": {
"issues": "https://github.com/Masterminds/html5-php/issues",
"source": "https://github.com/Masterminds/html5-php/tree/2.10.0"
},
"time": "2025-07-25T09:04:22+00:00"
},
{ {
"name": "monolog/monolog", "name": "monolog/monolog",
"version": "3.10.0", "version": "3.10.0",
@ -3301,6 +3600,86 @@
}, },
"time": "2025-12-14T04:43:48+00:00" "time": "2025-12-14T04:43:48+00:00"
}, },
{
"name": "sabberworm/php-css-parser",
"version": "v9.3.0",
"source": {
"type": "git",
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
"reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949",
"reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949",
"shasum": ""
},
"require": {
"ext-iconv": "*",
"php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
"thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "1.4.0",
"phpstan/extension-installer": "1.4.3",
"phpstan/phpstan": "1.12.32 || 2.1.32",
"phpstan/phpstan-phpunit": "1.4.2 || 2.0.8",
"phpstan/phpstan-strict-rules": "1.6.2 || 2.0.7",
"phpunit/phpunit": "8.5.52",
"rawr/phpunit-data-provider": "3.3.1",
"rector/rector": "1.2.10 || 2.2.8",
"rector/type-perfect": "1.0.0 || 2.1.0",
"squizlabs/php_codesniffer": "4.0.1",
"thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.1"
},
"suggest": {
"ext-mbstring": "for parsing UTF-8 CSS"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "9.4.x-dev"
}
},
"autoload": {
"files": [
"src/Rule/Rule.php",
"src/RuleSet/RuleContainer.php"
],
"psr-4": {
"Sabberworm\\CSS\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Raphael Schweikert"
},
{
"name": "Oliver Klee",
"email": "github@oliverklee.de"
},
{
"name": "Jake Hotson",
"email": "jake.github@qzdesign.co.uk"
}
],
"description": "Parser for CSS Files written in PHP",
"homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser",
"keywords": [
"css",
"parser",
"stylesheet"
],
"support": {
"issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
"source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.3.0"
},
"time": "2026-03-03T17:31:43+00:00"
},
{ {
"name": "symfony/clock", "name": "symfony/clock",
"version": "v7.4.8", "version": "v7.4.8",
@ -5890,6 +6269,149 @@
], ],
"time": "2026-03-30T13:44:50+00:00" "time": "2026-03-30T13:44:50+00:00"
}, },
{
"name": "thecodingmachine/safe",
"version": "v3.4.0",
"source": {
"type": "git",
"url": "https://github.com/thecodingmachine/safe.git",
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19",
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "^1.4",
"phpstan/phpstan": "^2",
"phpunit/phpunit": "^10",
"squizlabs/php_codesniffer": "^3.2"
},
"type": "library",
"autoload": {
"files": [
"lib/special_cases.php",
"generated/apache.php",
"generated/apcu.php",
"generated/array.php",
"generated/bzip2.php",
"generated/calendar.php",
"generated/classobj.php",
"generated/com.php",
"generated/cubrid.php",
"generated/curl.php",
"generated/datetime.php",
"generated/dir.php",
"generated/eio.php",
"generated/errorfunc.php",
"generated/exec.php",
"generated/fileinfo.php",
"generated/filesystem.php",
"generated/filter.php",
"generated/fpm.php",
"generated/ftp.php",
"generated/funchand.php",
"generated/gettext.php",
"generated/gmp.php",
"generated/gnupg.php",
"generated/hash.php",
"generated/ibase.php",
"generated/ibmDb2.php",
"generated/iconv.php",
"generated/image.php",
"generated/imap.php",
"generated/info.php",
"generated/inotify.php",
"generated/json.php",
"generated/ldap.php",
"generated/libxml.php",
"generated/lzf.php",
"generated/mailparse.php",
"generated/mbstring.php",
"generated/misc.php",
"generated/mysql.php",
"generated/mysqli.php",
"generated/network.php",
"generated/oci8.php",
"generated/opcache.php",
"generated/openssl.php",
"generated/outcontrol.php",
"generated/pcntl.php",
"generated/pcre.php",
"generated/pgsql.php",
"generated/posix.php",
"generated/ps.php",
"generated/pspell.php",
"generated/readline.php",
"generated/rnp.php",
"generated/rpminfo.php",
"generated/rrd.php",
"generated/sem.php",
"generated/session.php",
"generated/shmop.php",
"generated/sockets.php",
"generated/sodium.php",
"generated/solr.php",
"generated/spl.php",
"generated/sqlsrv.php",
"generated/ssdeep.php",
"generated/ssh2.php",
"generated/stream.php",
"generated/strings.php",
"generated/swoole.php",
"generated/uodbc.php",
"generated/uopz.php",
"generated/url.php",
"generated/var.php",
"generated/xdiff.php",
"generated/xml.php",
"generated/xmlrpc.php",
"generated/yaml.php",
"generated/yaz.php",
"generated/zip.php",
"generated/zlib.php"
],
"classmap": [
"lib/DateTime.php",
"lib/DateTimeImmutable.php",
"lib/Exceptions/",
"generated/Exceptions/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHP core functions that throw exceptions instead of returning FALSE on error",
"support": {
"issues": "https://github.com/thecodingmachine/safe/issues",
"source": "https://github.com/thecodingmachine/safe/tree/v3.4.0"
},
"funding": [
{
"url": "https://github.com/OskarStark",
"type": "github"
},
{
"url": "https://github.com/shish",
"type": "github"
},
{
"url": "https://github.com/silasjoisten",
"type": "github"
},
{
"url": "https://github.com/staabm",
"type": "github"
}
],
"time": "2026-02-04T18:08:13+00:00"
},
{ {
"name": "tijsverkoyen/css-to-inline-styles", "name": "tijsverkoyen/css-to-inline-styles",
"version": "v2.4.0", "version": "v2.4.0",

View File

@ -35,4 +35,10 @@
], ],
], ],
'fastapi' => [
'worker_url' => env('FASTAPI_WORKER_URL', 'http://127.0.0.1:8001'),
'secret_key' => env('FASTAPI_SECRET_KEY'),
'timeout' => env('FASTAPI_TIMEOUT', 120),
],
]; ];

View File

@ -18,7 +18,7 @@ public function up(): void
$table->string('tahun_lulus')->nullable(); $table->string('tahun_lulus')->nullable();
$table->text('job_text_raw'); $table->text('job_text_raw');
$table->enum('source_type', ['internal_mif', 'kemendik'])->default('internal_mif'); $table->enum('source_type', ['internal_mif', 'kemendik'])->default('internal_mif');
$table->enum('predicted_profile', ['Programmer', 'Data Analyst', 'Wirausaha Informatika', 'Non-IT'])->nullable(); $table->string('predicted_profile')->nullable();
$table->decimal('confidence_score', 5, 4)->nullable(); $table->decimal('confidence_score', 5, 4)->nullable();
$table->string('classification_method')->nullable(); $table->string('classification_method')->nullable();
$table->enum('status', ['auto_classified', 'needs_review', 'manual_override', 'failed'])->default('needs_review'); $table->enum('status', ['auto_classified', 'needs_review', 'manual_override', 'failed'])->default('needs_review');

View File

@ -13,8 +13,37 @@ public function up(): void
{ {
Schema::create('internal_raw_data', function (Blueprint $table) { Schema::create('internal_raw_data', function (Blueprint $table) {
$table->id(); $table->id();
$table->string('nama_lengkap')->nullable();
$table->string('nim')->index(); $table->string('nim')->index();
$table->json('raw_data')->nullable(); $table->string('email')->nullable();
$table->string('no_telepon')->nullable();
$table->text('alamat_domisili')->nullable();
$table->string('jurusan')->nullable();
$table->string('program_studi')->nullable();
$table->string('tahun_masuk')->nullable();
$table->string('tahun_lulus')->nullable();
$table->string('status_pekerjaan')->nullable();
$table->string('klasifikasi_pekerjaan')->nullable();
$table->string('nama_perusahaan')->nullable();
$table->string('jenis_perusahaan')->nullable();
$table->string('jabatan')->nullable();
$table->string('lokasi_perusahaan')->nullable();
$table->text('alamat_perusahaan')->nullable();
$table->text('deskripsi_pekerjaan')->nullable();
$table->string('tahun_mulai_kerja')->nullable();
$table->string('masa_kerja_bulan')->nullable();
$table->string('jumlah_lamaran_dikirim')->nullable();
$table->string('jumlah_respons_lamaran')->nullable();
$table->string('jumlah_undangan_wawancara')->nullable();
$table->string('masa_tunggu_pra_lulus')->nullable();
$table->string('masa_tunggu_pasca_lulus')->nullable();
$table->string('total_masa_tunggu')->nullable();
$table->string('instansi_terupdate')->nullable();
$table->string('jabatan_terupdate')->nullable();
$table->string('tahun_bekerja')->nullable();
$table->string('status_pekerjaan_terupdate')->nullable();
$table->string('linkedin_profile')->nullable();
$table->string('sosmed_ig')->nullable();
$table->timestamps(); $table->timestamps();
}); });
} }

View File

@ -29,29 +29,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": { "node_modules/@emnapi/wasi-threads": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
@ -931,6 +908,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.10.12", "baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782", "caniuse-lite": "^1.0.30001782",
@ -1916,6 +1894,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.12", "nanoid": "^3.3.12",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@ -2323,6 +2302,7 @@
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@alloc/quick-lru": "^5.2.0", "@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2", "arg": "^5.0.2",
@ -2443,6 +2423,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -2531,6 +2512,7 @@
"integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"lightningcss": "^1.32.0", "lightningcss": "^1.32.0",
"picomatch": "^4.0.4", "picomatch": "^4.0.4",

View File

@ -421,6 +421,7 @@ class="inline-flex items-center gap-2 px-6 py-3 rounded-xl text-sm font-semibold
@php @php
$colorClass = match($row->predicted_profile) { $colorClass = match($row->predicted_profile) {
'Programmer' => 'bg-emerald-100 text-emerald-700 border-emerald-200', 'Programmer' => 'bg-emerald-100 text-emerald-700 border-emerald-200',
'Tidak Diketahui' => 'bg-slate-100 text-slate-500 border-slate-200',
'Non-IT' => 'bg-slate-100 text-slate-700 border-slate-200', 'Non-IT' => 'bg-slate-100 text-slate-700 border-slate-200',
default => 'bg-blue-100 text-blue-700 border-blue-200', default => 'bg-blue-100 text-blue-700 border-blue-200',
}; };
@ -459,6 +460,57 @@ class="inline-flex items-center gap-2 px-6 py-3 rounded-xl text-sm font-semibold
@method('DELETE') @method('DELETE')
<button type="submit" class="text-rose-600 hover:text-rose-900 transition-colors">Hapus</button> <button type="submit" class="text-rose-600 hover:text-rose-900 transition-colors">Hapus</button>
</form> </form>
<div x-data="{ openDetail: false }" class="inline-block text-left ml-2">
<button @click="openDetail = true" class="text-purple-600 hover:text-purple-900 transition-colors">Detail</button>
<!-- Modal Detail -->
<div x-show="openDetail" style="display: none;" class="fixed inset-0 z-[70] overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div x-show="openDetail" @click="openDetail = false" x-transition.opacity class="fixed inset-0 bg-slate-900 bg-opacity-50 transition-opacity" aria-hidden="true"></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div x-show="openDetail" x-transition class="inline-block align-bottom bg-white rounded-2xl text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-3xl w-full">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4 border-b border-slate-100">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg leading-6 font-bold text-slate-800" id="modal-title">
Detail Data Internal - {{ $row->nama }}
</h3>
<button @click="openDetail = false" class="text-slate-400 hover:text-slate-600 focus:outline-none">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div class="max-h-[60vh] overflow-y-auto">
<table class="min-w-full divide-y divide-slate-200">
<tbody class="bg-white divide-y divide-slate-100">
@php
$raw = \App\Models\InternalRawData::where('nim', $row->nim)->first();
@endphp
@if($raw)
@foreach($raw->getAttributes() as $key => $val)
@if(!in_array($key, ['id', 'created_at', 'updated_at']) && $val !== null && $val !== '')
<tr>
<td class="px-4 py-3 text-sm font-medium text-slate-700 w-1/3 capitalize whitespace-normal">{{ str_replace('_', ' ', $key) }}</td>
<td class="px-4 py-3 text-sm text-slate-600 w-2/3 whitespace-normal">{{ $val }}</td>
</tr>
@endif
@endforeach
@else
<tr>
<td colspan="2" class="px-4 py-3 text-sm text-slate-500 text-center">Data detail tidak tersedia.</td>
</tr>
@endif
</tbody>
</table>
</div>
</div>
<div class="bg-slate-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse border-t border-slate-100">
<button type="button" @click="openDetail = false" class="mt-3 w-full inline-flex justify-center rounded-xl border border-slate-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-slate-700 hover:bg-slate-50 focus:outline-none sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
Tutup
</button>
</div>
</div>
</div>
</div>
</div>
</td> </td>
</tr> </tr>
@empty @empty
@ -508,6 +560,7 @@ class="inline-flex items-center gap-2 px-6 py-3 rounded-xl text-sm font-semibold
<option value="Data Analyst">Data Analyst</option> <option value="Data Analyst">Data Analyst</option>
<option value="Wirausaha Informatika">Wirausaha Informatika</option> <option value="Wirausaha Informatika">Wirausaha Informatika</option>
<option value="Non-IT">Non-IT</option> <option value="Non-IT">Non-IT</option>
<option value="Tidak Diketahui">Tidak Diketahui</option>
</select> </select>
<p class="mt-2 text-xs text-slate-400">Status akan otomatis berubah menjadi <span class="font-semibold text-blue-600">Manual</span> setelah diperbarui.</p> <p class="mt-2 text-xs text-slate-400">Status akan otomatis berubah menjadi <span class="font-semibold text-blue-600">Manual</span> setelah diperbarui.</p>
</div> </div>

View File

@ -1,22 +1,30 @@
<?php <?php
use App\Http\Controllers\ProfileController; use App\Http\Controllers\ProfileController;
use App\Http\Controllers\ClassificationController; use App\Http\Controllers\InternalDataController;
use App\Http\Controllers\KemendikDataController;
use App\Http\Controllers\ClassificationTaskController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::get('/', function () { return view('auth.login'); }); Route::get('/', function () { return view('auth.login'); });
Route::middleware(['auth'])->group(function () { Route::middleware(['auth'])->group(function () {
Route::get('/dashboard', [ClassificationController::class, 'dashboard'])->name('dashboard'); Route::get('/dashboard', [InternalDataController::class, 'index'])->name('dashboard');
Route::get('/kemendik', [ClassificationController::class, 'kemendik'])->name('kemendik'); Route::get('/kemendik', [KemendikDataController::class, 'index'])->name('kemendik');
Route::post('/upload', [ClassificationController::class, 'upload'])->name('upload.process');
Route::put('/internal/{id}', [ClassificationController::class, 'update'])->name('internal.update'); // Import Data
Route::delete('/internal/{id}', [ClassificationController::class, 'destroy'])->name('internal.destroy'); Route::post('/upload', [ClassificationTaskController::class, 'upload'])->name('upload.process');
Route::post('/internal/bulk-delete', [ClassificationController::class, 'bulkDestroy'])->name('internal.bulk_destroy');
Route::post('/internal/bulk-update', [ClassificationController::class, 'bulkUpdate'])->name('internal.bulk_update'); // Internal Data Management
Route::get('/internal/download-pdf', [ClassificationController::class, 'downloadPdf'])->name('internal.download_pdf'); Route::put('/internal/{id}', [InternalDataController::class, 'update'])->name('internal.update');
Route::post('/retrain', [ClassificationController::class, 'retrain'])->name('retrain.trigger'); Route::delete('/internal/{id}', [InternalDataController::class, 'destroy'])->name('internal.destroy');
Route::get('/retrain/status', [ClassificationController::class, 'retrainStatus'])->name('retrain.status'); Route::post('/internal/bulk-delete', [InternalDataController::class, 'bulkDestroy'])->name('internal.bulk_destroy');
Route::post('/internal/bulk-update', [InternalDataController::class, 'bulkUpdate'])->name('internal.bulk_update');
Route::get('/internal/download-pdf', [InternalDataController::class, 'exportPdf'])->name('internal.download_pdf');
// ML Task
Route::post('/retrain', [ClassificationTaskController::class, 'retrain'])->name('retrain.trigger');
Route::get('/retrain/status', [ClassificationTaskController::class, 'retrainStatus'])->name('retrain.status');
Route::prefix('profile')->group(function () { Route::prefix('profile')->group(function () {