Files
biiproject-kit-v1/resources/views/pages/dashboard.blade.php
T

584 lines
34 KiB
PHP

<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;">&times;</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,"&apos;")}'>
<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>