feat:update profil
This commit is contained in:
parent
d4727bb2d3
commit
8228484cfa
1
Procfile
1
Procfile
|
|
@ -1 +0,0 @@
|
||||||
web: php artisan migrate --force && php artisan db:seed --force && frankenphp run --config /Caddyfile
|
|
||||||
|
|
@ -102,6 +102,9 @@ public function receiveData(Request $request): JsonResponse
|
||||||
'recorded_at' => $recordedAt
|
'recorded_at' => $recordedAt
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Tandai device sebagai online
|
||||||
|
Device::markOnline($data['device_id']);
|
||||||
|
|
||||||
// Jika akses mencurigakan atau tidak sah, buat alert dan kirim notifikasi
|
// Jika akses mencurigakan atau tidak sah, buat alert dan kirim notifikasi
|
||||||
$alertSent = false;
|
$alertSent = false;
|
||||||
if ($isSuspicious || !$isAuthorizedAccess || $accessType === 'forced_entry') {
|
if ($isSuspicious || !$isAuthorizedAccess || $accessType === 'forced_entry') {
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,9 @@ public function receiveData(Request $request): JsonResponse
|
||||||
'recorded_at' => $recordedAt
|
'recorded_at' => $recordedAt
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Tandai device sebagai online
|
||||||
|
Device::markOnline($data['device_id']);
|
||||||
|
|
||||||
// Jika gerakan mencurigakan, buat alert dan kirim notifikasi
|
// Jika gerakan mencurigakan, buat alert dan kirim notifikasi
|
||||||
$alertSent = false;
|
$alertSent = false;
|
||||||
if ($isSuspicious || $motionType === 'unauthorized') {
|
if ($isSuspicious || $motionType === 'unauthorized') {
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,9 @@ public function receiveData(Request $request): JsonResponse
|
||||||
'recorded_at' => $recordedAt
|
'recorded_at' => $recordedAt
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Tandai device sebagai online
|
||||||
|
Device::markOnline($data['device_id']);
|
||||||
|
|
||||||
// Jika akses tidak sah atau pembukaan paksa, buat alert dan kirim notifikasi
|
// Jika akses tidak sah atau pembukaan paksa, buat alert dan kirim notifikasi
|
||||||
$alertSent = false;
|
$alertSent = false;
|
||||||
if ($isUnauthorized || $isForcedEntry || $accessLevel !== 'normal') {
|
if ($isUnauthorized || $isForcedEntry || $accessLevel !== 'normal') {
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,9 @@ public function receiveData(Request $request): JsonResponse
|
||||||
'recorded_at' => now()
|
'recorded_at' => now()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Tandai device sebagai online
|
||||||
|
Device::markOnline($data['device_id']);
|
||||||
|
|
||||||
// Jika getaran abnormal, buat alert dan kirim notifikasi
|
// Jika getaran abnormal, buat alert dan kirim notifikasi
|
||||||
if ($isAbnormal) {
|
if ($isAbnormal) {
|
||||||
$this->handleAbnormalVibration($vibrationReading);
|
$this->handleAbnormalVibration($vibrationReading);
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,28 @@ public function scopeActive($query)
|
||||||
return $query->where('is_active', true);
|
return $query->where('is_active', true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cek apakah device benar-benar online berdasarkan last_seen (dalam 2 menit terakhir)
|
||||||
|
*/
|
||||||
|
public function getIsOnlineAttribute(): bool
|
||||||
|
{
|
||||||
|
if (!$this->last_seen) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return $this->last_seen->diffInMinutes(now()) <= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update status device ke online dan catat last_seen
|
||||||
|
*/
|
||||||
|
public static function markOnline(int $deviceId): void
|
||||||
|
{
|
||||||
|
static::where('id', $deviceId)->update([
|
||||||
|
'status' => 'online',
|
||||||
|
'last_seen' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
// Accessor untuk status badge color
|
// Accessor untuk status badge color
|
||||||
public function getStatusColorAttribute()
|
public function getStatusColorAttribute()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,32 @@ class User extends Authenticatable
|
||||||
'name',
|
'name',
|
||||||
'email',
|
'email',
|
||||||
'password',
|
'password',
|
||||||
|
'profile_photo',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL foto profil — fallback ke null jika tidak ada
|
||||||
|
*/
|
||||||
|
public function getProfilePhotoUrlAttribute(): ?string
|
||||||
|
{
|
||||||
|
if ($this->profile_photo) {
|
||||||
|
return asset('storage/' . $this->profile_photo);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inisial nama untuk avatar fallback
|
||||||
|
*/
|
||||||
|
public function getInitialsAttribute(): string
|
||||||
|
{
|
||||||
|
$words = explode(' ', trim($this->name));
|
||||||
|
if (count($words) >= 2) {
|
||||||
|
return strtoupper(substr($words[0], 0, 1) . substr($words[1], 0, 1));
|
||||||
|
}
|
||||||
|
return strtoupper(substr($this->name, 0, 2));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that should be hidden for serialization.
|
* The attributes that should be hidden for serialization.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->string('profile_photo')->nullable()->after('email');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('profile_photo');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -118,13 +118,19 @@
|
||||||
|
|
||||||
<!-- USER -->
|
<!-- USER -->
|
||||||
<div class="flex items-center gap-4 p-4 bg-gradient-to-r from-gray-800/50 to-slate-700/50 rounded-2xl border border-gray-700/30">
|
<div class="flex items-center gap-4 p-4 bg-gradient-to-r from-gray-800/50 to-slate-700/50 rounded-2xl border border-gray-700/30">
|
||||||
<div class="relative">
|
<div class="relative cursor-pointer" onclick="openProfileModal()" title="Ubah foto profil">
|
||||||
<img src="https://i.pravatar.cc/48" class="w-12 h-12 rounded-xl shadow-lg">
|
@if(auth()->user()->profile_photo)
|
||||||
|
<img src="{{ auth()->user()->profile_photo_url }}" class="w-12 h-12 rounded-xl shadow-lg object-cover hover:opacity-80 transition-opacity">
|
||||||
|
@else
|
||||||
|
<div class="w-12 h-12 rounded-xl shadow-lg bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center hover:opacity-80 transition-opacity">
|
||||||
|
<span class="text-white font-bold text-sm">{{ auth()->user()->initials }}</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
<div class="absolute -bottom-1 -right-1 w-4 h-4 bg-green-500 rounded-full border-2 border-slate-800"></div>
|
<div class="absolute -bottom-1 -right-1 w-4 h-4 bg-green-500 rounded-full border-2 border-slate-800"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<p class="font-semibold text-white">Admin</p>
|
<p class="font-semibold text-white">{{ auth()->user()->name }}</p>
|
||||||
<p class="text-sm text-gray-400">System Operator</p>
|
<p class="text-sm text-gray-400">System Operator</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -152,18 +158,26 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 md:gap-6">
|
<div class="flex items-center gap-2 md:gap-6">
|
||||||
<div class="flex items-center gap-2 bg-gradient-to-r from-green-500/10 to-emerald-500/10 px-3 py-2 rounded-xl border border-green-200">
|
<div id="system-status-badge" class="flex items-center gap-2 {{ $sistemOnline ? 'bg-gradient-to-r from-green-500/10 to-emerald-500/10 border-green-200' : 'bg-gradient-to-r from-red-500/10 to-orange-500/10 border-red-200' }} px-3 py-2 rounded-xl border transition-all duration-500 cursor-default" title="Status koneksi ESP32">
|
||||||
<div class="w-2 h-2 md:w-3 md:h-3 bg-green-500 rounded-full animate-pulse-slow"></div>
|
<div id="system-status-dot" class="w-2 h-2 md:w-3 md:h-3 {{ $sistemOnline ? 'bg-green-500 animate-pulse-slow' : 'bg-red-500' }} rounded-full transition-all duration-500"></div>
|
||||||
<span class="text-green-700 font-semibold text-xs md:text-sm hidden sm:inline">Sistem Online</span>
|
<span id="system-status-text" class="{{ $sistemOnline ? 'text-green-700' : 'text-red-700' }} font-semibold text-xs md:text-sm hidden sm:inline transition-all duration-500">
|
||||||
|
{{ $sistemOnline ? 'Sistem Online' : 'Sistem Offline' }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="relative">
|
<div class="relative cursor-pointer" onclick="openProfileModal()" title="Klik untuk ubah foto profil">
|
||||||
<img src="https://i.pravatar.cc/44" class="w-9 h-9 md:w-11 md:h-11 rounded-xl shadow-lg">
|
@if(auth()->user()->profile_photo)
|
||||||
|
<img src="{{ auth()->user()->profile_photo_url }}" class="w-9 h-9 md:w-11 md:h-11 rounded-xl shadow-lg object-cover hover:opacity-80 transition-opacity">
|
||||||
|
@else
|
||||||
|
<div class="w-9 h-9 md:w-11 md:h-11 rounded-xl shadow-lg bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center hover:opacity-80 transition-opacity">
|
||||||
|
<span class="text-white font-bold text-xs md:text-sm">{{ auth()->user()->initials }}</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
<div class="absolute -bottom-1 -right-1 w-3 h-3 md:w-4 md:h-4 bg-green-500 rounded-full border-2 border-white"></div>
|
<div class="absolute -bottom-1 -right-1 w-3 h-3 md:w-4 md:h-4 bg-green-500 rounded-full border-2 border-white"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hidden sm:block">
|
<div class="hidden sm:block">
|
||||||
<span class="text-gray-800 font-semibold text-sm">Admin</span>
|
<span class="text-gray-800 font-semibold text-sm">{{ auth()->user()->name }}</span>
|
||||||
<p class="text-gray-500 text-xs">Online</p>
|
<p class="text-gray-500 text-xs">Online</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -565,6 +579,98 @@
|
||||||
feather.replace()
|
feather.replace()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- MODAL FOTO PROFIL -->
|
||||||
|
<div id="profile-modal" class="fixed inset-0 z-50 hidden flex items-center justify-center p-4">
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" onclick="closeProfileModal()"></div>
|
||||||
|
|
||||||
|
<!-- Modal Card -->
|
||||||
|
<div class="relative bg-white rounded-3xl shadow-2xl w-full max-w-md p-8 z-10">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-display font-bold text-gray-800">Foto Profil</h3>
|
||||||
|
<p class="text-gray-500 text-sm mt-0.5">Upload atau hapus foto profil Anda</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="closeProfileModal()" class="w-10 h-10 bg-gray-100 hover:bg-gray-200 rounded-xl flex items-center justify-center text-gray-500 transition-all">
|
||||||
|
<i data-feather="x" class="w-5 h-5"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview Foto -->
|
||||||
|
<div class="flex flex-col items-center mb-6">
|
||||||
|
<div class="relative group">
|
||||||
|
@if(auth()->user()->profile_photo)
|
||||||
|
<img id="photo-preview" src="{{ auth()->user()->profile_photo_url }}" class="w-28 h-28 rounded-2xl object-cover shadow-lg border-4 border-indigo-100">
|
||||||
|
@else
|
||||||
|
<div id="photo-preview-placeholder" class="w-28 h-28 rounded-2xl bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center shadow-lg border-4 border-indigo-100">
|
||||||
|
<span class="text-white font-bold text-3xl">{{ auth()->user()->initials }}</span>
|
||||||
|
</div>
|
||||||
|
<img id="photo-preview" src="" class="w-28 h-28 rounded-2xl object-cover shadow-lg border-4 border-indigo-100 hidden">
|
||||||
|
@endif
|
||||||
|
<label for="photo-input" class="absolute inset-0 bg-black/40 rounded-2xl flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer">
|
||||||
|
<i data-feather="camera" class="w-7 h-7 text-white"></i>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-400 text-xs mt-3">Klik foto untuk memilih gambar baru</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Flash messages -->
|
||||||
|
@if(session('success'))
|
||||||
|
<div class="mb-4 p-3 bg-green-50 border border-green-200 rounded-xl text-green-700 text-sm flex items-center gap-2">
|
||||||
|
<i data-feather="check-circle" class="w-4 h-4 flex-shrink-0"></i>
|
||||||
|
{{ session('success') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<!-- Form Upload -->
|
||||||
|
<form action="{{ route('profile.photo.upload') }}" method="POST" enctype="multipart/form-data" id="upload-form">
|
||||||
|
@csrf
|
||||||
|
<input type="file" id="photo-input" name="photo" accept="image/jpeg,image/png,image/jpg,image/gif" class="hidden" onchange="previewPhoto(this)">
|
||||||
|
|
||||||
|
<div id="upload-area" class="border-2 border-dashed border-gray-200 rounded-2xl p-6 text-center mb-4 hover:border-indigo-300 transition-colors cursor-pointer" onclick="document.getElementById('photo-input').click()">
|
||||||
|
<i data-feather="upload-cloud" class="w-8 h-8 text-gray-400 mx-auto mb-2"></i>
|
||||||
|
<p class="text-gray-500 text-sm font-medium">Klik atau drag foto ke sini</p>
|
||||||
|
<p class="text-gray-400 text-xs mt-1">JPEG, PNG, JPG, GIF — Maks. 2MB</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="selected-file-info" class="hidden mb-4 p-3 bg-indigo-50 border border-indigo-200 rounded-xl flex items-center gap-3">
|
||||||
|
<i data-feather="image" class="w-5 h-5 text-indigo-500 flex-shrink-0"></i>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p id="selected-file-name" class="text-indigo-700 text-sm font-medium truncate"></p>
|
||||||
|
<p id="selected-file-size" class="text-indigo-400 text-xs"></p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onclick="clearPhotoSelection()" class="text-indigo-400 hover:text-indigo-600">
|
||||||
|
<i data-feather="x" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@error('photo')
|
||||||
|
<p class="text-red-500 text-sm mb-3 flex items-center gap-1">
|
||||||
|
<i data-feather="alert-circle" class="w-4 h-4"></i> {{ $message }}
|
||||||
|
</p>
|
||||||
|
@enderror
|
||||||
|
|
||||||
|
<button type="submit" id="upload-btn" class="w-full bg-gradient-to-r from-indigo-500 to-purple-600 text-white py-3 rounded-xl font-semibold hover:shadow-lg hover:scale-[1.02] transition-all duration-300 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed disabled:scale-100" disabled>
|
||||||
|
<i data-feather="upload" class="w-5 h-5"></i>
|
||||||
|
Simpan Foto Profil
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Hapus Foto (hanya tampil jika ada foto) -->
|
||||||
|
@if(auth()->user()->profile_photo)
|
||||||
|
<form action="{{ route('profile.photo.delete') }}" method="POST" class="mt-3" onsubmit="return confirm('Hapus foto profil?')">
|
||||||
|
@csrf
|
||||||
|
@method('DELETE')
|
||||||
|
<button type="submit" class="w-full bg-red-50 hover:bg-red-100 text-red-600 py-3 rounded-xl font-semibold transition-all duration-300 flex items-center justify-center gap-2 border border-red-200">
|
||||||
|
<i data-feather="trash-2" class="w-5 h-5"></i>
|
||||||
|
Hapus Foto Profil
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Toggle sidebar mobile
|
// Toggle sidebar mobile
|
||||||
function toggleSidebar() {
|
function toggleSidebar() {
|
||||||
|
|
@ -778,5 +884,127 @@ function dismissAlert() {
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// =============================================
|
||||||
|
// POLLING STATUS SISTEM (ESP32 ONLINE/OFFLINE)
|
||||||
|
// =============================================
|
||||||
|
async function pollSystemStatus() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/device/status').then(r => r.json()).catch(() => null);
|
||||||
|
if (!res) return;
|
||||||
|
|
||||||
|
const badge = document.getElementById('system-status-badge');
|
||||||
|
const dot = document.getElementById('system-status-dot');
|
||||||
|
const text = document.getElementById('system-status-text');
|
||||||
|
|
||||||
|
if (res.online) {
|
||||||
|
badge.className = badge.className
|
||||||
|
.replace(/from-red-500\/10 to-orange-500\/10 border-red-200/g, '')
|
||||||
|
.replace(/from-green-500\/10 to-emerald-500\/10 border-green-200/g, '');
|
||||||
|
badge.className = 'flex items-center gap-2 bg-gradient-to-r from-green-500/10 to-emerald-500/10 border-green-200 px-3 py-2 rounded-xl border transition-all duration-500 cursor-default';
|
||||||
|
|
||||||
|
dot.className = 'w-2 h-2 md:w-3 md:h-3 bg-green-500 animate-pulse-slow rounded-full transition-all duration-500';
|
||||||
|
text.className = 'text-green-700 font-semibold text-xs md:text-sm hidden sm:inline transition-all duration-500';
|
||||||
|
text.textContent = 'Sistem Online';
|
||||||
|
} else {
|
||||||
|
badge.className = 'flex items-center gap-2 bg-gradient-to-r from-red-500/10 to-orange-500/10 border-red-200 px-3 py-2 rounded-xl border transition-all duration-500 cursor-default';
|
||||||
|
dot.className = 'w-2 h-2 md:w-3 md:h-3 bg-red-500 rounded-full transition-all duration-500';
|
||||||
|
text.className = 'text-red-700 font-semibold text-xs md:text-sm hidden sm:inline transition-all duration-500';
|
||||||
|
text.textContent = 'Sistem Offline';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update tooltip dengan info last seen
|
||||||
|
badge.title = res.online
|
||||||
|
? 'ESP32 aktif — terakhir kirim data: ' + (res.last_seen_human ?? '-')
|
||||||
|
: 'ESP32 tidak merespons — terakhir online: ' + (res.last_seen_human ?? 'Belum pernah');
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
// Diam saja jika gagal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jalankan polling status sistem setiap 10 detik
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
pollSystemStatus();
|
||||||
|
setInterval(pollSystemStatus, 10000);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// =============================================
|
||||||
|
// MODAL FOTO PROFIL
|
||||||
|
// =============================================
|
||||||
|
function openProfileModal() {
|
||||||
|
const modal = document.getElementById('profile-modal');
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
modal.classList.add('flex');
|
||||||
|
feather.replace();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeProfileModal() {
|
||||||
|
const modal = document.getElementById('profile-modal');
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
modal.classList.remove('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewPhoto(input) {
|
||||||
|
if (!input.files || !input.files[0]) return;
|
||||||
|
|
||||||
|
const file = input.files[0];
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onload = function (e) {
|
||||||
|
const preview = document.getElementById('photo-preview');
|
||||||
|
const placeholder = document.getElementById('photo-preview-placeholder');
|
||||||
|
|
||||||
|
preview.src = e.target.result;
|
||||||
|
preview.classList.remove('hidden');
|
||||||
|
if (placeholder) placeholder.classList.add('hidden');
|
||||||
|
|
||||||
|
// Info file
|
||||||
|
const info = document.getElementById('selected-file-info');
|
||||||
|
document.getElementById('selected-file-name').textContent = file.name;
|
||||||
|
document.getElementById('selected-file-size').textContent = (file.size / 1024).toFixed(1) + ' KB';
|
||||||
|
info.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Enable tombol upload
|
||||||
|
document.getElementById('upload-btn').disabled = false;
|
||||||
|
|
||||||
|
feather.replace();
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPhotoSelection() {
|
||||||
|
document.getElementById('photo-input').value = '';
|
||||||
|
document.getElementById('selected-file-info').classList.add('hidden');
|
||||||
|
document.getElementById('upload-btn').disabled = true;
|
||||||
|
|
||||||
|
// Kembalikan preview ke kondisi awal
|
||||||
|
@if(auth()->user()->profile_photo)
|
||||||
|
document.getElementById('photo-preview').src = '{{ auth()->user()->profile_photo_url }}';
|
||||||
|
@else
|
||||||
|
const preview = document.getElementById('photo-preview');
|
||||||
|
preview.src = '';
|
||||||
|
preview.classList.add('hidden');
|
||||||
|
const placeholder = document.getElementById('photo-preview-placeholder');
|
||||||
|
if (placeholder) placeholder.classList.remove('hidden');
|
||||||
|
@endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buka modal otomatis jika ada flash message dari upload/hapus foto
|
||||||
|
@if(session('success') && (str_contains(session('success'), 'foto') || str_contains(session('success'), 'Foto')))
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
openProfileModal();
|
||||||
|
});
|
||||||
|
@endif
|
||||||
|
|
||||||
|
// Tutup modal dengan Escape
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') closeProfileModal();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -80,11 +80,17 @@
|
||||||
<!-- USER -->
|
<!-- USER -->
|
||||||
<div class="flex items-center gap-4 p-4 bg-gradient-to-r from-gray-800/50 to-slate-700/50 rounded-2xl border border-gray-700/30">
|
<div class="flex items-center gap-4 p-4 bg-gradient-to-r from-gray-800/50 to-slate-700/50 rounded-2xl border border-gray-700/30">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<img src="https://i.pravatar.cc/48" class="w-12 h-12 rounded-xl shadow-lg">
|
@if(auth()->user()->profile_photo)
|
||||||
|
<img src="{{ auth()->user()->profile_photo_url }}" class="w-12 h-12 rounded-xl shadow-lg object-cover">
|
||||||
|
@else
|
||||||
|
<div class="w-12 h-12 rounded-xl shadow-lg bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center">
|
||||||
|
<span class="text-white font-bold text-sm">{{ auth()->user()->initials }}</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
<div class="absolute -bottom-1 -right-1 w-4 h-4 bg-green-500 rounded-full border-2 border-slate-800"></div>
|
<div class="absolute -bottom-1 -right-1 w-4 h-4 bg-green-500 rounded-full border-2 border-slate-800"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<p class="font-semibold text-white">Admin</p>
|
<p class="font-semibold text-white">{{ auth()->user()->name }}</p>
|
||||||
<p class="text-sm text-gray-400">System Operator</p>
|
<p class="text-sm text-gray-400">System Operator</p>
|
||||||
</div>
|
</div>
|
||||||
<a href="{{ route('logout') }}" class="w-10 h-10 bg-red-500/20 hover:bg-red-500/30 rounded-lg flex items-center justify-center text-red-400 hover:text-red-300 transition-all duration-300" title="Logout">
|
<a href="{{ route('logout') }}" class="w-10 h-10 bg-red-500/20 hover:bg-red-500/30 rounded-lg flex items-center justify-center text-red-400 hover:text-red-300 transition-all duration-300" title="Logout">
|
||||||
|
|
@ -115,11 +121,17 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<img src="https://i.pravatar.cc/44" class="w-9 h-9 md:w-11 md:h-11 rounded-xl shadow-lg">
|
@if(auth()->user()->profile_photo)
|
||||||
|
<img src="{{ auth()->user()->profile_photo_url }}" class="w-9 h-9 md:w-11 md:h-11 rounded-xl shadow-lg object-cover">
|
||||||
|
@else
|
||||||
|
<div class="w-9 h-9 md:w-11 md:h-11 rounded-xl shadow-lg bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center">
|
||||||
|
<span class="text-white font-bold text-xs md:text-sm">{{ auth()->user()->initials }}</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
<div class="absolute -bottom-1 -right-1 w-3 h-3 md:w-4 md:h-4 bg-green-500 rounded-full border-2 border-white"></div>
|
<div class="absolute -bottom-1 -right-1 w-3 h-3 md:w-4 md:h-4 bg-green-500 rounded-full border-2 border-white"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hidden sm:block">
|
<div class="hidden sm:block">
|
||||||
<span class="text-gray-800 font-semibold text-sm">Admin</span>
|
<span class="text-gray-800 font-semibold text-sm">{{ auth()->user()->name }}</span>
|
||||||
<p class="text-gray-500 text-xs">Online</p>
|
<p class="text-gray-500 text-xs">Online</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,20 @@
|
||||||
Route::post('/process-messages', [LoRaController::class, 'processUnprocessedMessages']);
|
Route::post('/process-messages', [LoRaController::class, 'processUnprocessedMessages']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Device Status API — untuk polling online/offline dari frontend
|
||||||
|
Route::get('/device/status', function () {
|
||||||
|
$device = \App\Models\Device::orderBy('last_seen', 'desc')->first();
|
||||||
|
$online = $device && $device->last_seen && $device->last_seen->diffInMinutes(now()) <= 2;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'online' => $online,
|
||||||
|
'last_seen' => $device?->last_seen?->toISOString(),
|
||||||
|
'last_seen_human' => $device?->last_seen?->diffForHumans() ?? 'Belum pernah online',
|
||||||
|
'device_name' => $device?->name ?? 'ESP32',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
// Test notification endpoints
|
// Test notification endpoints
|
||||||
Route::post('/test-notification', function () {
|
Route::post('/test-notification', function () {
|
||||||
$notificationService = new \App\Services\NotificationService();
|
$notificationService = new \App\Services\NotificationService();
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,41 @@
|
||||||
|
|
||||||
// Group routes yang memerlukan login
|
// Group routes yang memerlukan login
|
||||||
Route::middleware(['auth'])->group(function () {
|
Route::middleware(['auth'])->group(function () {
|
||||||
|
|
||||||
|
// Upload foto profil
|
||||||
|
Route::post('/profile/photo', function (\Illuminate\Http\Request $request) {
|
||||||
|
$request->validate([
|
||||||
|
'photo' => 'required|image|mimes:jpeg,png,jpg,gif|max:2048',
|
||||||
|
], [
|
||||||
|
'photo.required' => 'Pilih foto terlebih dahulu.',
|
||||||
|
'photo.image' => 'File harus berupa gambar.',
|
||||||
|
'photo.mimes' => 'Format gambar harus jpeg, png, jpg, atau gif.',
|
||||||
|
'photo.max' => 'Ukuran foto maksimal 2MB.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = \Illuminate\Support\Facades\Auth::user();
|
||||||
|
|
||||||
|
// Hapus foto lama jika ada
|
||||||
|
if ($user->profile_photo) {
|
||||||
|
\Illuminate\Support\Facades\Storage::disk('public')->delete($user->profile_photo);
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $request->file('photo')->store('profile-photos', 'public');
|
||||||
|
$user->update(['profile_photo' => $path]);
|
||||||
|
|
||||||
|
return back()->with('success', 'Foto profil berhasil diperbarui.');
|
||||||
|
})->name('profile.photo.upload');
|
||||||
|
|
||||||
|
// Hapus foto profil
|
||||||
|
Route::delete('/profile/photo', function () {
|
||||||
|
$user = \Illuminate\Support\Facades\Auth::user();
|
||||||
|
if ($user->profile_photo) {
|
||||||
|
\Illuminate\Support\Facades\Storage::disk('public')->delete($user->profile_photo);
|
||||||
|
$user->update(['profile_photo' => null]);
|
||||||
|
}
|
||||||
|
return back()->with('success', 'Foto profil berhasil dihapus.');
|
||||||
|
})->name('profile.photo.delete');
|
||||||
|
|
||||||
Route::get('/dashboard', function () {
|
Route::get('/dashboard', function () {
|
||||||
$totalSensor = \App\Models\Sensor::where('is_active', true)->count();
|
$totalSensor = \App\Models\Sensor::where('is_active', true)->count();
|
||||||
$sensorAktif = \App\Models\Sensor::where('status', 'active')->count();
|
$sensorAktif = \App\Models\Sensor::where('status', 'active')->count();
|
||||||
|
|
@ -100,9 +135,14 @@
|
||||||
$reedAktif = \App\Models\ReedSwitchReading::where('recorded_at', '>=', now()->subSeconds(30))
|
$reedAktif = \App\Models\ReedSwitchReading::where('recorded_at', '>=', now()->subSeconds(30))
|
||||||
->where('door_open', true)->exists();
|
->where('door_open', true)->exists();
|
||||||
|
|
||||||
|
// Status device online — berdasarkan last_seen dalam 2 menit terakhir
|
||||||
|
$device = \App\Models\Device::orderBy('last_seen', 'desc')->first();
|
||||||
|
$sistemOnline = $device && $device->last_seen && $device->last_seen->diffInMinutes(now()) <= 2;
|
||||||
|
|
||||||
return view('dashboard', compact(
|
return view('dashboard', compact(
|
||||||
'totalSensor', 'sensorAktif', 'peringatanHariIni', 'alertAktif',
|
'totalSensor', 'sensorAktif', 'peringatanHariIni', 'alertAktif',
|
||||||
'lastUpdate', 'alertTerbaru', 'pirAktif', 'vibAktif', 'reedAktif'
|
'lastUpdate', 'alertTerbaru', 'pirAktif', 'vibAktif', 'reedAktif',
|
||||||
|
'device', 'sistemOnline'
|
||||||
));
|
));
|
||||||
})->name('dashboard');
|
})->name('dashboard');
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue