fixing download admin & menambahkan fitur tambah folder pada dokumen akreditasi

This commit is contained in:
JokoPrasetio 2026-05-05 10:56:30 +07:00
parent 5786369973
commit f6031e32fe
6 changed files with 6965 additions and 1763 deletions

View 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

File diff suppressed because it is too large Load Diff

View File

View File

@ -36,6 +36,26 @@
.file-icon { color: #6c757d; font-size: 1.1rem; }
.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>
@section('body_main')
<div class="row">
@ -65,6 +85,17 @@
0 dipilih
</span>
</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 -->
@if(!Auth::guard('admin')->check())
<button
@ -137,12 +168,54 @@
@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>
// === STATE MANAGEMENT ===
let currentData = [];
let selectedIds = [];
let expandedFolders = new Set();
let searchTimer;
const isAdminUser = @json(Auth::guard('admin')->check());
const btn = document.getElementById('btnDownloadMultiple');
const tableState = { page: 1, pageSize: 10, lastPage: 1, total: 0, from: 0, to: 0, search: '' };
const paginationEl = document.getElementById('paginationControls');
@ -150,8 +223,20 @@
document.addEventListener('DOMContentLoaded', () => {
fetchData();
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 ===
function initEventListeners() {
// Search dengan Debounce
@ -244,6 +329,7 @@
expandAllFolders(tree, '');
}
renderTree(tbody, tree, '', 0);
initTooltips();
}
/**
@ -318,6 +404,8 @@
const folderPath = basePath ? `${basePath}/${folder}` : folder;
const isExpanded = expandedFolders.has(folderPath);
const folderNode = node.folders[folder];
const depth = folderPath.split('/').filter(Boolean).length;
const canAddChild = isAdminUser && (depth === 1 || depth === 2);
let lines = '';
for (let i = 0; i < level; i++) {
@ -334,6 +422,17 @@
</span>
<i class="ti ti-folder-filled folder-icon me-2"></i>
<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>
</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(){
if(akreFlat.length) return akreFlat;
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){
if(e.target.classList.contains('akre-select')){
const id = e.target.id || '';

View File

@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\AksesFileController;
use App\Http\Controllers\AkreditasiInstrumenController;
use App\Http\Controllers\AuthController;
use App\Http\Controllers\DashboardController;
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('/data-akreditasi', [DashboardController::class, 'dataAkreditasi']);
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::post('/uploadv2', [DashboardController::class, 'storeVersion2']);
Route::get('/file-preview/{id}', [DashboardController::class, 'dataPdf']);