feat: add resources and view components
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
<x-app-layout>
|
||||
<div class="container-fluid" id="main-content">
|
||||
<div class="row gx-3 gx-lg-4">
|
||||
<div class="col-12">
|
||||
<div class="card adminuiux-card">
|
||||
<div class="card-body p-0">
|
||||
|
||||
{{-- Action Log Header --}}
|
||||
<div class="p-4 pb-0">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h5 class="mb-0 fw-bold">{{ __('Action Log') }}</h5>
|
||||
<small class="text-muted">
|
||||
{{ __('Monitor all recorded user actions within the system, including authentication and data modifications.') }}
|
||||
</small>
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<button type="button" class="btn btn-outline-dark btn-sm rounded-pill px-3" id="export-csv-btn">
|
||||
<i class="bi bi-file-earmark-excel me-1"></i> {{ __('Export CSV') }}
|
||||
</button>
|
||||
@can('manage action history')
|
||||
<button type="button" class="btn btn-outline-danger btn-sm rounded-pill px-3" id="clear-activity-logs-btn">
|
||||
<i class="bi bi-trash me-1"></i> {{ __('Clear All Logs') }}
|
||||
</button>
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mb-4">
|
||||
<button type="button" class="btn btn-sm btn-dark rounded-pill px-3 filter-event" data-value="">
|
||||
<i class="bi bi-list-ul me-1"></i> {{ __('All Logs') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-dark border border-dark rounded-pill px-3 filter-event" data-value="auth">
|
||||
<i class="bi bi-shield-lock me-1"></i> {{ __('Authentication') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-dark border border-dark rounded-pill px-3 filter-event" data-value="data">
|
||||
<i class="bi bi-database-gear me-1"></i> {{ __('Data Modification') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-dark border border-dark rounded-pill px-3 filter-event" data-value="system">
|
||||
<i class="bi bi-cpu me-1"></i> {{ __('System') }}
|
||||
</button>
|
||||
</div>
|
||||
<input type="hidden" id="filter-event-val" name="event" class="filter-extra" value="">
|
||||
</div>
|
||||
|
||||
{{-- Action Log Table --}}
|
||||
<div class="p-4">
|
||||
<div class="table-responsive">
|
||||
<table id="datatables" class="table table-hover table-bordered w-100 nowrap mb-0"
|
||||
data-server-side="true" data-ajax-url="{{ route('action-logs') }}"
|
||||
data-order='@json([[4, "desc"]])'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-wrap">{{ __('User') }}</th>
|
||||
<th class="text-wrap">{{ __('Action') }}</th>
|
||||
<th class="text-wrap">{{ __('Preview') }}</th>
|
||||
<th class="text-wrap">{{ __('Module') }}</th>
|
||||
<th class="text-wrap">{{ __('Executed At') }}</th>
|
||||
<th class="text-wrap" data-hide="audit">{{ __('IP Address') }}</th>
|
||||
<th class="text-wrap" data-hide="audit">{{ __('Device / Agent') }}</th>
|
||||
<th class="text-wrap" data-hide="audit">{{ __('Properties') }}</th>
|
||||
<th class="text-end text-wrap" data-orderable="false"
|
||||
data-searchable="false">{{ __('Details') }}</th>
|
||||
</tr>
|
||||
|
||||
<tr class="filter-row">
|
||||
<th><input class="form-control form-control-sm" placeholder="{{ __('Search User') }}"></th>
|
||||
<th><input class="form-control form-control-sm" placeholder="{{ __('Action') }}"></th>
|
||||
<th><input class="form-control form-control-sm" placeholder="{{ __('Keyword') }}"></th>
|
||||
<th><input class="form-control form-control-sm" placeholder="{{ __('Module') }}"></th>
|
||||
<th><input type="date" class="form-control form-control-sm"></th>
|
||||
<th><input class="form-control form-control-sm" placeholder="{{ __('IP') }}"></th>
|
||||
<th><input class="form-control form-control-sm" placeholder="{{ __('Agent') }}"></th>
|
||||
<th><input class="form-control form-control-sm" placeholder="{{ __('Props') }}"></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ADVANCED AUDIT MODAL --}}
|
||||
<div class="modal fade" id="detailLogModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||
<div class="modal-content border-0 shadow-lg" style="border-radius: 1.5rem;">
|
||||
<div class="modal-header border-0 p-4 pb-0">
|
||||
<div>
|
||||
<h5 class="modal-title fw-black tracking-tight" id="modal-event-label">Action Log Detail</h5>
|
||||
<div id="modal-module-info" class="text-theme-1 small fw-bold text-uppercase"></div>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body p-4">
|
||||
|
||||
{{-- Initiator & Time --}}
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="p-3 bg-light rounded-4 h-100 shadow-sm border border-white">
|
||||
<label class="extra-small text-uppercase fw-black text-secondary mb-2 d-block opacity-50">{{ __('Initiator') }}</label>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div class="avatar avatar-40 rounded-circle bg-white shadow-sm d-flex align-items-center justify-content-center text-theme-1">
|
||||
<i class="bi bi-person-fill"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div id="modal-causer-name" class="fw-bold text-dark"></div>
|
||||
<div id="modal-causer-email" class="text-secondary extra-small"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="p-3 bg-light rounded-4 h-100 shadow-sm border border-white">
|
||||
<label class="extra-small text-uppercase fw-black text-secondary mb-2 d-block opacity-50">{{ __('Timestamp & Origin') }}</label>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div class="avatar avatar-40 rounded-circle bg-white shadow-sm d-flex align-items-center justify-content-center text-info">
|
||||
<i class="bi bi-clock-history"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div id="modal-time-info" class="fw-bold text-dark small"></div>
|
||||
<div id="modal-network-info" class="text-secondary extra-small"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Data Changes List (Professional Diff Style) --}}
|
||||
<div class="mb-4">
|
||||
<label class="extra-small text-uppercase fw-black text-secondary mb-3 d-block opacity-50 ms-1">{{ __('Data Modifications') }}</label>
|
||||
<div id="modal-changes-list" class="overflow-hidden border border-light" style="border-radius: 1rem;">
|
||||
<!-- Injected here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Technical Data --}}
|
||||
<div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-2 px-1">
|
||||
<label class="extra-small text-uppercase fw-black text-secondary mb-0 opacity-50">{{ __('Raw Metadata') }}</label>
|
||||
<button class="btn btn-sm btn-link extra-small text-decoration-none fw-bold p-0" onclick="copyRawJson()">{{ __('COPY JSON') }}</button>
|
||||
</div>
|
||||
<pre id="modal-raw-json" class="bg-dark text-warning p-4 rounded-4 small mb-0 shadow-sm scroll-custom" style="white-space: pre-wrap; font-size: 11px; max-height: 250px; overflow-y: auto; font-family: 'Fira Code', 'JetBrains Mono', monospace; line-height: 1.5;"></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-0 p-4 pt-0">
|
||||
<button type="button" class="btn btn-dark w-100 py-3 rounded-pill fw-bold shadow-sm" data-bs-dismiss="modal">{{ __('Close Audit View') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const modal = new bootstrap.Modal('#detailLogModal');
|
||||
|
||||
document.addEventListener("click", e => {
|
||||
// Event category filter handler
|
||||
const filterBtn = e.target.closest(".filter-event");
|
||||
if (filterBtn) {
|
||||
document.querySelectorAll(".filter-event").forEach(b => {
|
||||
b.classList.remove("btn-dark");
|
||||
b.classList.add("btn-outline-dark", "border", "border-dark");
|
||||
});
|
||||
|
||||
filterBtn.classList.add("btn-dark");
|
||||
filterBtn.classList.remove("btn-outline-dark", "border", "border-dark");
|
||||
|
||||
document.getElementById("filter-event-val").value = filterBtn.dataset.value;
|
||||
window.reloadDataTable?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = e.target.closest(".btn-detail-log");
|
||||
if (!btn) return;
|
||||
|
||||
const data = JSON.parse(btn.dataset.activity);
|
||||
|
||||
// Populating data
|
||||
document.getElementById('modal-event-label').textContent = data.event.label;
|
||||
document.getElementById('modal-causer-name').textContent = data.causer.name;
|
||||
document.getElementById('modal-causer-email').textContent = data.causer.email;
|
||||
document.getElementById('modal-module-info').textContent = data.subject.module + " — " + data.subject.type;
|
||||
document.getElementById('modal-time-info').textContent = data.meta.time;
|
||||
document.getElementById('modal-network-info').textContent = "IP: " + data.meta.ip;
|
||||
|
||||
// Build Changes
|
||||
const container = document.getElementById('modal-changes-list');
|
||||
container.innerHTML = '';
|
||||
|
||||
if (data.changes.length > 0) {
|
||||
data.changes.forEach(change => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'bg-white border-bottom p-3 transition-all hover-bg-light';
|
||||
|
||||
let diffContent = '';
|
||||
if (change.old === 'NULL' && change.new !== 'NULL') {
|
||||
// Creation / Initialization
|
||||
diffContent = `
|
||||
<div class="diff-box bg-success bg-opacity-10 border-success border-opacity-25 p-2 rounded small w-100">
|
||||
<span class="text-success fw-bold me-2">+</span> ${change.new}
|
||||
</div>`;
|
||||
} else if (change.old !== null) {
|
||||
// Update
|
||||
diffContent = `
|
||||
<div class="d-flex flex-column gap-2 w-100">
|
||||
<div class="diff-box bg-danger bg-opacity-10 border-danger border-opacity-25 p-2 rounded small">
|
||||
<span class="text-danger fw-bold me-2">-</span> ${change.old}
|
||||
</div>
|
||||
<div class="diff-box bg-success bg-opacity-10 border-success border-opacity-25 p-2 rounded small">
|
||||
<span class="text-success fw-bold me-2">+</span> ${change.new}
|
||||
</div>
|
||||
</div>`;
|
||||
} else {
|
||||
// General
|
||||
diffContent = `<div class="p-2 small text-dark fw-medium">${change.new}</div>`;
|
||||
}
|
||||
|
||||
row.innerHTML = `
|
||||
<div class="row align-items-start g-3">
|
||||
<div class="col-md-3">
|
||||
<span class="extra-small text-uppercase fw-black text-secondary d-block mt-1">${change.field}</span>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
${diffContent}
|
||||
</div>
|
||||
</div>`;
|
||||
container.appendChild(row);
|
||||
});
|
||||
} else {
|
||||
container.innerHTML = `
|
||||
<div class="p-5 text-center bg-light bg-opacity-50">
|
||||
<i class="bi bi-info-circle text-muted fs-2 mb-2 d-block"></i>
|
||||
<div class="text-muted small fw-bold">No data modifications recorded for this event.</div>
|
||||
<div class="extra-small text-secondary opacity-75">This usually happens for authentication or read events.</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
document.getElementById('modal-raw-json').textContent = JSON.stringify(data.raw, null, 2);
|
||||
modal.show();
|
||||
});
|
||||
|
||||
// Export CSV Handler
|
||||
const exportBtn = document.getElementById('export-csv-btn');
|
||||
if (exportBtn) {
|
||||
exportBtn.addEventListener('click', () => {
|
||||
const event = document.getElementById('filter-event-val').value;
|
||||
const search = document.querySelector('.dataTables_filter input')?.value || '';
|
||||
|
||||
let url = `{{ route('action-logs.export') }}?event=${event}&search=${encodeURIComponent(search)}`;
|
||||
window.location.href = url;
|
||||
toastr?.info('{{ __("Preparing your export, please wait...") }}');
|
||||
});
|
||||
}
|
||||
|
||||
// Clear Activity Logs Handler
|
||||
const clearBtn = document.getElementById('clear-activity-logs-btn');
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', () => {
|
||||
StandardSwal.fire({
|
||||
title: 'Clear Global Action Logs?',
|
||||
text: 'You are about to permanently erase all recorded system audit trails. This action cannot be undone.',
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
customClass: {
|
||||
confirmButton: 'btn-pill-danger',
|
||||
cancelButton: 'btn-pill-cancel'
|
||||
},
|
||||
confirmButtonText: 'Yes, Clear All',
|
||||
cancelButtonText: 'Cancel'
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
fetch('{{ route("action-logs.clear") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
window.reloadDataTable?.();
|
||||
StandardSwal.fire({
|
||||
icon: 'success',
|
||||
title: 'Logs Cleared!',
|
||||
text: data.message || 'System audit trails have been successfully removed.',
|
||||
timer: 2000,
|
||||
showConfirmButton: false,
|
||||
timerProgressBar: true
|
||||
});
|
||||
} else {
|
||||
StandardSwal.fire({ icon: 'error', title: 'Action Failed!', text: data.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function copyRawJson() {
|
||||
const content = document.getElementById('modal-raw-json').textContent;
|
||||
navigator.clipboard.writeText(content);
|
||||
toastr?.info('{{ __("Audit JSON Copied to Clipboard") }}');
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.fw-black { font-weight: 900; }
|
||||
.tracking-tight { letter-spacing: -1px; }
|
||||
.extra-small { font-size: 0.65rem; }
|
||||
.hover-bg-light:hover { background-color: #f8fafc !important; }
|
||||
.diff-box { border: 1px solid transparent; font-family: 'Fira Code', 'JetBrains Mono', monospace; word-break: break-all; }
|
||||
.scroll-custom::-webkit-scrollbar { width: 6px; }
|
||||
.scroll-custom::-webkit-scrollbar-thumb { background: #334155; border-radius: 10px; }
|
||||
.bg-dark { background-color: #0f172a !important; }
|
||||
</style>
|
||||
@endpush
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,192 @@
|
||||
{{--
|
||||
Two-panel permission picker — single source of truth.
|
||||
ALL items rendered once in Available. Pre-selected ones moved to Assigned by JS on init.
|
||||
Multi-select: click = single, Ctrl+click = toggle, Shift+click = range.
|
||||
--}}
|
||||
@php
|
||||
$preSelected = collect($rolePermIds ?? []);
|
||||
@endphp
|
||||
|
||||
<style>
|
||||
.dp-panel {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(72vh - 120px);
|
||||
min-height: 400px;
|
||||
}
|
||||
.dp-panel-head {
|
||||
background: #f8fafc;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dp-panel-search {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dp-hint-row {
|
||||
padding: 2px 10px 4px;
|
||||
font-size: 0.6rem;
|
||||
color: #b0b9c8;
|
||||
border-bottom: 1px solid #f8fafc;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dp-panel-body {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
.dp-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f8fafc;
|
||||
transition: background .1s;
|
||||
user-select: none;
|
||||
}
|
||||
.dp-item:hover { background: #f0f9ff; }
|
||||
.dp-item.selected {
|
||||
background: #dbeafe;
|
||||
outline: 1px solid #93c5fd;
|
||||
outline-offset: -1px;
|
||||
}
|
||||
.dp-item-icon { flex-shrink: 0; width: 18px; text-align: center; }
|
||||
.dp-item-name { font-size: 0.78rem; line-height: 1.3; flex: 1; min-width: 0; }
|
||||
.dp-item-cat { font-size: 0.6rem; color: #94a3b8; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.dp-item-tab { display: inline-block; font-size: 0.58rem; background: #e2e8f0; color: #475569; padding: 0 4px; border-radius: 3px; font-weight: 600; margin-right: 3px; }
|
||||
.dp-item-type-manage { color: #7c3aed; }
|
||||
.dp-item-type-view { color: #0369a1; }
|
||||
.dp-btn-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 0 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dp-btn {
|
||||
width: 34px; height: 34px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: all .15s;
|
||||
font-size: 0.8rem;
|
||||
color: #475569;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dp-btn:hover { background: #111827; color: white; border-color: #111827; }
|
||||
.dp-group-header {
|
||||
padding: 4px 10px 2px;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
.dp-empty-hint { padding: 40px 16px; text-align: center; color: #cbd5e1; font-size: 0.78rem; }
|
||||
</style>
|
||||
|
||||
<div class="d-flex align-items-stretch" id="dp-{{ $panelId }}" style="min-width:0;gap:0;">
|
||||
|
||||
{{-- ── LEFT: Available (ALL items live here initially) ──────── --}}
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div class="dp-panel">
|
||||
<div class="dp-panel-head d-flex justify-content-between align-items-center">
|
||||
<span class="fw-semibold small text-dark">{{ __('Available') }}</span>
|
||||
<span class="badge text-bg-secondary rounded-pill dp-available-count" style="font-size:0.6rem;">0</span>
|
||||
</div>
|
||||
<div class="dp-panel-search">
|
||||
<input type="text" class="form-control form-control-sm dp-search-available border-0 p-0 bg-transparent"
|
||||
placeholder="🔍 {{ __('Filter...') }}" style="font-size:0.78rem;box-shadow:none;">
|
||||
</div>
|
||||
<div class="dp-hint-row">
|
||||
<i class="bi bi-info-circle me-1"></i>Click = select · Ctrl+click = multi · Shift+click = range · Dbl-click = move
|
||||
</div>
|
||||
<div class="dp-panel-body dp-available-list">
|
||||
@foreach ($groupedPermissions as $category => $catPerms)
|
||||
@php
|
||||
$catItems = collect();
|
||||
foreach ($catPerms as $menuName => $menuData) {
|
||||
if ($menuData['manage']) $catItems->push(['perm' => $menuData['manage'], 'menu' => $menuName, 'tab' => null, 'type' => 'manage']);
|
||||
if ($menuData['view']) $catItems->push(['perm' => $menuData['view'], 'menu' => $menuName, 'tab' => null, 'type' => 'view']);
|
||||
foreach ($menuData['tabs'] as $tabSlug => $tabPerms) {
|
||||
if ($tabPerms['manage']) $catItems->push(['perm' => $tabPerms['manage'], 'menu' => $menuName, 'tab' => $tabSlug, 'type' => 'manage']);
|
||||
if ($tabPerms['view']) $catItems->push(['perm' => $tabPerms['view'], 'menu' => $menuName, 'tab' => $tabSlug, 'type' => 'view']);
|
||||
}
|
||||
}
|
||||
@endphp
|
||||
@if($catItems->isNotEmpty())
|
||||
<div class="dp-group-header dp-cat-header" data-cat="{{ Str::slug($category) }}">{{ $category }}</div>
|
||||
@foreach ($catItems as $entry)
|
||||
<div class="dp-item dp-avail-item"
|
||||
data-id="{{ $entry['perm']->id }}"
|
||||
data-name="{{ strtolower($entry['perm']->name) }}"
|
||||
data-cat="{{ Str::slug($category) }}"
|
||||
data-preselected="{{ $preSelected->contains($entry['perm']->id) ? '1' : '0' }}">
|
||||
<span class="dp-item-icon">
|
||||
@if($entry['type'] === 'manage')
|
||||
<i class="bi bi-pencil-square dp-item-type-manage" style="font-size:0.7rem;"></i>
|
||||
@else
|
||||
<i class="bi bi-eye dp-item-type-view" style="font-size:0.7rem;"></i>
|
||||
@endif
|
||||
</span>
|
||||
<span class="dp-item-name">
|
||||
@if($entry['tab'])
|
||||
<span class="dp-item-tab">{{ $entry['tab'] }}</span>
|
||||
@endif
|
||||
{{ $entry['perm']->name }}
|
||||
<span class="dp-item-cat">{{ $category }}</span>
|
||||
</span>
|
||||
</div>
|
||||
@endforeach
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── CENTER: Buttons ─────────────────────────────────────── --}}
|
||||
<div class="dp-btn-col">
|
||||
<button type="button" class="dp-btn dp-btn-add-selected" title="{{ __('Move selected →') }}"><i class="bi bi-chevron-right"></i></button>
|
||||
<button type="button" class="dp-btn dp-btn-add-all" title="{{ __('Move all →→') }}"><i class="bi bi-chevron-double-right"></i></button>
|
||||
<button type="button" class="dp-btn dp-btn-remove-selected" title="{{ __('← Remove selected') }}"><i class="bi bi-chevron-left"></i></button>
|
||||
<button type="button" class="dp-btn dp-btn-remove-all" title="{{ __('←← Remove all') }}"><i class="bi bi-chevron-double-left"></i></button>
|
||||
</div>
|
||||
|
||||
{{-- ── RIGHT: Assigned (empty on load, filled by JS) ──────── --}}
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div class="dp-panel">
|
||||
<div class="dp-panel-head d-flex justify-content-between align-items-center">
|
||||
<span class="fw-semibold small text-dark">{{ __('Assigned') }}</span>
|
||||
<span class="badge rounded-pill dp-assigned-count text-bg-secondary" style="font-size:0.6rem;">0</span>
|
||||
</div>
|
||||
<div class="dp-panel-search">
|
||||
<input type="text" class="form-control form-control-sm dp-search-assigned border-0 p-0 bg-transparent"
|
||||
placeholder="🔍 {{ __('Filter...') }}" style="font-size:0.78rem;box-shadow:none;">
|
||||
</div>
|
||||
<div class="dp-hint-row">
|
||||
<i class="bi bi-info-circle me-1"></i>Dbl-click or select + ◀ to remove
|
||||
</div>
|
||||
<div class="dp-panel-body dp-assigned-list">
|
||||
<div class="dp-empty-hint">
|
||||
<i class="bi bi-arrow-left-circle d-block mb-2" style="font-size:1.8rem;opacity:0.2;"></i>
|
||||
{{ __('No permissions assigned yet') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,206 @@
|
||||
{{--
|
||||
Permission matrix partial — tree view with collapsible tabs.
|
||||
Variables:
|
||||
$groupedPermissions — tree from RoleManagementController::groupPermissions()
|
||||
$idPrefix — 'add' | 'edit'
|
||||
$rolePermIds — array of pre-selected permission IDs (empty for add)
|
||||
--}}
|
||||
@foreach ($groupedPermissions as $category => $perms)
|
||||
@php
|
||||
$catSlug = Str::slug($category);
|
||||
$allInCat = collect($perms)->flatMap(function ($m) {
|
||||
$ids = [];
|
||||
if ($m['manage']) $ids[] = $m['manage']->id;
|
||||
if ($m['view']) $ids[] = $m['view']->id;
|
||||
foreach ($m['tabs'] as $t) {
|
||||
if ($t['manage']) $ids[] = $t['manage']->id;
|
||||
if ($t['view']) $ids[] = $t['view']->id;
|
||||
}
|
||||
return $ids;
|
||||
})->values();
|
||||
$totalInCat = $allInCat->count();
|
||||
@endphp
|
||||
|
||||
<div class="perm-category-group mb-2"
|
||||
data-total="{{ $totalInCat }}"
|
||||
data-cat="{{ $catSlug }}">
|
||||
|
||||
{{-- ── Category header ── --}}
|
||||
<div class="d-flex justify-content-between align-items-center px-2 py-1 rounded-2 mb-1"
|
||||
style="background:#f1f5f9; border-left:3px solid #64748b;">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="fw-bold small text-dark">{{ $category }}</span>
|
||||
<span class="perm-badge badge rounded-pill text-bg-secondary" style="font-size:0.6rem;">
|
||||
0 / {{ $totalInCat }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="form-check form-check-inline mb-0">
|
||||
<input class="form-check-input select-all-category" type="checkbox"
|
||||
id="{{ $idPrefix }}-all-{{ $catSlug }}"
|
||||
title="{{ __('Select all in this category') }}">
|
||||
<label class="form-check-label small text-muted cursor-pointer"
|
||||
for="{{ $idPrefix }}-all-{{ $catSlug }}">
|
||||
{{ __('All') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Column headers ── --}}
|
||||
<div class="row g-0 px-1 mb-1 text-uppercase text-secondary"
|
||||
style="font-size:0.6rem;font-weight:800;letter-spacing:0.4px;">
|
||||
<div class="col-6 ps-1">{{ __('Manage') }}</div>
|
||||
<div class="col-6 ps-3">{{ __('View') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="ms-1">
|
||||
@foreach ($perms as $menuName => $menuData)
|
||||
@php
|
||||
$hasTabs = ! empty($menuData['tabs']);
|
||||
$menuSlug = Str::slug($menuName);
|
||||
$collapseId = $idPrefix . '-tabs-' . $catSlug . '-' . $menuSlug;
|
||||
$tabCount = count($menuData['tabs']);
|
||||
@endphp
|
||||
|
||||
<div class="perm-menu-row mb-1" data-base="{{ strtolower($menuName) }}">
|
||||
|
||||
{{-- ── Menu-level row ── --}}
|
||||
<div class="row g-0 align-items-center permission-pair-row"
|
||||
data-base="{{ strtolower($menuName) }}">
|
||||
|
||||
{{-- Manage column --}}
|
||||
<div class="col-6 text-break pe-2">
|
||||
@if($menuData['manage'])
|
||||
@php $p = $menuData['manage']; @endphp
|
||||
<div class="permission-item d-flex align-items-center gap-1"
|
||||
data-name="{{ strtolower($p->name) }}">
|
||||
@if($hasTabs)
|
||||
<button type="button"
|
||||
class="btn btn-link btn-sm p-0 lh-1 text-secondary tab-collapse-toggle flex-shrink-0"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#{{ $collapseId }}"
|
||||
aria-expanded="false"
|
||||
title="{{ $tabCount }} tab permissions">
|
||||
<i class="bi bi-chevron-right perm-chevron" style="font-size:0.65rem;transition:transform .2s;"></i>
|
||||
</button>
|
||||
@else
|
||||
<span style="width:14px;display:inline-block;"></span>
|
||||
@endif
|
||||
<div class="form-check mb-0">
|
||||
<input class="form-check-input perm-checkbox perm-manage"
|
||||
type="checkbox" name="permissions[]"
|
||||
value="{{ $p->id }}"
|
||||
id="{{ $idPrefix }}-perm-{{ $p->id }}"
|
||||
data-manage-for="{{ $idPrefix }}-perm-{{ $menuData['view']?->id }}"
|
||||
@if(in_array($p->id, $rolePermIds ?? [])) checked @endif>
|
||||
<label class="form-check-label small cursor-pointer fw-semibold lh-sm"
|
||||
for="{{ $idPrefix }}-perm-{{ $p->id }}"
|
||||
title="{{ $p->name }}">
|
||||
{{ $p->name }}
|
||||
@if($hasTabs)
|
||||
<span class="text-muted fw-normal" style="font-size:0.6rem;">(+{{ $tabCount }} tabs)</span>
|
||||
@endif
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- View column --}}
|
||||
<div class="col-6 ps-3 text-break">
|
||||
@if($menuData['view'])
|
||||
@php $p = $menuData['view']; @endphp
|
||||
<div class="permission-item" data-name="{{ strtolower($p->name) }}">
|
||||
<div class="form-check mb-0">
|
||||
<input class="form-check-input perm-checkbox perm-view"
|
||||
type="checkbox" name="permissions[]"
|
||||
value="{{ $p->id }}"
|
||||
id="{{ $idPrefix }}-perm-{{ $p->id }}"
|
||||
@if(in_array($p->id, $rolePermIds ?? [])) checked @endif>
|
||||
<label class="form-check-label small cursor-pointer fw-semibold lh-sm"
|
||||
for="{{ $idPrefix }}-perm-{{ $p->id }}"
|
||||
title="{{ $p->name }}">
|
||||
{{ $p->name }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Collapsible tab rows ── --}}
|
||||
@if($hasTabs)
|
||||
<div class="collapse" id="{{ $collapseId }}">
|
||||
<div class="ms-4 mt-1 ps-2 border-start border-2"
|
||||
style="border-color:#cbd5e1 !important;">
|
||||
<div class="row g-0 mb-1 text-uppercase text-secondary"
|
||||
style="font-size:0.58rem;font-weight:800;letter-spacing:0.3px;">
|
||||
<div class="col-6">{{ __('Manage Tab') }}</div>
|
||||
<div class="col-6 ps-3">{{ __('View Tab') }}</div>
|
||||
</div>
|
||||
@foreach ($menuData['tabs'] as $tabSlug => $tabPerms)
|
||||
<div class="row g-0 align-items-center mb-1 permission-pair-row"
|
||||
data-base="{{ strtolower($menuName . ':' . $tabSlug) }}">
|
||||
|
||||
{{-- Manage tab --}}
|
||||
<div class="col-6 text-break pe-2">
|
||||
@if($tabPerms['manage'])
|
||||
@php $p = $tabPerms['manage']; @endphp
|
||||
<div class="permission-item" data-name="{{ strtolower($p->name) }}">
|
||||
<div class="form-check mb-0">
|
||||
<input class="form-check-input perm-checkbox perm-manage"
|
||||
type="checkbox" name="permissions[]"
|
||||
value="{{ $p->id }}"
|
||||
id="{{ $idPrefix }}-perm-{{ $p->id }}"
|
||||
data-manage-for="{{ $idPrefix }}-perm-{{ $tabPerms['view']?->id }}"
|
||||
@if(in_array($p->id, $rolePermIds ?? [])) checked @endif>
|
||||
<label class="form-check-label small cursor-pointer lh-sm"
|
||||
for="{{ $idPrefix }}-perm-{{ $p->id }}"
|
||||
title="{{ $p->name }}">
|
||||
<span class="badge me-1"
|
||||
style="font-size:0.55rem;background:#e2e8f0;color:#475569;font-weight:600;">
|
||||
{{ $tabSlug }}
|
||||
</span>
|
||||
manage
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- View tab --}}
|
||||
<div class="col-6 ps-3 text-break">
|
||||
@if($tabPerms['view'])
|
||||
@php $p = $tabPerms['view']; @endphp
|
||||
<div class="permission-item" data-name="{{ strtolower($p->name) }}">
|
||||
<div class="form-check mb-0">
|
||||
<input class="form-check-input perm-checkbox perm-view"
|
||||
type="checkbox" name="permissions[]"
|
||||
value="{{ $p->id }}"
|
||||
id="{{ $idPrefix }}-perm-{{ $p->id }}"
|
||||
@if(in_array($p->id, $rolePermIds ?? [])) checked @endif>
|
||||
<label class="form-check-label small cursor-pointer lh-sm"
|
||||
for="{{ $idPrefix }}-perm-{{ $p->id }}"
|
||||
title="{{ $p->name }}">
|
||||
<span class="badge me-1"
|
||||
style="font-size:0.55rem;background:#e2e8f0;color:#475569;font-weight:600;">
|
||||
{{ $tabSlug }}
|
||||
</span>
|
||||
view
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
@@ -0,0 +1,382 @@
|
||||
<x-app-layout>
|
||||
<div class="container-fluid" id="main-content">
|
||||
<div class="row gx-3 gx-lg-4">
|
||||
<div class="col-12">
|
||||
<div class="card adminuiux-card">
|
||||
<div class="card-body p-0">
|
||||
|
||||
{{-- permission management page header --}}
|
||||
<div class="p-4 pb-0">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h5 class="mb-0 fw-bold">{{ __('Permission Management') }}</h5>
|
||||
<small class="text-muted">
|
||||
{{ __('Manage permissions, define access rules, and control user capabilities within the system.') }}
|
||||
</small>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<a href="{{ route('roles') }}#rbac-docs"
|
||||
class="btn btn-outline-dark px-3 rounded-pill d-flex align-items-center gap-2">
|
||||
<i class="bi bi-book"></i> {{ __('Documentation') }}
|
||||
</a>
|
||||
@can('manage access rights')
|
||||
<button class="btn btn-primary px-3" data-bs-toggle="modal"
|
||||
data-bs-target="#addPermissionModal">
|
||||
 + {{ __('Add Permission') }} 
|
||||
</button>
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Quick reference banner --}}
|
||||
<div class="d-flex align-items-start gap-3 p-3 rounded-4 mb-3" style="background:#eff6ff;border:1px solid #bfdbfe;">
|
||||
<i class="bi bi-info-circle-fill text-primary fs-4"></i>
|
||||
<div class="flex-grow-1">
|
||||
<div class="fw-bold text-dark mb-1" style="font-size:.88rem;">{{ __('Permissions are the atomic building blocks') }}</div>
|
||||
<div class="small text-muted" style="line-height:1.6;">
|
||||
{{ __('Each permission represents ONE specific action (e.g.') }} <code>view dashboard</code>,
|
||||
<code>manage users</code>). {{ __('You assign permissions to') }} <span class="fw-bold">{{ __('roles') }}</span>,
|
||||
{{ __('then assign roles to') }} <span class="fw-bold">{{ __('users') }}</span>.
|
||||
<a href="{{ route('roles') }}#rbac-docs" class="text-primary fw-bold text-decoration-none">{{ __('Read the full RBAC guide →') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- permissions table --}}
|
||||
<div class="p-4">
|
||||
<div class="table-responsive overflow-hidden">
|
||||
<table id="datatables" class="table table-hover table-bordered w-100 nowrap mb-0"
|
||||
data-server-side="true" data-ajax-url="{{ route('permissions') }}"
|
||||
data-order='@json([[4, "desc"]])'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-wrap">{{ __('Status') }}</th>
|
||||
<th class="text-wrap">{{ __('Permission Name') }}</th>
|
||||
<th class="text-wrap">{{ __('Module / Guard') }}</th>
|
||||
<th class="text-wrap">{{ __('Assigned To (Roles)') }}</th>
|
||||
<th class="text-wrap" data-hide="audit">{{ __('Created At') }}</th>
|
||||
<th class="text-wrap" data-hide="audit">{{ __('Created By') }}</th>
|
||||
<th class="text-wrap" data-hide="audit">{{ __('Last Updated At') }}</th>
|
||||
<th class="text-wrap" data-hide="audit">{{ __('Last Updated By') }}</th>
|
||||
@can('manage access rights')
|
||||
<th class="text-end text-wrap" data-orderable="false"
|
||||
data-searchable="false">{{ __('Action') }}</th>
|
||||
@endcan
|
||||
</tr>
|
||||
|
||||
{{-- filter bar --}}
|
||||
<tr class="filter-row">
|
||||
<th>
|
||||
<select class="form-select form-select-sm">
|
||||
<option value="">{{ __('All') }}</option>
|
||||
<option value="active">{{ __('Active') }}</option>
|
||||
<option value="inactive">{{ __('Inactive') }}</option>
|
||||
</select>
|
||||
</th>
|
||||
<th><input class="form-control form-control-sm"
|
||||
placeholder="{{ __('Search Permission Name') }}"></th>
|
||||
<th>
|
||||
<select class="form-select form-select-sm">
|
||||
<option value="">{{ __('All') }}</option>
|
||||
<option value="web">web</option>
|
||||
<option value="api">api</option>
|
||||
</select>
|
||||
</th>
|
||||
<th><input class="form-control form-control-sm"
|
||||
placeholder="{{ __('Search Assigned Roles') }}"></th>
|
||||
<th><input type="date" class="form-control form-control-sm"></th>
|
||||
<th><input class="form-control form-control-sm"
|
||||
placeholder="{{ __('Search User') }}"></th>
|
||||
<th><input type="date" class="form-control form-control-sm"></th>
|
||||
<th><input class="form-control form-control-sm"
|
||||
placeholder="{{ __('Search User') }}"></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- add permission modal --}}
|
||||
<div class="modal fade" id="addPermissionModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content rounded-3">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ __('Add Permission') }}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('permissions.store') }}" autocomplete="off" class="ajax-form" data-reset="true">
|
||||
|
||||
@csrf
|
||||
|
||||
{{-- anti autofill trap --}}
|
||||
<input type="text" name="fakeuser" style="display:none">
|
||||
<input type="password" name="fakepass" style="display:none">
|
||||
|
||||
<div class="modal-body">
|
||||
|
||||
{{-- Permission Name --}}
|
||||
<label class="form-label fw-semibold">
|
||||
{{ __('Permission Name') }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="text" name="name" class="form-control mb-3"
|
||||
placeholder="ex: view_report / edit_data" required minlength="3"
|
||||
maxlength="100" pattern="^[a-zA-Z0-9_\-\.\/]+$"
|
||||
title="Minimum 3 characters. Allowed: letters, numbers, dash, underscore, dot, and slash">
|
||||
|
||||
{{-- Module / Guard --}}
|
||||
<label class="form-label fw-semibold">
|
||||
{{ __('Module / Guard') }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<select name="guard_name" class="form-select mb-3" required
|
||||
title="Select guard for this permission">
|
||||
<option value="web" selected>web</option>
|
||||
<option value="api">api</option>
|
||||
</select>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-dark rounded-pill"
|
||||
data-bs-dismiss="modal">
|
||||
 Close 
|
||||
</button>
|
||||
<button type="submit" class="btn btn-dark rounded-pill">
|
||||
 Save 
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- edit permission modal --}}
|
||||
<div class="modal fade" id="editPermissionModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content rounded-3">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ __('Edit Permission') }}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form id="editPermissionForm" method="POST" autocomplete="off" class="ajax-form">
|
||||
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
{{-- anti autofill trap --}}
|
||||
<input type="text" name="fakeuser" style="display:none">
|
||||
<input type="password" name="fakepass" style="display:none">
|
||||
|
||||
<div class="modal-body">
|
||||
|
||||
{{-- hidden id --}}
|
||||
<input type="hidden" id="edit-permission-id" name="id">
|
||||
|
||||
{{-- Permission Name --}}
|
||||
<label class="form-label fw-semibold">
|
||||
{{ __('Permission Name') }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input id="edit-permission-name" name="name" type="text"
|
||||
class="form-control mb-3" placeholder="ex: view_report / edit_data" required
|
||||
minlength="3" maxlength="100" pattern="^[a-zA-Z0-9_\-\.\/]+$"
|
||||
title="Minimum 3 characters. Allowed: letters, numbers, dash, underscore, dot, and slash">
|
||||
|
||||
{{-- Module / Guard --}}
|
||||
<label class="form-label fw-semibold">
|
||||
{{ __('Module / Guard') }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<select id="edit-permission-guard" name="guard_name" class="form-select mb-3"
|
||||
required title="Select guard for this permission">
|
||||
<option value="web">web</option>
|
||||
<option value="api">api</option>
|
||||
</select>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-dark rounded-pill"
|
||||
data-bs-dismiss="modal">
|
||||
 Close 
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary rounded-pill">
|
||||
 Update 
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- script handler (status, delete, edit data fill) --}}
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
// =========================
|
||||
// TOGGLE STATUS (SERVER-DRIVEN)
|
||||
// =========================
|
||||
document.addEventListener("change", e => {
|
||||
const toggle = e.target.closest(".permission-toggle");
|
||||
|
||||
if (!toggle) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = toggle.dataset.id;
|
||||
const name = toggle.dataset.name;
|
||||
const status = toggle.checked ? "activate" : "deactivate";
|
||||
|
||||
StandardSwal.fire({
|
||||
title: `${status === "activate" ? "Activate" : "Deactivate"} Permission?`,
|
||||
text: `You are about to change the system access rights for "${name}".`,
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "Yes, Continue",
|
||||
cancelButtonText: "Cancel",
|
||||
}).then(result => {
|
||||
if (!result.isConfirmed) {
|
||||
toggle.checked = !toggle.checked;
|
||||
return;
|
||||
}
|
||||
|
||||
fetch("{{ route('permissions.toggle-status') }}", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-CSRF-TOKEN": document.querySelector(
|
||||
'meta[name="csrf-token"]').content,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id,
|
||||
status
|
||||
}),
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Live Reload immediately
|
||||
window.reloadDataTable?.();
|
||||
|
||||
StandardSwal.fire({
|
||||
title: "{{ __('Success!') }}",
|
||||
text: data.message || "{{ __('The permission status has been updated successfully.') }}",
|
||||
icon: "success",
|
||||
timer: 2000,
|
||||
showConfirmButton: false,
|
||||
timerProgressBar: true
|
||||
});
|
||||
} else {
|
||||
throw new Error();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
StandardSwal.fire({
|
||||
title: "{{ __('Error!') }}",
|
||||
text: "{{ __('A server error occurred while updating the permission.') }}",
|
||||
icon: "error"
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =========================
|
||||
// FILL EDIT MODAL
|
||||
// =========================
|
||||
const form = document.getElementById("editPermissionForm");
|
||||
|
||||
document.addEventListener("click", e => {
|
||||
const editButton = e.target.closest(".btn-edit");
|
||||
|
||||
if (editButton) {
|
||||
const updateRoute =
|
||||
`{{ route('permissions.update', 'PERMISSION_ID') }}`
|
||||
.replace("PERMISSION_ID", editButton.dataset.id);
|
||||
|
||||
form.action = updateRoute;
|
||||
|
||||
document.getElementById("edit-permission-id").value = editButton
|
||||
.dataset.id ?? "";
|
||||
document.getElementById("edit-permission-name").value = editButton
|
||||
.dataset.name ?? "";
|
||||
document.getElementById("edit-permission-guard").value = editButton
|
||||
.dataset.guard ?? "";
|
||||
return;
|
||||
}
|
||||
|
||||
// =========================
|
||||
// DELETE / ARCHIVE
|
||||
// =========================
|
||||
const deleteButton = e.target.closest(".btn-delete");
|
||||
|
||||
if (!deleteButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = deleteButton.dataset.id;
|
||||
const name = deleteButton.dataset.name;
|
||||
|
||||
StandardSwal.fire({
|
||||
title: "Archive Permission?",
|
||||
text: `"${name}" will be deactivated and moved to global archives.`,
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
customClass: {
|
||||
confirmButton: 'btn-pill-danger',
|
||||
cancelButton: 'btn-pill-cancel'
|
||||
},
|
||||
confirmButtonText: "Yes, Archive",
|
||||
cancelButtonText: "Cancel",
|
||||
}).then(result => {
|
||||
if (!result.isConfirmed) return;
|
||||
|
||||
const url = "{{ route('permissions.destroy', 'ID') }}".replace("ID", id);
|
||||
|
||||
fetch(url, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"X-CSRF-TOKEN": document.querySelector(
|
||||
'meta[name="csrf-token"]').content,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
// Live Reload immediately
|
||||
window.reloadDataTable?.();
|
||||
|
||||
StandardSwal.fire({
|
||||
icon: "success",
|
||||
title: "Archived Successfully!",
|
||||
text: data.message || "The permission has been archived.",
|
||||
timer: 2000,
|
||||
showConfirmButton: false,
|
||||
timerProgressBar: true
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
StandardSwal.fire({
|
||||
title: "Error!",
|
||||
text: "An error occurred during the archive process.",
|
||||
icon: "error"
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,711 @@
|
||||
<x-app-layout>
|
||||
@php
|
||||
$canManageUsers = auth()->user()->can('manage user directory');
|
||||
$userOrderIndex = $canManageUsers ? 5 : 3;
|
||||
@endphp
|
||||
<div class="container-fluid" id="main-content">
|
||||
<div class="row gx-3 gx-lg-4">
|
||||
<div class="col-12">
|
||||
<div class="card adminuiux-card">
|
||||
<div class="card-body p-0">
|
||||
|
||||
{{-- header halaman user management --}}
|
||||
<div class="p-4 pb-0">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h5 class="mb-0 fw-bold">{{ __('User Management') }}</h5>
|
||||
<small class="text-muted">
|
||||
{{ __('Manage users, assign roles, and control access within the system.') }}
|
||||
</small>
|
||||
{{-- Extra Filters (Picked up by app.blade.php DataTable logic) --}}
|
||||
<input type="hidden" id="filter-trashed-val" name="trashed" class="filter-extra"
|
||||
value="active">
|
||||
</div>
|
||||
@can('manage user directory')
|
||||
<button class="btn btn-dark mt-3 px-3 rounded-pill" data-bs-toggle="modal"
|
||||
data-bs-target="#addUserModal">
|
||||
 + {{ __('Add User') }} 
|
||||
</button>
|
||||
@endcan
|
||||
</div>
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-4">
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-dark rounded-pill px-3 filter-trashed"
|
||||
data-value="active">
|
||||
<i class="bi bi-people me-1"></i> {{ __('Active Users') }}
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-dark border border-dark rounded-pill px-3 filter-trashed"
|
||||
data-value="archived">
|
||||
<i class="bi bi-archive me-1"></i> {{ __('Archived') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- tabel users --}}
|
||||
<div class="p-4">
|
||||
<div class="table-responsive overflow-hidden">
|
||||
<table id="datatables" class="table table-hover table-bordered w-100 nowrap mb-0"
|
||||
data-server-side="true" data-ajax-url="{{ route('users') }}"
|
||||
data-order='@json([[$userOrderIndex, "desc"]])'>
|
||||
<thead>
|
||||
<tr>
|
||||
@can('manage user directory')
|
||||
<th style="width: 20px;" data-orderable="false" data-searchable="false">
|
||||
<input type="checkbox" class="form-check-input check-all">
|
||||
</th>
|
||||
<th class="text-wrap">{{ __('Status') }}</th>
|
||||
@endcan
|
||||
<th class="text-wrap">{{ __('User Name') }}</th>
|
||||
<th class="text-wrap">{{ __('Email') }}</th>
|
||||
<th class="text-wrap">{{ __('Roles') }}</th>
|
||||
<th class="text-wrap" data-hide="audit">{{ __('Created At') }}</th>
|
||||
<th class="text-wrap" data-hide="audit">{{ __('Created By') }}</th>
|
||||
<th class="text-wrap" data-hide="audit">{{ __('Last Updated At') }}</th>
|
||||
<th class="text-wrap" data-hide="audit">{{ __('Last Updated By') }}</th>
|
||||
@can('manage user directory')
|
||||
<th class="text-wrap text-end" data-orderable="false"
|
||||
data-searchable="false">{{ __('Action') }}</th>
|
||||
@endcan
|
||||
</tr>
|
||||
|
||||
{{-- filter --}}
|
||||
<tr class="filter-row">
|
||||
@can('manage user directory')
|
||||
<th></th>
|
||||
<th>
|
||||
<select class="form-select form-select-sm">
|
||||
<option value="">{{ __('All') }}</option>
|
||||
<option value="active">{{ __('Active') }}</option>
|
||||
<option value="inactive">{{ __('Inactive') }}</option>
|
||||
</select>
|
||||
</th>
|
||||
@endcan
|
||||
<th><input class="form-control form-control-sm"
|
||||
placeholder="{{ __('Search Name') }}"></th>
|
||||
<th><input class="form-control form-control-sm"
|
||||
placeholder="{{ __('Search Email') }}"></th>
|
||||
<th><input class="form-control form-control-sm"
|
||||
placeholder="{{ __('Search Role') }}"></th>
|
||||
<th><input type="date" class="form-control form-control-sm"></th>
|
||||
<th><input class="form-control form-control-sm"
|
||||
placeholder="{{ __('Created By') }}"></th>
|
||||
<th><input type="date" class="form-control form-control-sm"></th>
|
||||
<th><input class="form-control form-control-sm"
|
||||
placeholder="{{ __('Updated By') }}"></th>
|
||||
@can('manage user directory')
|
||||
<th></th>
|
||||
@endcan
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- MODAL ADD USER --}}
|
||||
<div class="modal fade" id="addUserModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content rounded-3">
|
||||
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ __('Add User') }}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('users.store') }}" autocomplete="off"
|
||||
class="ajax-form" data-reset="true">
|
||||
|
||||
@csrf
|
||||
|
||||
{{-- anti autofill trap --}}
|
||||
<input type="text" name="fakeuser" style="display:none">
|
||||
<input type="password" name="fakepass" style="display:none">
|
||||
|
||||
<div class="modal-body">
|
||||
|
||||
{{-- User Name --}}
|
||||
<label class="form-label fw-semibold">
|
||||
{{ __('User Name') }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="text" name="name" class="form-control mb-3"
|
||||
placeholder="ex: John Wick" required minlength="3" maxlength="100"
|
||||
pattern="^[a-zA-Z\s]+$"
|
||||
title="Name must be at least 3 characters and contain letters and spaces only">
|
||||
|
||||
{{-- Email --}}
|
||||
<label class="form-label fw-semibold">
|
||||
{{ __('Email') }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="email" name="email" class="form-control mb-3"
|
||||
placeholder="john@email.com" required maxlength="150"
|
||||
title="Enter a valid and unique email address">
|
||||
|
||||
{{-- Password --}}
|
||||
<label class="form-label fw-semibold">
|
||||
{{ __('Password') }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<div class="input-group mb-3">
|
||||
<input type="password" name="password" class="form-control border-end-0"
|
||||
placeholder="Minimum 12 characters" required minlength="12"
|
||||
autocomplete="new-password"
|
||||
pattern="^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_]).{12,}$"
|
||||
title="Minimum 12 characters with uppercase, lowercase, number, and symbol">
|
||||
<button
|
||||
class="btn btn-outline-secondary bg-white border-start-0 password-toggle"
|
||||
type="button" style="border-color: #dee2e6;">
|
||||
<i class="bi bi-eye text-secondary"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Assign Role --}}
|
||||
<label class="form-label fw-semibold">
|
||||
{{ __('Assign Role') }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<select id="roleSelect" name="roles[]" class="form-select" multiple required
|
||||
title="Select at least one role">
|
||||
@foreach ($roles as $role)
|
||||
@if($role->name !== 'Developer' || auth()->user()->hasRole('Developer'))
|
||||
<option value="{{ $role->id }}">{{ $role->name }}</option>
|
||||
@endif
|
||||
@endforeach
|
||||
</select>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-dark rounded-pill"
|
||||
data-bs-dismiss="modal">
|
||||
 Close 
|
||||
</button>
|
||||
<button type="submit" class="btn btn-dark rounded-pill">
|
||||
 Save User 
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- MODAL EDIT USER --}}
|
||||
<div class="modal fade" id="editUserModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content rounded-3">
|
||||
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ __('Edit User') }}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form id="editUserForm" method="POST" autocomplete="off" class="ajax-form">
|
||||
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
{{-- anti autofill trap --}}
|
||||
<input type="text" name="fakeuser" style="display:none">
|
||||
<input type="password" name="fakepass" style="display:none">
|
||||
|
||||
<div class="modal-body">
|
||||
|
||||
<input type="hidden" id="edit-user-id" name="id">
|
||||
|
||||
{{-- User Name --}}
|
||||
<label class="form-label fw-semibold">
|
||||
{{ __('User Name') }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input id="edit-user-name" name="name" type="text" class="form-control mb-3"
|
||||
placeholder="ex: John Wick" required minlength="3" maxlength="100"
|
||||
pattern="^[a-zA-Z\s]+$"
|
||||
title="Name must be at least 3 characters and contain letters and spaces only">
|
||||
|
||||
{{-- Email --}}
|
||||
<label class="form-label fw-semibold">
|
||||
{{ __('Email') }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input id="edit-user-email" name="email" type="email"
|
||||
class="form-control mb-3" placeholder="john@email.com" required
|
||||
maxlength="150" title="Enter a valid and unique email address">
|
||||
|
||||
{{-- Password (Optional) --}}
|
||||
<label class="form-label fw-semibold">
|
||||
{{ __('Password (Optional)') }}
|
||||
</label>
|
||||
<div class="input-group mb-3">
|
||||
<input name="password" type="password" class="form-control border-end-0"
|
||||
placeholder="leave empty to keep current" minlength="12"
|
||||
autocomplete="new-password"
|
||||
pattern="^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_]).{12,}$"
|
||||
title="Minimum 12 characters with uppercase, lowercase, number, and symbol">
|
||||
<button
|
||||
class="btn btn-outline-secondary bg-white border-start-0 password-toggle"
|
||||
type="button" style="border-color: #dee2e6;">
|
||||
<i class="bi bi-eye text-secondary"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Roles --}}
|
||||
<label class="form-label fw-semibold">
|
||||
{{ __('Roles') }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<select id="roleSelect2" name="roles[]" class="form-select" multiple
|
||||
required title="Select at least one role">
|
||||
@foreach ($roles as $role)
|
||||
@if($role->name !== 'Developer' || auth()->user()->hasRole('Developer'))
|
||||
<option value="{{ $role->id }}">{{ $role->name }}</option>
|
||||
@endif
|
||||
@endforeach
|
||||
</select>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-dark rounded-pill"
|
||||
data-bs-dismiss="modal">
|
||||
 Close 
|
||||
</button>
|
||||
<button type="submit" class="btn btn-dark rounded-pill">
|
||||
 Update User 
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- SCRIPT HANDLER --}}
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
// init choices create
|
||||
const roleSelect = document.querySelector('#roleSelect');
|
||||
if (roleSelect && !roleSelect.dataset.choices) {
|
||||
new Choices(roleSelect, {
|
||||
removeItemButton: true,
|
||||
searchEnabled: true,
|
||||
placeholderValue: 'Select roles'
|
||||
});
|
||||
roleSelect.dataset.choices = "initialized";
|
||||
}
|
||||
|
||||
// init choices edit
|
||||
const roleSelect2 = document.querySelector('#roleSelect2');
|
||||
let editChoices = null;
|
||||
if (roleSelect2 && !roleSelect2.dataset.choices) {
|
||||
editChoices = new Choices(roleSelect2, {
|
||||
removeItemButton: true,
|
||||
searchEnabled: true,
|
||||
placeholderValue: 'Select roles'
|
||||
});
|
||||
roleSelect2.dataset.choices = "initialized";
|
||||
}
|
||||
|
||||
document.addEventListener("change", e => {
|
||||
const toggle = e.target.closest(".user-toggle");
|
||||
|
||||
if (!toggle) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = toggle.dataset.id;
|
||||
const name = toggle.dataset.name;
|
||||
const status = toggle.checked ? "activate" : "deactivate";
|
||||
const label = toggle.closest(".form-switch").querySelector(".status-label");
|
||||
|
||||
StandardSwal.fire({
|
||||
title: `${status === "activate" ? "Activate" : "Deactivate"} User?`,
|
||||
text: `You are about to change the access status for "${name}".`,
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "Yes, Continue",
|
||||
cancelButtonText: "Cancel",
|
||||
}).then(result => {
|
||||
if (!result.isConfirmed) {
|
||||
toggle.checked = !toggle.checked;
|
||||
return;
|
||||
}
|
||||
|
||||
label.textContent = toggle.checked ? "Active" : "Inactive";
|
||||
label.classList.toggle("text-success", toggle.checked);
|
||||
label.classList.toggle("text-danger", !toggle.checked);
|
||||
|
||||
fetch("{{ route('users.toggle-status') }}", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-TOKEN": "{{ csrf_token() }}"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id,
|
||||
status
|
||||
}),
|
||||
}).then(res => res.json())
|
||||
.then(data => {
|
||||
if (!data.success) {
|
||||
throw new Error("Failed to update status.");
|
||||
}
|
||||
|
||||
// Live Reload immediately
|
||||
window.reloadDataTable?.();
|
||||
|
||||
StandardSwal.fire({
|
||||
title: "{{ __('Success!') }}",
|
||||
text: data.message || "{{ __('User status has been updated successfully.') }}",
|
||||
icon: "success",
|
||||
timer: 2000,
|
||||
showConfirmButton: false,
|
||||
timerProgressBar: true
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toggle.checked = !toggle.checked;
|
||||
StandardSwal.fire({
|
||||
title: "{{ __('Error!') }}",
|
||||
text: "{{ __('An unexpected error occurred while updating status.') }}",
|
||||
icon: "error"
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("click", e => {
|
||||
// Filter Active/Archived
|
||||
const filterBtn = e.target.closest(".filter-trashed");
|
||||
if (filterBtn) {
|
||||
document.querySelectorAll(".filter-trashed").forEach(b => {
|
||||
b.classList.remove("btn-dark");
|
||||
b.classList.add("btn-outline-dark", "border", "border-dark");
|
||||
});
|
||||
filterBtn.classList.add("btn-dark");
|
||||
filterBtn.classList.remove("btn-outline-dark", "border", "border-dark");
|
||||
|
||||
document.getElementById("filter-trashed-val").value = filterBtn.dataset.value;
|
||||
window.reloadDataTable?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const editButton = e.target.closest(".btn-edit");
|
||||
|
||||
if (editButton) {
|
||||
const url = `{{ route('users.update', 'ID') }}`.replace("ID",
|
||||
editButton.dataset.id);
|
||||
document.getElementById("editUserForm").action = url;
|
||||
|
||||
document.getElementById("edit-user-id").value = editButton.dataset.id;
|
||||
document.getElementById("edit-user-name").value = editButton.dataset
|
||||
.name;
|
||||
document.getElementById("edit-user-email").value = editButton.dataset
|
||||
.email;
|
||||
|
||||
const selected = JSON.parse(editButton.dataset.roles || "[]");
|
||||
if (editChoices) {
|
||||
editChoices.removeActiveItems();
|
||||
selected.forEach(role => editChoices.setChoiceByValue(role
|
||||
.toString()));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const deleteButton = e.target.closest(".btn-delete");
|
||||
|
||||
if (deleteButton) {
|
||||
e.preventDefault();
|
||||
const id = deleteButton.dataset.id;
|
||||
const name = deleteButton.dataset.name;
|
||||
|
||||
StandardSwal.fire({
|
||||
title: "Archive User?",
|
||||
text: `"${name}" will be deactivated and moved to the system archives.`,
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
customClass: {
|
||||
confirmButton: 'btn-pill-danger',
|
||||
cancelButton: 'btn-pill-cancel'
|
||||
},
|
||||
confirmButtonText: "Yes, Archive",
|
||||
cancelButtonText: "Cancel",
|
||||
}).then(result => {
|
||||
if (!result.isConfirmed) return;
|
||||
|
||||
const url = `{{ route('users.destroy', 'ID') }}`.replace("ID", id);
|
||||
|
||||
fetch(url, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"X-CSRF-TOKEN": document.querySelector(
|
||||
'meta[name="csrf-token"]').content,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
}).then(res => res.json())
|
||||
.then(data => {
|
||||
window.reloadDataTable?.();
|
||||
StandardSwal.fire({
|
||||
icon: "success",
|
||||
title: "Archived Successfully!",
|
||||
text: data.message || "The user has been moved to the archived list.",
|
||||
timer: 2000,
|
||||
showConfirmButton: false,
|
||||
timerProgressBar: true
|
||||
});
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// RESTORE HANDLER
|
||||
const restoreBtn = e.target.closest(".btn-restore");
|
||||
if (restoreBtn) {
|
||||
e.preventDefault();
|
||||
const id = restoreBtn.dataset.id;
|
||||
const name = restoreBtn.dataset.name;
|
||||
|
||||
StandardSwal.fire({
|
||||
title: "{{ __('Restore User?') }}",
|
||||
text: `{{ __('Do you want to restore access for') }} "${name}"?`,
|
||||
icon: "info",
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "{{ __('Yes, Restore') }}",
|
||||
cancelButtonText: "{{ __('Cancel') }}",
|
||||
}).then(result => {
|
||||
if (!result.isConfirmed) return;
|
||||
|
||||
const url = `{{ route('users.restore', 'ID') }}`.replace("ID", id);
|
||||
fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-CSRF-TOKEN": "{{ csrf_token() }}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
}).then(res => res.json())
|
||||
.then(data => {
|
||||
window.reloadDataTable?.();
|
||||
StandardSwal.fire({
|
||||
icon: "success",
|
||||
title: "{{ __('Restored!') }}",
|
||||
text: data.message,
|
||||
timer: 2000,
|
||||
showConfirmButton: false,
|
||||
timerProgressBar: true
|
||||
});
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// FORCE DELETE HANDLER
|
||||
const forceBtn = e.target.closest(".btn-force-delete");
|
||||
if (forceBtn) {
|
||||
e.preventDefault();
|
||||
const id = forceBtn.dataset.id;
|
||||
const name = forceBtn.dataset.name;
|
||||
|
||||
StandardSwal.fire({
|
||||
title: "{{ __('Terminate Account?') }}",
|
||||
text: `{{ __('This will PERMANENTLY delete') }} "${name}". {{ __('This action cannot be undone.') }}`,
|
||||
icon: "error",
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "{{ __('Yes, Terminate') }}",
|
||||
cancelButtonText: "{{ __('Cancel') }}",
|
||||
confirmButtonColor: "#dc3545",
|
||||
}).then(result => {
|
||||
if (!result.isConfirmed) return;
|
||||
|
||||
const url = `{{ route('users.force-delete', 'ID') }}`.replace("ID", id);
|
||||
fetch(url, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"X-CSRF-TOKEN": "{{ csrf_token() }}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
}).then(res => res.json())
|
||||
.then(data => {
|
||||
window.reloadDataTable?.();
|
||||
StandardSwal.fire({
|
||||
icon: "success",
|
||||
title: "{{ __('Terminated!') }}",
|
||||
text: data.message,
|
||||
timer: 2000,
|
||||
showConfirmButton: false,
|
||||
timerProgressBar: true
|
||||
});
|
||||
}).catch(err => {
|
||||
StandardSwal.fire("Error", "Failed to terminate user.", "error");
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// BULK ACTION LOGIC
|
||||
const bulkBtn = e.target.closest(".bulk-btn");
|
||||
if (bulkBtn) {
|
||||
e.preventDefault();
|
||||
const action = bulkBtn.dataset.action;
|
||||
const selectedIds = Array.from(document.querySelectorAll(".user-checkbox:checked")).map(cb => cb.value);
|
||||
|
||||
if (selectedIds.length === 0) return;
|
||||
|
||||
let config = {
|
||||
title: "Are you sure?",
|
||||
text: `You are about to perform ${action} on ${selectedIds.length} users.`,
|
||||
icon: "warning",
|
||||
url: "",
|
||||
method: "POST",
|
||||
body: { ids: selectedIds }
|
||||
};
|
||||
|
||||
switch(action) {
|
||||
case 'activate':
|
||||
config.url = "{{ route('users.bulk-toggle-status') }}";
|
||||
config.body.status = 'activate';
|
||||
break;
|
||||
case 'deactivate':
|
||||
config.url = "{{ route('users.bulk-toggle-status') }}";
|
||||
config.body.status = 'deactivate';
|
||||
break;
|
||||
case 'archive':
|
||||
config.url = "{{ route('users.bulk-delete') }}";
|
||||
config.icon = "error";
|
||||
break;
|
||||
case 'restore':
|
||||
config.url = "{{ route('users.bulk-restore') }}";
|
||||
config.icon = "info";
|
||||
break;
|
||||
case 'terminate':
|
||||
config.url = "{{ route('users.bulk-force-delete') }}";
|
||||
config.icon = "error";
|
||||
config.text += " THIS ACTION IS PERMANENT!";
|
||||
break;
|
||||
}
|
||||
|
||||
StandardSwal.fire({
|
||||
title: config.title,
|
||||
text: config.text,
|
||||
icon: config.icon,
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "Yes, Proceed",
|
||||
}).then(result => {
|
||||
if (!result.isConfirmed) return;
|
||||
|
||||
fetch(config.url, {
|
||||
method: config.method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-TOKEN": "{{ csrf_token() }}",
|
||||
"Accept": "application/json"
|
||||
},
|
||||
body: JSON.stringify(config.body)
|
||||
}).then(res => res.json())
|
||||
.then(data => {
|
||||
window.reloadDataTable?.();
|
||||
document.querySelector(".check-all").checked = false;
|
||||
StandardSwal.fire("Success", data.message, "success");
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Check All Handler
|
||||
document.addEventListener("change", e => {
|
||||
if (e.target.classList.contains("check-all")) {
|
||||
document.querySelectorAll(".user-checkbox").forEach(cb => cb.checked = e.target.checked);
|
||||
updateBulkBar();
|
||||
}
|
||||
|
||||
if (e.target.classList.contains("user-checkbox")) {
|
||||
updateBulkBar();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("clear-selection")?.addEventListener("click", () => {
|
||||
document.querySelectorAll(".user-checkbox, .check-all").forEach(cb => cb.checked = false);
|
||||
updateBulkBar();
|
||||
});
|
||||
|
||||
function updateBulkBar() {
|
||||
const checked = document.querySelectorAll(".user-checkbox:checked");
|
||||
const bar = document.getElementById("bulk-action-bar");
|
||||
const count = document.getElementById("selected-count");
|
||||
const filterEl = document.getElementById("filter-trashed-val");
|
||||
|
||||
if (!bar || !count) return;
|
||||
|
||||
const filterType = filterEl ? filterEl.value : 'active';
|
||||
|
||||
if (checked.length > 0) {
|
||||
bar.classList.remove("d-none");
|
||||
count.textContent = checked.length;
|
||||
|
||||
// Toggle visibility of specific bulk actions based on active/archived view
|
||||
if (filterType === 'archived') {
|
||||
document.querySelectorAll(".show-if-active").forEach(el => el.classList.add("d-none"));
|
||||
document.querySelectorAll(".show-if-archived").forEach(el => el.classList.remove("d-none"));
|
||||
} else {
|
||||
document.querySelectorAll(".show-if-active").forEach(el => el.classList.remove("d-none"));
|
||||
document.querySelectorAll(".show-if-archived").forEach(el => el.classList.add("d-none"));
|
||||
}
|
||||
} else {
|
||||
bar.classList.add("d-none");
|
||||
}
|
||||
}
|
||||
|
||||
// Reset selection on dataTable reload
|
||||
window.addEventListener('dataTableReloaded', () => {
|
||||
document.querySelector(".check-all").checked = false;
|
||||
updateBulkBar();
|
||||
});
|
||||
|
||||
});
|
||||
</script>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Floating Bulk Action Bar --}}
|
||||
<div id="bulk-action-bar" class="d-none animate__animated animate__fadeIn position-fixed bottom-0 mb-4 start-50 translate-middle-x" style="z-index: 2500;">
|
||||
<div class="bg-dark text-white border-0 rounded-pill px-3 py-2 d-flex align-items-center gap-3 shadow-lg" style="backdrop-filter: blur(15px); background-color: rgba(20, 20, 20, 0.98) !important; border: 1px solid rgba(255,255,255,0.1) !important;">
|
||||
<div class="d-flex align-items-center gap-2 ps-2">
|
||||
<div class="bg-primary rounded-circle d-flex align-items-center justify-content-center" style="width: 28px; height: 28px;">
|
||||
<span class="small fw-bold text-white" id="selected-count">0</span>
|
||||
</div>
|
||||
<span class="small fw-bold d-none d-sm-inline">{{ __('Terpilih') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="vr bg-white opacity-25" style="height: 20px;"></div>
|
||||
|
||||
<div class="d-flex gap-1">
|
||||
{{-- Quick Actions --}}
|
||||
<button title="{{ __('Aktifkan') }}" class="btn btn-link text-success p-2 bulk-btn" data-action="activate">
|
||||
<i class="bi bi-check-circle fs-5"></i>
|
||||
</button>
|
||||
<button title="{{ __('Nonaktifkan') }}" class="btn btn-link text-white p-2 bulk-btn" data-action="deactivate">
|
||||
<i class="bi bi-dash-circle fs-5"></i>
|
||||
</button>
|
||||
|
||||
<div class="vr bg-white opacity-25 mx-1" style="height: 20px; margin-top: 10px;"></div>
|
||||
|
||||
{{-- Contextual Actions --}}
|
||||
<button title="{{ __('Arsipkan') }}" class="btn btn-link p-2 bulk-btn show-if-active" data-action="archive">
|
||||
<i class="bi bi-trash fs-5 text-danger" style="color: #dc3545 !important;"></i>
|
||||
</button>
|
||||
<button title="{{ __('Pulihkan') }}" class="btn btn-link text-info p-2 bulk-btn show-if-archived d-none" data-action="restore">
|
||||
<i class="bi bi-arrow-counterclockwise fs-5"></i>
|
||||
</button>
|
||||
<button title="{{ __('Hapus Permanen') }}" class="btn btn-link text-danger p-2 bulk-btn show-if-archived d-none" data-action="terminate">
|
||||
<i class="bi bi-x-circle fs-5"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="vr bg-white opacity-25" style="height: 20px;"></div>
|
||||
|
||||
<button type="button" class="btn-close btn-close-white btn-sm me-2" id="clear-selection" style="font-size: 0.6rem;"></button>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,583 @@
|
||||
<x-app-layout>
|
||||
@push('styles')
|
||||
<script src="https://cdn.jsdelivr.net/npm/apexcharts" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.3/Sortable.min.js" crossorigin="anonymous"></script>
|
||||
<style>
|
||||
.sparkline-container {
|
||||
position: absolute;
|
||||
bottom: 0; left: 0; right: 0;
|
||||
height: 60px;
|
||||
overflow: hidden;
|
||||
border-bottom-left-radius: 20px;
|
||||
border-bottom-right-radius: 20px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.card-body { position: relative; z-index: 1; }
|
||||
|
||||
/* Skeleton */
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e6e6e6 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-loading 1.5s infinite;
|
||||
border-radius: 4px; display: inline-block;
|
||||
}
|
||||
@keyframes skeleton-loading {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
.skeleton-text { height: 1rem; margin-bottom: .5rem; width: 100%; }
|
||||
.skeleton-title { height: 2.5rem; width: 60%; margin: 10px auto; }
|
||||
|
||||
/* Widget grid */
|
||||
.widget-col { transition: opacity .2s, transform .2s; }
|
||||
.widget-col.hidden-widget { display: none !important; }
|
||||
.widget-col.widget-ghost { opacity: .3; }
|
||||
.widget-col.widget-chosen { transform: scale(1.02); box-shadow: 0 10px 30px rgba(0,0,0,.15) !important; z-index: 10; }
|
||||
|
||||
/* Customize panel */
|
||||
#widget-customize-panel {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
display: none;
|
||||
}
|
||||
.widget-toggle-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 8px 12px; border-radius: 10px; cursor: pointer;
|
||||
border: 1px solid #f1f5f9; margin-bottom: 6px;
|
||||
transition: background .15s;
|
||||
}
|
||||
.widget-toggle-item:hover { background: #f8fafc; }
|
||||
.widget-toggle-item.active { border-color: #bfdbfe; background: #eff6ff; }
|
||||
|
||||
/* Live pulse dot */
|
||||
.live-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: #22c55e; display: inline-block;
|
||||
animation: pulse-dot 2s infinite;
|
||||
}
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: .5; transform: scale(1.4); }
|
||||
}
|
||||
|
||||
.fw-black { font-weight: 900; }
|
||||
.tracking-tight { letter-spacing: -2px; }
|
||||
.display-3 { font-size: 3.5rem; letter-spacing: -2px; }
|
||||
@media (max-width: 1400px) { .display-3 { font-size: 2.8rem; } }
|
||||
@media (max-width: 576px) { .display-3 { font-size: 2.2rem; } }
|
||||
.extra-small { font-size: .85rem !important; }
|
||||
.mini-progress { height: 4px; background: #f1f5f9; border-radius: 2px; overflow: hidden; }
|
||||
.mini-progress .bar { height: 100%; background: var(--adminuiux-theme-1); border-radius: 2px; transition: width .5s; }
|
||||
.bi-spin { animation: spin .8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.hover-lift { transition: transform .2s, box-shadow .2s; }
|
||||
.hover-lift:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,.1) !important; }
|
||||
|
||||
/* ── Terminal Modal ───────────────────────────────────── */
|
||||
@keyframes blink-cursor {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
#logDetailModal .modal-content {
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
#logDetailModal .modal-content::-webkit-scrollbar { width: 6px; }
|
||||
#logDetailModal .p-4::-webkit-scrollbar { width: 5px; }
|
||||
#logDetailModal .p-4::-webkit-scrollbar-track { background: #0d1117; }
|
||||
#logDetailModal .p-4::-webkit-scrollbar-thumb { background: #30363d; border-radius: 4px; }
|
||||
#terminal-output {
|
||||
min-height: 200px;
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
<div class="container-fluid pb-5" id="monitoring-master">
|
||||
|
||||
{{-- ── Welcome Header ───────────────────────────────── --}}
|
||||
<div class="card adminuiux-card bg-dark text-white mb-4 border-0 shadow-lg overflow-hidden animate__animated animate__fadeIn">
|
||||
<div class="card-body p-4 position-relative">
|
||||
<div class="row align-items-center position-relative z-1">
|
||||
<div class="col">
|
||||
<h1 class="display-5 fw-bold text-white mb-1 tracking-tight">{{ __('Operational Dashboard') }}</h1>
|
||||
<p class="small text-white-50 mb-0 d-flex align-items-center gap-2">
|
||||
<span class="live-dot"></span>
|
||||
System operational since
|
||||
<span class="badge text-bg-theme-1 rounded-pill px-3 shadow-sm" id="stat-uptime-badge">{{ $stats['uptime'] }}</span>
|
||||
at <span class="fw-bold">{{ $stats['hostname'] }}</span> ({{ $stats['ip'] }})
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-auto d-flex gap-2">
|
||||
<button class="btn btn-outline-light btn-sm rounded-pill px-3 fw-semibold small" id="btn-customize-widgets">
|
||||
<i class="bi bi-grid me-1"></i> Customize
|
||||
</button>
|
||||
<button class="btn btn-theme-1 btn-square rounded-circle shadow-sm" id="refresh-all-stats" title="Refresh">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-decoration"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Customize Panel ──────────────────────────────── --}}
|
||||
<div id="widget-customize-panel" class="shadow-sm border-0">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h6 class="fw-bold mb-0"><i class="bi bi-sliders me-2 text-theme-1"></i>Customize Widgets</h6>
|
||||
<p class="text-muted small mb-0 mt-1">Toggle widgets on/off. Drag cards to reorder your workspace.</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-outline-secondary rounded-pill px-3" id="btn-toggle-all-widgets">Toggle All</button>
|
||||
<form action="{{ route('dashboard.widgets.reset') }}" method="POST" class="d-inline" id="form-reset-widgets">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-sm btn-light rounded-pill px-3">Reset to Default</button>
|
||||
</form>
|
||||
<button class="btn btn-sm btn-dark rounded-pill px-3" id="btn-save-widgets">
|
||||
<i class="bi bi-check2 me-1"></i>Save Layout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-2" id="widget-toggle-list">
|
||||
@foreach ($widgets as $key => $widget)
|
||||
@php $allowed = !$widget['permission'] || auth()->user()->can($widget['permission']); @endphp
|
||||
@if ($allowed)
|
||||
<div class="col-6 col-md-4 col-lg-3">
|
||||
<div class="widget-toggle-item {{ $widget['visible'] ? 'active' : '' }}" data-widget-key="{{ $key }}">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="bi bi-grip-vertical text-muted"></i>
|
||||
<span class="small fw-semibold">{{ $widget['label'] }}</span>
|
||||
</div>
|
||||
<div class="form-check form-switch mb-0">
|
||||
<input class="form-check-input widget-visibility-toggle" type="checkbox"
|
||||
data-widget="{{ $key }}" {{ $widget['visible'] ? 'checked' : '' }}>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Stat Card Widgets (Top Row) ──────────────────── --}}
|
||||
<div class="row g-3 g-lg-4 mb-4" id="widget-grid-stats">
|
||||
@foreach ($widgets as $key => $widget)
|
||||
@php
|
||||
$isStat = in_array($key, ['cpu', 'ram', 'disk', 'live_users', 'queues']);
|
||||
$allowed = !$widget['permission'] || auth()->user()->can($widget['permission']);
|
||||
@endphp
|
||||
@if ($isStat && $allowed)
|
||||
<div class="col-6 col-sm-4 col-md-3 col-xl widget-col {{ !$widget['visible'] ? 'hidden-widget' : '' }}"
|
||||
data-widget-key="{{ $key }}" data-sort="{{ $widget['sort_order'] }}">
|
||||
@include('pages.dashboard.widget-' . str_replace('_', '-', $key))
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- ── Big Widgets (Bottom Row - Dynamic Sizing) ────── --}}
|
||||
@php
|
||||
$bigKeys = ['activity_feed', 'ai_insight'];
|
||||
$visibleBig = collect($widgets)
|
||||
->only($bigKeys)
|
||||
->filter(fn($w) => $w['visible'] && (!$w['permission'] || auth()->user()->can($w['permission'])));
|
||||
$vCount = $visibleBig->count();
|
||||
|
||||
// Dynamic class based on user request: 1->12, 2->6, 3->4
|
||||
$bigColClass = 'col-12';
|
||||
if ($vCount === 2) $bigColClass = 'col-12 col-lg-6';
|
||||
if ($vCount >= 3) $bigColClass = 'col-12 col-lg-4';
|
||||
@endphp
|
||||
|
||||
<div class="row g-3 g-lg-4" id="widget-grid-big">
|
||||
@foreach ($widgets as $key => $widget)
|
||||
@php
|
||||
$isBig = in_array($key, $bigKeys);
|
||||
$allowed = !$widget['permission'] || auth()->user()->can($widget['permission']);
|
||||
@endphp
|
||||
@if ($isBig && $allowed)
|
||||
<div class="widget-col big-widget-col {{ !$widget['visible'] ? 'hidden-widget' : '' }}"
|
||||
data-widget-key="{{ $key }}" data-sort="{{ $widget['sort_order'] }}">
|
||||
|
||||
@if ($key === 'activity_feed')
|
||||
<div class="card adminuiux-card border-0 shadow-sm h-100 animate__animated animate__fadeIn">
|
||||
<div class="card-header bg-white border-bottom p-4 d-flex justify-content-between align-items-center">
|
||||
<h6 class="fw-bold text-dark mb-0 d-flex align-items-center gap-2">
|
||||
<i class="bi bi-terminal text-theme-1"></i>
|
||||
{{ __('Runtime Activity Feed') }}
|
||||
<span class="live-dot ms-1" title="Live via Reverb"></span>
|
||||
</h6>
|
||||
<a href="{{ route('system-monitoring') }}" class="btn btn-sm btn-light rounded-pill px-3 fw-bold small">
|
||||
<i class="bi bi-gear me-1"></i> FULL MONITOR
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<style>
|
||||
#logs-datatable tbody td:nth-child(3) { white-space: normal !important; min-width: 250px; max-width: 400px; word-break: break-word; }
|
||||
#logs-datatable thead th { white-space: nowrap; }
|
||||
</style>
|
||||
<table id="logs-datatable" class="table table-hover align-middle mb-0 w-100 small compact-table">
|
||||
<thead>
|
||||
<tr class="bg-white">
|
||||
<th>INCIDENT TIME</th><th>LVL</th><th>MANIFEST</th>
|
||||
<th class="text-end pe-4">INTEL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($key === 'ai_insight')
|
||||
<div class="card adminuiux-card border-0 shadow-sm h-100 animate__animated animate__fadeIn" style="animation-delay:.5s">
|
||||
<div class="card-header bg-transparent border-0 pt-4 px-4 d-flex justify-content-between align-items-center">
|
||||
<h6 class="fw-bold text-dark mb-0 d-flex align-items-center gap-2">
|
||||
<i class="bi bi-robot text-theme-1"></i>
|
||||
{{ __('AI Security Insight') }}
|
||||
</h6>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button class="btn btn-sm btn-outline-danger rounded-pill px-3 extra-small" id="btn-ai-clear">
|
||||
<i class="bi bi-trash me-1"></i>Clear
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-dark rounded-pill px-3 extra-small" id="btn-ai-analyze">
|
||||
<i class="bi bi-cpu me-1"></i>Analyze
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body px-4">
|
||||
<div class="small text-muted" style="min-height:200px;line-height:1.6;">
|
||||
<div class="placeholder-glow" id="ai-placeholder" style="display:none;">
|
||||
<div class="d-flex flex-column gap-3">
|
||||
<span class="placeholder col-12 rounded-pill py-2"></span>
|
||||
<span class="placeholder col-10 rounded-pill py-1"></span>
|
||||
<span class="placeholder col-11 rounded-pill py-1"></span>
|
||||
<span class="placeholder col-8 rounded-pill py-1"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ai-content-display" class="animate__animated animate__fadeIn">
|
||||
<div class="text-center py-5 opacity-50">
|
||||
<i class="bi bi-robot display-4 d-block mb-3"></i>
|
||||
<p class="fst-italic">Click analyze to get security insights from your recent activity logs.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- ── Empty State ───────────────────────────────────── --}}
|
||||
<div id="dashboard-empty-state" class="text-center py-5 mt-4 animate__animated animate__fadeIn" style="{{ collect($widgets)->contains('visible', true) ? 'display:none' : '' }}">
|
||||
<div class="card adminuiux-card border-0 shadow-sm p-5 mx-auto" style="max-width: 500px; border-radius: 24px;">
|
||||
<div class="mb-4">
|
||||
<div class="bg-light rounded-circle d-inline-flex align-items-center justify-content-center" style="width: 80px; height: 80px;">
|
||||
<i class="bi bi-grid text-muted display-4"></i>
|
||||
</div>
|
||||
</div>
|
||||
<h4 class="fw-bold">Your dashboard is empty</h4>
|
||||
<p class="text-muted">It seems you have hidden all widgets. Customize your dashboard to display the metrics that matter to you.</p>
|
||||
<button class="btn btn-theme-1 rounded-pill px-4 mt-2" onclick="$('#widget-customize-panel').slideDown();">
|
||||
<i class="bi bi-plus-lg me-1"></i> Customize Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Log Detail Modal - Terminal Style --}}
|
||||
<div class="modal fade" id="logDetailModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered">
|
||||
<div class="modal-content border-0 overflow-hidden" style="background:#0d1117;border-radius:12px;box-shadow:0 32px 80px rgba(0,0,0,0.6);">
|
||||
|
||||
{{-- Terminal Title Bar --}}
|
||||
<div class="d-flex align-items-center justify-content-between px-4 py-3" style="background:#161b22;border-bottom:1px solid #30363d;">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
{{-- macOS-style traffic light dots --}}
|
||||
<span style="width:13px;height:13px;border-radius:50%;background:#ff5f57;display:inline-block;"></span>
|
||||
<span style="width:13px;height:13px;border-radius:50%;background:#febc2e;display:inline-block;"></span>
|
||||
<span style="width:13px;height:13px;border-radius:50%;background:#28c840;display:inline-block;"></span>
|
||||
<span class="ms-3 fw-bold" id="terminal-title" style="font-family:'JetBrains Mono','Fira Code','Courier New',monospace;font-size:0.78rem;color:#8b949e;letter-spacing:1px;">TELEMETRY_DUMP @node_01</span>
|
||||
</div>
|
||||
<button type="button" data-bs-dismiss="modal" class="border-0 bg-transparent p-0" style="color:#8b949e;font-size:1.1rem;line-height:1;">×</button>
|
||||
</div>
|
||||
|
||||
{{-- Terminal Body --}}
|
||||
<div class="p-4" style="max-height:72vh;overflow-y:auto;">
|
||||
<div id="terminal-output" style="font-family:'JetBrains Mono','Fira Code','Courier New',monospace;font-size:0.8rem;line-height:1.8;color:#3fb950;white-space:pre-wrap;word-break:break-word;"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>{{-- /container-fluid --}}
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
|
||||
// ── Sparkline charts (always rendered for stat cards) ─────
|
||||
const sparkOpts = (color) => ({
|
||||
series: [{ data: Array(10).fill(0) }],
|
||||
chart: { type: 'area', height: 60, sparkline: { enabled: true }, animations: { enabled: true, easing: 'linear', dynamicAnimation: { speed: 1000 } } },
|
||||
stroke: { curve: 'smooth', width: 2 },
|
||||
fill: { opacity: .3, type: 'gradient', gradient: { shadeIntensity: 1, opacityFrom: .4, opacityTo: .1 } },
|
||||
colors: [color], tooltip: { enabled: false }
|
||||
});
|
||||
|
||||
const cpuEl = document.querySelector('#chart-cpu-sparkline');
|
||||
const ramEl = document.querySelector('#chart-ram-sparkline');
|
||||
const diskEl = document.querySelector('#chart-disk-sparkline');
|
||||
const cpuChart = cpuEl ? new ApexCharts(cpuEl, sparkOpts('var(--adminuiux-theme-1)')) : null;
|
||||
const ramChart = ramEl ? new ApexCharts(ramEl, sparkOpts('#0dcaf0')) : null;
|
||||
const diskChart = diskEl ? new ApexCharts(diskEl, sparkOpts('#ffc107')) : null;
|
||||
cpuChart?.render(); ramChart?.render(); diskChart?.render();
|
||||
|
||||
function pushSparkline(chart, val) {
|
||||
if (!chart) return;
|
||||
let d = chart.w.globals.series[0].slice();
|
||||
d.push(val); if (d.length > 10) d.shift();
|
||||
chart.updateSeries([{ data: d }]);
|
||||
}
|
||||
|
||||
function updateVal(sel, newVal) {
|
||||
const el = $(sel);
|
||||
if (el.text() !== String(newVal)) {
|
||||
el.text(newVal).addClass('animate__animated animate__pulse');
|
||||
setTimeout(() => el.removeClass('animate__animated animate__pulse'), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function applyStats(d) {
|
||||
if (d.cpu !== undefined) { updateVal('#stat-cpu-percent', d.cpu + '%'); $('#cpu-bar').css('width', d.cpu + '%'); pushSparkline(cpuChart, d.cpu); }
|
||||
if (d.ram) { updateVal('#stat-ram-percent', d.ram.percentage + '%'); $('#stat-ram-used').text(d.ram.used + ' used'); if (d.ram.swap) $('#stat-swap-info').text('Swap: ' + d.ram.swap.percentage + '%'); pushSparkline(ramChart, d.ram.percentage); }
|
||||
if (d.disk) { updateVal('#stat-disk-percent', d.disk.percentage + '%'); $('#stat-disk-total').text(d.disk.free + ' available'); pushSparkline(diskChart, d.disk.percentage); }
|
||||
if (d.users){ updateVal('#stat-users-count', d.users.total); $('#stat-users-auth').text(d.users.authenticated); }
|
||||
if (d.queues){ updateVal('#stat-queues-pending', d.queues.pending); updateVal('#stat-queues-failed', d.queues.failed); }
|
||||
if (d.uptime) $('#stat-uptime-badge').text(d.uptime);
|
||||
const reverbOn = d.has_reverb;
|
||||
if (reverbOn !== undefined) {
|
||||
$('#reverb-icon').toggleClass('active', !!reverbOn);
|
||||
$('#reverb-status-text').text(reverbOn ? 'ACTIVE' : 'IDLE').toggleClass('text-success', !!reverbOn).toggleClass('text-muted', !reverbOn);
|
||||
}
|
||||
}
|
||||
|
||||
@can('view health and logs')
|
||||
// ── Activity DataTable ─────────────────────────────────────
|
||||
const logsTable = $('#logs-datatable').DataTable({
|
||||
processing: true, serverSide: true,
|
||||
ajax: '{{ route("system-monitoring.logs.datatable") }}',
|
||||
order: [[0,'desc']], pageLength: 5, autoWidth: false,
|
||||
dom: 'tr<"p-3 border-top d-flex justify-content-end"p>',
|
||||
columns: [
|
||||
{ data: 0, className: 'ps-4 datetime-col fw-bold' },
|
||||
{ data: 1 }, { data: 2 },
|
||||
{ data: 3, className: 'pe-4 text-end', orderable: false }
|
||||
],
|
||||
drawCallback: function () {
|
||||
$('.view-log-detail').off('click').on('click', function () {
|
||||
const log = $(this).data('log');
|
||||
renderTerminalModal(log);
|
||||
new bootstrap.Modal('#logDetailModal').show();
|
||||
});
|
||||
}
|
||||
});
|
||||
@endcan
|
||||
|
||||
// ── Manual refresh ─────────────────────────────────────────
|
||||
function refreshStats() {
|
||||
const btn = $('#refresh-all-stats');
|
||||
btn.find('i').addClass('bi-spin');
|
||||
$.get('{{ route("system-monitoring.stats") }}', function (d) {
|
||||
applyStats(d);
|
||||
@can('view health and logs')
|
||||
logsTable.ajax.reload(null, false);
|
||||
@endcan
|
||||
setTimeout(() => btn.find('i').removeClass('bi-spin'), 1000);
|
||||
});
|
||||
}
|
||||
$('#refresh-all-stats').on('click', refreshStats);
|
||||
|
||||
// ── Real-time via Reverb ───────────────────────────────────
|
||||
if (window.Echo) {
|
||||
@can('view health and logs')
|
||||
window.Echo.private('admin.monitoring')
|
||||
.listen('.stats.updated', applyStats)
|
||||
.listen('.activity.created', (e) => {
|
||||
const rowNode = logsTable.row.add([
|
||||
e.log.datetime, e.log.level, e.log.manifest,
|
||||
`<button class="btn btn-square btn-light btn-sm rounded-circle view-log-detail"
|
||||
data-log='${JSON.stringify({ message: e.log.description }).replace(/'/g,"'")}'>
|
||||
<i class="bi bi-info-circle"></i></button>`
|
||||
]).draw(false).node();
|
||||
$(rowNode).css('background-color','#fff9c4');
|
||||
setTimeout(() => $(rowNode).css('background-color',''), 3000);
|
||||
$(rowNode).find('.view-log-detail').on('click', function () {
|
||||
const log = $(this).data('log');
|
||||
renderTerminalModal(log);
|
||||
new bootstrap.Modal('#logDetailModal').show();
|
||||
});
|
||||
});
|
||||
@endcan
|
||||
} else {
|
||||
// Fallback polling when Reverb not connected
|
||||
setInterval(refreshStats, 30000);
|
||||
}
|
||||
|
||||
// ── Terminal Modal Renderer ────────────────────────────────
|
||||
function renderTerminalModal(log) {
|
||||
const msg = log.message || '';
|
||||
// Format markdown-ish text for terminal display
|
||||
const formatted = msg
|
||||
.replace(/\*\*(.*?)\*\*/g, '$1') // strip bold markers, keep text
|
||||
.replace(/^#+\s+/gm, '>> ') // headings → terminal prefix
|
||||
.replace(/^(\d+\.)/gm, ' $1'); // indent numbered lists
|
||||
$('#terminal-output').text(formatted);
|
||||
// Animate a typing cursor effect
|
||||
$('#terminal-output').append('<span id="cursor-blink" style="display:inline-block;width:8px;height:14px;background:#3fb950;vertical-align:middle;margin-left:4px;animation:blink-cursor 1s step-start infinite;"></span>');
|
||||
}
|
||||
|
||||
// ── AI Analysis ────────────────────────────────────────────
|
||||
function renderAiContent(text) {
|
||||
const html = text
|
||||
.replace(/### (.*)/g, '<h6 class="fw-bold text-dark mt-3 mb-2">$1</h6>')
|
||||
.replace(/## (.*)/g, '<h6 class="fw-bold text-dark mt-3 mb-2">$1</h6>')
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\n/g, '<br>');
|
||||
$('#ai-content-display').html(html);
|
||||
}
|
||||
|
||||
@can('view ai log analysis')
|
||||
$.get('{{ route("ai.log-analysis.index") }}', function (d) {
|
||||
if (d.analysis && !d.analysis.includes('Analysis not generated yet')) renderAiContent(d.analysis);
|
||||
});
|
||||
|
||||
$('#btn-ai-analyze').on('click', function () {
|
||||
const btn = $(this);
|
||||
btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-1"></span> Analyzing...');
|
||||
$('#ai-content-display').fadeOut(200, function () {
|
||||
$('#ai-placeholder').show();
|
||||
$.post('{{ route("ai.log-analysis.analyze") }}', { _token: '{{ csrf_token() }}' }, function (d) {
|
||||
renderAiContent(d.analysis);
|
||||
$('#ai-placeholder').hide();
|
||||
$('#ai-content-display').fadeIn();
|
||||
btn.prop('disabled', false).html('<i class="bi bi-cpu me-1"></i>Analyze');
|
||||
}).fail(function () {
|
||||
$('#ai-content-display').html('<div class="alert alert-danger p-2 small"><i class="bi bi-exclamation-triangle me-2"></i> Error connecting to AI service.</div>').fadeIn();
|
||||
$('#ai-placeholder').hide();
|
||||
btn.prop('disabled', false).html('<i class="bi bi-cpu me-1"></i>Analyze');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
$('#btn-ai-clear').on('click', function () {
|
||||
const btn = $(this);
|
||||
btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm"></span>');
|
||||
$.post('{{ route("ai.log-analysis.clear") }}', { _token: '{{ csrf_token() }}' }, function () {
|
||||
$('#ai-content-display').fadeOut(200, function () {
|
||||
$(this).html(`<div class="text-center py-5 opacity-50"><i class="bi bi-robot display-4 d-block mb-3"></i><p class="fst-italic">Click analyze to get security insights.</p></div>`).fadeIn();
|
||||
});
|
||||
btn.prop('disabled', false).html('<i class="bi bi-trash me-1"></i>Clear');
|
||||
}).fail(() => btn.prop('disabled', false).html('<i class="bi bi-trash me-1"></i>Clear'));
|
||||
});
|
||||
@endcan
|
||||
|
||||
// ── Widget Customize ───────────────────────────────────────
|
||||
$('#btn-customize-widgets').on('click', function () {
|
||||
$('#widget-customize-panel').slideToggle(200);
|
||||
});
|
||||
|
||||
function updateBigWidgetLayout() {
|
||||
const visible = $('#widget-grid-big .widget-col:not(.hidden-widget)');
|
||||
const vCount = visible.length;
|
||||
let colClass = 'col-12';
|
||||
if (vCount === 2) colClass = 'col-12 col-lg-6';
|
||||
if (vCount >= 3) colClass = 'col-12 col-lg-4';
|
||||
|
||||
$('#widget-grid-big .widget-col')
|
||||
.removeClass('col-12 col-lg-6 col-lg-4')
|
||||
.addClass(colClass);
|
||||
}
|
||||
updateBigWidgetLayout(); // Initial call
|
||||
|
||||
$(document).on('change', '.widget-visibility-toggle', function () {
|
||||
const key = $(this).data('widget');
|
||||
const visible = $(this).is(':checked');
|
||||
$(this).closest('.widget-toggle-item').toggleClass('active', visible);
|
||||
$(`[data-widget-key="${key}"]`).toggleClass('hidden-widget', !visible);
|
||||
|
||||
updateBigWidgetLayout();
|
||||
|
||||
// Show/hide empty state
|
||||
const anyVisible = $('.widget-visibility-toggle:checked').length > 0;
|
||||
$('#dashboard-empty-state').toggle(!anyVisible);
|
||||
});
|
||||
|
||||
$('#btn-toggle-all-widgets').on('click', function() {
|
||||
const allChecked = $('.widget-visibility-toggle:checked').length === $('.widget-visibility-toggle').length;
|
||||
$('.widget-visibility-toggle').prop('checked', !allChecked).trigger('change');
|
||||
});
|
||||
|
||||
// Drag-to-reorder stats
|
||||
if (document.getElementById('widget-grid-stats')) {
|
||||
Sortable.create(document.getElementById('widget-grid-stats'), {
|
||||
animation: 150, handle: '.card', ghostClass: 'widget-ghost', chosenClass: 'widget-chosen',
|
||||
});
|
||||
}
|
||||
|
||||
// Drag-to-reorder big widgets
|
||||
if (document.getElementById('widget-grid-big')) {
|
||||
Sortable.create(document.getElementById('widget-grid-big'), {
|
||||
animation: 150, handle: '.card', ghostClass: 'widget-ghost', chosenClass: 'widget-chosen',
|
||||
});
|
||||
}
|
||||
|
||||
// Save layout
|
||||
$('#btn-save-widgets').on('click', function () {
|
||||
const btn = $(this);
|
||||
const originalHtml = btn.html();
|
||||
btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-1"></span> Saving...');
|
||||
|
||||
const widgets = [];
|
||||
// Collect from both grids
|
||||
$('.widget-col').each(function (i) {
|
||||
widgets.push({
|
||||
key: $(this).data('widget-key'),
|
||||
visible: !$(this).hasClass('hidden-widget'),
|
||||
sort_order: i + 1,
|
||||
});
|
||||
});
|
||||
|
||||
$.ajax({
|
||||
url: '{{ route("dashboard.widgets.save") }}',
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({ widgets }),
|
||||
success: () => {
|
||||
btn.prop('disabled', false).html('<i class="bi bi-check2 me-1"></i>Saved!');
|
||||
setTimeout(() => btn.html(originalHtml), 2000);
|
||||
if (window.showNotificationToast) {
|
||||
window.showNotificationToast('success', 'Dashboard layout updated successfully');
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
btn.prop('disabled', false).html('<i class="bi bi-exclamation-circle me-1"></i>Error');
|
||||
setTimeout(() => btn.html(originalHtml), 2000);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
$('#form-reset-widgets').on('submit', function() {
|
||||
$(this).find('button').prop('disabled', true).html('<span class="spinner-border spinner-border-sm"></span>');
|
||||
});
|
||||
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,677 @@
|
||||
<x-app-layout>
|
||||
@push('styles')
|
||||
<script src="https://cdn.jsdelivr.net/npm/apexcharts" crossorigin="anonymous"></script>
|
||||
<style>
|
||||
.sparkline-container {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 60px;
|
||||
overflow: hidden;
|
||||
border-bottom-left-radius: 20px;
|
||||
border-bottom-right-radius: 20px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Skeleton Loading */
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e6e6e6 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-loading 1.5s infinite;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@keyframes skeleton-loading {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton-text {
|
||||
height: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.skeleton-title {
|
||||
height: 2.5rem;
|
||||
width: 60%;
|
||||
margin: 10px auto;
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
<div class="container-fluid mt-3 pb-5" id="monitoring-master">
|
||||
|
||||
{{-- Welcome Header --}}
|
||||
<div
|
||||
class="card adminuiux-card bg-dark text-white mb-4 border-0 shadow-lg overflow-hidden animate__animated animate__fadeIn">
|
||||
<div class="card-body p-4 position-relative">
|
||||
<div class="row align-items-center position-relative z-1">
|
||||
<div class="col">
|
||||
<h1 class="display-5 fw-bold text-white mb-1 tracking-tight">{{ __('Operational Dashboard') }}
|
||||
</h1>
|
||||
<p class="small text-white-50 mb-0 d-flex align-items-center gap-2">
|
||||
<span class="status-indicator"></span>
|
||||
System operational since <span class="badge text-bg-theme-1 rounded-pill px-3 shadow-sm"
|
||||
id="stat-uptime-badge">{{ $stats['uptime'] }}</span>
|
||||
at <span class="fw-bold">{{ $stats['hostname'] }}</span> ({{ $stats['ip'] }})
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-theme-1 btn-square rounded-circle shadow-sm" id="refresh-all-stats"
|
||||
title="Refresh Live Data">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-decoration"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Main Stats Row --}}
|
||||
<div class="row g-3 g-lg-4 mb-4">
|
||||
{{-- CPU --}}
|
||||
<div class="col-6 col-md-6 col-lg-3">
|
||||
<div class="card adminuiux-card border-0 shadow-sm h-100 hover-lift animate__animated animate__fadeIn"
|
||||
style="animation-delay: 0.1s">
|
||||
<div class="card-body p-4 text-center">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<h6 class="fw-bold text-dark small mb-0">CPU LOAD</h6>
|
||||
<i class="bi bi-speedometer2 text-theme-1"></i>
|
||||
</div>
|
||||
<h1 class="display-3 fw-black text-theme-1 mb-0 counter-value" id="stat-cpu-percent">
|
||||
{{ $stats['cpu'] }}%
|
||||
</h1>
|
||||
<div class="mini-progress mt-3">
|
||||
<div class="bar" id="cpu-bar" style="width:{{$stats['cpu']}}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="chart-cpu-sparkline" class="sparkline-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- RAM --}}
|
||||
<div class="col-6 col-md-6 col-lg-3">
|
||||
<div class="card adminuiux-card border-0 shadow-sm h-100 hover-lift animate__animated animate__fadeIn"
|
||||
style="animation-delay: 0.2s">
|
||||
<div class="card-body p-4 text-center">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<h6 class="fw-bold text-dark small mb-0">MEMORY</h6>
|
||||
<i class="bi bi-memory text-info"></i>
|
||||
</div>
|
||||
<h1 class="display-3 fw-black text-info mb-0 counter-value" id="stat-ram-percent">
|
||||
{{ $stats['ram']['percentage'] }}%
|
||||
</h1>
|
||||
<div class="d-flex justify-content-between extra-small text-muted mt-2">
|
||||
<span id="stat-ram-used">{{ $stats['ram']['used'] }} used</span>
|
||||
<span id="stat-swap-info" class="text-warning">Swap:
|
||||
{{ $stats['ram']['swap']['percentage'] ?? 0 }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="chart-ram-sparkline" class="sparkline-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- DISK --}}
|
||||
<div class="col-6 col-md-6 col-lg-3">
|
||||
<div class="card adminuiux-card border-0 shadow-sm h-100 hover-lift animate__animated animate__fadeIn"
|
||||
style="animation-delay: 0.3s">
|
||||
<div class="card-body p-4 text-center">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<h6 class="fw-bold text-dark small mb-0">STORAGE</h6>
|
||||
<i class="bi bi-hdd-network text-warning"></i>
|
||||
</div>
|
||||
<h1 class="display-3 fw-black text-warning mb-0 counter-value" id="stat-disk-percent">
|
||||
{{ $stats['disk']['percentage'] }}%
|
||||
</h1>
|
||||
<p class="extra-small text-muted mb-0 mt-2" id="stat-disk-total">
|
||||
{{ $stats['disk']['free'] }} available
|
||||
</p>
|
||||
</div>
|
||||
<div id="chart-disk-sparkline" class="sparkline-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- SESSIONS --}}
|
||||
<div class="col-6 col-md-6 col-lg-3">
|
||||
<div class="card adminuiux-card border-0 shadow-sm h-100 hover-lift bg-theme-1 text-white card-glow-theme animate__animated animate__fadeIn"
|
||||
style="animation-delay: 0.4s">
|
||||
<div class="card-body p-4 text-center">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<h6 class="fw-bold text-white small mb-0">LIVE USERS</h6>
|
||||
<i class="bi bi-people text-white-50"></i>
|
||||
</div>
|
||||
<h1 class="display-3 fw-black text-white mb-0 counter-value" id="stat-users-count">
|
||||
{{ $stats['users']['total'] }}
|
||||
</h1>
|
||||
<p class="extra-small text-white-50 mb-0 mt-2">
|
||||
<span id="stat-users-auth">{{ $stats['users']['authenticated'] }}</span> Authenticated
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 g-lg-6">
|
||||
@if(auth()->user()->can('view health and logs'))
|
||||
|
||||
{{-- Right Column: Live Console (Logs) --}}
|
||||
<div class="col-lg-7">
|
||||
<div
|
||||
class="card adminuiux-card border-0 shadow-sm h-100 mb-4 mb-lg-0 animate__animated animate__fadeIn">
|
||||
<div
|
||||
class="card-header bg-white border-bottom p-4 d-flex justify-content-between align-items-center">
|
||||
<h6 class="fw-bold text-dark mb-0 d-flex align-items-center gap-2">
|
||||
<i class="bi bi-terminal text-theme-1"></i>
|
||||
{{ __('Runtime Activity Feed') }}
|
||||
</h6>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ route('system-monitoring') }}"
|
||||
class="btn btn-sm btn-light rounded-pill px-3 fw-bold small">
|
||||
<i class="bi bi-gear me-1"></i> FULL MONITOR
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<style>
|
||||
#logs-datatable tbody td:nth-child(3) {
|
||||
white-space: normal !important;
|
||||
min-width: 250px;
|
||||
max-width: 400px;
|
||||
word-break: break-word;
|
||||
}
|
||||
#logs-datatable thead th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
<table id="logs-datatable"
|
||||
class="table table-hover align-middle mb-0 w-100 small compact-table">
|
||||
<thead>
|
||||
<tr class="bg-white">
|
||||
<th>INCIDENT TIME</th>
|
||||
<th>LVL</th>
|
||||
<th>MANIFEST</th>
|
||||
<th class="text-end pe-4">INTEL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@can('view ai log analysis')
|
||||
|
||||
{{-- AI Analysis Card --}}
|
||||
<div class="col-lg-5">
|
||||
<div class="card adminuiux-card border-0 shadow-sm h-100 mb-4 mb-lg-0 animate__animated animate__fadeIn"
|
||||
style="animation-delay: 0.5s">
|
||||
<div
|
||||
class="card-header bg-transparent border-0 pt-4 px-4 d-flex justify-content-between align-items-center">
|
||||
<h6 class="fw-bold text-dark mb-0 d-flex align-items-center gap-2">
|
||||
<i class="bi bi-robot text-theme-1"></i>
|
||||
{{ __('AI Security Insight') }}
|
||||
</h6>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button class="btn btn-sm btn-outline-danger rounded-pill px-3 extra-small"
|
||||
id="btn-ai-clear">
|
||||
<i class="bi bi-trash me-1"></i>Clear
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-dark rounded-pill px-3 extra-small"
|
||||
id="btn-ai-analyze">
|
||||
<i class="bi bi-cpu me-1"></i>Analyze
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body px-4">
|
||||
<div id="ai-analysis-container" class="small text-muted"
|
||||
style="min-height: 200px; line-height: 1.6;">
|
||||
<div class="placeholder-glow" id="ai-placeholder" style="display:none;">
|
||||
<div class="d-flex flex-column gap-3">
|
||||
<span class="placeholder col-12 rounded-pill py-2"></span>
|
||||
<span class="placeholder col-10 rounded-pill py-1"></span>
|
||||
<span class="placeholder col-11 rounded-pill py-1"></span>
|
||||
<span class="placeholder col-8 rounded-pill py-1"></span>
|
||||
<span class="placeholder col-9 rounded-pill py-1"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ai-content-display" class="animate__animated animate__fadeIn">
|
||||
<div class="text-center py-5 opacity-50">
|
||||
<i class="bi bi-robot display-4 d-block mb-3"></i>
|
||||
<p class="fst-italic">Click analyze to get security insights from your recent
|
||||
activity logs.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Log Detail Modal --}}
|
||||
<div class="modal fade animate__animated animate__fadeIn" id="logDetailModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-xl animate__animated animate__fadeIn">
|
||||
<div class="modal-content adminuiux-card border-0 shadow-2xl terminal-box">
|
||||
<div class="modal-header terminal-header">
|
||||
<div class="window-controls me-3">
|
||||
<span class="dot red"></span><span class="dot yellow"></span><span class="dot green"></span>
|
||||
</div>
|
||||
<h6 class="modal-title fw-bold extra-small text-white-50 letter-spacing-1">TELEMETRY_DUMP @node_01
|
||||
</h6>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body p-0">
|
||||
<div class="code-container p-4">
|
||||
<pre id="detail-message" class="mb-0 small text-success font-monospace scroll-custom"
|
||||
style="max-height: 700px; overflow-y: auto; overflow-x: hidden; line-height: 1.6; white-space: pre-wrap; word-break: break-all;"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
@if(auth()->user()->can('view health and logs'))
|
||||
const REFRESH_RATE = 10000; // 10s for dashboard to be less aggressive
|
||||
|
||||
// Logs Init
|
||||
const logsTable = $('#logs-datatable').DataTable({
|
||||
processing: true, serverSide: true, ajax: '{{ route("system-monitoring.logs.datatable") }}',
|
||||
order: [[0, 'desc']], pageLength: 5, autoWidth: false, dom: 'tr<"p-3 border-top d-flex justify-content-end"p>',
|
||||
columns: [{ data: 0, className: 'ps-4 datetime-col fw-bold' }, { data: 1 }, { data: 2 }, { data: 3, className: 'pe-4 text-end', orderable: false }],
|
||||
drawCallback: function () {
|
||||
$('.view-log-detail').off('click').on('click', function () {
|
||||
const log = $(this).data('log');
|
||||
$('#detail-message').text(log.message);
|
||||
new bootstrap.Modal('#logDetailModal').show();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function refreshStats() {
|
||||
const btn = $('#refresh-all-stats');
|
||||
btn.find('i').addClass('bi-spin');
|
||||
|
||||
$.get('{{ route("system-monitoring.stats") }}', function (d) {
|
||||
updateVal('#stat-cpu-percent', d.cpu + '%');
|
||||
updateVal('#stat-ram-percent', d.ram.percentage + '%');
|
||||
$('#stat-ram-used').text(d.ram.used + ' used');
|
||||
if (d.ram.swap) {
|
||||
$('#stat-swap-info').text('Swap: ' + d.ram.swap.percentage + '%');
|
||||
}
|
||||
|
||||
updateVal('#stat-disk-percent', d.disk.percentage + '%');
|
||||
$('#stat-disk-total').text(d.disk.free + ' available');
|
||||
|
||||
// Refined Users
|
||||
updateVal('#stat-users-count', d.users.total);
|
||||
$('#stat-users-auth').text(d.users.authenticated);
|
||||
|
||||
updateVal('#stat-queues-pending', d.queues.pending);
|
||||
updateVal('#stat-queues-failed', d.queues.failed);
|
||||
|
||||
$('#stat-uptime-badge').text(d.uptime);
|
||||
$('#cpu-bar').css('width', d.cpu + '%');
|
||||
|
||||
// Update Charts
|
||||
updateSparkline(cpuChart, d.cpu);
|
||||
updateSparkline(ramChart, d.ram.percentage);
|
||||
updateSparkline(diskChart, d.disk.percentage);
|
||||
|
||||
if (d.has_reverb) {
|
||||
$('#reverb-icon').addClass('active');
|
||||
$('#reverb-status-text').text('ACTIVE').removeClass('text-muted').addClass('text-success');
|
||||
} else {
|
||||
$('#reverb-icon').removeClass('active');
|
||||
$('#reverb-status-text').text('IDLE').addClass('text-muted').removeClass('text-success');
|
||||
}
|
||||
|
||||
logsTable.ajax.reload(null, false);
|
||||
setTimeout(() => btn.find('i').removeClass('bi-spin'), 1000);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Sparkline Helpers ---
|
||||
const sparklineOptions = (color) => ({
|
||||
series: [{ data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] }],
|
||||
chart: { type: 'area', height: 60, sparkline: { enabled: true }, animations: { enabled: true, easing: 'linear', dynamicAnimation: { speed: 1000 } } },
|
||||
stroke: { curve: 'smooth', width: 2 },
|
||||
fill: { opacity: 0.3, type: 'gradient', gradient: { shadeIntensity: 1, opacityFrom: 0.4, opacityTo: 0.1 } },
|
||||
colors: [color],
|
||||
tooltip: { enabled: false }
|
||||
});
|
||||
|
||||
const cpuChart = new ApexCharts(document.querySelector("#chart-cpu-sparkline"), sparklineOptions('var(--adminuiux-theme-1)'));
|
||||
const ramChart = new ApexCharts(document.querySelector("#chart-ram-sparkline"), sparklineOptions('#0dcaf0'));
|
||||
const diskChart = new ApexCharts(document.querySelector("#chart-disk-sparkline"), sparklineOptions('#ffc107'));
|
||||
|
||||
cpuChart.render();
|
||||
ramChart.render();
|
||||
diskChart.render();
|
||||
|
||||
function updateSparkline(chart, val) {
|
||||
let newData = chart.w.globals.series[0].slice();
|
||||
newData.push(val);
|
||||
if (newData.length > 10) newData.shift();
|
||||
chart.updateSeries([{ data: newData }]);
|
||||
}
|
||||
|
||||
function updateVal(selector, newVal) {
|
||||
const el = $(selector);
|
||||
if (el.text() !== newVal.toString()) {
|
||||
el.text(newVal).addClass('animate__animated animate__pulse');
|
||||
setTimeout(() => el.removeClass('animate__animated animate__pulse'), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
$('#refresh-all-stats').on('click', refreshStats);
|
||||
setInterval(refreshStats, REFRESH_RATE);
|
||||
|
||||
// 📡 Real-time Activity Feed (Broadcasting)
|
||||
if (window.Echo) {
|
||||
window.Echo.private('admin.monitoring')
|
||||
.listen('.activity.created', (e) => {
|
||||
console.log('Real-time Log received:', e.log);
|
||||
|
||||
// Prepend to DataTable
|
||||
const rowNode = logsTable.row.add([
|
||||
e.log.datetime,
|
||||
e.log.level,
|
||||
e.log.manifest,
|
||||
`<button class="btn btn-square btn-light btn-sm rounded-circle view-log-detail" data-log='${JSON.stringify({ message: e.log.description }).replace(/'/g, "'")}'>
|
||||
<i class="bi bi-info-circle"></i>
|
||||
</button>`
|
||||
]).draw(false).node();
|
||||
|
||||
$(rowNode).addClass('animate__animated animate__highlight').css('background-color', '#fff9c4');
|
||||
setTimeout(() => $(rowNode).css('background-color', ''), 3000);
|
||||
|
||||
// Re-bind modal click
|
||||
$(rowNode).find('.view-log-detail').on('click', function () {
|
||||
const log = $(this).data('log');
|
||||
$('#detail-message').text(log.message);
|
||||
new bootstrap.Modal('#logDetailModal').show();
|
||||
});
|
||||
});
|
||||
}
|
||||
@endif
|
||||
|
||||
// 🤖 AI Log Analysis JS
|
||||
function fetchAiAnalysis() {
|
||||
$.get('{{ route("ai.log-analysis.index") }}', function (d) {
|
||||
if (d.analysis && !d.analysis.includes('Analysis not generated yet')) {
|
||||
renderAiContent(d.analysis);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderAiContent(text) {
|
||||
// Simple markdown-ish bold headers conversion
|
||||
let formatted = text.replace(/### (.*)/g, '<h6 class="fw-bold text-dark mt-3 mb-2">$1</h6>')
|
||||
.replace(/## (.*)/g, '<h6 class="fw-bold text-dark mt-3 mb-2">$1</h6>')
|
||||
.replace(/\*\*(.*)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\n/g, '<br>');
|
||||
$('#ai-content-display').html(formatted);
|
||||
}
|
||||
|
||||
$('#btn-ai-analyze').on('click', function () {
|
||||
const btn = $(this);
|
||||
btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-1"></span> Analyzing...');
|
||||
|
||||
$('#ai-content-display').fadeOut(200, function () {
|
||||
$('#ai-placeholder').show();
|
||||
|
||||
$.post('{{ route("ai.log-analysis.analyze") }}', { _token: '{{ csrf_token() }}' }, function (d) {
|
||||
renderAiContent(d.analysis);
|
||||
$('#ai-placeholder').hide();
|
||||
$('#ai-content-display').fadeIn();
|
||||
btn.prop('disabled', false).html('<i class="bi bi-cpu me-1"></i>Analyze');
|
||||
}).fail(function () {
|
||||
$('#ai-content-display').html('<div class="alert alert-danger p-2 small"><i class="bi bi-exclamation-triangle me-2"></i> Error connecting to AI service.</div>').fadeIn();
|
||||
$('#ai-placeholder').hide();
|
||||
btn.prop('disabled', false).html('<i class="bi bi-cpu me-1"></i>Analyze');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
$('#btn-ai-clear').on('click', function () {
|
||||
const btn = $(this);
|
||||
btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm"></span>');
|
||||
|
||||
$.post('{{ route("ai.log-analysis.clear") }}', { _token: '{{ csrf_token() }}' }, function (d) {
|
||||
$('#ai-content-display').fadeOut(200, function () {
|
||||
$(this).html(`
|
||||
<div class="text-center py-5 opacity-50">
|
||||
<i class="bi bi-robot display-4 d-block mb-3"></i>
|
||||
<p class="fst-italic">Click analyze to get security insights from your recent activity logs.</p>
|
||||
</div>
|
||||
`).fadeIn();
|
||||
});
|
||||
btn.prop('disabled', false).html('<i class="bi bi-trash me-1"></i>Clear');
|
||||
}).fail(function () {
|
||||
btn.prop('disabled', false).html('<i class="bi bi-trash me-1"></i>Clear');
|
||||
});
|
||||
});
|
||||
|
||||
fetchAiAnalysis();
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
.fw-black {
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.ls-1 {
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.ls-2 {
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.tracking-tight {
|
||||
letter-spacing: -2px;
|
||||
}
|
||||
|
||||
.display-3 {
|
||||
font-size: 3.5rem;
|
||||
letter-spacing: -2px;
|
||||
}
|
||||
|
||||
.extra-small {
|
||||
font-size: 0.85rem !important;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.hover-lift {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.hover-lift:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
|
||||
.bg-decoration {
|
||||
position: absolute;
|
||||
right: -50px;
|
||||
bottom: -50px;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 50%;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.icon-shape {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bg-theme-1-subtle {
|
||||
background-color: rgba(var(--adminuiux-theme-1-rgb), 0.1);
|
||||
}
|
||||
|
||||
.bg-info-subtle {
|
||||
background-color: rgba(13, 202, 240, 0.1);
|
||||
}
|
||||
|
||||
.bg-warning-subtle {
|
||||
background-color: rgba(255, 193, 7, 0.1);
|
||||
}
|
||||
|
||||
.bg-success-subtle {
|
||||
background-color: rgba(25, 135, 84, 0.1);
|
||||
}
|
||||
|
||||
.bg-danger-subtle {
|
||||
background-color: rgba(220, 53, 69, 0.1);
|
||||
}
|
||||
|
||||
.icon-box-modern {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hover-bg-light:hover {
|
||||
background-color: #f8fafc;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pulse-tiny {
|
||||
animation: pulse-glow-tiny 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-glow-tiny {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.bi-spin {
|
||||
animation: spin 1.5s infinite linear;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.compact-table thead th {
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: #64748b;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.compact-table tbody td {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.scroll-custom::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.scroll-custom::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scroll-custom::-webkit-scrollbar-thumb {
|
||||
background: #334155;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.markdown-body {
|
||||
font-size: 0.7rem !important;
|
||||
line-height: 1.4 !important;
|
||||
}
|
||||
|
||||
.markdown-body p {
|
||||
margin-bottom: 0.4rem !important;
|
||||
}
|
||||
|
||||
/* Terminal Styling */
|
||||
.terminal-box {
|
||||
background: #0c121e !important;
|
||||
border: 1px solid #1e293b !important;
|
||||
color: #10b981 !important;
|
||||
min-height: 500px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.terminal-header {
|
||||
background: #1e293b !important;
|
||||
border-bottom: 1px solid #334155 !important;
|
||||
padding: 12px 20px !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.window-controls .dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.dot.red {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.dot.yellow {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.dot.green {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.code-container pre {
|
||||
font-family: 'Fira Code', 'JetBrains Mono', monospace !important;
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="card adminuiux-card border-0 shadow-sm h-100 hover-lift" data-widget="cpu">
|
||||
<div class="card-body p-4 text-center position-relative">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<h6 class="fw-bold text-dark small mb-0">CPU LOAD</h6>
|
||||
<i class="bi bi-speedometer2 text-theme-1"></i>
|
||||
</div>
|
||||
<h1 class="display-3 fw-black text-theme-1 mb-0 counter-value" id="stat-cpu-percent">{{ $stats['cpu'] }}%</h1>
|
||||
<div class="mini-progress mt-3">
|
||||
<div class="bar" id="cpu-bar" style="width:{{ $stats['cpu'] }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="chart-cpu-sparkline" class="sparkline-container"></div>
|
||||
</div>
|
||||
@@ -0,0 +1,11 @@
|
||||
<div class="card adminuiux-card border-0 shadow-sm h-100 hover-lift" data-widget="disk">
|
||||
<div class="card-body p-4 text-center">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<h6 class="fw-bold text-dark small mb-0">STORAGE</h6>
|
||||
<i class="bi bi-hdd-network text-warning"></i>
|
||||
</div>
|
||||
<h1 class="display-3 fw-black text-warning mb-0 counter-value" id="stat-disk-percent">{{ $stats['disk']['percentage'] }}%</h1>
|
||||
<p class="extra-small text-muted mb-0 mt-2" id="stat-disk-total">{{ $stats['disk']['free'] }} available</p>
|
||||
</div>
|
||||
<div id="chart-disk-sparkline" class="sparkline-container"></div>
|
||||
</div>
|
||||
@@ -0,0 +1,12 @@
|
||||
<div class="card adminuiux-card border-0 shadow-sm h-100 hover-lift bg-theme-1 text-white card-glow-theme" data-widget="live_users">
|
||||
<div class="card-body p-4 text-center">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<h6 class="fw-bold text-white small mb-0">LIVE USERS</h6>
|
||||
<i class="bi bi-people text-white-50"></i>
|
||||
</div>
|
||||
<h1 class="display-3 fw-black text-white mb-0 counter-value" id="stat-users-count">{{ $stats['users']['total'] }}</h1>
|
||||
<p class="extra-small text-white-50 mb-0 mt-2">
|
||||
<span id="stat-users-auth">{{ $stats['users']['authenticated'] }}</span> Authenticated
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,27 @@
|
||||
<div class="card adminuiux-card border-0 shadow-sm h-100 hover-lift" data-widget="queues">
|
||||
<div class="card-body p-4">
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<h6 class="fw-bold text-dark small mb-0">QUEUE STATS</h6>
|
||||
<i class="bi bi-stack text-secondary"></i>
|
||||
</div>
|
||||
<div class="row g-2 text-center">
|
||||
<div class="col-6">
|
||||
<div class="p-2 rounded-3 bg-light">
|
||||
<div class="fw-black display-6 text-dark counter-value" id="stat-queues-pending">{{ $stats['queues']['pending'] ?? 0 }}</div>
|
||||
<div class="extra-small text-muted fw-semibold">PENDING</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="p-2 rounded-3 bg-danger-subtle">
|
||||
<div class="fw-black display-6 text-danger counter-value" id="stat-queues-failed">{{ $stats['queues']['failed'] ?? 0 }}</div>
|
||||
<div class="extra-small text-danger fw-semibold">FAILED</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 d-flex align-items-center gap-2">
|
||||
<span class="extra-small text-muted">Reverb:</span>
|
||||
<i class="bi bi-broadcast text-muted" id="reverb-icon"></i>
|
||||
<span class="extra-small text-muted" id="reverb-status-text">IDLE</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,14 @@
|
||||
<div class="card adminuiux-card border-0 shadow-sm h-100 hover-lift" data-widget="ram">
|
||||
<div class="card-body p-4 text-center">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<h6 class="fw-bold text-dark small mb-0">MEMORY</h6>
|
||||
<i class="bi bi-memory text-info"></i>
|
||||
</div>
|
||||
<h1 class="display-3 fw-black text-info mb-0 counter-value" id="stat-ram-percent">{{ $stats['ram']['percentage'] }}%</h1>
|
||||
<div class="d-flex justify-content-between extra-small text-muted mt-2">
|
||||
<span id="stat-ram-used">{{ $stats['ram']['used'] }} used</span>
|
||||
<span id="stat-swap-info" class="text-warning">Swap: {{ $stats['ram']['swap']['percentage'] ?? 0 }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="chart-ram-sparkline" class="sparkline-container"></div>
|
||||
</div>
|
||||
@@ -0,0 +1,597 @@
|
||||
<x-app-layout>
|
||||
@push('styles')
|
||||
<link href="https://unpkg.com/filepond/dist/filepond.css" rel="stylesheet" />
|
||||
<link href="https://unpkg.com/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.css" rel="stylesheet" />
|
||||
<style>
|
||||
:root {
|
||||
--admin-bg: #f5f7fb;
|
||||
--tab-active-bg: #ffffff;
|
||||
--text-dark: #1e293b;
|
||||
--text-muted: #64748b;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--admin-bg);
|
||||
}
|
||||
|
||||
.ck-editor__editable {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* FilePond Sleek Styling */
|
||||
.filepond--root {
|
||||
margin-bottom: 0;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.filepond--panel-root {
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.filepond--drop-label {
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.filepond--label-action {
|
||||
color: #2563eb;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.filepond--credits {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Asset Card Styling */
|
||||
.asset-item-group {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.asset-preview-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.asset-preview-card img {
|
||||
max-height: 80px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.form-control-sleek {
|
||||
border-radius: 50rem;
|
||||
padding: 0.6rem 1.5rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #ffffff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-control-sleek:focus {
|
||||
border-color: #94a3b8;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-save-floating {
|
||||
background: #111827;
|
||||
color: white;
|
||||
border-radius: 50rem;
|
||||
padding: 10px 30px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.btn-save-floating:hover {
|
||||
transform: translateY(-2px);
|
||||
background: #000000;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Color Picker Styling */
|
||||
.color-preview-circle {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #ffffff;
|
||||
box-shadow: 0 0 0 1px #e2e8f0;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.color-preview-circle:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 0 0 1px #cbd5e1, 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.input-group-text-color {
|
||||
background-color: #f8fafc !important;
|
||||
border-right: none !important;
|
||||
padding: 0 12px !important;
|
||||
}
|
||||
|
||||
.color-input-field {
|
||||
border-left: none !important;
|
||||
text-transform: uppercase;
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Sticky Bottom Bar */
|
||||
.sticky-bottom-bar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
backdrop-filter: blur(10px);
|
||||
border-top: 1px solid #e2e8f0;
|
||||
padding: 1rem 2rem;
|
||||
margin: 0 -1.5rem -1.5rem -1.5rem;
|
||||
border-bottom-left-radius: 12px;
|
||||
border-bottom-right-radius: 12px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Mobile Mockup CSS */
|
||||
.mobile-mockup-container {
|
||||
width: 280px;
|
||||
height: 580px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mobile-frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #111827;
|
||||
border-radius: 40px;
|
||||
padding: 12px;
|
||||
position: relative;
|
||||
box-shadow: 0 50px 100px -20px rgba(0,0,0,0.25), 0 30px 60px -30px rgba(0,0,0,0.3);
|
||||
border: 4px solid #374151;
|
||||
}
|
||||
|
||||
.mobile-screen {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 30px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: #f8fafc;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mobile-home-indicator {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 80px;
|
||||
height: 4px;
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.app-content-preview {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mockup-app-name {
|
||||
font-size: 1.4rem;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.x-small {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.fw-black {
|
||||
font-weight: 900 !important;
|
||||
}
|
||||
|
||||
.op-75 {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* Terminal Styling for JSON Preview */
|
||||
.terminal-box {
|
||||
background: #0c121e !important;
|
||||
border: 1px solid #1e293b !important;
|
||||
color: #10b981 !important;
|
||||
min-height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 12px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.terminal-header {
|
||||
background: #1e293b !important;
|
||||
border-bottom: 1px solid #334155 !important;
|
||||
padding: 10px 20px !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.window-controls .dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.dot.red { background: #ef4444; }
|
||||
.dot.yellow { background: #f59e0b; }
|
||||
.dot.green { background: #22c55e; }
|
||||
|
||||
.terminal-content {
|
||||
padding: 20px;
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.terminal-content pre {
|
||||
font-family: 'Fira Code', 'JetBrains Mono', 'Monaco', 'Consolas', monospace !important;
|
||||
color: #10b981 !important;
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
<div class="container-fluid" id="main-content">
|
||||
<div class="row gx-3 gx-lg-4">
|
||||
<div class="col-12">
|
||||
<div class="card adminuiux-card">
|
||||
<div class="card-body p-0">
|
||||
<!-- Header Section -->
|
||||
<div class="p-4 pb-0">
|
||||
<div class="row align-items-center g-3 mb-3">
|
||||
<div class="col">
|
||||
<h5 class="mb-0 fw-bold text-dark">{{ __('Mobile Settings') }}</h5>
|
||||
<small class="text-muted">
|
||||
{{ __('Manage branding and feature flags with secure, real-time mobile sync.') }}
|
||||
</small>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<div class="input-group input-group-sm bg-light rounded-pill px-3 py-1 border">
|
||||
<i class="bi bi-search text-muted my-auto me-2"></i>
|
||||
<input type="text" id="mobileSearch" class="form-control border-0 bg-transparent" placeholder="{{ __('Search mobile settings...') }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<div class="row gx-4">
|
||||
<!-- Left Side: Settings Form -->
|
||||
<div class="col-xl-8 col-lg-7">
|
||||
<form id="mobileConfigForm" action="{{ route('mobile-settings.update') }}" method="POST" enctype="multipart/form-data"
|
||||
autocomplete="off" class="ajax-form" data-reset="false" data-alert="false">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
@php
|
||||
$groupIcons = [
|
||||
'branding' => 'bi-palette',
|
||||
'control_center' => 'bi-command',
|
||||
'app_updates' => 'bi-cloud-download',
|
||||
'features' => 'bi-stars',
|
||||
'security_auth' => 'bi-shield-lock',
|
||||
'connectivity' => 'bi-wifi',
|
||||
'notifications' => 'bi-bell',
|
||||
'support_social' => 'bi-headset',
|
||||
'analytics_system' => 'bi-cpu',
|
||||
'localization' => 'bi-translate',
|
||||
];
|
||||
@endphp
|
||||
<!-- Nav Tabs -->
|
||||
<div class="overflow-x-auto mb-4" style="ms-overflow-style: -ms-autohiding-scrollbar;">
|
||||
<ul class="nav nav-tabs border-bottom-0 flex-nowrap" id="mobileTabs" role="tablist">
|
||||
@foreach($settings as $group => $items)
|
||||
@cantab('mobile settings', $group)
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link px-3 fw-semibold text-nowrap text-capitalize @if($loop->first) active @endif"
|
||||
id="{{ $group }}-tab" data-bs-toggle="tab" data-bs-target="#{{ $group }}" type="button" role="tab" aria-selected="{{ $loop->first ? 'true' : 'false' }}">
|
||||
<i class="bi {{ $groupIcons[$group] ?? 'bi-gear' }} me-2"></i>
|
||||
{{ str_replace('_', ' ', $group) }}
|
||||
</button>
|
||||
</li>
|
||||
@endcantab
|
||||
@endforeach
|
||||
@cantab('mobile settings', 'developer')
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link px-3 fw-semibold text-nowrap"
|
||||
id="developer-tab" data-bs-toggle="tab" data-bs-target="#developer" type="button" role="tab" aria-selected="false">
|
||||
<i class="bi bi-code-square me-2 text-primary"></i>
|
||||
{{ __('Developer Console') }}
|
||||
</button>
|
||||
</li>
|
||||
@endcantab
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="tab-content" id="mobileTabsContent">
|
||||
@foreach($settings as $group => $groupSettings)
|
||||
@cantab('mobile settings', $group)
|
||||
<div class="tab-pane fade @if($loop->first) show active @endif" id="{{ $group }}" role="tabpanel">
|
||||
<div class="row gx-3">
|
||||
<div class="col-12">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center mb-4">
|
||||
<div class="avatar avatar-40 rounded-circle bg-primary-subtle text-primary me-3">
|
||||
<i class="bi {{ $groupIcons[$group] ?? 'bi-gear' }} fs-5"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h6 class="fw-bold mb-0 text-uppercase tracking-wider text-dark">{{ str_replace('_', ' ', $group) }} {{ __('Configuration') }}</h6>
|
||||
<small class="text-muted">{{ __('Manage mobile specific ' . str_replace('_', ' ', $group) . ' settings and synchronization.') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
@foreach($groupSettings as $setting)
|
||||
@if($setting->type !== 'image_path')
|
||||
<div class="{{ $setting->type === 'boolean' ? 'col-md-6 col-lg-6' : 'col-12' }} mb-4">
|
||||
<label class="form-label fw-semibold">
|
||||
{{ str_replace(['lang_en_', 'lang_id_', '_'], ['', '', ' '], $setting->key) }}
|
||||
@if($setting->key === 'app_name')<span class="text-danger">*</span>@endif
|
||||
</label>
|
||||
|
||||
@if($setting->type === 'boolean')
|
||||
<div class="form-check form-switch mt-1">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="{{ $setting->key }}" name="{{ $setting->key }}" value="1" @checked($setting->value === 'true' || $setting->value === '1' || $setting->value === true)>
|
||||
<label class="form-check-label small text-muted ms-2" for="{{ $setting->key }}">{{ __('Enable') }}</label>
|
||||
</div>
|
||||
@elseif(str_ends_with($setting->key, '_at'))
|
||||
<input type="datetime-local" class="form-control" name="{{ $setting->key }}"
|
||||
value="{{ !empty($setting->value) ? date('Y-m-d\TH:i', strtotime($setting->value)) : '' }}">
|
||||
@elseif(str_contains($setting->key, 'color') || str_contains($setting->key, 'background'))
|
||||
<div class="input-group color-picker-container w-100">
|
||||
<span class="input-group-text color-preview-visual"
|
||||
style="background-color: {{ $setting->value ?? '#000000' }}; width: 50px; border-radius: 50rem 0 0 50rem !important; border-right: none;"></span>
|
||||
<input type="text" class="form-control color-input-field fw-bold text-center"
|
||||
name="{{ $setting->key }}" value="{{ $setting->value }}"
|
||||
placeholder="#000000" style="border-left: none; border-right: none; max-width: 110px; background: #f8fafc; font-size: 0.85rem; z-index: 2;">
|
||||
<div class="input-group-text p-0 border-start-0 flex-grow-1 position-relative" style="background: white; border-radius: 0 50rem 50rem 0 !important; overflow: hidden; min-height: 42px;">
|
||||
<input type="color" class="color-picker-sync position-absolute"
|
||||
value="{{ $setting->value ?? '#000000' }}"
|
||||
title="Choose color" style="top: 0; left: 0; width: 100%; height: 100%; cursor: pointer; border: none; padding: 0; opacity: 0; z-index: 1;">
|
||||
<div class="w-100 h-100 color-bar-visual" style="background-color: {{ $setting->value ?? '#000000' }};"></div>
|
||||
</div>
|
||||
</div>
|
||||
@elseif(str_contains($setting->key, 'message') || str_contains($setting->key, 'description') || str_contains($setting->key, 'content') || str_contains($setting->key, 'schedule'))
|
||||
<textarea class="form-control rich-editor" id="editor-{{ $setting->key }}"
|
||||
name="{{ $setting->key }}"
|
||||
placeholder="Enter {{ str_replace('_', ' ', $setting->key) }} here...">{{ $setting->value }}</textarea>
|
||||
@elseif($setting->key === 'announcement_type')
|
||||
<select class="form-select" name="{{ $setting->key }}">
|
||||
<option value="info" @selected($setting->value === 'info')>Info (Blue)</option>
|
||||
<option value="warning" @selected($setting->value === 'warning')>Warning (Yellow)</option>
|
||||
<option value="danger" @selected($setting->value === 'danger')>Danger (Red)</option>
|
||||
</select>
|
||||
@elseif($setting->key === 'environment_selector')
|
||||
<select class="form-select" name="{{ $setting->key }}">
|
||||
<option value="production" @selected($setting->value === 'production')>Production</option>
|
||||
<option value="staging" @selected($setting->value === 'staging')>Staging</option>
|
||||
<option value="development" @selected($setting->value === 'development')>Development</option>
|
||||
</select>
|
||||
@elseif($setting->key === 'biometric_auth_type')
|
||||
<select class="form-select" name="{{ $setting->key }}">
|
||||
<option value="any" @selected($setting->value === 'any')>Any (Fingerprint/Face)</option>
|
||||
<option value="biometrics" @selected($setting->value === 'biometrics')>Biometrics Only</option>
|
||||
<option value="passcode" @selected($setting->value === 'passcode')>Passcode Only</option>
|
||||
</select>
|
||||
@elseif($setting->key === 'log_level')
|
||||
<select class="form-select" name="{{ $setting->key }}">
|
||||
<option value="debug" @selected($setting->value === 'debug')>Debug</option>
|
||||
<option value="info" @selected($setting->value === 'info')>Info</option>
|
||||
<option value="warn" @selected($setting->value === 'warn')>Warning</option>
|
||||
<option value="error" @selected($setting->value === 'error')>Error</option>
|
||||
</select>
|
||||
@elseif($setting->key === 'priority_level')
|
||||
<select class="form-select" name="{{ $setting->key }}">
|
||||
<option value="high" @selected($setting->value === 'high')>High</option>
|
||||
<option value="normal" @selected($setting->value === 'normal')>Normal</option>
|
||||
<option value="low" @selected($setting->value === 'low')>Low</option>
|
||||
</select>
|
||||
@elseif($setting->key === 'default_locale')
|
||||
<select class="form-select" name="{{ $setting->key }}">
|
||||
<option value="en" @selected($setting->value === 'en')>English (EN)</option>
|
||||
<option value="id" @selected($setting->value === 'id')>Indonesian (ID)</option>
|
||||
</select>
|
||||
@elseif($setting->type === 'integer' || $setting->type === 'int')
|
||||
<input type="number" class="form-control" name="{{ $setting->key }}" value="{{ $setting->value }}"
|
||||
placeholder="Enter {{ str_replace('_', ' ', $setting->key) }}...">
|
||||
@elseif(str_contains($setting->key, 'message') || str_contains($setting->key, 'text') || str_contains($setting->key, 'subtitle'))
|
||||
<textarea class="form-control" name="{{ $setting->key }}" rows="2"
|
||||
placeholder="Enter {{ str_replace('_', ' ', $setting->key) }}...">{{ $setting->value }}</textarea>
|
||||
@else
|
||||
<input type="text" class="form-control" name="{{ $setting->key }}" value="{{ $setting->value }}"
|
||||
placeholder="Enter {{ str_replace('_', ' ', $setting->key) }}...">
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
@foreach($groupSettings as $setting)
|
||||
@if($setting->type === 'image_path')
|
||||
<div class="col-12 mb-5">
|
||||
<label class="form-label fw-semibold text-capitalize">
|
||||
{{ str_replace(['_url', '_'], ['', ' '], $setting->key) }}
|
||||
</label>
|
||||
|
||||
<div class="asset-preview-container shadow-sm mb-3" id="preview-container-{{ $setting->key }}">
|
||||
@php
|
||||
$previewUrl = $setting->value;
|
||||
if ($previewUrl && !Str::startsWith($previewUrl, ['http', 'https', 'data:'])) {
|
||||
$previewUrl = asset($previewUrl);
|
||||
}
|
||||
@endphp
|
||||
@if($previewUrl)
|
||||
<img src="{{ $previewUrl }}" id="img-preview-{{ $setting->key }}" alt="Preview" class="img-fluid rounded" style="max-height: 120px; object-fit: contain;" loading="lazy">
|
||||
@else
|
||||
<i class="bi bi-image text-muted opacity-25 fs-1" id="icon-preview-{{ $setting->key }}"></i>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<input type="file" id="pond-{{ $setting->key }}" name="{{ $setting->key }}"
|
||||
class="filepond-mobile" data-key="{{ $setting->key }}"
|
||||
accept="image/png,image/jpeg,image/webp">
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endcantab
|
||||
@endforeach
|
||||
|
||||
<!-- Developer Console Tab Content -->
|
||||
@cantab('mobile settings', 'developer')
|
||||
<div class="tab-pane fade" id="developer" role="tabpanel">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="avatar avatar-40 rounded-circle bg-dark text-white me-3">
|
||||
<i class="bi bi-code-slash fs-5"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h6 class="fw-bold mb-0 text-uppercase tracking-wider text-dark">{{ __('Sync Payload Preview') }}</h6>
|
||||
<small class="text-muted">{{ __('Raw JSON data being synchronized to mobile clients.') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-dark rounded-pill px-3" onclick="copyConfigJson()">
|
||||
<i class="bi bi-file-earmark-code me-1"></i> {{ __('Copy JSON') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="terminal-box shadow-lg">
|
||||
<div class="terminal-header">
|
||||
<div class="window-controls">
|
||||
<span class="dot red"></span><span class="dot yellow"></span><span class="dot green"></span>
|
||||
</div>
|
||||
<span class="extra-small text-white-50 opacity-50 font-monospace">SYNC_PAYLOAD.JSON</span>
|
||||
<i class="bi bi-braces text-white-50"></i>
|
||||
</div>
|
||||
<div class="terminal-content">
|
||||
<pre id="json-viewer" class="scroll-custom" style="max-height: 500px; overflow-y: auto; white-space: pre-wrap; word-break: break-all;">@json(app(\App\Services\MobileConfig\MobileConfigService::class)->all(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mt-4 border-0 bg-info-subtle rounded-4">
|
||||
<div class="d-flex">
|
||||
<i class="bi bi-lightbulb-fill fs-4 me-3"></i>
|
||||
<div>
|
||||
<h6 class="fw-bold mb-1">{{ __('Developer Tip') }}</h6>
|
||||
<p class="small mb-0 opacity-75">
|
||||
Use this JSON to mock responses during mobile development or to verify that all system fallbacks (like branding) are working correctly before release.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endcantab
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@can('manage mobile settings')
|
||||
<div class="sticky-bottom-bar d-flex justify-content-between align-items-center">
|
||||
<div class="small text-muted d-none d-md-block">
|
||||
<i class="bi bi-shield-check me-1"></i> Changes will be synchronized to all mobile clients.
|
||||
</div>
|
||||
<button type="submit" class="btn btn-save-floating px-5">
|
||||
<i class="bi bi-cloud-arrow-up me-2"></i> {{ __('Save Configuration') }}
|
||||
</button>
|
||||
</div>
|
||||
@endcan
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Right Side: Sticky Mobile Mockup -->
|
||||
<div class="col-xl-4 col-lg-5 d-none d-lg-block">
|
||||
<div class="sticky-top" style="top: 100px; z-index: 5;">
|
||||
<div class="text-center mb-3">
|
||||
<span class="badge bg-primary-subtle text-primary rounded-pill px-3 py-2">
|
||||
<i class="bi bi-phone me-1"></i> {{ __('Live App Preview') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- iPhone Mockup Shell -->
|
||||
<div class="mobile-mockup-container mx-auto">
|
||||
<div class="mobile-frame">
|
||||
<div class="mobile-screen bg-light">
|
||||
<!-- Status Bar -->
|
||||
<div class="mobile-status-bar d-flex justify-content-between px-3 pt-2 small text-dark opacity-75">
|
||||
<span class="fw-bold">9:41</span>
|
||||
<div class="d-flex gap-1">
|
||||
<i class="bi bi-reception-4"></i>
|
||||
<i class="bi bi-wifi"></i>
|
||||
<i class="bi bi-battery-full"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic App Content -->
|
||||
<div class="app-content-preview d-flex flex-column align-items-center justify-content-center h-100 p-4 text-center">
|
||||
<div class="app-logo-preview mb-3 animate__animated animate__zoomIn">
|
||||
@php
|
||||
$logo = $settings['branding']->firstWhere('key', 'logo_url')->value ?? '';
|
||||
if ($logo && !Str::startsWith($logo, ['http', 'https', 'data:'])) $logo = asset($logo);
|
||||
@endphp
|
||||
<img src="{{ $logo ?: asset('assets/img/logo-placeholder.png') }}" id="mockup-logo" style="max-height: 80px; object-fit: contain;">
|
||||
</div>
|
||||
<h4 class="fw-black mb-1 mockup-app-name" style="color: #1a1a1a;">{{ $settings['branding']->firstWhere('key', 'app_name')->value ?? 'biiproject' }}</h4>
|
||||
<p class="text-muted small mockup-app-tagline mb-4 px-2">{{ $settings['branding']->firstWhere('key', 'app_tagline')->value ?? 'Smart Solution' }}</p>
|
||||
|
||||
<div class="w-100 px-3">
|
||||
<div class="btn w-100 rounded-pill mb-2 py-2 mockup-primary-bg text-white shadow-sm" style="background-color: {{ $settings['branding']->firstWhere('key', 'theme_color_primary')->value ?? '#C6F135' }}; border: none;">
|
||||
{{ __('Sign In') }}
|
||||
</div>
|
||||
<div class="btn w-100 rounded-pill py-2 border shadow-sm bg-white small">
|
||||
{{ __('Create Account') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto w-100 pt-4">
|
||||
<div class="d-flex justify-content-between x-small text-muted op-75 px-2">
|
||||
<span>v{{ $settings['app_updates']->firstWhere('key', 'app_version')->value ?? '2.0.0' }}</span>
|
||||
<span>{{ __('Secure Connection') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-home-indicator"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@include('pages.mobile-settings.scripts')
|
||||
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,214 @@
|
||||
@push('scripts')
|
||||
<script src="https://cdn.ckeditor.com/ckeditor5/41.1.0/classic/ckeditor.js" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.js" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/filepond/dist/filepond.js" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
/**
|
||||
* 1. TAB PERSISTENCE
|
||||
*/
|
||||
(function () {
|
||||
const STORAGE_KEY = 'mobileConfigActiveTab';
|
||||
document.querySelectorAll('[data-bs-toggle="tab"]').forEach(function (tabEl) {
|
||||
tabEl.addEventListener('shown.bs.tab', e => {
|
||||
localStorage.setItem(STORAGE_KEY, e.target.getAttribute('data-bs-target'));
|
||||
});
|
||||
});
|
||||
|
||||
const savedTab = localStorage.getItem(STORAGE_KEY);
|
||||
if (savedTab) {
|
||||
const tabEl = document.querySelector(`[data-bs-target="${savedTab}"]`);
|
||||
if (tabEl) {
|
||||
setTimeout(() => {
|
||||
bootstrap.Tab.getOrCreateInstance(tabEl).show();
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
/**
|
||||
* 2. RICH EDITORS & FILEPOND
|
||||
*/
|
||||
$('.rich-editor').each(function () {
|
||||
ClassicEditor.create(this, {
|
||||
toolbar: ['undo', 'redo', '|', 'heading', '|', 'bold', 'italic', 'link', 'bulletedList', 'numberedList']
|
||||
}).catch(e => console.error(e));
|
||||
});
|
||||
|
||||
FilePond.registerPlugin(FilePondPluginImagePreview);
|
||||
const ponds = {};
|
||||
$('.filepond-mobile').each(function () {
|
||||
const key = $(this).data('key');
|
||||
ponds[key] = FilePond.create(this, {
|
||||
labelIdle: 'Drop file or <span class="filepond--label-action">Browse</span>',
|
||||
imagePreviewHeight: 150,
|
||||
stylePanelLayout: 'compact',
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 3. FORM HANDLING (SAVE CONFIG)
|
||||
*/
|
||||
$('#mobileConfigForm').on('ajaxForm:beforeSend', function (e, formData) {
|
||||
Object.keys(ponds).forEach(key => {
|
||||
const files = ponds[key].getFiles();
|
||||
if (files.length > 0) formData.set(key, files[0].file);
|
||||
});
|
||||
});
|
||||
|
||||
$('#mobileConfigForm').on('ajaxForm:success', function (e, response) {
|
||||
StandardSwal.fire({
|
||||
title: "{{ __('Success!') }}",
|
||||
text: response.message,
|
||||
icon: 'success',
|
||||
showConfirmButton: false,
|
||||
timer: 2000,
|
||||
timerProgressBar: true
|
||||
});
|
||||
|
||||
if (response.settings) {
|
||||
Object.keys(response.settings).forEach(key => {
|
||||
const value = response.settings[key];
|
||||
const $previewContainer = $(`#preview-container-${key}`);
|
||||
const $img = $(`#img-preview-${key}`);
|
||||
|
||||
if (value && (key.includes('url') || key.includes('image'))) {
|
||||
const bust = '?v=' + Date.now();
|
||||
const finalUrl = (value.startsWith('http') ? value : ('/' + value.replace(/^\//, ''))) + bust;
|
||||
|
||||
if ($img.length) {
|
||||
$img.attr('src', finalUrl);
|
||||
} else if ($previewContainer.length) {
|
||||
$previewContainer.html(`<img src="${finalUrl}" id="img-preview-${key}" alt="Preview" class="img-fluid rounded" style="max-height: 120px; object-fit: contain;">`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Object.keys(ponds).forEach(key => ponds[key].removeFiles());
|
||||
});
|
||||
|
||||
/**
|
||||
* 4. COLOR PICKER SYNC
|
||||
*/
|
||||
$(document).on('input', '.color-picker-sync', function () {
|
||||
const color = $(this).val();
|
||||
const $container = $(this).closest('.input-group');
|
||||
$container.find('.color-preview-visual').css('background-color', color);
|
||||
$container.find('.color-bar-visual').css('background-color', color);
|
||||
$container.find('.color-input-field').val(color.toUpperCase());
|
||||
});
|
||||
|
||||
$(document).on('input', '.color-input-field', function () {
|
||||
let color = $(this).val().trim();
|
||||
if (color && !color.startsWith('#')) {
|
||||
color = '#' + color;
|
||||
$(this).val(color);
|
||||
}
|
||||
if (/^#[0-9A-F]{3,6}$/i.test(color)) {
|
||||
const $container = $(this).closest('.input-group');
|
||||
$container.find('.color-preview-visual').css('background-color', color);
|
||||
$container.find('.color-bar-visual').css('background-color', color);
|
||||
$container.find('.color-picker-sync').val(color);
|
||||
}
|
||||
});
|
||||
/**
|
||||
* 5. SMART SEARCH ENGINE (TAB FILTER ONLY)
|
||||
*/
|
||||
$('#mobileSearch').on('input', function () {
|
||||
const query = $(this).val().toLowerCase();
|
||||
const $navItems = $('#mobileTabs .nav-item');
|
||||
const $tabPanes = $('.tab-pane');
|
||||
|
||||
if (query === '') {
|
||||
$navItems.show();
|
||||
$tabPanes.find('.mb-3, .mb-4, .row > div, hr, h6').show(); // Reset visibility
|
||||
return;
|
||||
}
|
||||
|
||||
let firstMatchingTabId = null;
|
||||
let currentTabHasMatch = false;
|
||||
const activeTabId = $('.tab-pane.active').attr('id');
|
||||
|
||||
// Reset all content visibility inside panes to "Full"
|
||||
$tabPanes.find('.mb-3, .mb-4, hr, h6, .row > div').show();
|
||||
|
||||
$tabPanes.each(function () {
|
||||
const $pane = $(this);
|
||||
const paneId = $pane.attr('id');
|
||||
let paneHasMatch = false;
|
||||
|
||||
// Check if this tab contains the query anywhere
|
||||
const paneText = $pane.text().toLowerCase();
|
||||
if (paneText.includes(query)) {
|
||||
paneHasMatch = true;
|
||||
}
|
||||
|
||||
// Update Nav Tabs visibility
|
||||
const $navLink = $(`button[data-bs-target="#${paneId}"]`);
|
||||
if (paneHasMatch) {
|
||||
$navLink.parent('.nav-item').show();
|
||||
if (!firstMatchingTabId) firstMatchingTabId = paneId;
|
||||
if (paneId === activeTabId) currentTabHasMatch = true;
|
||||
} else {
|
||||
$navLink.parent('.nav-item').hide();
|
||||
}
|
||||
});
|
||||
|
||||
// Pivot to the first matching tab if current one doesn't match
|
||||
if (!currentTabHasMatch && firstMatchingTabId) {
|
||||
$(`#${firstMatchingTabId}-tab`).tab('show');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 6. LIVE MOCKUP SYNC
|
||||
*/
|
||||
// Sync Text Fields
|
||||
$('input[name="app_name"]').on('input', function () { $('.mockup-app-name').text($(this).val() || 'biiproject'); });
|
||||
$('textarea[name="app_tagline"]').on('input', function () { $('.mockup-app-tagline').text($(this).val() || 'Smart Solution'); });
|
||||
$('input[name="app_version"]').on('input', function () { $('.mockup-version').text('v' + $(this).val()); });
|
||||
|
||||
// Sync Primary Color
|
||||
$(document).on('input', 'input[name="theme_color_primary"], .color-picker-sync', function () {
|
||||
const color = $(this).val();
|
||||
$('.mockup-primary-bg').css('background-color', color);
|
||||
});
|
||||
|
||||
// Sync Logo from FilePond
|
||||
Object.keys(ponds).forEach(key => {
|
||||
if (key === 'logo_url') {
|
||||
ponds[key].on('addfile', (error, file) => {
|
||||
if (!error) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
$('#mockup-logo').attr('src', e.target.result).addClass('animate__animated animate__pulse');
|
||||
setTimeout(() => $('#mockup-logo').removeClass('animate__animated animate__pulse'), 1000);
|
||||
};
|
||||
reader.readAsDataURL(file.file);
|
||||
}
|
||||
});
|
||||
|
||||
ponds[key].on('removefile', () => {
|
||||
// Revert to original or placeholder if needed
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Copy JSON payload to clipboard
|
||||
*/
|
||||
function copyConfigJson() {
|
||||
const jsonText = document.getElementById('json-viewer').textContent;
|
||||
navigator.clipboard.writeText(jsonText).then(() => {
|
||||
StandardSwal.fire({
|
||||
title: "{{ __('Copied!') }}",
|
||||
text: "{{ __('JSON payload has been copied to clipboard.') }}",
|
||||
icon: 'success',
|
||||
timer: 1500,
|
||||
showConfirmButton: false
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
@@ -0,0 +1,146 @@
|
||||
<x-guest-layout>
|
||||
@push('styles')
|
||||
<style>
|
||||
.legal-container {
|
||||
max-width: 1000px;
|
||||
margin: 60px auto;
|
||||
padding: 0 25px;
|
||||
}
|
||||
.legal-header {
|
||||
margin-bottom: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
.legal-card {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 40px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
box-shadow: 0 30px 60px rgba(0,0,0,0.1);
|
||||
padding: 60px;
|
||||
}
|
||||
.last-revised-badge {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 50px;
|
||||
padding: 8px 20px;
|
||||
font-size: 0.85rem;
|
||||
display: inline-block;
|
||||
margin-bottom: 15px;
|
||||
color: #6c757d;
|
||||
}
|
||||
.legal-content {
|
||||
font-family: var(--adminuiux-content-font), "Open Sans", sans-serif;
|
||||
line-height: 1.8;
|
||||
color: #2c3e50;
|
||||
}
|
||||
.legal-content h1 {
|
||||
font-size: clamp(2rem, 5vw, 3.5rem);
|
||||
line-height: 1.1;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.legal-content h2 {
|
||||
font-size: clamp(1.5rem, 4vw, 2rem);
|
||||
}
|
||||
.legal-content h1, .legal-content h2, .legal-content h3 {
|
||||
font-family: var(--adminuiux-title-font), "Outfit", sans-serif;
|
||||
font-weight: 700;
|
||||
margin-top: 45px;
|
||||
margin-bottom: 25px;
|
||||
color: #1a1a1a;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.legal-content p { margin-bottom: 20px; }
|
||||
.legal-content ul, .legal-content ol { margin-bottom: 25px; padding-left: 20px; }
|
||||
.legal-content li { margin-bottom: 10px; }
|
||||
.legal-back-btn {
|
||||
font-family: var(--adminuiux-title-font), "Outfit", sans-serif;
|
||||
font-weight: 600;
|
||||
color: #ffffff; /* High contrast on dark bg */
|
||||
background: rgba(0,0,0,0.15);
|
||||
padding: 10px 25px;
|
||||
border-radius: 50px;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
backdrop-filter: blur(5px);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
.legal-back-btn:hover {
|
||||
transform: translateX(-5px);
|
||||
background: rgba(0,0,0,0.3);
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 20px rgba(0,0,0,0.1);
|
||||
}
|
||||
.legal-footer {
|
||||
margin-top: 40px;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
border-top: 1px solid #f1f3f5;
|
||||
}
|
||||
.toc-floating {
|
||||
position: sticky;
|
||||
top: 100px;
|
||||
height: fit-content;
|
||||
}
|
||||
@media (max-width: 991px) {
|
||||
.legal-card { padding: 40px; }
|
||||
.legal-container { margin: 30px auto; padding: 0 15px; }
|
||||
}
|
||||
@media (max-width: 575px) {
|
||||
.legal-card { padding: 30px 20px; border-radius: 30px; }
|
||||
.legal-header { margin-bottom: 35px; }
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
<div class="legal-container">
|
||||
<a href="{{ route('login') }}" class="legal-back-btn">
|
||||
<i class="bi bi-arrow-left me-2"></i> {{ __('Back to Login') }}
|
||||
</a>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="legal-card overflow-hidden">
|
||||
<header class="legal-header">
|
||||
<div class="last-revised-badge">
|
||||
<i class="bi bi-clock-history me-2"></i>
|
||||
{{ __('Version') }} {{ $version }} •
|
||||
{{ __('Last Updated') }}: {{ $lastUpdated ?? now()->format('Y-m-d') }}
|
||||
</div>
|
||||
<h1 class="display-5 fw-bold text-dark">{{ $title }}</h1>
|
||||
</header>
|
||||
|
||||
<article class="legal-content">
|
||||
@if(!empty($content))
|
||||
{!! strip_tags($content, '<h1><h2><h3><h4><h5><h6><p><br><strong><em><u><s><ul><ol><li><a><blockquote><table><thead><tbody><tr><th><td><hr><span><div><img>') !!}
|
||||
@else
|
||||
<div class="text-center py-5 opacity-50">
|
||||
<i class="bi bi-file-earmark-text fs-1 mb-3 d-block"></i>
|
||||
<p>{{ __('Content for this page has not been published yet.') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($type === 'privacy' && !empty($dpo_email))
|
||||
<div class="alert alert-light border rounded-4 mt-5 p-4">
|
||||
<h6 class="fw-bold"><i class="bi bi-shield-check text-success me-2"></i> {{ __('Data Protection Officer') }}</h6>
|
||||
<p class="small mb-2">{{ __('If you have questions regarding your data privacy, please contact our DPO:') }}</p>
|
||||
<a href="mailto:{{ $dpo_email }}" class="text-primary fw-bold">{{ $dpo_email }}</a>
|
||||
@if(!empty($company_address))
|
||||
<div class="mt-3 x-small text-muted">
|
||||
<i class="bi bi-geo-alt me-1"></i> {{ $company_address }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</article>
|
||||
|
||||
<footer class="legal-footer">
|
||||
<p class="small text-muted mb-0">© {{ date('Y') }} {{ config('app.name') }}. {{ __('All rights reserved.') }}</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-guest-layout>
|
||||
@@ -0,0 +1,150 @@
|
||||
<x-guest-layout :maxWidthClass="'maxwidth-800'">
|
||||
@push('styles')
|
||||
<style>
|
||||
.re-agree-container {
|
||||
width: 100%;
|
||||
margin: 40px auto;
|
||||
}
|
||||
.re-agree-card {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
border-radius: 40px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
box-shadow: 0 40px 80px rgba(0,0,0,0.1);
|
||||
padding: 50px;
|
||||
}
|
||||
.legal-preview-box {
|
||||
height: 250px;
|
||||
overflow-y: auto;
|
||||
background: rgba(248, 249, 250, 0.8);
|
||||
border: 1px solid rgba(0,0,0,0.05);
|
||||
border-radius: 20px;
|
||||
padding: 25px;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
font-family: var(--adminuiux-content-font), sans-serif;
|
||||
}
|
||||
.legal-preview-box h1, .legal-preview-box h2 {
|
||||
font-family: var(--adminuiux-title-font);
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
color: #000;
|
||||
}
|
||||
.step-badge {
|
||||
background: #1e1e1e;
|
||||
color: #fff;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-weight: bold;
|
||||
margin-right: 12px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.maxwidth-800 {
|
||||
max-width: 800px !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
.form-check.custom-check {
|
||||
display: flex;
|
||||
align-items: center; /* Center the checkbox vertically with the text */
|
||||
padding-left: 1.5rem !important;
|
||||
}
|
||||
.form-check.custom-check .form-check-input {
|
||||
margin-top: 0 !important;
|
||||
margin-left: -0.75rem !important;
|
||||
}
|
||||
.btn-pill-primary {
|
||||
white-space: normal !important; /* Allow text to wrap if very long */
|
||||
min-height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
<div class="re-agree-container">
|
||||
<div class="re-agree-card">
|
||||
<div class="text-center mb-5">
|
||||
<div class="avatar avatar-80 rounded-circle bg-primary-subtle text-primary mx-auto mb-4">
|
||||
<i class="bi bi-shield-check fs-1"></i>
|
||||
</div>
|
||||
<h2 class="fw-bold text-dark mb-2" style="font-family: var(--adminuiux-title-font); font-size: clamp(1.5rem, 4vw, 2.25rem);">{{ __('Legal Update Required') }}</h2>
|
||||
<p class="text-muted mx-auto" style="max-width: 500px;">{{ __('We have updated our legal documents to improve our services and compliance with UU PDP regulations. please review and accept the latest terms to continue.') }}</p>
|
||||
</div>
|
||||
|
||||
<form action="{{ route('legal.re-agree.post') }}" method="POST">
|
||||
@csrf
|
||||
|
||||
@if($missingTos)
|
||||
<div class="mb-5">
|
||||
<h5 class="fw-bold mb-4 d-flex align-items-center">
|
||||
<span class="step-badge">1</span> {{ __('Terms of Use Update') }}
|
||||
<span class="badge bg-light text-dark border ms-2 fw-normal fs-xs">v{{ $tosVersion }}</span>
|
||||
</h5>
|
||||
<div class="legal-preview-box">
|
||||
@if(!empty($tosContent))
|
||||
{!! strip_tags($tosContent, '<h1><h2><h3><h4><h5><h6><p><br><strong><em><u><s><ul><ol><li><a><blockquote><table><thead><tbody><tr><th><td><hr><span><div><img>') !!}
|
||||
@else
|
||||
<div class="text-center py-5 opacity-50 italic">
|
||||
<i class="bi bi-file-earmark-text fs-2 mb-2 d-block"></i>
|
||||
{{ __('Terms of Use content is being updated...') }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="form-check custom-check p-3 bg-light rounded-4 border">
|
||||
<input type="checkbox" name="agree_tos" class="form-check-input flex-shrink-0" id="agree_tos" required>
|
||||
<label class="form-check-label fw-bold ms-2 mb-0" for="agree_tos" style="cursor: pointer;">
|
||||
{{ __('I have read and agree to the latest Terms of Use') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($missingPrivacy)
|
||||
<div class="mb-5">
|
||||
<h5 class="fw-bold mb-4 d-flex align-items-center">
|
||||
<span class="step-badge">{{ $missingTos ? '2' : '1' }}</span> {{ __('Privacy Policy Update') }}
|
||||
<span class="badge bg-light text-dark border ms-2 fw-normal fs-xs">v{{ $privacyVersion }}</span>
|
||||
</h5>
|
||||
<div class="legal-preview-box">
|
||||
@if(!empty($privacyContent))
|
||||
{!! strip_tags($privacyContent, '<h1><h2><h3><h4><h5><h6><p><br><strong><em><u><s><ul><ol><li><a><blockquote><table><thead><tbody><tr><th><td><hr><span><div><img>') !!}
|
||||
@else
|
||||
<div class="text-center py-5 opacity-50 italic">
|
||||
<i class="bi bi-shield-lock fs-2 mb-2 d-block"></i>
|
||||
{{ __('Privacy Policy content is being updated...') }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="form-check custom-check p-3 bg-light rounded-4 border">
|
||||
<input type="checkbox" name="agree_privacy" class="form-check-input flex-shrink-0" id="agree_privacy" required>
|
||||
<label class="form-check-label fw-bold ms-2 mb-0" for="agree_privacy" style="cursor: pointer;">
|
||||
{{ __('I have read and agree to the latest Privacy Policy (UU PDP)') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-5">
|
||||
<button type="submit" class="btn btn-pill-primary w-100 py-3 fs-5 shadow-sm">
|
||||
{{ __('Update Agreements & Proceed') }} <i class="bi bi-arrow-right ms-2"></i>
|
||||
</button>
|
||||
<a href="{{ route('logout') }}" onclick="event.preventDefault(); document.getElementById('logout-form').submit();" class="btn btn-link text-muted small w-100 mt-4 text-decoration-none">
|
||||
{{ __('Logout and review later') }}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form id="logout-form" action="{{ route('logout') }}" method="POST" class="d-none">
|
||||
@csrf
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</x-guest-layout>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,851 @@
|
||||
<x-app-layout>
|
||||
@push('styles')
|
||||
<style>
|
||||
.status-widget-dark {
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(135deg, #1e1e1e 0%, #2d2d2d 100%);
|
||||
padding: 1.5rem;
|
||||
color: white;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.btn-premium-action {
|
||||
border-radius: 100px;
|
||||
padding: 10px 24px;
|
||||
font-weight: 700;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.table-premium thead th {
|
||||
background: #f8fafc;
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
padding: 1.25rem 1rem;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.table-premium tbody td {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.custom-switch-premium .form-check-input {
|
||||
width: 3rem;
|
||||
height: 1.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@keyframes pulse-danger {
|
||||
0% { box-shadow: 0 0 0 0 rgba(220, 53, 69, 0.4); }
|
||||
70% { box-shadow: 0 0 0 10px rgba(220, 53, 69, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(220, 53, 69, 0); }
|
||||
}
|
||||
|
||||
.pulse-danger {
|
||||
animation: pulse-danger 2s infinite;
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
<div class="container-fluid pb-5">
|
||||
{{-- Premium Page Header --}}
|
||||
<div class="d-flex align-items-center justify-content-between mb-4 animate__animated animate__fadeIn">
|
||||
<div>
|
||||
<h4 class="fw-bold mb-1" style="font-family: 'Outfit', sans-serif; letter-spacing: -0.5px;">
|
||||
{{ __('Backup & Storage') }}
|
||||
</h4>
|
||||
<p class="text-muted small mb-0">
|
||||
{{ __('Securely manage your system archives and cloud synchronization.') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button"
|
||||
class="btn btn-outline-dark btn-premium-action shadow-sm d-flex align-items-center gap-2"
|
||||
data-bs-toggle="modal" data-bs-target="#backupDocsModal">
|
||||
<i class="bi bi-book"></i> {{ __('Documentation') }}
|
||||
</button>
|
||||
@can('manage backup and storage')
|
||||
<button type="button"
|
||||
class="btn btn-primary btn-premium-action shadow-sm d-flex align-items-center gap-2"
|
||||
id="btn-create-backup">
|
||||
<i class="bi bi-plus-lg"></i> {{ __('Instant Backup') }}
|
||||
</button>
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row gx-4">
|
||||
{{-- Left Side: Control Center --}}
|
||||
<div class="col-lg-4 animate__animated animate__fadeIn">
|
||||
|
||||
{{-- Storage Health Widget (Dark Premium) --}}
|
||||
<div id="storage-health-widget" class="status-widget-dark" style="display:none;">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="bg-white bg-opacity-10 rounded-circle p-2">
|
||||
<i class="bi bi-cloud-check text-info"></i>
|
||||
</div>
|
||||
<span class="small fw-bold opacity-75 text-uppercase tracking-wider"
|
||||
id="health-label">Storage</span>
|
||||
</div>
|
||||
<span class="badge rounded-pill bg-info text-dark fw-bold px-3 py-2" id="health-status"
|
||||
style="font-size: 10px;">Checking...</span>
|
||||
</div>
|
||||
|
||||
<h2 class="mb-1 fw-bold" id="health-used" style="font-family: 'Outfit', sans-serif;">0</h2>
|
||||
<p class="small opacity-50 mb-4" id="health-total-label">of <span id="health-total">0</span> used
|
||||
</p>
|
||||
|
||||
<div class="progress bg-white bg-opacity-10 mb-2" style="height: 8px; border-radius: 10px;"
|
||||
id="health-progress-wrapper">
|
||||
<div class="progress-bar bg-info" id="health-progress-bar" role="progressbar"
|
||||
style="width: 0%; border-radius: 10px;"></div>
|
||||
</div>
|
||||
|
||||
{{-- Requirements Alert --}}
|
||||
<div id="requirements-alert"
|
||||
class="mt-3 p-2 rounded-3 bg-danger bg-opacity-25 border border-danger border-opacity-25 small"
|
||||
style="display:none;">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i> <span id="req-msg">Check reqs</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="backupConfigForm" action="{{ route('system-config.update') }}" method="POST"
|
||||
autocomplete="off" class="ajax-form" data-reset="false">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
{{-- Automation Card --}}
|
||||
<div class="card adminuiux-card mb-4">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-bold mb-4 d-flex align-items-center gap-2">
|
||||
<i class="bi bi-clock-history text-primary"></i>
|
||||
{{ __('Automation Settings') }}
|
||||
</h6>
|
||||
|
||||
<div
|
||||
class="form-check form-switch custom-switch-premium mb-4 p-0 d-flex align-items-center justify-content-between">
|
||||
<label class="form-check-label fw-semibold text-dark"
|
||||
for="backup_db_enabled">{{ __('AUTO BACKUP') }}</label>
|
||||
<input class="form-check-input ms-0" type="checkbox" role="switch"
|
||||
id="backup_db_enabled" name="backup_db_enabled" value="1"
|
||||
@checked(old('backup_db_enabled', $settings['backup_db_enabled'] ?? true))>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">{{ __('Backup Frequency') }}</label>
|
||||
<select class="form-select" name="backup_db_frequency">
|
||||
<option value="hourly" @selected(($settings['backup_db_frequency'] ?? '') == 'hourly')>{{ __('Hourly') }}</option>
|
||||
<option value="daily" @selected(($settings['backup_db_frequency'] ?? 'daily') == 'daily')>{{ __('Daily') }}</option>
|
||||
<option value="weekly" @selected(($settings['backup_db_frequency'] ?? '') == 'weekly')>{{ __('Weekly') }}</option>
|
||||
<option value="monthly" @selected(($settings['backup_db_frequency'] ?? '') == 'monthly')>{{ __('Monthly') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label fw-semibold">{{ __('Execution Time') }}</label>
|
||||
<input type="time" class="form-control" name="backup_db_time"
|
||||
value="{{ $settings['backup_db_time'] ?? '02:00' }}">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label fw-semibold">{{ __('Retention (Days)') }}</label>
|
||||
<input type="number" class="form-control text-center" name="backup_db_retention"
|
||||
value="{{ $settings['backup_db_retention'] ?? 7 }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Storage Destination Card --}}
|
||||
<div class="card adminuiux-card mb-4">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-bold mb-4 d-flex align-items-center gap-2">
|
||||
<i class="bi bi-hdd-network text-primary"></i>
|
||||
{{ __('Storage Target') }}
|
||||
</h6>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold mb-2">{{ __('Primary Driver') }}</label>
|
||||
<select class="form-select" name="backup_db_driver" id="backup_db_driver">
|
||||
<option value="local" @selected(($settings['backup_db_driver'] ?? 'local') == 'local')>Local Filesystem</option>
|
||||
<option value="gdrive" @selected(($settings['backup_db_driver'] ?? '') == 'gdrive')>
|
||||
Google Drive</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{-- Cloud Fields Wrapper --}}
|
||||
<div id="cloud_config_wrapper"
|
||||
style="display: {{ ($settings['backup_db_driver'] ?? '') == 'gdrive' ? 'block' : 'none' }}">
|
||||
<div class="p-3 rounded-4 bg-light border border-opacity-10 mb-4">
|
||||
{{-- Google Drive --}}
|
||||
<div id="gdrive_fields"
|
||||
style="display: {{ ($settings['backup_db_driver'] ?? '') == 'gdrive' ? 'block' : 'none' }}">
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<i class="bi bi-google text-primary"></i>
|
||||
<h6 class="fw-semibold mb-0 small">{{ __('Google Cloud Auth') }}</h6>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<input type="text"
|
||||
class="form-control form-control-sm rounded-pill px-3 bg-white"
|
||||
name="gdrive_client_id"
|
||||
value="{{ $settings['gdrive_client_id'] ?? '' }}"
|
||||
placeholder="Client ID">
|
||||
</div>
|
||||
<div class="input-group input-group-sm mb-2">
|
||||
<input type="password"
|
||||
class="form-control border-end-0 rounded-start-pill px-3 bg-white"
|
||||
name="gdrive_client_secret"
|
||||
value="{{ $settings['gdrive_client_secret'] ?? '' }}"
|
||||
placeholder="Client Secret">
|
||||
<button
|
||||
class="btn btn-outline-secondary bg-white border-start-0 password-toggle rounded-end-pill pe-3"
|
||||
type="button" style="border-color: #dee2e6;">
|
||||
<i class="bi bi-eye text-secondary"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="input-group input-group-sm mb-2">
|
||||
<input type="password"
|
||||
class="form-control border-0 rounded-start-pill px-3 bg-white"
|
||||
name="gdrive_refresh_token"
|
||||
value="{{ $settings['gdrive_refresh_token'] ?? '' }}"
|
||||
placeholder="Refresh Token">
|
||||
<button class="btn btn-outline-secondary bg-white border-0 password-toggle"
|
||||
type="button">
|
||||
<i class="bi bi-eye text-secondary"></i>
|
||||
</button>
|
||||
<a href="{{ route('backup-restore.google-auth') }}"
|
||||
class="btn btn-dark rounded-end-pill px-3" id="btn-gdrive-auth">
|
||||
<i class="bi bi-key"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text"
|
||||
class="form-control border-0 rounded-start-pill px-3 bg-white"
|
||||
name="gdrive_folder"
|
||||
value="{{ $settings['gdrive_folder'] ?? 'LaravelBackups' }}"
|
||||
placeholder="Folder Name">
|
||||
<button type="button"
|
||||
class="btn btn-primary rounded-end-pill px-3 btn-test-connection"
|
||||
data-driver="gdrive">
|
||||
<i class="bi bi-broadcast"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@can('manage backup and storage')
|
||||
<div class="d-flex justify-content-end">
|
||||
<button type="submit" class="btn btn-primary rounded-pill px-4 shadow-sm">
|
||||
{{ __('Save Configuration') }}
|
||||
</button>
|
||||
</div>
|
||||
@endcan
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- Right Side: History (Col-8) --}}
|
||||
<div class="col-lg-8 animate__animated animate__fadeIn">
|
||||
<div class="card adminuiux-card h-100">
|
||||
<div class="card-body p-0">
|
||||
<div class="p-4 d-flex justify-content-between align-items-center border-bottom bg-white sticky-top"
|
||||
style="z-index: 10; border-top-left-radius: 20px; border-top-right-radius: 20px;">
|
||||
<h6 class="fw-bold mb-0 d-flex align-items-center gap-2">
|
||||
<i class="bi bi-collection-play text-primary"></i>
|
||||
{{ __('Archive Inventory') }}
|
||||
</h6>
|
||||
<button type="button" class="btn btn-sm btn-light rounded-pill px-3"
|
||||
id="btn-refresh-backups">
|
||||
<i class="bi bi-arrow-clockwise me-1"></i> {{ __('Refresh List') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-premium align-middle mb-0" id="backup-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="ps-4">{{ __('Archive Name') }}</th>
|
||||
<th>{{ __('Status') }}</th>
|
||||
<th>{{ __('Timestamp') }}</th>
|
||||
<th class="text-end pe-4">{{ __('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr id="backup-loading">
|
||||
<td colspan="4" class="text-center py-5">
|
||||
<div class="spinner-grow spinner-grow-sm text-primary me-2"></div>
|
||||
<span
|
||||
class="text-muted small fw-bold">{{ __('Scanning Archives...') }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ====================================================================
|
||||
DOCUMENTATION (MODAL)
|
||||
==================================================================== --}}
|
||||
<div class="modal fade" id="backupDocsModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-fullscreen-lg-down modal-dialog-scrollable modal-dialog-centered">
|
||||
<div class="modal-content rounded-4 border-0 shadow-lg overflow-hidden">
|
||||
<div class="modal-header p-0 border-0" style="background:#0f172a;">
|
||||
<div class="d-flex w-100 align-items-center justify-content-between p-4 p-lg-5 text-white">
|
||||
<div>
|
||||
<div class="extra-small opacity-50 fw-bold mb-2" style="letter-spacing:1.5px;">REFERENCE GUIDE</div>
|
||||
<h3 class="fw-black mb-2" style="letter-spacing:-1px;">{{ __('Backup & Restore Documentation') }}</h3>
|
||||
<p class="mb-0 opacity-75" style="font-size:.9rem;line-height:1.7;max-width:720px;">
|
||||
Everything you need to know about creating, scheduling, restoring, and securing your system archives.
|
||||
<span class="text-warning fw-bold">Read this first</span> — restoring overwrites your live data.
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex align-items-start gap-3">
|
||||
<i class="bi bi-shield-fill-check display-3 opacity-25 d-none d-md-inline"></i>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body p-0">
|
||||
<div class="p-4 p-lg-5">
|
||||
|
||||
{{-- DANGER NOTICE --}}
|
||||
<div class="d-flex align-items-start gap-3 p-3 rounded-4 mb-5" style="background:#fef2f2;border:2px solid #fecaca;">
|
||||
<i class="bi bi-exclamation-triangle-fill text-danger fs-2"></i>
|
||||
<div>
|
||||
<div class="fw-black text-danger mb-1">CRITICAL — Restore is destructive</div>
|
||||
<div class="small text-dark" style="line-height:1.7;">
|
||||
Restoring a backup <span class="fw-bold">overwrites every table</span> in your current database with the data from the archive.
|
||||
All records created since that backup was taken will be <span class="fw-bold text-danger">permanently lost</span>.
|
||||
Always create a fresh <span class="fw-bold">Instant Backup</span> immediately before restoring, so you have a return path.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- WORKFLOW STEPS --}}
|
||||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||||
<i class="bi bi-diagram-3 text-primary me-1"></i>How a backup is created
|
||||
</h6>
|
||||
<div class="row g-3 mb-5">
|
||||
@php
|
||||
$steps = [
|
||||
['icon'=>'bi-database', 'color'=>'#3b82f6', 'title'=>'1. Snapshot', 'desc'=>'A full SQL dump of every table is generated via <code>mysqldump</code> / <code>pg_dump</code> at the moment you click Instant Backup or when the scheduler fires.'],
|
||||
['icon'=>'bi-file-zip', 'color'=>'#8b5cf6', 'title'=>'2. Compress', 'desc'=>'The SQL dump is gzip-compressed into a single <code>.sql.gz</code> archive named with timestamp + driver tag.'],
|
||||
['icon'=>'bi-hdd-network', 'color'=>'#22c55e', 'title'=>'3. Transfer', 'desc'=>'Archive is written to the configured driver. <span class="fw-bold">Local</span> saves to <code>storage/app/backups/</code>. <span class="fw-bold">Google Drive</span> uploads via OAuth refresh token.'],
|
||||
['icon'=>'bi-clipboard-check','color'=>'#f59e0b', 'title'=>'4. Verify', 'desc'=>'File size and SHA-256 checksum are recorded. The Archive Inventory table reflects the new row immediately.'],
|
||||
['icon'=>'bi-trash3', 'color'=>'#ef4444', 'title'=>'5. Prune', 'desc'=>'Archives older than the configured retention (days) are auto-deleted on the next scheduled run. Local + cloud are pruned independently.'],
|
||||
];
|
||||
@endphp
|
||||
@foreach($steps as $s)
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="p-3 rounded-4 h-100" style="background:#f8fafc;border:1px solid #e2e8f0;">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<div class="rounded-3 d-flex align-items-center justify-content-center flex-shrink-0"
|
||||
style="background:{{ $s['color'] }}1a;color:{{ $s['color'] }};width:36px;height:36px;">
|
||||
<i class="{{ $s['icon'] }}"></i>
|
||||
</div>
|
||||
<div class="fw-bold text-dark">{{ $s['title'] }}</div>
|
||||
</div>
|
||||
<div class="small text-muted" style="line-height:1.6;">{!! $s['desc'] !!}</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- STORAGE DRIVERS --}}
|
||||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||||
<i class="bi bi-hdd-stack text-info me-1"></i>Storage drivers comparison
|
||||
</h6>
|
||||
<div class="table-responsive mb-5">
|
||||
<table class="table table-hover align-middle mb-0" style="background:#f8fafc;border-radius:12px;overflow:hidden;">
|
||||
<thead style="background:#e2e8f0;">
|
||||
<tr>
|
||||
<th class="ps-3">DRIVER</th>
|
||||
<th>WHERE</th>
|
||||
<th>SETUP</th>
|
||||
<th>BEST FOR</th>
|
||||
<th>RISK IF SERVER DIES</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="ps-3 fw-bold text-dark">
|
||||
<i class="bi bi-hdd text-secondary me-1"></i>Local Filesystem
|
||||
</td>
|
||||
<td class="small text-muted"><code>storage/app/backups/</code></td>
|
||||
<td class="small"><span class="badge bg-success-subtle text-success rounded-pill">Zero config</span></td>
|
||||
<td class="small text-muted">Quick recovery, small datasets, dev environments</td>
|
||||
<td class="small text-danger fw-bold">Total loss — backups on same disk</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="ps-3 fw-bold text-dark">
|
||||
<i class="bi bi-google text-primary me-1"></i>Google Drive
|
||||
</td>
|
||||
<td class="small text-muted">OAuth folder (default: <code>LaravelBackups</code>)</td>
|
||||
<td class="small"><span class="badge bg-warning-subtle text-warning rounded-pill">Client ID + Secret + Refresh Token</span></td>
|
||||
<td class="small text-muted">Off-site redundancy, production, disaster recovery</td>
|
||||
<td class="small text-success fw-bold">Safe — off-server copy</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{-- GOOGLE DRIVE SETUP --}}
|
||||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||||
<i class="bi bi-google text-primary me-1"></i>Google Drive setup · Step-by-step
|
||||
</h6>
|
||||
<div class="row g-3 mb-5">
|
||||
<div class="col-md-6">
|
||||
<div class="p-3 rounded-4 h-100" style="background:#eff6ff;border:1px solid #bfdbfe;">
|
||||
<div class="fw-bold text-primary mb-2">1. Create OAuth client</div>
|
||||
<ol class="small text-muted mb-0" style="line-height:1.8;padding-left:1.2rem;">
|
||||
<li>Go to <a href="https://console.cloud.google.com/" target="_blank">Google Cloud Console</a></li>
|
||||
<li>Create a project → enable <span class="fw-bold">Google Drive API</span></li>
|
||||
<li>OAuth consent screen → External → fill app name</li>
|
||||
<li>Credentials → Create → OAuth client ID → Web application</li>
|
||||
<li>Add redirect URI: <code>{{ url('/backup-restore/google-callback') }}</code></li>
|
||||
<li>Copy <span class="fw-bold">Client ID</span> & <span class="fw-bold">Client Secret</span></li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="p-3 rounded-4 h-100" style="background:#eff6ff;border:1px solid #bfdbfe;">
|
||||
<div class="fw-bold text-primary mb-2">2. Link this app</div>
|
||||
<ol class="small text-muted mb-0" style="line-height:1.8;padding-left:1.2rem;">
|
||||
<li>Paste Client ID + Secret in the form on the left</li>
|
||||
<li>Click the <i class="bi bi-key"></i> key button next to Refresh Token</li>
|
||||
<li>Authorize on the Google consent screen</li>
|
||||
<li>Refresh token is auto-filled on return</li>
|
||||
<li>Click <i class="bi bi-broadcast"></i> to test connection</li>
|
||||
<li>Save configuration</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- SCHEDULING --}}
|
||||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||||
<i class="bi bi-clock-history text-warning me-1"></i>Automation & scheduling
|
||||
</h6>
|
||||
<div class="row g-3 mb-5">
|
||||
<div class="col-md-3">
|
||||
<div class="p-3 rounded-4 h-100" style="background:#fff;border:1px solid #e2e8f0;">
|
||||
<div class="fw-bold text-dark mb-1">Frequency</div>
|
||||
<div class="small text-muted">Hourly / Daily / Weekly / Monthly. The Laravel scheduler must be running (<code>cron</code> calling <code>php artisan schedule:run</code> every minute).</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="p-3 rounded-4 h-100" style="background:#fff;border:1px solid #e2e8f0;">
|
||||
<div class="fw-bold text-dark mb-1">Execution Time</div>
|
||||
<div class="small text-muted">24-hour format (default <code>02:00</code>). Pick low-traffic hours — backup briefly locks tables.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="p-3 rounded-4 h-100" style="background:#fff;border:1px solid #e2e8f0;">
|
||||
<div class="fw-bold text-dark mb-1">Retention</div>
|
||||
<div class="small text-muted">Days to keep. Older archives auto-pruned. Default <code>7</code>. Set to <code>0</code> to keep forever (not recommended).</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="p-3 rounded-4 h-100" style="background:#fff;border:1px solid #e2e8f0;">
|
||||
<div class="fw-bold text-dark mb-1">Auto Backup toggle</div>
|
||||
<div class="small text-muted">Master switch. When OFF, only manual <span class="fw-bold">Instant Backup</span> runs.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- RESTORE PROCEDURE --}}
|
||||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||||
<i class="bi bi-arrow-counterclockwise text-danger me-1"></i>How to restore safely
|
||||
</h6>
|
||||
<div class="p-4 rounded-4 mb-5" style="background:#0f172a;color:#e2e8f0;">
|
||||
<pre class="mb-0" style="font-family:'Fira Code',monospace;font-size:.78rem;line-height:2;white-space:pre-wrap;color:#cbd5e1;">
|
||||
<span style="color:#22c55e;">RECOMMENDED RESTORE SEQUENCE:</span>
|
||||
|
||||
<span style="color:#fbbf24;">[1]</span> Click <span style="color:#22c55e;">Instant Backup</span> NOW — creates a return path
|
||||
<span style="color:#fbbf24;">[2]</span> Enable Maintenance Mode (so users can't write data mid-restore)
|
||||
<span style="color:#fbbf24;">[3]</span> Open Archive Inventory → identify the archive you want
|
||||
<span style="color:#fbbf24;">[4]</span> Click the row action menu → <span style="color:#ef4444;">Restore</span>
|
||||
<span style="color:#fbbf24;">[5]</span> Confirm the dialog (read it carefully — points of no return)
|
||||
<span style="color:#fbbf24;">[6]</span> Wait for the success toast (do NOT navigate away)
|
||||
<span style="color:#fbbf24;">[7]</span> Verify data integrity on a couple of critical tables
|
||||
<span style="color:#fbbf24;">[8]</span> Disable Maintenance Mode
|
||||
|
||||
<span style="color:#94a3b8;"># If something looks wrong after restore:
|
||||
# → Use the safety backup from step [1] to roll back</span></pre>
|
||||
</div>
|
||||
|
||||
{{-- TROUBLESHOOTING --}}
|
||||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||||
<i class="bi bi-wrench-adjustable-circle text-secondary me-1"></i>Troubleshooting
|
||||
</h6>
|
||||
<div class="table-responsive mb-5">
|
||||
<table class="table table-hover align-middle mb-0" style="background:#f8fafc;border-radius:12px;overflow:hidden;">
|
||||
<thead style="background:#e2e8f0;">
|
||||
<tr>
|
||||
<th class="ps-3">SYMPTOM</th>
|
||||
<th>LIKELY CAUSE</th>
|
||||
<th>FIX</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="ps-3 fw-bold text-dark">Backup button does nothing</td>
|
||||
<td class="small text-muted"><code>storage/app/backups/</code> not writable</td>
|
||||
<td class="small text-muted"><code>chmod -R 775 storage/</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="ps-3 fw-bold text-dark">"Requirements not met" alert</td>
|
||||
<td class="small text-muted"><code>mysqldump</code> / <code>pg_dump</code> not in PATH</td>
|
||||
<td class="small text-muted">Install DB client tools on the server</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="ps-3 fw-bold text-dark">Scheduled backup never runs</td>
|
||||
<td class="small text-muted">Laravel scheduler cron not registered</td>
|
||||
<td class="small text-muted">Add <code>* * * * * php artisan schedule:run</code> to crontab</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="ps-3 fw-bold text-dark">Google Drive upload fails</td>
|
||||
<td class="small text-muted">Refresh token expired or revoked</td>
|
||||
<td class="small text-muted">Re-authorize via the <i class="bi bi-key"></i> key button</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="ps-3 fw-bold text-dark">Disk fills up rapidly</td>
|
||||
<td class="small text-muted">Retention too high or hourly schedule on large DB</td>
|
||||
<td class="small text-muted">Lower retention, switch to daily, prune old archives manually</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="ps-3 fw-bold text-dark">Restore hangs / times out</td>
|
||||
<td class="small text-muted">Large archive, low PHP <code>max_execution_time</code></td>
|
||||
<td class="small text-muted">Run restore via CLI: <code>php artisan backup:restore {file}</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{-- BEST PRACTICES --}}
|
||||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||||
<i class="bi bi-stars text-success me-1"></i>Best practices
|
||||
</h6>
|
||||
<div class="row g-2 mb-5">
|
||||
@php
|
||||
$tips = [
|
||||
['icon'=>'bi-cloud-arrow-up', 'title'=>'Use 3-2-1 rule', 'desc'=>'<span class="fw-bold">3</span> copies of data, on <span class="fw-bold">2</span> different storage types, with <span class="fw-bold">1</span> off-site (Google Drive).'],
|
||||
['icon'=>'bi-check2-circle', 'title'=>'Test your restores', 'desc'=>'A backup you have never restored is just hope, not insurance. Test in staging every quarter.'],
|
||||
['icon'=>'bi-clock', 'title'=>'Off-hours schedule', 'desc'=>'Pick a time when traffic is lowest (typically 02:00–04:00 local) to minimise lock contention.'],
|
||||
['icon'=>'bi-shield-lock', 'title'=>'Restrict access', 'desc'=>'Only grant <code>manage backup and storage</code> to senior admins. Restore is one click away from catastrophe.'],
|
||||
['icon'=>'bi-bar-chart-line', 'title'=>'Monitor storage health', 'desc'=>'Watch the dark widget in the sidebar — when it turns red, prune or expand storage.'],
|
||||
['icon'=>'bi-clipboard-pulse', 'title'=>'Document recovery plan', 'desc'=>'Write down (outside this system) the exact steps to restore + who has the Google account credentials.'],
|
||||
];
|
||||
@endphp
|
||||
@foreach($tips as $tip)
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex align-items-start gap-2 p-3 rounded-3" style="background:#f0fdf4;border:1px solid #bbf7d0;">
|
||||
<i class="{{ $tip['icon'] }} text-success fs-5 mt-1"></i>
|
||||
<div>
|
||||
<div class="fw-bold text-dark mb-1" style="font-size:.85rem;">{{ $tip['title'] }}</div>
|
||||
<div class="small text-muted" style="line-height:1.6;">{!! $tip['desc'] !!}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- PERMISSIONS --}}
|
||||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||||
<i class="bi bi-person-badge text-secondary me-1"></i>Permissions
|
||||
</h6>
|
||||
<div class="row g-2 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="p-3 rounded-4" style="background:#f0fdf4;border:1px solid #bbf7d0;">
|
||||
<div class="fw-bold text-success mb-1"><code class="text-success">view backup and storage</code></div>
|
||||
<div class="small text-muted">Read-only access. Can see archive list and storage health but cannot create / restore / delete.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="p-3 rounded-4" style="background:#fef2f2;border:1px solid #fecaca;">
|
||||
<div class="fw-bold text-danger mb-1"><code class="text-danger">manage backup and storage</code></div>
|
||||
<div class="small text-muted">Full control: create, restore, delete archives + change driver config + edit retention. <span class="fw-bold">Restrict tightly.</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- FOOTER NOTE --}}
|
||||
<div class="d-flex align-items-center gap-3 p-3 rounded-4" style="background:#fef3c7;border:1px solid #fde68a;">
|
||||
<i class="bi bi-info-circle-fill text-warning fs-3"></i>
|
||||
<div>
|
||||
<div class="fw-bold text-dark mb-1" style="font-size:.85rem;">Where archives are stored on this server</div>
|
||||
<div class="small text-muted">
|
||||
Local backups live at <code>storage/app/backups/</code>. They are <span class="fw-bold">excluded from your code repository</span> by <code>.gitignore</code>. To migrate archives to a new server, copy this directory (and the Google Drive token from Global Settings → Backup).
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div> {{-- closes p-4 p-lg-5 --}}
|
||||
</div> {{-- closes modal-body --}}
|
||||
</div> {{-- closes modal-content --}}
|
||||
</div> {{-- closes modal-dialog --}}
|
||||
</div> {{-- closes modal --}}
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
function renderBackups(backups) {
|
||||
const $tbody = $('#backup-list-table tbody');
|
||||
$tbody.empty();
|
||||
|
||||
if (!backups || backups.length === 0) {
|
||||
$tbody.append('<tr><td colspan="4" class="text-center py-5 text-muted small fw-bold">{{ __("No system archives found.") }}</td></tr>');
|
||||
return;
|
||||
}
|
||||
|
||||
backups.forEach(function (item) {
|
||||
let storageIcon = 'bi-hdd text-secondary';
|
||||
let storageClass = 'bg-secondary-subtle text-secondary';
|
||||
|
||||
if (item.storage === 'gdrive') {
|
||||
storageIcon = 'bi-google text-primary';
|
||||
storageClass = 'bg-primary-subtle text-primary';
|
||||
} else if (item.storage === 's3') {
|
||||
storageIcon = 'bi-clouds text-warning';
|
||||
storageClass = 'bg-warning-subtle text-warning-emphasis';
|
||||
}
|
||||
|
||||
$tbody.append(`
|
||||
<tr>
|
||||
<td class="ps-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="avatar avatar-35 bg-light rounded-circle d-flex align-items-center justify-content-center me-3 border">
|
||||
<i class="bi ${storageIcon}"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-bold text-dark" style="font-size: 0.85rem; max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${item.name}">${item.name.split('/').pop()}</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="text-muted" style="font-size: 10px;">${item.size}</span>
|
||||
<span class="badge ${storageClass} border-0 rounded-pill text-uppercase" style="font-size: 8px; padding: 2px 6px;">${item.storage}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle rounded-pill px-3 py-1" style="font-size: 10px;">
|
||||
${item.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-muted small">${item.date}</span>
|
||||
</td>
|
||||
<td class="text-end pe-4">
|
||||
<div class="d-flex justify-content-end gap-1">
|
||||
<a href="{{ route('backup-restore.download') }}?disk=${item.storage}&path=${item.name}" class="btn btn-icon btn-sm btn-light rounded-circle" title="Download">
|
||||
<i class="bi bi-download"></i>
|
||||
</a>
|
||||
@can('manage backup and storage')
|
||||
<button type="button" class="btn btn-icon btn-sm btn-outline-primary rounded-circle btn-restore-backup" data-disk="${item.storage}" data-path="${item.name}" title="Restore">
|
||||
<i class="bi bi-arrow-counterclockwise"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-icon btn-sm btn-outline-danger rounded-circle btn-delete-backup" data-disk="${item.storage}" data-path="${item.name}" title="Delete">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
@endcan
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
function loadBackups(driver = null) {
|
||||
const $tbody = $('#backup-list-table tbody');
|
||||
$tbody.html('<tr id="backup-loading"><td colspan="4" class="text-center py-5"><div class="spinner-border spinner-border-sm text-primary me-2"></div><span class="small fw-bold">{{ __("Refreshing inventory...") }}</span></td></tr>');
|
||||
|
||||
const url = new URL("{{ route('backup-restore.index') }}", window.location.origin);
|
||||
if (driver) url.searchParams.append('driver', driver);
|
||||
|
||||
$.get(url.toString(), function (response) {
|
||||
if (response.success) {
|
||||
renderBackups(response.backups);
|
||||
if (response.stats) {
|
||||
const stats = response.stats;
|
||||
$('#storage-health-widget').fadeIn();
|
||||
$('#health-label').text(stats.label + " Usage");
|
||||
$('#health-used').text(stats.used);
|
||||
|
||||
$('#health-total').text(stats.total);
|
||||
$('#health-progress-bar').css('width', stats.percentage + '%');
|
||||
$('#health-progress-bar').removeClass('bg-success bg-warning bg-danger').addClass('bg-' + stats.health);
|
||||
$('#health-status').text(stats.percentage + '% Cap').removeClass('text-success text-warning text-danger bg-success-subtle bg-warning-subtle bg-danger-subtle').addClass('text-' + stats.health + ' bg-' + stats.health + '-subtle');
|
||||
|
||||
if (stats.health === 'danger') {
|
||||
$('#storage-health-widget').addClass('pulse-danger border border-danger border-opacity-50');
|
||||
} else {
|
||||
$('#storage-health-widget').removeClass('pulse-danger border border-danger border-opacity-50');
|
||||
}
|
||||
|
||||
$('#health-total-label').show();
|
||||
$('#health-progress-wrapper').show();
|
||||
|
||||
if (stats.requirements && !stats.requirements.status) {
|
||||
$('#requirements-alert').fadeIn();
|
||||
$('#req-msg').text(stats.requirements.message);
|
||||
} else {
|
||||
$('#requirements-alert').hide();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$tbody.html(`<tr><td colspan="4" class="text-center py-5 text-danger small fw-bold">${response.message || '{{ __("Failed to load inventory.") }}'}</td></tr>`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadBackups($('#backup_db_driver').val());
|
||||
|
||||
$('#btn-refresh-backups').on('click', function () {
|
||||
const $icon = $(this).find('i');
|
||||
$icon.addClass('animate-spin');
|
||||
loadBackups($('#backup_db_driver').val());
|
||||
setTimeout(() => $icon.removeClass('animate-spin'), 1000);
|
||||
});
|
||||
|
||||
$('#backup_db_driver').on('change', function () {
|
||||
const driver = $(this).val();
|
||||
|
||||
// UI Toggle
|
||||
if (driver === 'gdrive') {
|
||||
$('#cloud_config_wrapper').slideDown();
|
||||
$('#gdrive_fields').fadeIn();
|
||||
} else {
|
||||
$('#cloud_config_wrapper').slideUp();
|
||||
}
|
||||
|
||||
// Dynamic Usage Update
|
||||
loadBackups(driver);
|
||||
});
|
||||
|
||||
$('#btn-create-backup').on('click', function () {
|
||||
const $btn = $(this);
|
||||
const original = $btn.html();
|
||||
$btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-1"></span> Preparing...');
|
||||
|
||||
$.post("{{ route('backup-restore.create') }}", { _token: "{{ csrf_token() }}" }, function (response) {
|
||||
if (response.success) {
|
||||
renderBackups(response.backups || []);
|
||||
StandardSwal.fire({ title: "Backup Started", text: response.message, icon: 'success', timer: 2000, showConfirmButton: false });
|
||||
} else {
|
||||
StandardSwal.fire({ title: 'Error', text: response.message, icon: 'error' });
|
||||
}
|
||||
}).always(() => $btn.prop('disabled', false).html(original));
|
||||
});
|
||||
|
||||
$(document).on('click', '.btn-delete-backup', function () {
|
||||
const $btn = $(this);
|
||||
StandardSwal.fire({
|
||||
title: "Delete Archive?",
|
||||
text: "This file will be permanently removed from storage.",
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "Delete Now"
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
$.post("{{ route('backup-restore.delete') }}", {
|
||||
_token: "{{ csrf_token() }}",
|
||||
disk: $btn.data('disk'),
|
||||
path: $btn.data('path')
|
||||
}, function (response) {
|
||||
if (response.success) {
|
||||
renderBackups(response.backups);
|
||||
StandardSwal.fire({ title: "Deleted", text: response.message, icon: 'success', timer: 1500, showConfirmButton: false });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$(document).on('click', '.btn-restore-backup', function () {
|
||||
const $btn = $(this);
|
||||
StandardSwal.fire({
|
||||
title: "{{ __('Restore System?') }}",
|
||||
html: `
|
||||
<div class="text-start small">
|
||||
<p class="mb-3">{{ __('This will replace your current database with the selected archive. The system will enter Maintenance Mode during the process.') }}</p>
|
||||
<div class="alert alert-danger border-0 shadow-sm d-flex align-items-center gap-3 mb-0">
|
||||
<i class="bi bi-exclamation-octagon display-6"></i>
|
||||
<div>
|
||||
<strong class="d-block">{{ __('CRITICAL ACTION') }}</strong>
|
||||
{{ __('All current unsaved data will be lost permanently.') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "{{ __('I Understand, Restore Now') }}",
|
||||
confirmButtonColor: '#dc3545',
|
||||
cancelButtonText: "{{ __('Cancel') }}"
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
StandardSwal.fire({
|
||||
title: "{{ __('System Restoration In Progress') }}",
|
||||
html: "{{ __('Please wait, do not close this window. The system is currently in Maintenance Mode.') }}",
|
||||
allowOutsideClick: false,
|
||||
didOpen: () => StandardSwal.showLoading()
|
||||
});
|
||||
|
||||
$.post("{{ route('backup-restore.restore') }}", {
|
||||
_token: "{{ csrf_token() }}",
|
||||
disk: $btn.data('disk'),
|
||||
path: $btn.data('path')
|
||||
}, function (response) {
|
||||
StandardSwal.fire({ title: "{{ __('Restored Successfully') }}", text: response.message, icon: 'success', timer: 3000 });
|
||||
setTimeout(() => location.reload(), 3000);
|
||||
}).fail(function(xhr) {
|
||||
const msg = xhr.responseJSON?.message || '{{ __("Restoration failed. System is back online.") }}';
|
||||
StandardSwal.fire({ title: "{{ __('Restoration Failed') }}", text: msg, icon: 'error' });
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$('.btn-test-connection').on('click', function () {
|
||||
const $btn = $(this);
|
||||
const original = $btn.html();
|
||||
$btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm"></span>');
|
||||
$.post("{{ route('backup-restore.test-connection') }}", {
|
||||
_token: "{{ csrf_token() }}",
|
||||
driver: $btn.data('driver')
|
||||
}, function (response) {
|
||||
StandardSwal.fire({ title: response.success ? "Success" : "Failed", text: response.message, icon: response.success ? 'success' : 'error' });
|
||||
}).always(() => $btn.prop('disabled', false).html(original));
|
||||
});
|
||||
|
||||
$('#btn-gdrive-auth').on('click', function (e) {
|
||||
const clientId = $('input[name="gdrive_client_id"]').val();
|
||||
const clientSecret = $('input[name="gdrive_client_secret"]').val();
|
||||
if (!clientId || !clientSecret) {
|
||||
e.preventDefault();
|
||||
StandardSwal.fire({ title: "Auth Failed", text: "Save Client ID & Secret first.", icon: 'warning' });
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,542 @@
|
||||
<x-app-layout>
|
||||
@push('styles')
|
||||
<link href="https://unpkg.com/filepond/dist/filepond.css" rel="stylesheet" />
|
||||
<link href="https://unpkg.com/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.css"
|
||||
rel="stylesheet" />
|
||||
<style>
|
||||
/* Shared Styles from Backup & Storage */
|
||||
.status-widget-dark {
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(135deg, #1e1e1e 0%, #2d2d2d 100%);
|
||||
padding: 1.5rem;
|
||||
color: white;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Attached Design Preview Styles */
|
||||
.mockup-container {
|
||||
position: sticky;
|
||||
top: 100px;
|
||||
}
|
||||
|
||||
.browser-mockup-frame {
|
||||
background: #e2e8f0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 30px 60px -12px rgba(0, 0, 0, 0.15);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.browser-top-bar {
|
||||
background: #f8fafc;
|
||||
padding: 10px 18px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.browser-dots {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.browser-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.browser-url-field {
|
||||
background: #ffffff;
|
||||
border-radius: 6px;
|
||||
padding: 3px 15px;
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
flex-grow: 1;
|
||||
max-width: 450px;
|
||||
text-align: center;
|
||||
border: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
/* VISITOR VIEW BACKGROUND (STRICT AS PER IMAGE) */
|
||||
.viewport-preview {
|
||||
height: 700px;
|
||||
background: #f4f4f4;
|
||||
/* Base light gray */
|
||||
background-image: repeating-linear-gradient(45deg, #f0f2f5, #f0f2f5 10px, #ffffff 10px, #ffffff 20px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* WHITE CARD (STRICT AS PER IMAGE) */
|
||||
.visitor-card-premium {
|
||||
background: #ffffff;
|
||||
border-radius: 42px;
|
||||
padding: 4rem 3rem;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
box-shadow: 0 15px 35px -5px rgba(0, 0, 0, 0.04);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.under-maintenance-pill {
|
||||
background: #ffffff;
|
||||
border: 1px solid #eef0f3;
|
||||
border-radius: 100px;
|
||||
padding: 8px 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 2.5rem;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: #1a1c1e;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.pulse-dot-red {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: #ef4444;
|
||||
border-radius: 50%;
|
||||
animation: pulse-red 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-red {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
70% {
|
||||
box-shadow: 0 0 0 8px rgba(239, 68, 68, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* COUNTDOWN SQUARES (STRICT AS PER IMAGE) */
|
||||
.countdown-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.countdown-square {
|
||||
background: #1a1c1e;
|
||||
border-radius: 20px;
|
||||
aspect-ratio: 1/1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
color: white;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.countdown-square:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.countdown-number {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.countdown-label {
|
||||
font-size: 7px;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.5;
|
||||
letter-spacing: 1px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.custom-switch-premium .form-check-input {
|
||||
width: 3rem;
|
||||
height: 1.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filepond--root {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.filepond--panel-root {
|
||||
background-color: #f8fafc;
|
||||
border: 2px dashed #e2e8f0;
|
||||
border-radius: 16px;
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
<div class="container-fluid pb-5">
|
||||
{{-- Page Header --}}
|
||||
<div class="d-flex align-items-center justify-content-between mb-4 animate__animated animate__fadeIn">
|
||||
<div>
|
||||
<h4 class="fw-bold mb-1" style="font-family: 'Outfit', sans-serif; letter-spacing: -0.5px;">
|
||||
{{ __('Maintenance Mode') }}
|
||||
</h4>
|
||||
<p class="text-muted small mb-0">
|
||||
{{ __('Take your application offline for scheduled updates and optimization.') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="maintenanceConfigForm" action="{{ route('system-config.update') }}" method="POST"
|
||||
enctype="multipart/form-data" autocomplete="off" class="ajax-form" data-reset="false">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<div class="row gx-4">
|
||||
{{-- Left: Controls (Col-4) --}}
|
||||
<div class="col-lg-4 animate__animated animate__fadeIn">
|
||||
|
||||
{{-- Status Card (Backup Styling) --}}
|
||||
<div class="status-widget-dark">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="bg-white bg-opacity-10 rounded-circle p-2">
|
||||
<i class="bi bi-broadcast text-info"></i>
|
||||
</div>
|
||||
<span class="small fw-bold opacity-75 text-uppercase tracking-wider">Storage
|
||||
Health</span>
|
||||
</div>
|
||||
@if($is_down)
|
||||
<span class="badge rounded-pill bg-danger text-white fw-bold px-3 py-2"
|
||||
style="font-size: 10px;">Maintenance</span>
|
||||
@else
|
||||
<span class="badge rounded-pill bg-info text-dark fw-bold px-3 py-2"
|
||||
style="font-size: 10px;">Operational</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h2 class="mb-1 fw-bold" style="font-family: 'Outfit', sans-serif;">
|
||||
@if($is_down)
|
||||
System Offline
|
||||
@else
|
||||
Systems Ready
|
||||
@endif
|
||||
</h2>
|
||||
<p class="small opacity-50 mb-0">Public access control center.</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="form-check form-switch custom-switch-premium p-0 d-flex align-items-center justify-content-between bg-white bg-opacity-5 p-3 rounded-4 border border-white border-opacity-10">
|
||||
<label class="form-check-label fw-semibold small text-white"
|
||||
for="maintenance_mode_enabled">{{ __('ENABLE MODE') }}</label>
|
||||
<input class="form-check-input ms-0" type="checkbox" role="switch"
|
||||
id="maintenance_mode_enabled" name="maintenance_mode_enabled" value="1"
|
||||
@checked(old('maintenance_mode_enabled', $settings['maintenance_mode_enabled'] ?? false))>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Config Sections --}}
|
||||
<div class="card adminuiux-card mb-4">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-bold mb-4 d-flex align-items-center gap-2">
|
||||
<i class="bi bi-palette-fill text-primary"></i>
|
||||
{{ __('Visual & Branding') }}
|
||||
</h6>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">{{ __('Main Headline') }}</label>
|
||||
<input type="text" class="form-control" name="maintenance_mode_title"
|
||||
value="{{ $settings['maintenance_mode_title'] ?? 'biiproject.com' }}">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">{{ __('Description') }}</label>
|
||||
<textarea class="form-control" name="maintenance_mode_message"
|
||||
rows="3">{{ $settings['maintenance_mode_message'] ?? 'We are currently performing scheduled maintenance. We will be back shortly!' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-0">
|
||||
<label class="form-label fw-semibold mb-2">{{ __('Illustration / Logo') }}</label>
|
||||
<input type="file" id="maintenance_mode_image" name="maintenance_mode_image"
|
||||
accept="image/png,image/jpeg,image/svg+xml">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card adminuiux-card mb-4">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-bold mb-4 d-flex align-items-center gap-2">
|
||||
<i class="bi bi-clock-fill text-primary"></i>
|
||||
{{ __('Time & Access') }}
|
||||
</h6>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">{{ __('Secret Bypass Key') }}</label>
|
||||
<input type="text" class="form-control" name="maintenance_mode_secret"
|
||||
placeholder="e.g. admin-only"
|
||||
value="{{ $settings['maintenance_mode_secret'] ?? '' }}">
|
||||
</div>
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-12 mb-2">
|
||||
<label class="form-label fw-semibold">{{ __('End Time') }}</label>
|
||||
<input type="datetime-local" class="form-control" name="maintenance_mode_end_at"
|
||||
value="{{ !empty($settings['maintenance_mode_end_at']) ? date('Y-m-d\TH:i', strtotime($settings['maintenance_mode_end_at'])) : '' }}">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">{{ __('Retry Interval (Seconds)') }}</label>
|
||||
<input type="number" class="form-control" name="maintenance_mode_retry"
|
||||
value="{{ $settings['maintenance_mode_retry'] ?? 3600 }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card adminuiux-card mb-4 border-warning border-opacity-25 bg-warning bg-opacity-5">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-bold mb-3 d-flex align-items-center gap-2 text-warning-emphasis">
|
||||
<i class="bi bi-megaphone-fill"></i>
|
||||
{{ __('Broadcast Warning') }}
|
||||
</h6>
|
||||
<p class="extra-small text-muted mb-4">
|
||||
{{ __('Alert all active users before shutting down the system. They will receive a real-time notification.') }}
|
||||
</p>
|
||||
|
||||
<div class="input-group">
|
||||
<select class="form-select" id="broadcast_minutes">
|
||||
<option value="1">1 {{ __('Minute') }}</option>
|
||||
<option value="5" selected>5 {{ __('Minutes') }}</option>
|
||||
<option value="10">10 {{ __('Minutes') }}</option>
|
||||
<option value="30">30 {{ __('Minutes') }}</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-warning fw-bold px-3" id="btn-broadcast-warning">
|
||||
<i class="bi bi-send-fill me-1"></i> {{ __('Send Alert') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@can('manage maintenance mode')
|
||||
<div class="d-flex justify-content-end">
|
||||
<button type="submit" class="btn btn-primary rounded-pill px-4 shadow-sm">
|
||||
{{ __('Apply Configuration') }}
|
||||
</button>
|
||||
</div>
|
||||
@endcan
|
||||
</div>
|
||||
|
||||
{{-- Right: Live Preview (Col-8) --}}
|
||||
<div class="col-lg-8 animate__animated animate__fadeIn">
|
||||
<div class="mockup-container">
|
||||
<div class="browser-mockup-frame">
|
||||
{{-- Toolbar --}}
|
||||
<div class="browser-top-bar">
|
||||
<div class="browser-dots">
|
||||
<div class="browser-dot" style="background: #ff5f56;"></div>
|
||||
<div class="browser-dot" style="background: #ffbd2e;"></div>
|
||||
<div class="browser-dot" style="background: #27c93f;"></div>
|
||||
</div>
|
||||
<div class="browser-url-field">
|
||||
{{ url('/') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Viewport (Design from Image) --}}
|
||||
<div class="viewport-preview">
|
||||
<div class="visitor-card-premium">
|
||||
{{-- Maintenance Pill --}}
|
||||
<div class="under-maintenance-pill">
|
||||
<span class="pulse-dot-red"></span>
|
||||
{{ __('UNDER MAINTENANCE') }}
|
||||
</div>
|
||||
|
||||
{{-- Logo Container --}}
|
||||
<div class="mb-4 mx-auto" id="preview-mnt-image-container"
|
||||
style="max-width: 140px; min-height: 80px; display: flex; align-items: center; justify-content: center;">
|
||||
@php
|
||||
$mnt_img = $settings['maintenance_mode_image'] ?? '';
|
||||
$display_img = null;
|
||||
if (!empty($mnt_img)) {
|
||||
$display_img = str_starts_with($mnt_img, 'assets/') ? asset($mnt_img) : asset('storage/' . $mnt_img);
|
||||
} elseif (!empty($settings['app_logo'])) {
|
||||
$display_img = str_starts_with($settings['app_logo'], 'assets/') ? asset($settings['app_logo']) : asset('storage/' . $settings['app_logo']);
|
||||
}
|
||||
@endphp
|
||||
|
||||
@if($display_img)
|
||||
<img src="{{ $display_img }}" class="img-fluid" id="mnt-preview-img" alt="Logo">
|
||||
@else
|
||||
<i class="bi bi-gear-wide-connected fs-1 text-secondary opacity-25"
|
||||
style="font-size: 3.5rem !important;"></i>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Content --}}
|
||||
<h2 class="fw-black mb-3 text-dark" id="preview-mnt-title"
|
||||
style="font-family: 'Outfit', sans-serif;">
|
||||
{{ $settings['maintenance_mode_title'] ?? 'biiproject.com' }}
|
||||
</h2>
|
||||
<p class="text-secondary small mb-5" id="preview-mnt-message"
|
||||
style="line-height: 1.6; opacity: 0.8; font-weight: 500;">
|
||||
{{ $settings['maintenance_mode_message'] ?? 'We are currently performing scheduled maintenance. We will be back shortly!' }}
|
||||
</p>
|
||||
|
||||
{{-- Countdown Grid (Image Style) --}}
|
||||
<div class="countdown-grid" id="preview-mnt-countdown">
|
||||
@foreach(['Days', 'Hours', 'Mins', 'Secs'] as $label)
|
||||
<div class="countdown-square shadow-lg">
|
||||
<div class="countdown-number" id="cd-{{ strtolower($label) }}">00</div>
|
||||
<div class="countdown-label">{{ $label }}</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script src="https://unpkg.com/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.js" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/filepond/dist/filepond.js" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
// Initialize FilePond
|
||||
FilePond.registerPlugin(FilePondPluginImagePreview);
|
||||
const pond = FilePond.create(document.querySelector('#maintenance_mode_image'), {
|
||||
name: 'maintenance_mode_image',
|
||||
labelIdle: '<span class="text-muted small">Drop Logo or <span class="text-primary fw-bold">Browse</span></span>',
|
||||
imagePreviewHeight: 120,
|
||||
stylePanelLayout: 'compact',
|
||||
});
|
||||
|
||||
// Form Handling
|
||||
$('#maintenanceConfigForm').on('ajaxForm:beforeSend', function (e, formData) {
|
||||
const files = pond.getFiles();
|
||||
if (files.length > 0) {
|
||||
formData.set('maintenance_mode_image', files[0].file);
|
||||
}
|
||||
});
|
||||
|
||||
$('#maintenanceConfigForm').on('ajaxForm:success', function (e, response) {
|
||||
StandardSwal.fire({ title: 'Success!', text: response.message, icon: 'success', timer: 1500, showConfirmButton: false });
|
||||
|
||||
if (response.settings && response.settings.maintenance_mode_image) {
|
||||
const path = response.settings.maintenance_mode_image;
|
||||
const newUrl = path.startsWith('assets/') ? `/${path}` : `/storage/${path}`;
|
||||
originalImgSrc = `${newUrl}?v=${new Date().getTime()}`;
|
||||
}
|
||||
|
||||
if (response.hasOwnProperty('is_down')) {
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
}
|
||||
pond.removeFiles();
|
||||
});
|
||||
|
||||
// Live Preview Sync
|
||||
$('input[name="maintenance_mode_title"]').on('input', function () {
|
||||
$('#preview-mnt-title').text($(this).val() || 'biiproject.com');
|
||||
});
|
||||
$('textarea[name="maintenance_mode_message"]').on('input', function () {
|
||||
$('#preview-mnt-message').text($(this).val() || 'Maintenance in progress...');
|
||||
});
|
||||
|
||||
// Countdown Logic
|
||||
let countdownInterval;
|
||||
function updateCountdown() {
|
||||
const val = $('input[name="maintenance_mode_end_at"]').val();
|
||||
if (!val) {
|
||||
$('#preview-mnt-countdown').addClass('opacity-25');
|
||||
return;
|
||||
}
|
||||
$('#preview-mnt-countdown').removeClass('opacity-25');
|
||||
|
||||
if (countdownInterval) clearInterval(countdownInterval);
|
||||
const target = new Date(val).getTime();
|
||||
|
||||
countdownInterval = setInterval(() => {
|
||||
const now = new Date().getTime();
|
||||
const diff = target - now;
|
||||
|
||||
if (diff < 0) {
|
||||
clearInterval(countdownInterval);
|
||||
$('#cd-days, #cd-hours, #cd-mins, #cd-secs').text('00');
|
||||
return;
|
||||
}
|
||||
|
||||
const d = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
const h = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
const m = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||
const s = Math.floor((diff % (1000 * 60)) / 1000);
|
||||
|
||||
$('#cd-days').text(d.toString().padStart(2, '0'));
|
||||
$('#cd-hours').text(h.toString().padStart(2, '0'));
|
||||
$('#cd-mins').text(m.toString().padStart(2, '0'));
|
||||
$('#cd-secs').text(s.toString().padStart(2, '0'));
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
$('input[name="maintenance_mode_end_at"]').on('change', updateCountdown);
|
||||
updateCountdown();
|
||||
|
||||
// Image Swap
|
||||
let originalImgSrc = $('#mnt-preview-img').attr('src');
|
||||
pond.on('addfile', (error, file) => {
|
||||
if (!error) {
|
||||
const url = URL.createObjectURL(file.file);
|
||||
$('#preview-mnt-image-container').html(`<img src="${url}" class="img-fluid" id="mnt-preview-img" alt="Logo">`);
|
||||
}
|
||||
});
|
||||
pond.on('removefile', () => {
|
||||
if (originalImgSrc) {
|
||||
$('#preview-mnt-image-container').html(`<img src="${originalImgSrc}" class="img-fluid" id="mnt-preview-img" alt="Logo">`);
|
||||
} else {
|
||||
$('#preview-mnt-image-container').html(`<i class="bi bi-gear-wide-connected fs-1 text-secondary opacity-25" style="font-size: 3.5rem !important;"></i>`);
|
||||
}
|
||||
});
|
||||
|
||||
// Broadcast Warning
|
||||
$('#btn-broadcast-warning').on('click', function () {
|
||||
const minutes = $('#broadcast_minutes').val();
|
||||
const $btn = $(this);
|
||||
|
||||
StandardSwal.fire({
|
||||
title: "{{ __('Send Broadcast Alert?') }}",
|
||||
text: "{{ __('All active users will receive a warning about the upcoming maintenance.') }}",
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "{{ __('Send Alert') }}"
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
$btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-1"></span>');
|
||||
|
||||
$.post("{{ route('maintenance-mode.broadcast') }}", {
|
||||
_token: "{{ csrf_token() }}",
|
||||
minutes: minutes
|
||||
}, function (response) {
|
||||
StandardSwal.fire({ title: "{{ __('Success') }}", text: response.message, icon: 'success' });
|
||||
}).always(() => $btn.prop('disabled', false).html('<i class="bi bi-send-fill me-1"></i> {{ __("Send Alert") }}'));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
.x-small {
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,707 @@
|
||||
<x-app-layout>
|
||||
@push('styles')
|
||||
<style>
|
||||
.ck-editor__editable {
|
||||
min-height: 200px;
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
<div class="container-fluid pb-4">
|
||||
<div class="row align-items-center mb-4">
|
||||
<div class="col">
|
||||
<h4 class="fw-bold mb-1">{{ __('Notification Center') }}</h4>
|
||||
<p class="text-secondary small mb-0">{{ __('Manage system notifications and activity feed.') }}</p>
|
||||
</div>
|
||||
@hasanyrole('Developer|Administrator')
|
||||
<div class="col-auto d-flex flex-wrap gap-2">
|
||||
<button type="button"
|
||||
class="btn btn-outline-dark btn-sm rounded-pill px-3 d-inline-flex align-items-center gap-1"
|
||||
data-bs-toggle="modal" data-bs-target="#notifDocsModal">
|
||||
<i class="bi bi-book"></i>{{ __('Documentation') }}
|
||||
</button>
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-outline-dark btn-sm rounded-pill px-3" id="page-mark-all-read">
|
||||
<i class="bi bi-check-all me-1"></i>{{ __('Mark all read') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm rounded-pill px-3 ms-2" id="page-clear-read">
|
||||
<i class="bi bi-trash3 me-1"></i>{{ __('Clear read') }}
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-dark btn-sm rounded-pill px-3" data-bs-toggle="modal" data-bs-target="#sendNotificationModal">
|
||||
<i class="bi bi-plus-lg me-1"></i>{{ __('Send') }}
|
||||
</button>
|
||||
</div>
|
||||
@else
|
||||
<div class="col-auto d-flex gap-2">
|
||||
<button type="button"
|
||||
class="btn btn-outline-dark btn-sm rounded-pill px-3 d-inline-flex align-items-center gap-1"
|
||||
data-bs-toggle="modal" data-bs-target="#notifDocsModal">
|
||||
<i class="bi bi-book"></i>{{ __('Documentation') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-dark btn-sm rounded-pill px-3" id="page-mark-all-read">
|
||||
<i class="bi bi-check-all me-1"></i>{{ __('Mark all read') }}
|
||||
</button>
|
||||
</div>
|
||||
@endhasanyrole
|
||||
</div>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-xl-10 col-lg-11">
|
||||
{{-- Standardized Notification Feed --}}
|
||||
<div id="notification-feed">
|
||||
<div class="card adminuiux-card border-0 shadow-sm rounded-3">
|
||||
<div class="card-body text-center py-5">
|
||||
<div class="spinner-border text-primary spinner-border-sm" role="status"></div>
|
||||
<p class="text-secondary small mt-2">{{ __('Loading...') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Pagination --}}
|
||||
<div id="feed-pagination" class="d-flex justify-content-center mt-5"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ====================================================================
|
||||
DOCUMENTATION (MODAL)
|
||||
==================================================================== --}}
|
||||
<div class="modal fade" id="notifDocsModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-fullscreen-lg-down modal-dialog-scrollable modal-dialog-centered">
|
||||
<div class="modal-content rounded-4 border-0 shadow-lg overflow-hidden">
|
||||
<div class="modal-header p-0 border-0" style="background:#0f172a;">
|
||||
<div class="d-flex w-100 align-items-center justify-content-between p-4 p-lg-5 text-white">
|
||||
<div>
|
||||
<div class="extra-small opacity-50 fw-bold mb-2" style="letter-spacing:1.5px;">REFERENCE GUIDE</div>
|
||||
<h3 class="fw-black mb-2" style="letter-spacing:-1px;">{{ __('Notification Center Guide') }}</h3>
|
||||
<p class="mb-0 opacity-75" style="font-size:.9rem;line-height:1.7;max-width:720px;">
|
||||
{{ __('How notifications flow through this system — from sending to bell icon to acknowledged. Who sees what, when, and why.') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex align-items-start gap-3">
|
||||
<i class="bi bi-bell-fill display-3 opacity-25 d-none d-md-inline"></i>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body p-0">
|
||||
<div class="p-4 p-lg-5">
|
||||
|
||||
{{-- LIFECYCLE DIAGRAM --}}
|
||||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||||
<i class="bi bi-arrow-right-circle text-primary me-1"></i>{{ __('Notification lifecycle') }}
|
||||
</h6>
|
||||
<div class="p-4 rounded-4 mb-5" style="background:#f8fafc;border:1px solid #e2e8f0;">
|
||||
<div class="row text-center align-items-center g-3">
|
||||
<div class="col-md-2">
|
||||
<div class="p-3 rounded-4 bg-white shadow-sm h-100">
|
||||
<i class="bi bi-send-fill text-primary fs-3"></i>
|
||||
<div class="fw-bold small mt-2">{{ __('Sent') }}</div>
|
||||
<div class="extra-small text-muted">Admin clicks Send / system triggers</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-1 d-none d-md-block"><i class="bi bi-arrow-right fs-4 text-muted"></i></div>
|
||||
<div class="col-md-2">
|
||||
<div class="p-3 rounded-4 bg-white shadow-sm h-100">
|
||||
<i class="bi bi-database-fill text-info fs-3"></i>
|
||||
<div class="fw-bold small mt-2">{{ __('Persisted') }}</div>
|
||||
<div class="extra-small text-muted">Row written to <code>notifications</code> table</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-1 d-none d-md-block"><i class="bi bi-arrow-right fs-4 text-muted"></i></div>
|
||||
<div class="col-md-2">
|
||||
<div class="p-3 rounded-4 bg-white shadow-sm h-100">
|
||||
<i class="bi bi-broadcast text-warning fs-3"></i>
|
||||
<div class="fw-bold small mt-2">{{ __('Broadcast') }}</div>
|
||||
<div class="extra-small text-muted">Real-time via Reverb WebSocket</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-1 d-none d-md-block"><i class="bi bi-arrow-right fs-4 text-muted"></i></div>
|
||||
<div class="col-md-2">
|
||||
<div class="p-3 rounded-4 bg-white shadow-sm h-100">
|
||||
<i class="bi bi-bell-fill text-success fs-3"></i>
|
||||
<div class="fw-bold small mt-2">{{ __('Delivered') }}</div>
|
||||
<div class="extra-small text-muted">Bell icon + feed updates</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="small text-muted text-center mb-0 mt-4" style="line-height:1.7;">
|
||||
{{ __('Notifications persist in the database even after they are read — only "Clear read" deletes them. The feed paginates 15 per page.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{-- THREE TYPES --}}
|
||||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||||
<i class="bi bi-tags-fill text-info me-1"></i>{{ __('Notification types · pick the right one') }}
|
||||
</h6>
|
||||
<div class="row g-3 mb-5">
|
||||
<div class="col-md-4">
|
||||
<div class="p-4 rounded-4 h-100" style="background:#eff6ff;border:1px solid #bfdbfe;">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<i class="bi bi-info-circle-fill text-primary fs-3"></i>
|
||||
<div class="fw-bold text-primary fs-6">{{ __('INFORMATION') }}</div>
|
||||
</div>
|
||||
<div class="small text-muted mb-3" style="line-height:1.7;">
|
||||
{{ __('Neutral updates. No action required from the recipient.') }}
|
||||
</div>
|
||||
<div class="extra-small text-muted">
|
||||
<span class="fw-bold text-dark">{{ __('Use for:') }}</span>
|
||||
<ul class="mb-0 ps-3 mt-1" style="line-height:1.8;">
|
||||
<li>{{ __('New feature announcement') }}</li>
|
||||
<li>{{ __('Scheduled maintenance reminder') }}</li>
|
||||
<li>{{ __('Newsletter / company update') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="p-4 rounded-4 h-100" style="background:#fffbeb;border:1px solid #fde68a;">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<i class="bi bi-exclamation-triangle-fill text-warning fs-3"></i>
|
||||
<div class="fw-bold fs-6" style="color:#a16207;">{{ __('WARNING') }}</div>
|
||||
</div>
|
||||
<div class="small text-muted mb-3" style="line-height:1.7;">
|
||||
{{ __('Recipient should pay attention. Soft urgency.') }}
|
||||
</div>
|
||||
<div class="extra-small text-muted">
|
||||
<span class="fw-bold text-dark">{{ __('Use for:') }}</span>
|
||||
<ul class="mb-0 ps-3 mt-1" style="line-height:1.8;">
|
||||
<li>{{ __('Quota nearing limit') }}</li>
|
||||
<li>{{ __('Pending approval required') }}</li>
|
||||
<li>{{ __('Deprecated feature retiring soon') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="p-4 rounded-4 h-100" style="background:#fef2f2;border:1px solid #fecaca;">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<i class="bi bi-megaphone-fill text-danger fs-3"></i>
|
||||
<div class="fw-bold text-danger fs-6">{{ __('SYSTEM ALERT') }}</div>
|
||||
</div>
|
||||
<div class="small text-muted mb-3" style="line-height:1.7;">
|
||||
{{ __('Immediate attention. Affects the platform itself.') }}
|
||||
</div>
|
||||
<div class="extra-small text-muted">
|
||||
<span class="fw-bold text-dark">{{ __('Use for:') }}</span>
|
||||
<ul class="mb-0 ps-3 mt-1" style="line-height:1.8;">
|
||||
<li>{{ __('Emergency maintenance starting NOW') }}</li>
|
||||
<li>{{ __('Security incident notification') }}</li>
|
||||
<li>{{ __('Service outage / degradation') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- RECIPIENT TARGETING --}}
|
||||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||||
<i class="bi bi-people-fill text-success me-1"></i>{{ __('Recipient targeting') }}
|
||||
</h6>
|
||||
<div class="row g-3 mb-5">
|
||||
<div class="col-md-6">
|
||||
<div class="p-4 rounded-4 h-100" style="background:#f0fdf4;border:1px solid #bbf7d0;">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<i class="bi bi-globe text-success fs-4"></i>
|
||||
<div class="fw-bold text-success">{{ __('All Users (Public)') }}</div>
|
||||
</div>
|
||||
<div class="small text-muted" style="line-height:1.7;">
|
||||
{{ __('Broadcast to every active account in the system, regardless of role. Use for company-wide announcements only — over-using this trains users to ignore the bell icon.') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="p-4 rounded-4 h-100" style="background:#eff6ff;border:1px solid #bfdbfe;">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<i class="bi bi-funnel-fill text-primary fs-4"></i>
|
||||
<div class="fw-bold text-primary">{{ __('By Role') }}</div>
|
||||
</div>
|
||||
<div class="small text-muted" style="line-height:1.7;">
|
||||
{{ __('Pick a specific role (e.g. Developer, Manager, Staff). Only users holding that role will see the notification. Precise — recommended default.') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- USER ACTIONS --}}
|
||||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||||
<i class="bi bi-hand-index-fill text-warning me-1"></i>{{ __('Reader actions') }}
|
||||
</h6>
|
||||
<div class="row g-3 mb-5">
|
||||
@php
|
||||
$actions = [
|
||||
['icon'=>'bi-check2','color'=>'#22c55e','title'=>'Mark as read','desc'=>'Click a single notification card. Removes its unread highlight; stays in the feed.','boldWord'=>null],
|
||||
['icon'=>'bi-check-all','color'=>'#3b82f6','title'=>'Mark all read','desc'=>'Bulk action. Marks every unread notification at once. Useful after vacation / morning catch-up.','boldWord'=>null],
|
||||
['icon'=>'bi-trash3-fill','color'=>'#ef4444','title'=>'Clear read','desc1'=>'Permanently deletes ','boldWord'=>'already-read','desc2'=>' notifications. Unread ones are preserved. Cannot be undone.'],
|
||||
['icon'=>'bi-x-lg','color'=>'#94a3b8','title'=>'Delete one','desc'=>'Dismiss a single notification (read or unread). Removes it from your feed only — other recipients still see theirs.','boldWord'=>null],
|
||||
];
|
||||
@endphp
|
||||
@foreach($actions as $a)
|
||||
<div class="col-md-6 col-lg-3">
|
||||
<div class="p-3 rounded-4 h-100" style="background:#fff;border:1px solid #e2e8f0;">
|
||||
<div class="rounded-3 d-inline-flex align-items-center justify-content-center mb-2"
|
||||
style="background:{{ $a['color'] }}1a;color:{{ $a['color'] }};width:40px;height:40px;">
|
||||
<i class="{{ $a['icon'] }} fs-5"></i>
|
||||
</div>
|
||||
<div class="fw-bold text-dark mb-1" style="font-size:.88rem;">{{ $a['title'] }}</div>
|
||||
<div class="small text-muted" style="line-height:1.6;">
|
||||
@if(isset($a['boldWord']))
|
||||
{{ $a['desc1'] }}<span class="fw-bold">{{ $a['boldWord'] }}</span>{{ $a['desc2'] }}
|
||||
@else
|
||||
{{ $a['desc'] }}
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- WRITING GUIDE --}}
|
||||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||||
<i class="bi bi-pencil-square text-info me-1"></i>{{ __('Writing a good notification') }}
|
||||
</h6>
|
||||
<div class="row g-3 mb-5">
|
||||
<div class="col-md-6">
|
||||
<div class="p-3 rounded-4 h-100" style="background:#f0fdf4;border:1px solid #bbf7d0;">
|
||||
<div class="fw-bold text-success mb-2"><i class="bi bi-check-circle me-1"></i>{{ __('DO') }}</div>
|
||||
<ul class="small text-muted mb-0" style="line-height:1.8;padding-left:1.2rem;">
|
||||
<li>{{ __('Lead with the most important info — recipients scan, not read') }}</li>
|
||||
<li>{{ __('Keep title under 60 characters (it gets truncated on mobile)') }}</li>
|
||||
<li>{{ __('Use plain language, avoid internal jargon') }}</li>
|
||||
<li>{{ __('Include an action verb if user needs to do something ("Click here", "Approve", "Review")') }}</li>
|
||||
<li>{{ __('Match the type to the urgency') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="p-3 rounded-4 h-100" style="background:#fef2f2;border:1px solid #fecaca;">
|
||||
<div class="fw-bold text-danger mb-2"><i class="bi bi-x-circle me-1"></i>{{ __("DON'T") }}</div>
|
||||
<ul class="small text-muted mb-0" style="line-height:1.8;padding-left:1.2rem;">
|
||||
<li>{{ __('Use SYSTEM ALERT for routine info — it loses meaning') }}</li>
|
||||
<li>{{ __('Send the same message twice — there is no undo') }}</li>
|
||||
<li>{{ __('Include sensitive data (passwords, tokens) — notifications are stored plaintext') }}</li>
|
||||
<li>{{ __('Mass-send "All Users" for things only a few care about') }}</li>
|
||||
<li>{{ __('Forget to test on the recipient role first (small group) before broadcasting') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ARCHITECTURE --}}
|
||||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||||
<i class="bi bi-gear-fill text-secondary me-1"></i>{{ __('How it works under the hood') }}
|
||||
</h6>
|
||||
<div class="p-4 rounded-4 mb-5" style="background:#0f172a;color:#e2e8f0;">
|
||||
<pre class="mb-0" style="font-family:'Fira Code',monospace;font-size:.78rem;line-height:2;white-space:pre-wrap;color:#cbd5e1;">
|
||||
<span style="color:#22c55e;">DELIVERY PIPELINE:</span>
|
||||
|
||||
<span style="color:#fbbf24;">[1]</span> Admin submits form → <span style="color:#22c55e;">NotificationCenterController::store()</span>
|
||||
<span style="color:#fbbf24;">[2]</span> Recipients resolved:
|
||||
<span style="color:#94a3b8;">• "all" → User::where('active', true)->get()
|
||||
• role-X → User::role('X')->get()</span>
|
||||
<span style="color:#fbbf24;">[3]</span> Laravel <span style="color:#22c55e;">SystemManagementNotification</span> dispatched per user
|
||||
<span style="color:#94a3b8;">Channels: ['database', 'broadcast']</span>
|
||||
<span style="color:#fbbf24;">[4]</span> <span style="color:#22c55e;">database</span> channel → row in <code style="color:#f59e0b;">notifications</code> table
|
||||
<span style="color:#fbbf24;">[5]</span> <span style="color:#22c55e;">broadcast</span> channel → Reverb WebSocket → bell icon updates live
|
||||
<span style="color:#fbbf24;">[6]</span> Recipient pulls feed → <span style="color:#22c55e;">GET /notification-center/api/recent</span>
|
||||
|
||||
<span style="color:#94a3b8;"># Feature flag: <code style="color:#f59e0b;">feature_notification_center</code> in Global Settings
|
||||
# When OFF, the menu hides for non-admins but admins still see it.</span></pre>
|
||||
</div>
|
||||
|
||||
{{-- PERMISSIONS --}}
|
||||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||||
<i class="bi bi-person-badge text-secondary me-1"></i>{{ __('Who can do what') }}
|
||||
</h6>
|
||||
<div class="table-responsive mb-5">
|
||||
<table class="table table-hover align-middle mb-0" style="background:#f8fafc;border-radius:12px;overflow:hidden;">
|
||||
<thead style="background:#e2e8f0;">
|
||||
<tr>
|
||||
<th class="ps-3">{{ __('CAPABILITY') }}</th>
|
||||
<th>{{ __('REQUIRED ROLE / PERMISSION') }}</th>
|
||||
<th>{{ __('NOTES') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="ps-3 fw-bold text-dark">{{ __('Read own feed') }}</td>
|
||||
<td class="small"><code>view notification center</code></td>
|
||||
<td class="small text-muted">{{ __('Required to even open this page') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="ps-3 fw-bold text-dark">{{ __('Mark / delete own notifications') }}</td>
|
||||
<td class="small"><code>view notification center</code></td>
|
||||
<td class="small text-muted">{{ __('Implicit — users can always manage their own feed') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="ps-3 fw-bold text-dark">{{ __('Send to a role') }}</td>
|
||||
<td class="small">{{ __('Role:') }} <span class="badge bg-dark text-white">Developer</span> {{ __('or') }} <span class="badge bg-dark text-white">Administrator</span></td>
|
||||
<td class="small text-muted">{{ __('Anyone outside these roles will not see the Send button') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="ps-3 fw-bold text-dark">{{ __('Toggle the whole feature off') }}</td>
|
||||
<td class="small"><code>manage global settings</code></td>
|
||||
<td class="small text-muted">{{ __('Global Settings → Notifications →') }} <code>feature_notification_center</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{-- TROUBLESHOOTING --}}
|
||||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||||
<i class="bi bi-wrench-adjustable-circle text-secondary me-1"></i>{{ __('Troubleshooting') }}
|
||||
</h6>
|
||||
<div class="table-responsive mb-5">
|
||||
<table class="table table-hover align-middle mb-0" style="background:#f8fafc;border-radius:12px;overflow:hidden;">
|
||||
<thead style="background:#e2e8f0;">
|
||||
<tr>
|
||||
<th class="ps-3">{{ __('SYMPTOM') }}</th>
|
||||
<th>{{ __('LIKELY CAUSE') }}</th>
|
||||
<th>{{ __('FIX') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="ps-3 fw-bold text-dark">{{ __('Sent but recipient sees nothing') }}</td>
|
||||
<td class="small text-muted">{{ __('Recipient has no matching role, or is inactive') }}</td>
|
||||
<td class="small text-muted">{{ __('Verify the user is active + holds the targeted role') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="ps-3 fw-bold text-dark">{{ __('Bell icon does not update live') }}</td>
|
||||
<td class="small text-muted">{{ __('Reverb WebSocket disconnected') }}</td>
|
||||
<td class="small text-muted">{{ __('Check Monitoring Center → Reverb status. Restart if IDLE during traffic.') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="ps-3 fw-bold text-dark">{{ __('"Clear read" did nothing') }}</td>
|
||||
<td class="small text-muted">{{ __('Nothing was read yet — only read items get purged') }}</td>
|
||||
<td class="small text-muted">{{ __('Mark as read first, then Clear read') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="ps-3 fw-bold text-dark">{{ __('Menu hidden in sidebar') }}</td>
|
||||
<td class="small text-muted">{{ __('Feature flag') }} <code>feature_notification_center</code> {{ __('is OFF') }}</td>
|
||||
<td class="small text-muted">{{ __('Global Settings → Notifications → enable the toggle') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="ps-3 fw-bold text-dark">{{ __('Send button missing') }}</td>
|
||||
<td class="small text-muted">{{ __('Account lacks Developer / Administrator role') }}</td>
|
||||
<td class="small text-muted">{{ __('Ask an admin to grant the role, or use CLI to seed') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="ps-3 fw-bold text-dark">{{ __('Feed empty even after sending') }}</td>
|
||||
<td class="small text-muted">{{ __('The notifications table was truncated') }}</td>
|
||||
<td class="small text-muted">{{ __('Send a fresh one — old data is gone, new ones will appear') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{-- BEST PRACTICES --}}
|
||||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||||
<i class="bi bi-stars text-success me-1"></i>{{ __('Best practices') }}
|
||||
</h6>
|
||||
<div class="row g-2 mb-4">
|
||||
@php
|
||||
$tips = [
|
||||
['icon'=>'bi-target','title'=>'Be specific with audience','desc'=>'Target a role, not "All Users", whenever the message only matters to a subset. Saves everyone scroll time.'],
|
||||
['icon'=>'bi-clock-history','title'=>'Mind the timing','desc'=>'Avoid sending non-urgent notifications outside business hours — push fatigue eats engagement.'],
|
||||
['icon'=>'bi-funnel','title'=>'One topic per notification','desc'=>'If you have three things to say, send three notifications. Long mixed messages get skimmed.'],
|
||||
['icon'=>'bi-chat-square-quote','title'=>'Test on yourself first','desc'=>'Send to "Developer" role first, check how it looks on mobile + desktop, then broadcast.'],
|
||||
['icon'=>'bi-shield-check','title'=>'Never include secrets','desc'=>'Database rows persist until cleared. Passwords, tokens, payment info → never via notification.'],
|
||||
['icon'=>'bi-bell-slash','title'=>'Respect the bell','desc'=>'Each unnecessary notification is one step closer to users disabling the bell entirely. Less is more.'],
|
||||
];
|
||||
@endphp
|
||||
@foreach($tips as $tip)
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex align-items-start gap-2 p-3 rounded-3" style="background:#f0fdf4;border:1px solid #bbf7d0;">
|
||||
<i class="{{ $tip['icon'] }} text-success fs-4"></i>
|
||||
<div>
|
||||
<div class="fw-bold text-dark mb-1" style="font-size:.85rem;">{{ $tip['title'] }}</div>
|
||||
<div class="small text-muted" style="line-height:1.6;">{{ $tip['desc'] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- FOOTER NOTE --}}
|
||||
<div class="d-flex align-items-center gap-3 p-3 rounded-4" style="background:#fef3c7;border:1px solid #fde68a;">
|
||||
<i class="bi bi-info-circle-fill text-warning fs-3"></i>
|
||||
<div>
|
||||
<div class="fw-bold text-dark mb-1" style="font-size:.85rem;">{{ __('Implementation note') }}</div>
|
||||
<div class="small text-muted">
|
||||
Notifications use Laravel's built-in <code>Illuminate\Notifications</code> with two channels:
|
||||
<code>database</code> (persistent storage) and <code>broadcast</code> (real-time via Reverb WebSocket).
|
||||
The notification class is <code>App\Notifications\SystemManagementNotification</code>.
|
||||
Per-user rows live in the <code>notifications</code> table with Laravel-standard UUIDs.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- MODAL --}}
|
||||
<div class="modal fade" id="sendNotificationModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content rounded-3 border-0 shadow-lg">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ __('Send Notification') }}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('notification-center.store') }}" id="manualNotificationForm" class="ajax-form" data-reset="true">
|
||||
@csrf
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">{{ __('Title') }} <span class="text-danger">*</span></label>
|
||||
<input type="text" name="title" class="form-control" placeholder="{{ __('Notification Title') }}" required maxlength="100">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">{{ __('Message') }} <span class="text-danger">*</span></label>
|
||||
<textarea id="notificationMessage" name="message" class="form-control" rows="4"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label fw-semibold">{{ __('Recipient') }} <span class="text-danger">*</span></label>
|
||||
<select name="recipient" class="form-select">
|
||||
<option value="all">{{ __('All Users (Public)') }}</option>
|
||||
@foreach($roles as $roleName)
|
||||
<option value="{{ $roleName }}" {{ $roleName === 'Developer' ? 'selected' : '' }}>{{ ucfirst($roleName) }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label fw-semibold">{{ __('Type') }} <span class="text-danger">*</span></label>
|
||||
<select name="type" class="form-select">
|
||||
<option value="info">{{ __('Information') }}</option>
|
||||
<option value="warning">{{ __('Warning') }}</option>
|
||||
<option value="system">{{ __('System Alert') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-dark rounded-pill px-4" data-bs-dismiss="modal">
|
||||
{{ __('Close') }}
|
||||
</button>
|
||||
<button type="submit" class="btn btn-dark rounded-pill px-4">
|
||||
<i class="bi bi-send me-1"></i> {{ __('Send Notification') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script src="https://cdn.ckeditor.com/ckeditor5/41.1.0/classic/ckeditor.js" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
let notificationEditor;
|
||||
let currentPage = 1;
|
||||
|
||||
const editorEl = document.querySelector('#notificationMessage');
|
||||
if (editorEl && typeof ClassicEditor !== 'undefined') {
|
||||
ClassicEditor
|
||||
.create(editorEl, {
|
||||
ckfinder: { uploadUrl: "{{ route('editor.upload') }}?_token={{ csrf_token() }}" }
|
||||
})
|
||||
.then(newEditor => {
|
||||
notificationEditor = newEditor;
|
||||
editorEl.ckeditorInstance = newEditor;
|
||||
})
|
||||
.catch(error => console.error('CKEditor Error:', error));
|
||||
}
|
||||
|
||||
// Load feed — this MUST always run
|
||||
loadFeed(1);
|
||||
|
||||
function loadFeed(page = 1) {
|
||||
currentPage = page;
|
||||
const $container = $('#notification-feed');
|
||||
|
||||
$.ajax({
|
||||
url: "{{ route('notification-center.index') }}",
|
||||
data: {
|
||||
start: (page - 1) * 10,
|
||||
length: 10,
|
||||
draw: 1
|
||||
},
|
||||
success: function(response) {
|
||||
console.log('Notification Response:', response);
|
||||
if (!response.data || response.data.length === 0) {
|
||||
$container.html(`
|
||||
<div class="text-center py-5 opacity-50">
|
||||
<i class="bi bi-inbox h1 display-1"></i>
|
||||
<p class="small mt-2">{{ __('Inbox is empty') }}</p>
|
||||
</div>
|
||||
`);
|
||||
} else {
|
||||
let html = '';
|
||||
response.data.forEach(n => {
|
||||
try {
|
||||
html += window.renderNotificationCard(n);
|
||||
} catch (e) {
|
||||
console.error('Render Error:', e, n);
|
||||
}
|
||||
});
|
||||
$container.html(html);
|
||||
}
|
||||
renderPagination(response.recordsTotal, page);
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
console.error('AJAX Error:', status, error, xhr.responseText);
|
||||
let message = 'Failed to load notifications.';
|
||||
try {
|
||||
if (xhr.responseJSON && xhr.responseJSON.message) {
|
||||
message = xhr.responseJSON.message;
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
$container.html(`
|
||||
<div class="card adminuiux-card border-0 shadow-sm rounded-3">
|
||||
<div class="card-body text-center py-5 text-danger">
|
||||
<i class="bi bi-exclamation-circle h3"></i>
|
||||
<p class="small mt-2">${message}</p>
|
||||
<p class="text-muted smallest">Status: ${xhr.status}</p>
|
||||
<button class="btn btn-sm btn-outline-danger mt-2" onclick="location.reload()">Refresh Page</button>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.reloadFeed = () => loadFeed(currentPage);
|
||||
|
||||
function renderPagination(total, current) {
|
||||
const pages = Math.ceil(total / 10);
|
||||
if (pages <= 1) { $('#feed-pagination').html(''); return; }
|
||||
|
||||
let html = '<ul class="pagination pagination-sm m-0">';
|
||||
|
||||
// Previous button
|
||||
html += `<li class="page-item ${current === 1 ? 'disabled' : ''}"><a class="page-link rounded-circle mx-1 border-0 shadow-sm" href="#" data-page="${current - 1}">«</a></li>`;
|
||||
|
||||
// Page numbers with sliding window (current page +/- 2)
|
||||
const delta = 2;
|
||||
const left = current - delta;
|
||||
const right = current + delta;
|
||||
const range = [];
|
||||
|
||||
for (let i = 1; i <= pages; i++) {
|
||||
if (i === 1 || i === pages || (i >= left && i <= right)) {
|
||||
range.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
let last = 0;
|
||||
for (let i of range) {
|
||||
if (last) {
|
||||
if (i - last === 2) {
|
||||
html += `<li class="page-item"><a class="page-link rounded-circle mx-1 border-0 shadow-sm" href="#" data-page="${last + 1}">${last + 1}</a></li>`;
|
||||
} else if (i - last !== 1) {
|
||||
html += `<li class="page-item disabled"><span class="page-link rounded-circle mx-1 border-0 shadow-sm">...</span></li>`;
|
||||
}
|
||||
}
|
||||
html += `<li class="page-item ${i === current ? 'active' : ''}"><a class="page-link rounded-circle mx-1 border-0 shadow-sm" href="#" data-page="${i}">${i}</a></li>`;
|
||||
last = i;
|
||||
}
|
||||
|
||||
// Next button
|
||||
html += `<li class="page-item ${current === pages ? 'disabled' : ''}"><a class="page-link rounded-circle mx-1 border-0 shadow-sm" href="#" data-page="${current + 1}">»</a></li>`;
|
||||
|
||||
html += '</ul>';
|
||||
$('#feed-pagination').html(html);
|
||||
}
|
||||
|
||||
$(document).on('click', '.page-link', function(e) {
|
||||
e.preventDefault();
|
||||
loadFeed($(this).data('page'));
|
||||
});
|
||||
|
||||
$(document).on('click', '.btn-delete', function() {
|
||||
const url = $(this).data('url');
|
||||
StandardSwal.fire({
|
||||
title: 'Delete this notification?',
|
||||
text: 'This notification will be permanently removed from your history.',
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
customClass: {
|
||||
confirmButton: 'btn-pill-danger',
|
||||
cancelButton: 'btn-pill-cancel'
|
||||
},
|
||||
confirmButtonText: 'Yes, Delete',
|
||||
cancelButtonText: "Cancel",
|
||||
}).then(result => {
|
||||
if (result.isConfirmed) {
|
||||
$.ajax({
|
||||
url: url,
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') },
|
||||
success: (res) => {
|
||||
window.reloadNotificationUI();
|
||||
window.showNotificationToast('success', res.message || 'Notification deleted');
|
||||
},
|
||||
error: (xhr) => window.showNotificationToast('error', 'Delete failed')
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$('#page-mark-all-read').on('click', function() {
|
||||
$.ajax({
|
||||
url: "{{ route('notification-center.read-all') }}",
|
||||
method: 'PATCH',
|
||||
headers: { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') },
|
||||
success: (res) => {
|
||||
window.reloadNotificationUI();
|
||||
window.showNotificationToast('success', res.message || 'All marked as read');
|
||||
},
|
||||
error: () => window.showNotificationToast('error', 'Process failed')
|
||||
});
|
||||
});
|
||||
|
||||
$('#page-clear-read').on('click', function() {
|
||||
StandardSwal.fire({
|
||||
title: "Clear all read notifications?",
|
||||
text: "All read updates will be permanently purged from your feed.",
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
customClass: {
|
||||
confirmButton: 'btn-pill-danger',
|
||||
cancelButton: 'btn-pill-cancel'
|
||||
},
|
||||
confirmButtonText: "Yes, Clear",
|
||||
cancelButtonText: "Cancel",
|
||||
}).then(result => {
|
||||
if (result.isConfirmed) {
|
||||
$.ajax({
|
||||
url: "{{ route('notification-center.clear-read') }}",
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') },
|
||||
success: (res) => {
|
||||
window.reloadNotificationUI();
|
||||
window.showNotificationToast('success', res.message || 'Read notifications cleared');
|
||||
},
|
||||
error: () => window.showNotificationToast('error', 'Clear failed')
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Listen for standard AJAX success to reload feed
|
||||
$('#manualNotificationForm').on('ajaxForm:success', function() {
|
||||
window.reloadNotificationUI();
|
||||
if (notificationEditor) notificationEditor.setData('');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
</x-app-layout>
|
||||
+317
@@ -0,0 +1,317 @@
|
||||
<x-app-layout>
|
||||
@push('styles')
|
||||
<style>
|
||||
.ck-editor__editable {
|
||||
min-height: 200px;
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="row align-items-center mb-4">
|
||||
<div class="col">
|
||||
<h4 class="fw-bold mb-1">{{ __('Notification Center') }}</h4>
|
||||
<p class="text-secondary small mb-0">{{ __('Manage system notifications and activity feed.') }}</p>
|
||||
</div>
|
||||
@hasanyrole('Developer|Administrator')
|
||||
<div class="col-auto">
|
||||
<div class="btn-group me-2">
|
||||
<button type="button" class="btn btn-outline-dark btn-sm rounded-pill px-3" id="page-mark-all-read">
|
||||
<i class="bi bi-check-all me-1"></i>{{ __('Mark all read') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm rounded-pill px-3 ms-2" id="page-clear-read">
|
||||
<i class="bi bi-trash3 me-1"></i>{{ __('Clear read') }}
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-dark btn-sm rounded-pill px-3" data-bs-toggle="modal" data-bs-target="#sendNotificationModal">
|
||||
<i class="bi bi-plus-lg me-1"></i>{{ __('Send') }}
|
||||
</button>
|
||||
</div>
|
||||
@else
|
||||
<div class="col-auto">
|
||||
<button type="button" class="btn btn-outline-dark btn-sm rounded-pill px-3" id="page-mark-all-read">
|
||||
<i class="bi bi-check-all me-1"></i>{{ __('Mark all read') }}
|
||||
</button>
|
||||
</div>
|
||||
@endhasanyrole
|
||||
</div>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-xl-10 col-lg-11">
|
||||
{{-- Standardized Notification Feed --}}
|
||||
<div id="notification-feed">
|
||||
<div class="card adminuiux-card border-0 shadow-sm rounded-3">
|
||||
<div class="card-body text-center py-5">
|
||||
<div class="spinner-border text-primary spinner-border-sm" role="status"></div>
|
||||
<p class="text-secondary small mt-2">{{ __('Loading...') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Pagination --}}
|
||||
<div id="feed-pagination" class="d-flex justify-content-center mt-5"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- MODAL --}}
|
||||
<div class="modal fade" id="sendNotificationModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content rounded-3 border-0 shadow-lg">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ __('Send Notification') }}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ rout('notification-center.store') }}" id="manualNotificationForm" class="ajax-form" data-reset="true">
|
||||
@csrf
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">{{ __('Title') }} <span class="text-danger">*</span></label>
|
||||
<input type="text" name="title" class="form-control" placeholder="{{ __('Notification Title') }}" required maxlength="100">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">{{ __('Message') }} <span class="text-danger">*</span></label>
|
||||
<textarea id="notificationMessage" name="message" class="form-control" rows="4"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label fw-semibold">{{ __('Recipient') }} <span class="text-danger">*</span></label>
|
||||
<select name="recipient" class="form-select">
|
||||
<option value="all">{{ __('All Users (Public)') }}</option>
|
||||
@foreach($roles as $roleName)
|
||||
<option value="{{ $roleName }}" {{ $roleName === 'Developer' ? 'selected' : '' }}>{{ ucfirst($roleName) }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label fw-semibold">{{ __('Type') }} <span class="text-danger">*</span></label>
|
||||
<select name="type" class="form-select">
|
||||
<option value="info">{{ __('Information') }}</option>
|
||||
<option value="warning">{{ __('Warning') }}</option>
|
||||
<option value="system">{{ __('System Alert') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-dark rounded-pill px-4" data-bs-dismiss="modal">
|
||||
{{ __('Close') }}
|
||||
</button>
|
||||
<button type="submit" class="btn btn-dark rounded-pill px-4">
|
||||
<i class="bi bi-send me-1"></i> {{ __('Send Notification') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script src="https://cdn.ckeditor.com/ckeditor5/41.1.0/classic/ckeditor.js" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
let notificationEditor;
|
||||
let currentPage = 1;
|
||||
|
||||
const editorEl = document.querySelector('#notificationMessage');
|
||||
if (editorEl && typeof ClassicEditor !== 'undefined') {
|
||||
ClassicEditor
|
||||
.create(editorEl, {
|
||||
ckfinder: { uploadUrl: "{{ route('editor.upload') }}?_token={{ csrf_token() }}" }
|
||||
})
|
||||
.then(newEditor => {
|
||||
notificationEditor = newEditor;
|
||||
editorEl.ckeditorInstance = newEditor;
|
||||
})
|
||||
.catch(error => console.error('CKEditor Error:', error));
|
||||
}
|
||||
|
||||
// Load feed — this MUST always run
|
||||
loadFeed(1);
|
||||
|
||||
function loadFeed(page = 1) {
|
||||
currentPage = page;
|
||||
const $container = $('#notification-feed');
|
||||
|
||||
$.ajax({
|
||||
url: "{{ route('notification-center.index') }}",
|
||||
data: {
|
||||
start: (page - 1) * 10,
|
||||
length: 10,
|
||||
draw: 1
|
||||
},
|
||||
success: function(response) {
|
||||
console.log('Notification Response:', response);
|
||||
if (!response.data || response.data.length === 0) {
|
||||
$container.html(`
|
||||
<div class="text-center py-5 opacity-50">
|
||||
<i class="bi bi-inbox h1 display-1"></i>
|
||||
<p class="small mt-2">{{ __('Inbox is empty') }}</p>
|
||||
</div>
|
||||
`);
|
||||
} else {
|
||||
let html = '';
|
||||
response.data.forEach(n => {
|
||||
try {
|
||||
html += window.renderNotificationCard(n);
|
||||
} catch (e) {
|
||||
console.error('Render Error:', e, n);
|
||||
}
|
||||
});
|
||||
$container.html(html);
|
||||
}
|
||||
renderPagination(response.recordsTotal, page);
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
console.error('AJAX Error:', status, error, xhr.responseText);
|
||||
let message = 'Failed to load notifications.';
|
||||
try {
|
||||
if (xhr.responseJSON && xhr.responseJSON.message) {
|
||||
message = xhr.responseJSON.message;
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
$container.html(`
|
||||
<div class="card adminuiux-card border-0 shadow-sm rounded-3">
|
||||
<div class="card-body text-center py-5 text-danger">
|
||||
<i class="bi bi-exclamation-circle h3"></i>
|
||||
<p class="small mt-2">${message}</p>
|
||||
<p class="text-muted smallest">Status: ${xhr.status}</p>
|
||||
<button class="btn btn-sm btn-outline-danger mt-2" onclick="location.reload()">Refresh Page</button>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.reloadFeed = () => loadFeed(currentPage);
|
||||
|
||||
function renderPagination(total, current) {
|
||||
const pages = Math.ceil(total / 10);
|
||||
if (pages <= 1) { $('#feed-pagination').html(''); return; }
|
||||
|
||||
let html = '<ul class="pagination pagination-sm m-0">';
|
||||
|
||||
// Previous button
|
||||
html += `<li class="page-item ${current === 1 ? 'disabled' : ''}"><a class="page-link rounded-circle mx-1 border-0 shadow-sm" href="#" data-page="${current - 1}">«</a></li>`;
|
||||
|
||||
// Page numbers with sliding window (current page +/- 2)
|
||||
const delta = 2;
|
||||
const left = current - delta;
|
||||
const right = current + delta;
|
||||
const range = [];
|
||||
|
||||
for (let i = 1; i <= pages; i++) {
|
||||
if (i === 1 || i === pages || (i >= left && i <= right)) {
|
||||
range.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
let last = 0;
|
||||
for (let i of range) {
|
||||
if (last) {
|
||||
if (i - last === 2) {
|
||||
html += `<li class="page-item"><a class="page-link rounded-circle mx-1 border-0 shadow-sm" href="#" data-page="${last + 1}">${last + 1}</a></li>`;
|
||||
} else if (i - last !== 1) {
|
||||
html += `<li class="page-item disabled"><span class="page-link rounded-circle mx-1 border-0 shadow-sm">...</span></li>`;
|
||||
}
|
||||
}
|
||||
html += `<li class="page-item ${i === current ? 'active' : ''}"><a class="page-link rounded-circle mx-1 border-0 shadow-sm" href="#" data-page="${i}">${i}</a></li>`;
|
||||
last = i;
|
||||
}
|
||||
|
||||
// Next button
|
||||
html += `<li class="page-item ${current === pages ? 'disabled' : ''}"><a class="page-link rounded-circle mx-1 border-0 shadow-sm" href="#" data-page="${current + 1}">»</a></li>`;
|
||||
|
||||
html += '</ul>';
|
||||
$('#feed-pagination').html(html);
|
||||
}
|
||||
|
||||
$(document).on('click', '.page-link', function(e) {
|
||||
e.preventDefault();
|
||||
loadFeed($(this).data('page'));
|
||||
});
|
||||
|
||||
$(document).on('click', '.btn-delete', function() {
|
||||
const url = $(this).data('url');
|
||||
StandardSwal.fire({
|
||||
title: 'Delete this notification?',
|
||||
text: 'This notification will be permanently removed from your history.',
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
customClass: {
|
||||
confirmButton: 'btn-pill-danger',
|
||||
cancelButton: 'btn-pill-cancel'
|
||||
},
|
||||
confirmButtonText: 'Yes, Delete',
|
||||
cancelButtonText: "Cancel",
|
||||
}).then(result => {
|
||||
if (result.isConfirmed) {
|
||||
$.ajax({
|
||||
url: url,
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') },
|
||||
success: (res) => {
|
||||
window.reloadNotificationUI();
|
||||
window.showNotificationToast('success', res.message || 'Notification deleted');
|
||||
},
|
||||
error: (xhr) => window.showNotificationToast('error', 'Delete failed')
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$('#page-mark-all-read').on('click', function() {
|
||||
$.ajax({
|
||||
url: "{{ route('notification-center.read-all') }}",
|
||||
method: 'PATCH',
|
||||
headers: { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') },
|
||||
success: (res) => {
|
||||
window.reloadNotificationUI();
|
||||
window.showNotificationToast('success', res.message || 'All marked as read');
|
||||
},
|
||||
error: () => window.showNotificationToast('error', 'Process failed')
|
||||
});
|
||||
});
|
||||
|
||||
$('#page-clear-read').on('click', function() {
|
||||
StandardSwal.fire({
|
||||
title: "Clear all read notifications?",
|
||||
text: "All read updates will be permanently purged from your feed.",
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
customClass: {
|
||||
confirmButton: 'btn-pill-danger',
|
||||
cancelButton: 'btn-pill-cancel'
|
||||
},
|
||||
confirmButtonText: "Yes, Clear",
|
||||
cancelButtonText: "Cancel",
|
||||
}).then(result => {
|
||||
if (result.isConfirmed) {
|
||||
$.ajax({
|
||||
url: "{{ route('notification-center.clear-read') }}",
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') },
|
||||
success: (res) => {
|
||||
window.reloadNotificationUI();
|
||||
window.showNotificationToast('success', res.message || 'Read notifications cleared');
|
||||
},
|
||||
error: () => window.showNotificationToast('error', 'Clear failed')
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Listen for standard AJAX success to reload feed
|
||||
$('#manualNotificationForm').on('ajaxForm:success', function() {
|
||||
window.reloadNotificationUI();
|
||||
if (notificationEditor) notificationEditor.setData('');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,246 @@
|
||||
<x-app-layout>
|
||||
<div id="session-manager-root" x-data="{
|
||||
sessionDetail: { session: { user_name: '', user_email: '', ip_address: '', id: '' }, device: { browser: '', browser_icon: '', os: '', os_icon: '' }, time: '', is_current: false },
|
||||
openSession(btn) {
|
||||
try {
|
||||
this.sessionDetail = JSON.parse(btn.dataset.activity);
|
||||
const modalEl = document.getElementById('sessionDetailModal');
|
||||
let modal = bootstrap.Modal.getInstance(modalEl);
|
||||
if (!modal) {
|
||||
modal = new bootstrap.Modal(modalEl);
|
||||
}
|
||||
modal.show();
|
||||
} catch (e) {
|
||||
console.error('Error opening session detail:', e);
|
||||
window.StandardSwal.fire({ title: 'Error', text: 'Could not parse session data.', icon: 'error' });
|
||||
}
|
||||
},
|
||||
async terminate() {
|
||||
const sid = this.sessionDetail.session.id;
|
||||
const url = `{{ route('session-manager.terminate', ':id') }}`.replace(':id', sid);
|
||||
|
||||
const r = await window.StandardSwal.fire({
|
||||
title: 'Terminate Connection?',
|
||||
text: 'Sever all encrypted links for this digital session?',
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Yes, Terminate'
|
||||
});
|
||||
|
||||
if (r.isConfirmed) {
|
||||
const res = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Accept': 'application/json' }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('sessionDetailModal')).hide();
|
||||
window.reloadDataTable?.();
|
||||
window.StandardSwal.fire({ title: 'Severed', text: data.message, icon: 'success', timer: 1500, showConfirmButton: false });
|
||||
}
|
||||
}
|
||||
},
|
||||
copyMetadata() {
|
||||
navigator.clipboard.writeText(JSON.stringify(this.sessionDetail.session, null, 2));
|
||||
window.StandardSwal.fire({ title: 'Copied!', text: 'Session metadata context copied.', icon: 'success', timer: 1500, showConfirmButton: false });
|
||||
}
|
||||
}">
|
||||
<div class="container-fluid" id="main-content">
|
||||
<div class="row gx-3 gx-lg-4">
|
||||
<div class="col-12">
|
||||
<div class="card adminuiux-card">
|
||||
<div class="card-body p-0">
|
||||
|
||||
{{-- Header Section --}}
|
||||
<div class="p-4 pb-0">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h5 class="mb-0 fw-bold">{{ __('Session Manager') }}</h5>
|
||||
<small class="text-muted">
|
||||
{{ __('Real-time monitoring and management of authenticated user sessions.') }}
|
||||
</small>
|
||||
</div>
|
||||
<button @click="window.reloadDataTable?.()" class="btn btn-outline-secondary btn-sm rounded-pill px-3">
|
||||
<i class="bi bi-arrow-clockwise me-1"></i> {{ __('Refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Table Section --}}
|
||||
<div class="p-4">
|
||||
<div class="table-responsive">
|
||||
<table id="datatables" class="table table-hover table-bordered w-100 nowrap mb-0"
|
||||
data-server-side="true" data-ajax-url="{{ route('session-manager') }}"
|
||||
data-order='@json([[4, "desc"]])'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-wrap">{{ __('Status') }}</th>
|
||||
<th class="text-wrap">{{ __('Identity Profile') }}</th>
|
||||
<th class="text-wrap">{{ __('Device') }}</th>
|
||||
<th class="text-wrap">{{ __('IP Address') }}</th>
|
||||
<th class="text-wrap">{{ __('Last Activity') }}</th>
|
||||
<th class="text-wrap text-end" data-orderable="false" data-searchable="false">{{ __('Action') }}</th>
|
||||
</tr>
|
||||
|
||||
{{-- Filter Row --}}
|
||||
<tr class="filter-row">
|
||||
<th>
|
||||
<select class="form-select form-select-sm">
|
||||
<option value="">{{ __('All') }}</option>
|
||||
<option value="active">{{ __('Active') }}</option>
|
||||
<option value="ended">{{ __('Idle') }}</option>
|
||||
</select>
|
||||
</th>
|
||||
<th><input class="form-control form-control-sm" placeholder="{{ __('Search Identity') }}"></th>
|
||||
<th><input class="form-control form-control-sm" placeholder="{{ __('Search Device') }}"></th>
|
||||
<th><input class="form-control form-control-sm" placeholder="{{ __('IP Trace') }}"></th>
|
||||
<th><input class="form-control form-control-sm" placeholder="{{ __('Search Time') }}"></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Session Diagnostic Modal (Bootstrap) --}}
|
||||
<div class="modal fade" id="sessionDetailModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content rounded-4 border-0 shadow-lg">
|
||||
<div class="modal-header border-bottom-0 p-4">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div>
|
||||
<h5 class="modal-title fw-bold mb-0">{{ __('Session Diagnostics') }}</h5>
|
||||
<small class="text-uppercase text-muted fw-black ls-1" style="font-size: 10px;">{{ __('End-to-End Security Audit') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body p-4 pt-0">
|
||||
<div class="row g-4">
|
||||
<div class="col-md-6">
|
||||
<label class="ls-1 text-uppercase text-muted fw-bold mb-2" style="font-size: 10px;">{{ __('Identity Context') }}</label>
|
||||
<div class="p-3 bg-light rounded-3 border">
|
||||
<div class="fw-bold text-dark" x-text="sessionDetail.session.user_name || 'Guest Observer'"></div>
|
||||
<div class="small text-muted" x-text="sessionDetail.session.user_email || 'anonymous'"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="ls-1 text-uppercase text-muted fw-bold mb-2 d-block text-md-end" style="font-size: 10px;">{{ __('Temporal Sync') }}</label>
|
||||
<div class="p-3 bg-light rounded-3 border text-md-end">
|
||||
<div class="fw-bold text-dark" x-text="sessionDetail.time"></div>
|
||||
<div class="small text-muted text-uppercase ls-1" style="font-size: 10px;">Last Pulse Detected</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="ls-1 text-uppercase text-muted fw-bold mb-2" style="font-size: 10px;">{{ __('Connected Node Details') }}</label>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<div class="card card-body text-center py-4 rounded-4 border">
|
||||
<i class="bi fs-2 mb-2" :class="sessionDetail.device.browser_icon"></i>
|
||||
<div class="fw-black text-uppercase ls-1" style="font-size: 10px;" x-text="sessionDetail.device.browser"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card card-body text-center py-4 rounded-4 border">
|
||||
<i class="bi fs-2 mb-2" :class="sessionDetail.device.os_icon"></i>
|
||||
<div class="fw-black text-uppercase ls-1" style="font-size: 10px;" x-text="sessionDetail.device.os"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card card-body text-center py-4 rounded-4 border">
|
||||
<i class="bi bi-broadcast fs-2 mb-2"></i>
|
||||
<div class="fw-black text-uppercase ls-1" style="font-size: 10px;" x-text="sessionDetail.session.ip_address"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<label class="ls-1 text-uppercase text-muted fw-bold" style="font-size: 10px;">{{ __('Raw Metadata Payload') }}</label>
|
||||
<a href="javascript:void(0)" @click="copyMetadata" class="text-decoration-none fw-bold ls-1 text-uppercase" style="font-size: 10px;">Copy manifest</a>
|
||||
</div>
|
||||
<div class="bg-dark rounded-4 p-4 shadow-inner">
|
||||
<pre class="text-success m-0 fw-mono small overflow-auto no-scrollbar" style="max-height: 200px;" x-text="JSON.stringify(sessionDetail.session, null, 2)"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer border-top-0 p-4">
|
||||
<button type="button" class="btn btn-outline-dark rounded-pill px-4" data-bs-dismiss="modal">Close</button>
|
||||
@can('manage active sessions')
|
||||
<template x-if="!sessionDetail.is_current">
|
||||
<button type="button" @click="terminate" class="btn btn-danger rounded-pill px-4 shadow-sm">Terminate Session</button>
|
||||
</template>
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const tableElement = $('#datatables');
|
||||
|
||||
window.reloadDataTable = () => tableElement.DataTable().ajax.reload(null, false);
|
||||
|
||||
$(document).on("click", ".btn-detail-session", function() {
|
||||
const root = document.getElementById('session-manager-root');
|
||||
if (root && window.Alpine) {
|
||||
const alpine = window.Alpine.$data(root);
|
||||
alpine.openSession(this);
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on("click", ".btn-terminate-session", async function() {
|
||||
const id = $(this).data('id');
|
||||
const url = $(this).data('url');
|
||||
const r = await window.StandardSwal.fire({
|
||||
title: 'Terminate Connection?',
|
||||
text: 'Sever all encrypted links for this digital session?',
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Yes, Terminate'
|
||||
});
|
||||
if (r.isConfirmed) {
|
||||
const res = await fetch(url, { method: 'DELETE', headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Accept': 'application/json' } });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
window.reloadDataTable();
|
||||
window.StandardSwal.fire({ title: 'Severed', text: data.message, icon: 'success', timer: 1500, showConfirmButton: false });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tableElement.on('draw.dt', function() {
|
||||
tableElement.find('tbody tr').each(function() {
|
||||
const rowData = tableElement.DataTable().row(this).data();
|
||||
if (rowData && rowData[6] === true) {
|
||||
$(this).addClass('table-success-light');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
.table-success-light { background-color: rgba(25, 135, 84, 0.05) !important; border-left: 4px solid #198754 !important; }
|
||||
.ls-1 { letter-spacing: 0.1em; }
|
||||
.fw-black { font-weight: 900; }
|
||||
.extra-small { font-size: 10px; }
|
||||
pre::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||
pre::-webkit-scrollbar-thumb { background: rgba(25, 135, 84, 0.3); border-radius: 4px; }
|
||||
</style>
|
||||
@endpush
|
||||
</x-app-layout>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user