This commit is contained in:
jouel88 2026-05-15 11:03:00 +07:00
parent 5b8f276437
commit 0c08fefabe
3 changed files with 191 additions and 1 deletions

View File

@ -11,12 +11,21 @@
use App\Jobs\ReextractAllFrames; use App\Jobs\ReextractAllFrames;
use App\Jobs\RetrainAllModels; use App\Jobs\RetrainAllModels;
use App\Jobs\MigrateExistingEmbeddings; use App\Jobs\MigrateExistingEmbeddings;
use App\Services\FaceRecognitionService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
class FaceApprovalController extends Controller class FaceApprovalController extends Controller
{ {
protected $faceService;
public function __construct(FaceRecognitionService $faceService)
{
$this->faceService = $faceService;
}
public function index(Request $request) public function index(Request $request)
{ {
$query = User::with(['jabatan', 'divisi', 'dataWajah']); $query = User::with(['jabatan', 'divisi', 'dataWajah']);
@ -251,4 +260,26 @@ public function migrateEmbeddings()
dispatch(new MigrateExistingEmbeddings()); dispatch(new MigrateExistingEmbeddings());
return redirect()->back()->with('success', 'Proses migrasi embedding sedang berjalan di background. Anda akan menerima notifikasi setelah selesai.'); return redirect()->back()->with('success', 'Proses migrasi embedding sedang berjalan di background. Anda akan menerima notifikasi setelah selesai.');
} }
public function uploadVideo(Request $request, $id)
{
$request->validate([
'video_wajah' => 'required|file|mimetypes:video/mp4,video/quicktime,video/x-msvideo,video/3gpp,video/x-matroska|max:51200',
], [
'video_wajah.required' => 'File video wajah wajib diupload.',
'video_wajah.mimetypes' => 'Format video tidak didukung. Gunakan MP4, MOV, atau AVI.',
'video_wajah.max' => 'Ukuran video terlalu besar (maksimal 50MB).',
]);
$user = User::findOrFail($id);
try {
$result = $this->faceService->enrollFace($user->id, $request->file('video_wajah'));
return redirect()->back()->with('success', "Video wajah untuk {$user->nama_lengkap} berhasil diupload. Proses ekstraksi frame sedang berjalan.");
} catch (\Exception $e) {
Log::error("Upload video wajah gagal untuk user {$id}: " . $e->getMessage());
return redirect()->back()->with('error', 'Gagal upload video: ' . $e->getMessage());
}
}
} }

View File

@ -271,8 +271,23 @@ class="inline-flex items-center px-3 py-1.5 border border-amber-200 text-xs font
</svg> </svg>
Reset Reset
</button> </button>
<button onclick="openUploadModal({{ $user->id }}, '{{ addslashes($user->nama_lengkap) }}')"
class="inline-flex items-center px-3 py-1.5 border border-blue-200 text-xs font-medium rounded-lg text-blue-700 bg-blue-50 hover:bg-blue-100 transition-colors shadow-sm"
title="Upload Ulang Video Wajah">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path>
</svg>
Re-Upload
</button>
@else @else
<span class="text-xs text-slate-400 italic"></span> <button onclick="openUploadModal({{ $user->id }}, '{{ addslashes($user->nama_lengkap) }}')"
class="inline-flex items-center px-3 py-1.5 border border-slate-200 text-xs font-medium rounded-lg text-slate-700 bg-white hover:bg-slate-50 transition-colors shadow-sm"
title="Upload Video Wajah">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path>
</svg>
Upload Video
</button>
@endif @endif
</div> </div>
</td> </td>
@ -304,6 +319,80 @@ class="inline-flex items-center px-3 py-1.5 border border-amber-200 text-xs font
</div> </div>
</div> </div>
</div> </div>
<div id="uploadVideoOverlay" class="fixed inset-0 z-50 hidden items-center justify-center bg-gray-500/50 backdrop-blur-sm" onclick="closeUploadModal()">
<div class="relative max-w-md w-full mx-4" onclick="event.stopPropagation()">
<div class="bg-white rounded-2xl shadow-2xl overflow-hidden">
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-100">
<div>
<h3 class="font-bold text-slate-800">Upload Video Wajah</h3>
<p class="text-sm text-slate-500 mt-0.5" id="uploadUserName">Nama Karyawan</p>
</div>
<button onclick="closeUploadModal()" class="p-1.5 hover:bg-slate-100 rounded-lg transition">
<svg class="w-5 h-5 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<form id="uploadVideoForm" method="POST" enctype="multipart/form-data" class="p-6">
@csrf
<div class="mb-4">
<label class="block text-sm font-semibold text-slate-700 mb-2">File Video</label>
<div id="dropZone" class="border-2 border-dashed border-slate-300 rounded-xl p-6 text-center hover:border-primary transition-colors cursor-pointer">
<svg class="w-10 h-10 text-slate-400 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path>
</svg>
<p class="text-sm text-slate-600 font-medium">Klik atau drag & drop video di sini</p>
<p class="text-xs text-slate-400 mt-1">Format: MP4, MOV, AVI (maks. 50MB)</p>
<input type="file" name="video_wajah" id="videoFileInput" accept="video/mp4,video/quicktime,video/x-msvideo,.mp4,.mov,.avi" class="hidden" required>
</div>
<div id="fileInfo" class="hidden mt-3 p-3 bg-slate-50 rounded-lg border border-slate-200">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
</svg>
<div>
<p class="text-sm font-medium text-slate-700" id="fileName"></p>
<p class="text-xs text-slate-400" id="fileSize"></p>
</div>
</div>
<button type="button" onclick="clearFile()" class="p-1 hover:bg-slate-200 rounded transition">
<svg class="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
</div>
<div class="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-4">
<div class="flex items-start gap-2">
<svg class="w-4 h-4 text-amber-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"></path>
</svg>
<p class="text-xs text-amber-700">Video akan diproses untuk ekstraksi frame wajah. Jika user sudah punya data wajah sebelumnya, data lama akan ditimpa.</p>
</div>
</div>
<div class="flex justify-end gap-3">
<button type="button" onclick="closeUploadModal()"
class="px-4 py-2 text-sm font-semibold text-slate-600 bg-slate-100 hover:bg-slate-200 rounded-lg transition">
Batal
</button>
<button type="submit" id="uploadSubmitBtn"
class="px-4 py-2 text-sm font-semibold text-white bg-slate-800 hover:bg-slate-700 rounded-lg transition shadow-sm disabled:opacity-50 disabled:cursor-not-allowed" disabled>
<span id="uploadBtnText">Upload & Proses</span>
<span id="uploadBtnLoading" class="hidden inline-flex items-center">
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Mengupload...
</span>
</button>
</div>
</form>
</div>
</div>
</div>
@endsection @endsection
@section('script') @section('script')
@ -405,5 +494,74 @@ function stopPolling() {
btn.disabled = false; btn.disabled = false;
btn.classList.remove('opacity-50', 'cursor-not-allowed'); btn.classList.remove('opacity-50', 'cursor-not-allowed');
} }
function openUploadModal(userId, userName) {
const form = document.getElementById('uploadVideoForm');
form.action = `/face-approval/${userId}/upload-video`;
document.getElementById('uploadUserName').textContent = userName;
clearFile();
const overlay = document.getElementById('uploadVideoOverlay');
overlay.classList.remove('hidden');
overlay.classList.add('flex');
}
function closeUploadModal() {
const overlay = document.getElementById('uploadVideoOverlay');
overlay.classList.add('hidden');
overlay.classList.remove('flex');
}
// Drop zone & file input
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('videoFileInput');
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('border-primary', 'bg-primary/5');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('border-primary', 'bg-primary/5');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('border-primary', 'bg-primary/5');
if (e.dataTransfer.files.length > 0) {
fileInput.files = e.dataTransfer.files;
showFileInfo(e.dataTransfer.files[0]);
}
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
showFileInfo(e.target.files[0]);
}
});
function showFileInfo(file) {
const sizeMB = (file.size / 1024 / 1024).toFixed(2);
document.getElementById('fileName').textContent = file.name;
document.getElementById('fileSize').textContent = sizeMB + ' MB';
document.getElementById('fileInfo').classList.remove('hidden');
dropZone.classList.add('hidden');
document.getElementById('uploadSubmitBtn').disabled = false;
}
function clearFile() {
fileInput.value = '';
document.getElementById('fileInfo').classList.add('hidden');
dropZone.classList.remove('hidden');
document.getElementById('uploadSubmitBtn').disabled = true;
}
document.getElementById('uploadVideoForm').addEventListener('submit', function() {
const btn = document.getElementById('uploadSubmitBtn');
btn.disabled = true;
document.getElementById('uploadBtnText').classList.add('hidden');
document.getElementById('uploadBtnLoading').classList.remove('hidden');
});
</script> </script>
@endsection @endsection

View File

@ -125,6 +125,7 @@
Route::put('/face-approval/{id}/approve', [FaceApprovalController::class, 'approve'])->name('face.approve'); Route::put('/face-approval/{id}/approve', [FaceApprovalController::class, 'approve'])->name('face.approve');
Route::delete('/face-approval/{id}/reject', [FaceApprovalController::class, 'reject'])->name('face.reject'); Route::delete('/face-approval/{id}/reject', [FaceApprovalController::class, 'reject'])->name('face.reject');
Route::delete('/face-approval/{id}/reset', [FaceApprovalController::class, 'reset'])->name('face.reset'); Route::delete('/face-approval/{id}/reset', [FaceApprovalController::class, 'reset'])->name('face.reset');
Route::post('/face-approval/{id}/upload-video', [FaceApprovalController::class, 'uploadVideo'])->name('face.upload_video');
Route::resource('role', RoleController::class)->except(['create', 'edit']); Route::resource('role', RoleController::class)->except(['create', 'edit']);
}); });