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>
|
||||
Reference in New Issue
Block a user