fixing download admin & menambahkan fitur tambah folder pada dokumen akreditasi
This commit is contained in:
parent
5786369973
commit
f6031e32fe
316
app/Http/Controllers/AkreditasiInstrumenController.php
Normal file
316
app/Http/Controllers/AkreditasiInstrumenController.php
Normal file
@ -0,0 +1,316 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
|
||||||
|
class AkreditasiInstrumenController extends Controller
|
||||||
|
{
|
||||||
|
private function akreditasiJsonPath(): string
|
||||||
|
{
|
||||||
|
return public_path('json/akreditasi.jff');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function akreditasiLockPath(): string
|
||||||
|
{
|
||||||
|
return public_path('json/akreditasi.jff.lock');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureJsonDirExists(): void
|
||||||
|
{
|
||||||
|
$dir = public_path('json');
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
@mkdir($dir, 0777, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function atomicWrite(string $path, string $contents): bool
|
||||||
|
{
|
||||||
|
$dir = dirname($path);
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
@mkdir($dir, 0777, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tmp = $path . '.tmp';
|
||||||
|
$bytes = @file_put_contents($tmp, $contents);
|
||||||
|
if ($bytes === false || $bytes < strlen($contents)) {
|
||||||
|
@unlink($tmp);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// best-effort backup
|
||||||
|
if (file_exists($path)) {
|
||||||
|
@copy($path, $path . '.bak');
|
||||||
|
}
|
||||||
|
|
||||||
|
// replace
|
||||||
|
@unlink($path);
|
||||||
|
if (@rename($tmp, $path)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback copy
|
||||||
|
$copied = @copy($tmp, $path);
|
||||||
|
@unlink($tmp);
|
||||||
|
return (bool) $copied;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stripUtf8Bom(string $raw): string
|
||||||
|
{
|
||||||
|
// Remove UTF-8 BOM if present
|
||||||
|
if (strncmp($raw, "\xEF\xBB\xBF", 3) === 0) {
|
||||||
|
return substr($raw, 3);
|
||||||
|
}
|
||||||
|
return $raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decodeJsonArray(string $raw, bool $strict = false): ?array
|
||||||
|
{
|
||||||
|
$raw = $this->stripUtf8Bom($raw);
|
||||||
|
$data = json_decode($raw, true);
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||||
|
return $strict ? null : [];
|
||||||
|
}
|
||||||
|
return is_array($data) ? $data : ($strict ? null : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function tryRepairJsonArray(string $raw): ?array
|
||||||
|
{
|
||||||
|
// Common corruption: trailing commas before ']' or '}'
|
||||||
|
$raw = $this->stripUtf8Bom($raw);
|
||||||
|
$repaired = preg_replace('/,(\s*[}\]])/m', '$1', $raw);
|
||||||
|
if (!is_string($repaired) || $repaired === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$data = json_decode($repaired, true);
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return is_array($data) ? $data : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function splitList(?string $value): array
|
||||||
|
{
|
||||||
|
$value = trim((string) $value);
|
||||||
|
if ($value === '') return [];
|
||||||
|
|
||||||
|
$parts = preg_split('/[,\r\n]+/', $value) ?: [];
|
||||||
|
$out = [];
|
||||||
|
foreach ($parts as $p) {
|
||||||
|
$p = trim($p);
|
||||||
|
if ($p !== '') $out[] = $p;
|
||||||
|
}
|
||||||
|
return array_values(array_unique($out));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureTypeExists(array &$data, string $typeName): int
|
||||||
|
{
|
||||||
|
foreach ($data as $i => $t) {
|
||||||
|
if (($t['name'] ?? null) === $typeName) {
|
||||||
|
if (!isset($data[$i]['segment']) || !is_array($data[$i]['segment'])) {
|
||||||
|
$data[$i]['segment'] = [];
|
||||||
|
}
|
||||||
|
return $i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$data[] = [
|
||||||
|
'name' => $typeName,
|
||||||
|
'segment' => [],
|
||||||
|
];
|
||||||
|
return count($data) - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureSegmentExists(array &$typeSegments, string $segmentName): int
|
||||||
|
{
|
||||||
|
foreach ($typeSegments as $j => $s) {
|
||||||
|
if (($s['name'] ?? null) === $segmentName) {
|
||||||
|
if (!isset($typeSegments[$j]['turunan']) || !is_array($typeSegments[$j]['turunan'])) {
|
||||||
|
$typeSegments[$j]['turunan'] = [];
|
||||||
|
}
|
||||||
|
return $j;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$typeSegments[] = [
|
||||||
|
'name' => $segmentName,
|
||||||
|
'turunan' => [],
|
||||||
|
];
|
||||||
|
return count($typeSegments) - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureItemExists(array &$segmentChildren, string $itemName): bool
|
||||||
|
{
|
||||||
|
foreach ($segmentChildren as $c) {
|
||||||
|
if (($c['name'] ?? null) === $itemName) {
|
||||||
|
return false; // already exists
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$segmentChildren[] = ['name' => $itemName];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read akreditasi.jff (array)
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$path = $this->akreditasiJsonPath();
|
||||||
|
if (!file_exists($path)) {
|
||||||
|
return response()->json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = @file_get_contents($path);
|
||||||
|
$data = $this->decodeJsonArray($raw === false ? '[]' : $raw, false) ?? [];
|
||||||
|
return response()->json($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add "folder" structure to akreditasi.jff.
|
||||||
|
*
|
||||||
|
* Payload:
|
||||||
|
* - type (required)
|
||||||
|
* - segment (optional)
|
||||||
|
* - item (optional)
|
||||||
|
*
|
||||||
|
* Behavior:
|
||||||
|
* - type only: creates new top-level type {name, segment: []}
|
||||||
|
* - type + segment: creates segment {name, turunan: []} under type
|
||||||
|
* - type + segment + item: creates item {name} under segment.turunan
|
||||||
|
*/
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'type' => ['required', 'string', 'max:255'],
|
||||||
|
'segment' => ['nullable', 'string', 'max:255'],
|
||||||
|
'item' => ['nullable', 'string', 'max:2000'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$typeName = trim($validated['type'] ?? '');
|
||||||
|
$segmentRaw = isset($validated['segment']) ? (string) $validated['segment'] : '';
|
||||||
|
$itemRaw = isset($validated['item']) ? (string) $validated['item'] : '';
|
||||||
|
|
||||||
|
$segments = $this->splitList($segmentRaw);
|
||||||
|
$items = $this->splitList($itemRaw);
|
||||||
|
|
||||||
|
if ($typeName === '') {
|
||||||
|
return response()->json([
|
||||||
|
'status' => false,
|
||||||
|
'message' => 'Type wajib diisi.',
|
||||||
|
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($items) > 0 && count($segments) === 0) {
|
||||||
|
return response()->json([
|
||||||
|
'status' => false,
|
||||||
|
'message' => 'Segment wajib diisi jika ingin menambah item.',
|
||||||
|
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->ensureJsonDirExists();
|
||||||
|
$path = $this->akreditasiJsonPath();
|
||||||
|
$lockPath = $this->akreditasiLockPath();
|
||||||
|
|
||||||
|
// Use separate lock file so Windows can replace the target JSON file safely.
|
||||||
|
$lockFp = @fopen($lockPath, 'c+');
|
||||||
|
if (!$lockFp) {
|
||||||
|
return response()->json([
|
||||||
|
'status' => false,
|
||||||
|
'message' => 'Gagal membuka file lock untuk akreditasi.jff.',
|
||||||
|
], Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!flock($lockFp, LOCK_EX)) {
|
||||||
|
return response()->json([
|
||||||
|
'status' => false,
|
||||||
|
'message' => 'Gagal mengunci file akreditasi.jff.',
|
||||||
|
], Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = @file_get_contents($path);
|
||||||
|
if ($raw === false) {
|
||||||
|
$data = [];
|
||||||
|
} else {
|
||||||
|
$data = $this->decodeJsonArray($raw, true);
|
||||||
|
if ($data === null) {
|
||||||
|
// Try safe repair (e.g., remove trailing commas). If still invalid, abort to avoid data loss.
|
||||||
|
$repaired = $this->tryRepairJsonArray($raw);
|
||||||
|
if ($repaired === null) {
|
||||||
|
return response()->json([
|
||||||
|
'status' => false,
|
||||||
|
'message' => 'File public/json/akreditasi.jff tidak valid (JSON rusak). Perubahan dibatalkan agar data tidak hilang. Silakan restore dari akreditasi.jff.bak lalu coba lagi.',
|
||||||
|
], Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
$data = $repaired;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$typeIndex = $this->ensureTypeExists($data, $typeName);
|
||||||
|
|
||||||
|
$added = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
|
||||||
|
// Case: type only
|
||||||
|
if (count($segments) === 0) {
|
||||||
|
// type already ensured; treat as skipped if it existed, added if new.
|
||||||
|
// We can't easily detect "new" without extra flag; keep message generic.
|
||||||
|
} elseif (count($items) === 0) {
|
||||||
|
// Case: add one or more segments
|
||||||
|
$typeSegments = $data[$typeIndex]['segment'];
|
||||||
|
foreach ($segments as $segName) {
|
||||||
|
$beforeCount = count($typeSegments);
|
||||||
|
$this->ensureSegmentExists($typeSegments, $segName);
|
||||||
|
$afterCount = count($typeSegments);
|
||||||
|
if ($afterCount > $beforeCount) $added++;
|
||||||
|
else $skipped++;
|
||||||
|
}
|
||||||
|
$data[$typeIndex]['segment'] = $typeSegments;
|
||||||
|
} else {
|
||||||
|
// Case: add one or more items under the given segment(s)
|
||||||
|
// If user provides multiple segments + items, add all items to each segment.
|
||||||
|
$typeSegments = $data[$typeIndex]['segment'];
|
||||||
|
foreach ($segments as $segName) {
|
||||||
|
$segIndex = $this->ensureSegmentExists($typeSegments, $segName);
|
||||||
|
$children = $typeSegments[$segIndex]['turunan'] ?? [];
|
||||||
|
if (!is_array($children)) $children = [];
|
||||||
|
|
||||||
|
foreach ($items as $itemName) {
|
||||||
|
$didAdd = $this->ensureItemExists($children, $itemName);
|
||||||
|
if ($didAdd) $added++;
|
||||||
|
else $skipped++;
|
||||||
|
}
|
||||||
|
$typeSegments[$segIndex]['turunan'] = $children;
|
||||||
|
}
|
||||||
|
$data[$typeIndex]['segment'] = $typeSegments;
|
||||||
|
}
|
||||||
|
|
||||||
|
// write back
|
||||||
|
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
if ($json === false) {
|
||||||
|
return response()->json([
|
||||||
|
'status' => false,
|
||||||
|
'message' => 'Gagal mengubah data menjadi JSON.',
|
||||||
|
], Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
$json .= "\n";
|
||||||
|
|
||||||
|
if (!$this->atomicWrite($path, $json)) {
|
||||||
|
return response()->json([
|
||||||
|
'status' => false,
|
||||||
|
'message' => 'Gagal menulis file akreditasi.jff (atomic write).',
|
||||||
|
], Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => true,
|
||||||
|
'message' => ($added > 0 || $skipped > 0)
|
||||||
|
? "Selesai. Ditambahkan: {$added}, dilewati (sudah ada): {$skipped}."
|
||||||
|
: 'Selesai.',
|
||||||
|
'data' => $data,
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
@flock($lockFp, LOCK_UN);
|
||||||
|
@fclose($lockFp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
3215
public/json/akreditasi.jff.bak
Normal file
3215
public/json/akreditasi.jff.bak
Normal file
File diff suppressed because it is too large
Load Diff
0
public/json/akreditasi.jff.lock
Normal file
0
public/json/akreditasi.jff.lock
Normal file
@ -36,6 +36,26 @@
|
|||||||
.file-icon { color: #6c757d; font-size: 1.1rem; }
|
.file-icon { color: #6c757d; font-size: 1.1rem; }
|
||||||
|
|
||||||
.badge-pdf { background-color: #fff0f0; color: #e44d26; border: 1px solid #ffcccc; }
|
.badge-pdf { background-color: #fff0f0; color: #e44d26; border: 1px solid #ffcccc; }
|
||||||
|
|
||||||
|
/* Aksi Folder (lebih user-friendly) */
|
||||||
|
.folder-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-left: 8px;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||||
|
}
|
||||||
|
.tree-folder:hover .folder-actions {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
.btn-folder-action {
|
||||||
|
padding: 1px 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.2;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@section('body_main')
|
@section('body_main')
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@ -65,6 +85,17 @@
|
|||||||
0 dipilih
|
0 dipilih
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
@if(Auth::guard('admin')->check())
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline-primary btn-sm"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#modalAkreInstrumen"
|
||||||
|
>
|
||||||
|
<i class="ti ti-folder-plus me-1"></i>
|
||||||
|
Tambah Folder Akreditasi
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
<!-- Tambah Dokumen -->
|
<!-- Tambah Dokumen -->
|
||||||
@if(!Auth::guard('admin')->check())
|
@if(!Auth::guard('admin')->check())
|
||||||
<button
|
<button
|
||||||
@ -137,12 +168,54 @@
|
|||||||
|
|
||||||
@include('dataUnit.modal.create')
|
@include('dataUnit.modal.create')
|
||||||
|
|
||||||
|
@if(Auth::guard('admin')->check())
|
||||||
|
<div class="modal fade" id="modalAkreInstrumen" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h1 class="modal-title fs-5">Tambah Folder/Instrumen Akreditasi</h1>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<form id="formAkreInstrumen" autocomplete="off">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="alert alert-info small mb-3">
|
||||||
|
Isi <strong>Type</strong> untuk membuat folder utama. Isi <strong>Segment</strong> untuk membuat sub-folder di dalam type.
|
||||||
|
Isi <strong>Item</strong> untuk membuat item di dalam segment.
|
||||||
|
</div>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label fw-semibold">Type <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" name="type" id="akreTypeInput" placeholder="Contoh: Tata Kelola Rumah Sakit (TKRS)" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-semibold">Segment</label>
|
||||||
|
<textarea class="form-control" name="segment" id="akreSegmentInput" rows="3" placeholder="Contoh: TKRS 7, TKRS 8, TKRS 9"></textarea>
|
||||||
|
<div class="form-text text-muted">Opsional. Bisa isi banyak (pisahkan dengan koma atau baris baru).</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-semibold">Item</label>
|
||||||
|
<textarea class="form-control" name="item" id="akreItemInput" rows="3" placeholder="Contoh: TKRS 7.a, TKRS 7.b, TKRS 7.c" disabled></textarea>
|
||||||
|
<div class="form-text text-muted">Aktif jika segment diisi. Bisa isi banyak (pisahkan dengan koma atau baris baru).</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Tutup</button>
|
||||||
|
<button type="submit" class="btn btn-primary" id="btnSaveAkreInstrumen">Simpan</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// === STATE MANAGEMENT ===
|
// === STATE MANAGEMENT ===
|
||||||
let currentData = [];
|
let currentData = [];
|
||||||
let selectedIds = [];
|
let selectedIds = [];
|
||||||
let expandedFolders = new Set();
|
let expandedFolders = new Set();
|
||||||
let searchTimer;
|
let searchTimer;
|
||||||
|
const isAdminUser = @json(Auth::guard('admin')->check());
|
||||||
const btn = document.getElementById('btnDownloadMultiple');
|
const btn = document.getElementById('btnDownloadMultiple');
|
||||||
const tableState = { page: 1, pageSize: 10, lastPage: 1, total: 0, from: 0, to: 0, search: '' };
|
const tableState = { page: 1, pageSize: 10, lastPage: 1, total: 0, from: 0, to: 0, search: '' };
|
||||||
const paginationEl = document.getElementById('paginationControls');
|
const paginationEl = document.getElementById('paginationControls');
|
||||||
@ -150,8 +223,20 @@
|
|||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
fetchData();
|
fetchData();
|
||||||
initEventListeners();
|
initEventListeners();
|
||||||
|
initTooltips();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function initTooltips() {
|
||||||
|
try {
|
||||||
|
if (!window.bootstrap?.Tooltip) return;
|
||||||
|
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
|
||||||
|
const existing = bootstrap.Tooltip.getInstance(el);
|
||||||
|
if (existing) existing.dispose();
|
||||||
|
new bootstrap.Tooltip(el);
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
// === EVENT LISTENERS ===
|
// === EVENT LISTENERS ===
|
||||||
function initEventListeners() {
|
function initEventListeners() {
|
||||||
// Search dengan Debounce
|
// Search dengan Debounce
|
||||||
@ -244,6 +329,7 @@
|
|||||||
expandAllFolders(tree, '');
|
expandAllFolders(tree, '');
|
||||||
}
|
}
|
||||||
renderTree(tbody, tree, '', 0);
|
renderTree(tbody, tree, '', 0);
|
||||||
|
initTooltips();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -318,6 +404,8 @@
|
|||||||
const folderPath = basePath ? `${basePath}/${folder}` : folder;
|
const folderPath = basePath ? `${basePath}/${folder}` : folder;
|
||||||
const isExpanded = expandedFolders.has(folderPath);
|
const isExpanded = expandedFolders.has(folderPath);
|
||||||
const folderNode = node.folders[folder];
|
const folderNode = node.folders[folder];
|
||||||
|
const depth = folderPath.split('/').filter(Boolean).length;
|
||||||
|
const canAddChild = isAdminUser && (depth === 1 || depth === 2);
|
||||||
|
|
||||||
let lines = '';
|
let lines = '';
|
||||||
for (let i = 0; i < level; i++) {
|
for (let i = 0; i < level; i++) {
|
||||||
@ -334,6 +422,17 @@
|
|||||||
</span>
|
</span>
|
||||||
<i class="ti ti-folder-filled folder-icon me-2"></i>
|
<i class="ti ti-folder-filled folder-icon me-2"></i>
|
||||||
<strong class="text-dark">${folder}</strong>
|
<strong class="text-dark">${folder}</strong>
|
||||||
|
${canAddChild ? `
|
||||||
|
<span class="folder-actions">
|
||||||
|
<button type="button"
|
||||||
|
class="badge bg-primary"
|
||||||
|
data-bs-toggle="tooltip"
|
||||||
|
data-bs-title="${depth === 1 ? 'Tambah Segment' : 'Tambah Item'}"
|
||||||
|
onclick="event.stopPropagation(); openAddAkreFromFolder('${folderPath.replace(/'/g, "\\\\'")}')">
|
||||||
|
<i class="ti ti-plus me-1"></i>${depth === 1 ? 'Segment' : 'Item'}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td><span class="badge bg-light text-muted border-0">FOLDER</span></td>
|
<td><span class="badge bg-light text-muted border-0">FOLDER</span></td>
|
||||||
@ -603,6 +702,29 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function refreshAkreData(){
|
||||||
|
akreLoaded = false;
|
||||||
|
akreData = [];
|
||||||
|
akreFlat = [];
|
||||||
|
return loadAkreData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshAkreSelects(){
|
||||||
|
return refreshAkreData().then(() => {
|
||||||
|
document.querySelectorAll('select.akre-select').forEach(selectEl => {
|
||||||
|
const current = selectEl.value;
|
||||||
|
fillAkreSelect(selectEl);
|
||||||
|
if(current) {
|
||||||
|
// restore selection if still exists
|
||||||
|
selectEl.value = current;
|
||||||
|
}
|
||||||
|
if(window.$ && $.fn.select2){
|
||||||
|
$(selectEl).trigger('change');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getAkreFlat(){
|
function getAkreFlat(){
|
||||||
if(akreFlat.length) return akreFlat;
|
if(akreFlat.length) return akreFlat;
|
||||||
akreFlat = (akreData || []).flatMap(type => {
|
akreFlat = (akreData || []).flatMap(type => {
|
||||||
@ -967,6 +1089,134 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Admin: tambah folder/instrumen akreditasi (disimpan di public/json/akreditasi.jff)
|
||||||
|
const formAkreInstrumen = document.getElementById('formAkreInstrumen');
|
||||||
|
const akreSegmentInput = document.getElementById('akreSegmentInput');
|
||||||
|
const akreItemInput = document.getElementById('akreItemInput');
|
||||||
|
|
||||||
|
if (akreSegmentInput && akreItemInput) {
|
||||||
|
const updateItemEnabled = () => {
|
||||||
|
const hasSegment = String(akreSegmentInput.value || '').trim().length > 0;
|
||||||
|
akreItemInput.disabled = !hasSegment;
|
||||||
|
if (!hasSegment) akreItemInput.value = '';
|
||||||
|
};
|
||||||
|
akreSegmentInput.addEventListener('input', updateItemEnabled);
|
||||||
|
updateItemEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formAkreInstrumen) {
|
||||||
|
formAkreInstrumen.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const btn = document.getElementById('btnSaveAkreInstrumen');
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
if (btn) btn.textContent = 'menyimpan...';
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
type: String(document.getElementById('akreTypeInput')?.value || '').trim(),
|
||||||
|
segment: String(document.getElementById('akreSegmentInput')?.value || '').trim(),
|
||||||
|
item: String(document.getElementById('akreItemInput')?.value || '').trim(),
|
||||||
|
};
|
||||||
|
if (!payload.segment) delete payload.segment;
|
||||||
|
if (!payload.item) delete payload.item;
|
||||||
|
|
||||||
|
fetch('/akreditasi/instrumen', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': document.querySelector('input[name="_token"]')?.value || '',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
.then(async (res) => {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = data?.message || (data?.errors ? Object.values(data.errors).flat().join(' ') : '') || 'Terjadi kesalahan.';
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
if (data?.status === false) {
|
||||||
|
throw new Error(data?.message || 'Terjadi kesalahan.');
|
||||||
|
}
|
||||||
|
Swal.fire({
|
||||||
|
icon: 'success',
|
||||||
|
title: 'Berhasil',
|
||||||
|
text: data?.message || 'Folder akreditasi berhasil ditambahkan.',
|
||||||
|
timer: 1500,
|
||||||
|
showConfirmButton: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const modalEl = document.getElementById('modalAkreInstrumen');
|
||||||
|
const modalInstance = modalEl ? bootstrap.Modal.getInstance(modalEl) : null;
|
||||||
|
modalInstance?.hide();
|
||||||
|
|
||||||
|
formAkreInstrumen.reset();
|
||||||
|
if (akreItemInput) akreItemInput.disabled = true;
|
||||||
|
|
||||||
|
// refresh dropdown instrumen di form upload
|
||||||
|
return refreshAkreSelects();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
Swal.fire({
|
||||||
|
icon: 'error',
|
||||||
|
title: 'Gagal',
|
||||||
|
text: err?.message || 'Terjadi kesalahan.',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
if (btn) btn.textContent = 'Simpan';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Klik tombol "+" di samping folder (tree view) untuk menambah sub-folder sesuai level:
|
||||||
|
// - depth 1 (Type) -> tambah Segment
|
||||||
|
// - depth 2 (Type/Segment) -> tambah Item
|
||||||
|
window.openAddAkreFromFolder = function(folderPath){
|
||||||
|
const modalEl = document.getElementById('modalAkreInstrumen');
|
||||||
|
if (!modalEl) return;
|
||||||
|
|
||||||
|
const typeInput = document.getElementById('akreTypeInput');
|
||||||
|
const segmentInput = document.getElementById('akreSegmentInput');
|
||||||
|
const itemInput = document.getElementById('akreItemInput');
|
||||||
|
if (!typeInput || !segmentInput || !itemInput) return;
|
||||||
|
|
||||||
|
const parts = String(folderPath || '').split('/').filter(Boolean);
|
||||||
|
const depth = parts.length;
|
||||||
|
|
||||||
|
// reset state
|
||||||
|
typeInput.readOnly = false;
|
||||||
|
segmentInput.readOnly = false;
|
||||||
|
itemInput.disabled = true;
|
||||||
|
itemInput.readOnly = false;
|
||||||
|
|
||||||
|
if (depth >= 1) {
|
||||||
|
typeInput.value = parts[0] || '';
|
||||||
|
typeInput.readOnly = true;
|
||||||
|
} else {
|
||||||
|
typeInput.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (depth >= 2) {
|
||||||
|
segmentInput.value = parts[1] || '';
|
||||||
|
segmentInput.readOnly = true;
|
||||||
|
itemInput.disabled = false;
|
||||||
|
itemInput.value = '';
|
||||||
|
} else {
|
||||||
|
segmentInput.value = '';
|
||||||
|
itemInput.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = bootstrap.Modal.getOrCreateInstance(modalEl);
|
||||||
|
instance.show();
|
||||||
|
|
||||||
|
// fokus ke field yang tepat
|
||||||
|
if (depth >= 2) {
|
||||||
|
itemInput.focus();
|
||||||
|
} else {
|
||||||
|
segmentInput.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('change', function(e){
|
document.addEventListener('change', function(e){
|
||||||
if(e.target.classList.contains('akre-select')){
|
if(e.target.classList.contains('akre-select')){
|
||||||
const id = e.target.id || '';
|
const id = e.target.id || '';
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\AksesFileController;
|
use App\Http\Controllers\AksesFileController;
|
||||||
|
use App\Http\Controllers\AkreditasiInstrumenController;
|
||||||
use App\Http\Controllers\AuthController;
|
use App\Http\Controllers\AuthController;
|
||||||
use App\Http\Controllers\DashboardController;
|
use App\Http\Controllers\DashboardController;
|
||||||
use App\Http\Controllers\MasterKategoriController;
|
use App\Http\Controllers\MasterKategoriController;
|
||||||
@ -18,6 +19,10 @@ Route::middleware(['auth:admin,web'])->group(function(){
|
|||||||
Route::get('/datatable-umum', [DashboardController::class, 'datatableDataUmum']);
|
Route::get('/datatable-umum', [DashboardController::class, 'datatableDataUmum']);
|
||||||
Route::get('/data-akreditasi', [DashboardController::class, 'dataAkreditasi']);
|
Route::get('/data-akreditasi', [DashboardController::class, 'dataAkreditasi']);
|
||||||
Route::get('/datatable-akreditasi', [DashboardController::class, 'dataTableAkreditasi']);
|
Route::get('/datatable-akreditasi', [DashboardController::class, 'dataTableAkreditasi']);
|
||||||
|
|
||||||
|
// Kelola "folder/instrumen" akreditasi via file: public/json/akreditasi.jff (tanpa database)
|
||||||
|
Route::get('/akreditasi/instrumen', [AkreditasiInstrumenController::class, 'index']);
|
||||||
|
Route::post('/akreditasi/instrumen', [AkreditasiInstrumenController::class, 'store']);
|
||||||
Route::get('/download-excel/data-umum', [DashboardController::class, 'downloadDataUmumExcel']);
|
Route::get('/download-excel/data-umum', [DashboardController::class, 'downloadDataUmumExcel']);
|
||||||
Route::post('/uploadv2', [DashboardController::class, 'storeVersion2']);
|
Route::post('/uploadv2', [DashboardController::class, 'storeVersion2']);
|
||||||
Route::get('/file-preview/{id}', [DashboardController::class, 'dataPdf']);
|
Route::get('/file-preview/{id}', [DashboardController::class, 'dataPdf']);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user