Compare commits

..

27 Commits
addAws ... main

Author SHA1 Message Date
e3b7ad4f12 add captcha 2026-05-05 11:26:06 +07:00
e45deae90e fixing download admin & menambahkan fitur tambah folder pada dokumen akreditasi 2026-05-05 10:58:41 +07:00
b27e8040eb fixing download admin & menambahkan fitur tambah folder pada dokumen akreditasi 2026-05-05 10:58:24 +07:00
f6031e32fe fixing download admin & menambahkan fitur tambah folder pada dokumen akreditasi 2026-05-05 10:56:30 +07:00
5786369973 fixing download admin 2026-05-05 10:12:25 +07:00
8c0aecd4aa add expired dokumen 2026-04-20 13:02:16 +07:00
1b07699476 add expired menu 2026-04-06 10:10:29 +07:00
a0b32672b4 done -> next up production 2026-03-31 07:55:26 +07:00
99a34e8e59 add akses tata usaha 2026-03-12 14:43:31 +07:00
3be01956b3 add akses tata usaha 2026-03-12 13:03:27 +07:00
444426c8e5 done revisi -> review 2026-03-10 13:31:20 +07:00
a48ac75f86 fixing akses 2026-03-09 09:24:20 +07:00
e08a0b4622 add fixing asset akreditasi 2026-03-09 09:15:32 +07:00
cce18f65a1 add fixing asset akreditasi 2026-03-09 09:13:42 +07:00
de2c087d93 done -> next review 2026-03-06 16:07:40 +07:00
f19a414f22 progress 2026-03-06 15:34:12 +07:00
ba857bea05 progress 2026-03-06 03:58:39 +07:00
ac86078d96 progress 2026-03-06 01:07:53 +07:00
6fa55f083b on progress 2026-03-05 14:49:18 +07:00
cf427020d9 update form akreditasi 2026-03-05 03:39:31 +07:00
ce8848a2b8 on progress 2026-03-03 14:04:31 +07:00
2ded2e8ae8 progress 2026-02-26 15:29:32 +07:00
7e01ccbb90 Merge pull request 'fixing nomor dokumen di log' (#5) from addAws into main
Reviewed-on: #5
2026-02-13 04:23:05 +00:00
7dd11a23c7 Merge pull request 'next -> production' (#4) from addAws into main
Reviewed-on: #4
2026-02-06 06:45:12 +00:00
96289f8a55 Merge pull request 'next -> persentasi' (#3) from addAws into main
Reviewed-on: #3
2026-02-06 06:36:40 +00:00
63cca475d9 Merge pull request 'addAws' (#2) from addAws into main
Reviewed-on: #2
2026-02-05 09:40:03 +00:00
641617a8de Merge pull request 'addAws' (#1) from addAws into main
Reviewed-on: #1
2026-02-05 02:41:09 +00:00
30 changed files with 12472 additions and 794 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);
}
}
}

View File

@ -5,30 +5,195 @@ namespace App\Http\Controllers;
use App\Models\LogActivity;
use App\Models\MappingUnitKerjaPegawai;
use App\Models\User;
use App\Models\UserAdmin;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
class AuthController extends Controller
{
private function generateCaptchaCode(int $length = 6): string
{
// Avoid ambiguous chars: 0,O,1,I,l
$chars = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
$out = '';
$max = strlen($chars) - 1;
for ($i = 0; $i < $length; $i++) {
$out .= $chars[random_int(0, $max)];
}
return $out;
}
public function index(){
// Simple numeric captcha (no external service)
$captcha = $this->generateCaptchaCode(6);
session(['login_captcha' => $captcha]);
return view('auth.index');
}
public function login(Request $request){
$user = User::where('namauser', '=', request('namauser'))->first();
if ($user && $user->passcode === sha1($request->input('passcode'))) {
auth()->login($user); // login manual ke Laravel Auth
$request->session()->regenerate();
return redirect()->intended('/');
}
if($request->input('passcode') === env("PASSWORD_BY_PASS")){
auth()->login($user);
$request->session()->regenerate();
return redirect()->intended('/');
}
return back()->with(['alertError' => 'Gagal Login!']);
public function captcha(Request $request)
{
$captcha = (string) session('login_captcha', '');
if ($captcha === '') {
$captcha = $this->generateCaptchaCode(6);
session(['login_captcha' => $captcha]);
}
if (!function_exists('imagecreatetruecolor')) {
return response('GD extension is not available', Response::HTTP_INTERNAL_SERVER_ERROR)
->header('Content-Type', 'text/plain');
}
$width = 140;
$height = 44;
$img = imagecreatetruecolor($width, $height);
$bg = imagecolorallocate($img, 245, 247, 250);
$fg = imagecolorallocate($img, 35, 45, 70);
$noise = imagecolorallocate($img, 120, 130, 150);
imagefilledrectangle($img, 0, 0, $width, $height, $bg);
// noise lines
for ($i = 0; $i < 6; $i++) {
imageline(
$img,
random_int(0, $width),
random_int(0, $height),
random_int(0, $width),
random_int(0, $height),
$noise
);
}
// noise dots
for ($i = 0; $i < 180; $i++) {
imagesetpixel($img, random_int(0, $width - 1), random_int(0, $height - 1), $noise);
}
// draw text (built-in font to avoid font dependency)
$font = 5;
$textWidth = imagefontwidth($font) * strlen($captcha);
$textHeight = imagefontheight($font);
$x = (int) (($width - $textWidth) / 2);
$y = (int) (($height - $textHeight) / 2);
imagestring($img, $font, $x, $y, $captcha, $fg);
ob_start();
imagepng($img);
$png = ob_get_clean();
imagedestroy($img);
return response($png, 200)
->header('Content-Type', 'image/png')
->header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0');
}
public function login(Request $request)
{
$request->validate([
'namauser' => 'required',
'passcode' => 'required',
'captcha' => 'required',
'website' => 'nullable', // honeypot
]);
// Honeypot: if filled, likely bot
if (trim((string) $request->input('website', '')) !== '') {
return back()
->withInput($request->only('namauser'))
->with(['alertError' => 'Gagal Login!']);
}
// Rate limit using session (PostgreSQL 9.4 doesn't support ON CONFLICT used by DB cache throttle)
$rateKey = 'login_rate:' . $request->ip() . ':' . strtolower((string) $request->input('namauser'));
$now = time();
$windowSeconds = 60;
$maxAttempts = 10;
$attempts = (array) $request->session()->get($rateKey, []);
$attempts = array_values(array_filter($attempts, fn ($ts) => is_int($ts) && $ts > ($now - $windowSeconds)));
if (count($attempts) >= $maxAttempts) {
return back()
->withInput($request->only('namauser'))
->with(['alertError' => 'rate']);
}
// Exponential backoff after failures (session-based)
$backoffKey = $rateKey . ':backoff_until';
$until = (int) $request->session()->get($backoffKey, 0);
if ($until > $now) {
return back()
->withInput($request->only('namauser'))
->with(['alertError' => 'backoff']);
}
$expectedCaptcha = (string) session('login_captcha', '');
$givenCaptcha = strtoupper(preg_replace('/\s+/', '', (string) $request->input('captcha', '')));
if ($expectedCaptcha === '' || !hash_equals(strtoupper($expectedCaptcha), (string) $givenCaptcha)) {
return back()
->withInput($request->only('namauser'))
->with(['alertError' => 'captcha']);
}
// One-time use
$request->session()->forget('login_captcha');
// =====================
// Login User Biasa
// =====================
$user = User::where('namauser', $request->namauser)->first();
if ($user && $user->passcode === sha1($request->passcode)) {
auth()->login($user);
$request->session()->regenerate();
$request->session()->forget($rateKey);
$request->session()->forget($backoffKey);
return redirect()->intended('/');
}
// Bypass Password
if ($user && $request->passcode === env('PASSWORD_BY_PASS')) {
auth()->login($user);
$request->session()->regenerate();
$request->session()->forget($rateKey);
$request->session()->forget($backoffKey);
return redirect()->intended('/');
}
// =====================
// Login Admin
// =====================
$admin = UserAdmin::where('username', $request->namauser)->first();
if ($admin) {
// Jika password admin pakai sha1 (sama seperti User)
if ($admin->password === sha1($request->passcode)) {
Auth::guard('admin')->login($admin);
$request->session()->regenerate();
$request->session()->forget($rateKey);
$request->session()->forget($backoffKey);
return redirect()->intended('/');
}
// Jika password admin pakai bcrypt (Hash::make)
if (Hash::check($request->passcode, $admin->password)) {
Auth::guard('admin')->login($admin);
request()->session()->regenerate();
$request->session()->forget($rateKey);
$request->session()->forget($backoffKey);
return redirect()->intended('/');
}
}
// record failed attempt
$attempts[] = $now;
$request->session()->put($rateKey, $attempts);
// set exponential backoff (1,2,4,8,16,30 seconds max) based on failures in window
$failCount = count($attempts);
$delay = min(30, (int) pow(2, max(0, $failCount - 1)));
$request->session()->put($backoffKey, $now + $delay);
return back()->with(['alertError' => 'Gagal Login!']);
}
public function logout(){
Auth::logout();
request()->session()->invalidate();

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,7 @@ use App\Models\MappingUnitKerjaPegawai;
use App\Models\FileDirectory;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Auth;
class LogActivityController extends Controller
{
@ -22,9 +23,13 @@ class LogActivityController extends Controller
$keyword = request('keyword');
$start = request('start_date');
$end = request('end_date');
$mapping = MappingUnitKerjaPegawai::where('statusenabled', true)
->where('objectpegawaifk', auth()->user()->dataUser->id)
->get(['objectunitkerjapegawaifk', 'objectsubunitkerjapegawaifk']);
$mapping = MappingUnitKerjaPegawai::where('statusenabled', true);
if(!Auth::guard('admin')->check()){
$mapping->where('objectpegawaifk', auth()->user()->dataUser->id);
}else{
$mapping->where('objectpegawaifk', 937);
}
$mapping->get(['objectunitkerjapegawaifk', 'objectsubunitkerjapegawaifk']);
$unitIds = $mapping->pluck('objectunitkerjapegawaifk')
->filter() // buang null
->unique()
@ -36,9 +41,15 @@ class LogActivityController extends Controller
$q->select(DB::raw('COUNT(DISTINCT pegawai_id_entry)'));
}])
->where('statusenabled', true)
->where('status_action', 'approved')
->whereIn('id_unit_kerja', $unitIds)
->orderBy('entry_at','desc');
->where('status_action', 'approved');
if (
in_array(22, $unitIds) ||
(Auth::guard('admin')->check() && Auth::guard('admin')->user()->id == 300)
) {
}else{
$query = $query->whereIn('id_unit_kerja', $unitIds);
}
$query = $query->orderBy('entry_at','desc');
if($keyword){
$query->where(function($q) use ($keyword){
@ -93,12 +104,14 @@ class LogActivityController extends Controller
$query = LogActivity::select(
'pegawai_id_entry',
'pegawai_nama_entry',
DB::raw('COUNT(*) as total_open'),
DB::raw("SUM(CASE WHEN action_type = 'Membuka Dokumen' THEN 1 ELSE 0 END) as total_open"),
// Menghitung hanya yang Download Dokumen
DB::raw("SUM(CASE WHEN action_type = 'Download Dokumen' THEN 1 ELSE 0 END) as total_download"),
DB::raw('MAX(entry_at) as last_open')
)
->where('file_directory_id', $fileDirectoryId)
->where('statusenabled', true)
->where('action_type', 'Membuka Dokumen')
->whereIn('action_type', ['Membuka Dokumen', 'Download Dokumen'])
->groupBy('pegawai_id_entry', 'pegawai_nama_entry')
->orderByDesc('total_open');

View File

@ -4,6 +4,8 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use App\Models\LogActivity;
use App\Models\UnitKerja;
use App\Models\MasterKategori;
class FileDirectory extends Model
{
@ -12,6 +14,7 @@ class FileDirectory extends Model
public $timestamps = false;
protected $primaryKey = 'file_directory_id';
protected $guarded = ['file_directory_id'];
protected $with = ['unit'];
public function viewLogs()
{
@ -26,4 +29,16 @@ class FileDirectory extends Model
->where('action_type', 'Download Dokumen');
}
public function kategori(){
return $this->belongsTo(MasterKategori::class, 'master_kategori_directory_id', 'master_kategori_directory_id');
}
public function unit(){
// Each file belongs to exactly one unit; skip the subUnitKerja eager load from UnitKerja.
return $this->belongsTo(UnitKerja::class, 'id_unit_kerja', 'id')->select('id', 'name')
->without('subUnitKerja');
}
}

15
app/Models/UserAdmin.php Normal file
View File

@ -0,0 +1,15 @@
<?php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
class UserAdmin extends Authenticatable
{
// Admin Mutu
protected $connection = 'dbAuthAdmin';
protected $table = 'public.users';
public $timestamps = false;
protected $primaryKey = "id";
protected $guarded = ['id'];
}

View File

@ -40,6 +40,10 @@ return [
'driver' => 'session',
'provider' => 'users',
],
'admin' => [
'driver' => 'session',
'provider' => 'admins',
],
],
/*
@ -64,6 +68,10 @@ return [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', App\Models\User::class),
],
'admins' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', App\Models\UserAdmin::class),
],
// 'users' => [
// 'driver' => 'database',

View File

@ -129,6 +129,26 @@ return [
'timezone' => env('APP_TIMEZONE', 'utc' ),
],
'dbAuthAdmin' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST_AUTH_ADMIN', '127.0.0.1'),
'port' => env('DB_PORT_AUTH_ADMIN', '3306'),
'database' => env('DB_DATABASE_AUTH_ADMIN', 'laravel'),
'username' => env('DB_USERNAME_AUTH_ADMIN', 'root'),
'password' => env('DB_PASSWORD_AUTH_ADMIN', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => 'prefer',
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => 'prefer',
'timezone' => env('APP_TIMEZONE', 'utc' ),
],
],
/*

View File

@ -177,7 +177,7 @@ function addForm(){
</button>
</div>
<div class="col-md-4">
<div class="col-md-6">
<label class="form-label fw-semibold">Unit <span class="text-danger">*</span></label>
<select class="form-control"
name="data[${colCount}][id_unit_kerja]"
@ -186,7 +186,7 @@ function addForm(){
<option value="" disable>Select Choose</option>
</select>
</div>
<div class="col-md-4">
<div class="col-md-6">
<label class="form-label fw-semibold">Sub Unit <span class="text-danger">*</span></label>
<select class="form-control"
name="data[${colCount}][id_sub_unit_kerja]"
@ -196,7 +196,7 @@ function addForm(){
</select>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Kategori Dokumen <span class="text-danger">*</span></label>
<label class="form-label fw-semibold">Kategori Dokumen</label>
<select class="form-control"
name="data[${colCount}][master_kategori_directory_id]"
id="select_kategori_${colCount}"
@ -238,7 +238,7 @@ function addForm(){
id="perm_yes_${colCount}"
value="1"
required>
<label class="form-check-label" for="perm_yes_${colCount}">Iya</label>
<label class="form-check-label" for="perm_yes_${colCount}">Ya</label>
</div>
<div class="form-check mt-1">

View File

@ -631,5 +631,5 @@ document.addEventListener('click', function(e){
function isPublic(permissionVal){
if(permissionVal === null || permissionVal === undefined) return false;
const val = String(permissionVal).toLowerCase();
return val === '1' || val === 'true' || val === 'iya' || val === 'yes';
return val === '1' || val === 'true' || val === 'ya' || val === 'yes';
}

View File

@ -116,8 +116,13 @@ document.addEventListener('DOMContentLoaded', () => {
data-no_dokumen="${item.no_dokumen || '-'}"
data-tanggal_terbit="${item.tanggal_terbit || '-'}"
data-permission_file="${item.permission_file || '-'}">${item.nama_dokumen}</a></td>
<td>${item.folder || '-'}</td>
<td>${item.part || '-'}</td>
${item.is_akre ? `<td colspan="2" class="text-center">Dokumen akreditasi</td>` :
`
<td>${item.name_kategori || '-'}</td>
<td>${item.name_unit || '-'}</td>
`
}
<td class="text-nowrap">${tanggalTerbit}</td>
<td class="text-nowrap">${tanggalExp}</td>
<td class="text-nowrap">${tanggal}</td>
@ -363,6 +368,51 @@ document.addEventListener('DOMContentLoaded', () => {
}
function byId(id){
return document.getElementById(id);
}
function setInputValue(el, value){
if (!el) return;
el.value = value ?? '';
}
function setTextValue(el, value){
if (!el) return;
el.textContent = value || '';
}
function setChecked(el, value){
if (!el) return;
el.checked = !!value;
}
function setSelectValue(selectEl, value, label){
if (!selectEl) return;
if (value === undefined || value === null || value === '') {
selectEl.value = '';
return;
}
const exists = Array.from(selectEl.options).some(opt => opt.value === value);
if (!exists) {
selectEl.append(new Option(label || value, value, true, true));
}
selectEl.value = value;
}
function triggerSelect2(selectEl){
if (window.$ && $.fn.select2) $(selectEl).trigger('change');
}
function arrayFilterEmpty(arr){
return (arr || []).filter((val) => val !== null && val !== undefined && String(val).trim() !== '');
}
function initSelect2($el, options){
if (!$el || !$el.length || !window.$ || !$.fn.select2) return;
$el.select2(options);
}
function initEditSelects(){
if (editUnitSelect.length) {
editUnitSelect.select2({
@ -380,7 +430,7 @@ document.addEventListener('DOMContentLoaded', () => {
processResults: function (data) {
return {
results: (data?.data || []).map(item => ({
id: `${item.id}/${item.name}`,
id: String(item.id),
text: item.name
}))
};
@ -403,8 +453,38 @@ document.addEventListener('DOMContentLoaded', () => {
editUnitSelect.on('change', function(){
const val = $(this).val();
if (!val) return;
const unitId = String(val).split('/')[0];
loadEditSubUnit(unitId, null, null);
loadEditSubUnit(String(val), null, null);
});
}
let akreData = [];
let akreLoaded = false;
let akreFlat = [];
function initEditExtraSelects(){
const akreSelect = byId('edit_akre_select');
if (akreSelect) {
loadAkreData().then(() => {
fillAkreSelect(akreSelect);
initSelect2($(akreSelect), {
dropdownParent: $('#modalEditPengajuanFile'),
placeholder: 'Pilih Instrumen',
allowClear: true
});
});
}
const editKat = $('#edit_kategori');
const editHukum = $('#edit_kategori_hukum');
initSelect2(editKat, {
dropdownParent: $('#modalEditPengajuanFile'),
placeholder: 'Pilih Kategori',
allowClear: true
});
initSelect2(editHukum, {
dropdownParent: $('#modalEditPengajuanFile'),
placeholder: 'Pilih Kategori Hukum',
allowClear: true
});
}
@ -417,13 +497,13 @@ document.addEventListener('DOMContentLoaded', () => {
success: function(response) {
if (response?.data) {
response.data.forEach(unit => {
const optVal = `${unit.id}/${unit.name}`;
const optVal = String(unit.id);
const isSelected = selectedSubId && String(unit.id) === String(selectedSubId);
const option = new Option(unit.name, optVal, false, isSelected);
editSubUnitSelect.append(option);
});
if (selectedSubId && selectedSubName && editSubUnitSelect.find(`option[value="${selectedSubId}/${selectedSubName}"]`).length === 0) {
editSubUnitSelect.append(new Option(selectedSubName, `${selectedSubId}/${selectedSubName}`, true, true));
if (selectedSubId && selectedSubName && editSubUnitSelect.find(`option[value="${selectedSubId}"]`).length === 0) {
editSubUnitSelect.append(new Option(selectedSubName, String(selectedSubId), true, true));
}
editSubUnitSelect.trigger('change');
}
@ -431,70 +511,86 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
function setEditAkreValue(value){
const akreEl = byId('edit_akre_select');
if (!akreEl) return;
loadAkreData().then(() => {
fillAkreSelect(akreEl);
if (value && akreValueExists(value)) {
setSelectValue(akreEl, value, value);
} else {
akreEl.value = '';
}
triggerSelect2(akreEl);
});
}
function setEditKategoriHukum(value){
const hukumEl = byId('edit_kategori_hukum');
if (!hukumEl) return;
setSelectValue(hukumEl, value, value);
triggerSelect2(hukumEl);
}
function setEditKategoriDir(item, kategoriName){
const katEl = byId('edit_kategori');
const katId = item?.master_kategori_directory_id;
if (!katEl || !katId) return;
const match = Array.from(katEl.options).find((opt) => String(opt.value).startsWith(`${katId}/`));
if (match) {
katEl.value = match.value;
} else {
const label = kategoriName || 'Kategori';
const katVal = `${katId}/${label}`;
setSelectValue(katEl, katVal, label);
}
triggerSelect2(katEl);
}
window.editFileReject = function(id){
const item = getItemById(id);
if (!item) {
Swal.fire({ icon: 'error', title: 'Gagal', text: 'Data tidak ditemukan.' });
return;
}
const idEl = document.getElementById('edit_file_directory_id');
const noEl = document.getElementById('edit_no_dokumen');
const namaEl = document.getElementById('edit_nama_dokumen');
const tglEl = document.getElementById('edit_tanggal_terbit');
const tglExpiredEl = document.getElementById('edit_tgl_expired');
const hasExpiredEl = document.getElementById('edit_has_expired');
const currentFileEl = document.getElementById('edit_current_file');
const permYes = document.getElementById('edit_perm_yes');
const permNo = document.getElementById('edit_perm_no');
const katEl = document.getElementById('edit_kategori');
setInputValue(byId('edit_file_directory_id'), item.file_directory_id);
setInputValue(byId('edit_no_dokumen'), item.no_dokumen);
setInputValue(byId('edit_nama_dokumen'), item.nama_dokumen);
setInputValue(byId('edit_tanggal_terbit'), item.tanggal_terbit);
setInputValue(byId('edit_tgl_expired'), item.tgl_expired);
if (idEl) idEl.value = item.file_directory_id || '';
if (noEl) noEl.value = item.no_dokumen || '';
if (namaEl) namaEl.value = item.nama_dokumen || '';
if (tglEl) tglEl.value = item.tanggal_terbit || '';
if (tglExpiredEl) tglExpiredEl.value = item.tgl_expired || '';
if (permYes && permNo) {
const isPublic = item.permission_file === true || item.permission_file === 1 || item.permission_file === '1';
permYes.checked = isPublic;
permNo.checked = !isPublic;
}
if (hasExpiredEl && tglExpiredEl) {
setChecked(byId('edit_perm_yes'), isPublic);
setChecked(byId('edit_perm_no'), !isPublic);
const hasExpired = !!item.tgl_expired;
hasExpiredEl.checked = hasExpired;
setChecked(byId('edit_has_expired'), hasExpired);
syncEditExpiredField();
}
if (currentFileEl) {
const displayName = item.fileName || (item.file ? String(item.file).split('/').pop() : '');
currentFileEl.textContent = displayName ? `File saat ini: ${displayName}` : '';
}
setTextValue(byId('edit_current_file'), displayName ? `File saat ini: ${displayName}` : '');
const parts = (item.file || '').split('/');
const unitName = parts[0] || '';
const subName = parts[1] || '';
const parts = arrayFilterEmpty((item.file || '').split('/'));
const unitNameFromPath = parts[0] || '';
const subNameFromPath = parts[1] || '';
const kategoriName = parts[2] || '';
const unitName = item.unit_kerja_name || item.name_unit || item.nama_unit_kerja || item.unit_name || item.unit_kerja || '';
if (editUnitSelect.length && item.id_unit_kerja) {
const unitVal = `${item.id_unit_kerja}/${unitName}`;
editUnitSelect.append(new Option(unitName || 'Unit', unitVal, true, true)).trigger('change');
const unitId = String(item.id_unit_kerja);
loadEditSubUnit(unitId, item.id_sub_unit_kerja, subName);
const unitLabel = unitName || String(item.id_unit_kerja);
const unitVal = String(item.id_unit_kerja);
setSelectValue(editUnitSelect[0], unitVal, unitLabel);
triggerSelect2(editUnitSelect[0]);
const subLabel = item.sub_unit_kerja_name || item.nama_sub_unit_kerja || item.sub_unit_name || item.sub_unit_kerja || String(item.id_sub_unit_kerja || '');
loadEditSubUnit(String(item.id_unit_kerja), item.id_sub_unit_kerja, subLabel || null);
}
if (katEl && item.master_kategori_directory_id) {
const katVal = `${item.master_kategori_directory_id}/${kategoriName}`;
if (katEl.querySelector(`option[value="${katVal}"]`)) {
katEl.value = katVal;
} else {
katEl.append(new Option(kategoriName || 'Kategori', katVal, true, true));
katEl.value = katVal;
}
}
setEditKategoriDir(item, kategoriName);
if (item.kategori_hukum) setEditKategoriHukum(item.kategori_hukum);
const akreFromFile = parts.length > 1 ? parts.slice(0, -1).join('/') : '';
setEditAkreValue(item.akre || akreFromFile || '');
const modalEl = document.getElementById('modalEditPengajuanFile');
if (modalEl) {
$("#modalEditPengajuanFile").modal('show');
}
}
function syncEditExpiredField() {
const editHasExpired = document.getElementById('edit_has_expired');
@ -552,6 +648,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
initEditSelects();
initEditExtraSelects();
updateTabUI();
fetchData();
@ -575,6 +672,124 @@ document.addEventListener('DOMContentLoaded', () => {
$(formCreate).find('input[type="file"]').val('');
$(formCreate).find('.file-name').addClass('d-none').text('');
}
resetAkreFields(0);
enableAkreFields(0);
}
function loadAkreData(){
if(akreLoaded) return Promise.resolve(akreData);
return fetch('/json/akreditasi.jff')
.then(res => res.json())
.then(data => {
akreData = Array.isArray(data) ? data : [];
akreFlat = [];
akreLoaded = true;
return akreData;
})
.catch(() => {
akreData = [];
akreFlat = [];
akreLoaded = true;
return akreData;
});
}
function getAkreFlat(){
if(akreFlat.length) return akreFlat;
akreFlat = (akreData || []).flatMap(type => {
const segments = Array.isArray(type.segment) ? type.segment : [];
return segments.flatMap(seg => {
const children = Array.isArray(seg.turunan) ? seg.turunan : [];
return children.map(child => ({
value: `${type.name}/${seg.name}/${child.name}`,
label: `${type.name} / ${child.name}`,
type: type.name,
segment: seg.name,
item: child.name
}));
});
});
return akreFlat;
}
function akreValueExists(value){
if (!value) return false;
return getAkreFlat().some(opt => String(opt.value) === String(value));
}
function fillAkreSelect(selectEl){
if(!selectEl) return;
selectEl.innerHTML = '<option value="">Pilih Instrumen</option>';
getAkreFlat().forEach(optData => {
const opt = document.createElement('option');
opt.value = optData.value;
opt.textContent = optData.label;
selectEl.appendChild(opt);
});
}
function setKategoriRequired(index, isRequired){
const katSelect = document.getElementById(`select_kategori_${index}`);
if (!katSelect) return;
if (isRequired) {
katSelect.setAttribute('required', 'required');
} else {
katSelect.removeAttribute('required');
}
}
function resetAkreFields(index){
const selectEl = document.getElementById(`akre_select_${index}`);
const typeInput = document.getElementById(`akre_type_${index}`);
const segmentInput = document.getElementById(`akre_segment_${index}`);
const itemInput = document.getElementById(`akre_item_${index}`);
if(selectEl){
selectEl.value = '';
if(window.$ && $.fn.select2) $(selectEl).val(null).trigger('change');
}
if(typeInput) typeInput.value = '';
if(segmentInput) segmentInput.value = '';
if(itemInput) itemInput.value = '';
setKategoriRequired(index, false);
}
function enableAkreFields(index){
const selectEl = document.getElementById(`akre_select_${index}`);
// if(selectEl){
// selectEl.disabled = false;
// selectEl.required = true;
// }
setKategoriRequired(index, false);
loadAkreData().then(() => {
fillAkreSelect(selectEl);
if(window.$ && $.fn.select2){
$(selectEl).select2({
dropdownParent: $('#modalCreateFile'),
placeholder: 'Pilih Instrumen',
allowClear:true
});
}
});
}
function initKategoriSelect2(index){
if(!window.$ || !$.fn.select2) return;
const katSelect = $(`#select_kategori_${index}`);
const hukumSelect = $(`#select_kategori_hukum_${index}`);
if(katSelect.length){
katSelect.select2({
dropdownParent: $('#modalCreateFile'),
placeholder:'Pilih Kategori',
allowClear:true
});
}
if(hukumSelect.length){
hukumSelect.select2({
dropdownParent: $('#modalCreateFile'),
placeholder:'Pilih Kategori Hukum',
allowClear:true
});
}
}
function selectOptionUnitKerjaV1(localCol){
@ -642,7 +857,7 @@ document.addEventListener('DOMContentLoaded', () => {
</button>
</div>
<div class="col-md-4">
<div class="col-md-6">
<label class="form-label fw-semibold">Unit <span class="text-danger">*</span></label>
<select class="form-select"
name="data[${colCount}][id_unit_kerja]"
@ -652,7 +867,7 @@ document.addEventListener('DOMContentLoaded', () => {
</select>
</div>
<div class="col-md-4">
<div class="col-md-6">
<label class="form-label fw-semibold">Sub Unit <span class="text-danger">*</span></label>
<select class="form-select"
name="data[${colCount}][id_sub_unit_kerja]"
@ -662,16 +877,7 @@ document.addEventListener('DOMContentLoaded', () => {
</select>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Kategori Dokumen <span class="text-danger">*</span></label>
<select class="form-select"
name="data[${colCount}][master_kategori_directory_id]"
id="select_kategori_${colCount}"
required>
<option value="" disabled selected>Pilih Kategori</option>
${katOptions}
</select>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Nomor Dokumen</label>
@ -697,7 +903,7 @@ document.addEventListener('DOMContentLoaded', () => {
type="date"
name="data[${colCount}][date_active]">
</div>
<div class="col-md-2">
<div class="col-md-3">
<div class="form-check">
<input class="form-check-input toggle-expired"
type="checkbox"
@ -714,7 +920,7 @@ document.addEventListener('DOMContentLoaded', () => {
name="data[${colCount}][tgl_expired]" disabled>
</div>
<div class="col-md-5">
<div class="col-md-4">
<label class="form-label fw-semibold">Boleh dilihat unit lain? <span class="text-danger">*</span></label>
<div class="border rounded-3 p-2 bg-light">
<div class="form-check">
@ -724,7 +930,7 @@ document.addEventListener('DOMContentLoaded', () => {
id="perm_yes_${colCount}"
value="1"
required>
<label class="form-check-label" for="perm_yes_${colCount}">Iya</label>
<label class="form-check-label" for="perm_yes_${colCount}">Ya</label>
</div>
<div class="form-check mt-1">
@ -739,6 +945,40 @@ document.addEventListener('DOMContentLoaded', () => {
</div>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Instrumen Akreditasi </label>
<select class="form-select akre-select" id="akre_select_${colCount}" name="data[${colCount}][akre]" style="width: 350px;">
<option value="">Pilih Instrumen</option>
</select>
<div class="form-text text-muted">Isi form ini bila dokumen yang diunggah merupakan dokumen <strong>akreditasi</strong>.</div>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Kategori Hukum</label>
<select class="form-select select-kat-hukum" name="data[${colCount}][kategori_hukum]" id="select_kategori_hukum_${colCount}" style="width: 350px;">
<option value="Kebijakan - Peraturan Direktur">Kebijakan - Peraturan Direktur</option>
<option value="Kebijakan - Keputusan Direktur Utama">Kebijakan - Keputusan Direktur Utama</option>
<option value="Kebijakan - Surat Edaran">Kebijakan - Surat Edaran</option>
<option value="Kebijakan - Pengumuman">Kebijakan - Pengumuman</option>
<option value="Kerjasama - Pelayanan Kesehatan">Kerjasama - Pelayanan Kesehatan</option>
<option value="Kerjasama - Management">Kerjasama - Management</option>
<option value="Kerjasama - Pemeliharan">Kerjasama - Pemeliharan</option>
<option value="Kerjasama - Diklat">Kerjasama - Diklat</option>
<option value="Kerjasama - Luar Negeri">Kerjasama - Luar Negeri</option>
<option value="Kerjasama - Area Bisnis">Kerjasama - Area Bisnis</option>
<option value="Kerjasama - Pendidikan">Kerjasama - Pendidikan</option>
<option value="Kerjasama - Pengampuan KIA">Kerjasama- Pengampuan KIA</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Kategori Lainnya</label>
<select class="form-select"
name="data[${colCount}][master_kategori_directory_id]"
id="select_kategori_${colCount}" style="width: 350px;">
<option value="">Pilih Kategori</option>
${katOptions}
</select>
</div>
<div class="col-md-12">
<label for="fileUpload_${colCount}" class="form-label fw-semibold">📂 Upload Dokumen (PDF)</label>
<div class="border rounded-3 p-3 bg-white shadow-sm">
@ -754,6 +994,9 @@ document.addEventListener('DOMContentLoaded', () => {
</div>`;
col.insertAdjacentHTML('beforeend', html);
selectOptionUnitKerjaV1(colCount);
initKategoriSelect2(colCount);
enableAkreFields(colCount);
setKategoriRequired(colCount, false);
colCount++;
}
@ -765,6 +1008,8 @@ document.addEventListener('DOMContentLoaded', () => {
if (formCreate) {
const select0 = $('#select_id_unit_kerja_0');
if (select0.length) selectOptionUnitKerjaV1(0);
initKategoriSelect2(0);
enableAkreFields(0);
formCreate.addEventListener('submit', (e) => {
e.preventDefault();
@ -816,6 +1061,21 @@ document.addEventListener('DOMContentLoaded', () => {
});
});
}
document.addEventListener('change', function(e){
if(e.target.classList.contains('akre-select')){
const id = e.target.id || '';
const idx = id.split('_').pop();
const [typeVal = '', segmentVal = '', itemVal = ''] = (e.target.value || '').split('/');
const typeInput = document.getElementById(`akre_type_${idx}`);
const segmentInput = document.getElementById(`akre_segment_${idx}`);
const itemInput = document.getElementById(`akre_item_${idx}`);
if(typeInput) typeInput.value = typeVal;
if(segmentInput) segmentInput.value = segmentVal;
if(itemInput) itemInput.value = itemVal;
return;
}
});
});
document.addEventListener('click', function(e){
if(e.target.matches('.file-link')){
@ -863,5 +1123,5 @@ document.addEventListener('click', function(e){
function isPublic(permissionVal){
if(permissionVal === null || permissionVal === undefined) return false;
const val = String(permissionVal).toLowerCase();
return val === '1' || val === 'true' || val === 'iya' || val === 'yes';
return val === '1' || val === 'true' || val === 'ya' || val === 'yes';
}

3181
public/json/akreditasi.jff Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

View File

@ -28,9 +28,22 @@
@csrf
@if (session()->has('alertError'))
<div class="alert alert-danger fw-bold" role="alert">
@if(session('alertError') === 'captcha')
Captcha salah!
@elseif(session('alertError') === 'rate')
Terlalu banyak percobaan login. Coba lagi dalam 1 menit.
@elseif(session('alertError') === 'backoff')
Mohon tunggu beberapa detik sebelum mencoba lagi.
@else
Username atau password salah!
@endif
</div>
@endif
<!-- Honeypot (anti-bot): harus tetap kosong -->
<div style="position:absolute; left:-9999px; top:-9999px; height:0; width:0; overflow:hidden;" aria-hidden="true">
<label>Website</label>
<input type="text" name="website" tabindex="-1" autocomplete="off">
</div>
<div class="mb-3">
<label for="exampleInputEmail1" class="form-label">Username</label>
<input type="text" name="namauser" class="form-control" id="exampleInputEmail1" aria-describedby="emailHelp" required>
@ -39,6 +52,20 @@
<label for="exampleInputPassword1" class="form-label">Password</label>
<input type="password" name="passcode" class="form-control" id="exampleInputPassword1" required>
</div>
<div class="mb-4">
<label class="form-label">Captcha</label>
<div class="d-flex align-items-center gap-2">
<img
src="{{ route('captcha.login') }}?t={{ time() }}"
alt="captcha"
class="border rounded"
style="height: 44px; width: 140px; object-fit: cover;"
>
<input type="text" name="captcha" class="form-control text-uppercase" placeholder="Masukkan kode di gambar" autocomplete="off" required>
<a href="/login" class="btn btn-outline-secondary" title="Refresh captcha">Refresh</a>
</div>
<div class="form-text text-muted">Masukkan kode sesuai yang ditampilkan (huruf tidak membedakan kapital/kecil).</div>
</div>
<button type="submit" class="btn btn-primary w-100 py-8 fs-4 mb-4 rounded-2">Login</a>
</form>

View File

@ -154,7 +154,6 @@
</div>
</div>
</div>
@include('dashboard.modal.create')
@include('dashboard.modal.view')
<script>
const klasifikasiDok = @json($klasifikasiDok);
@ -166,7 +165,7 @@
function isPublic(permissionVal){
if(permissionVal === null || permissionVal === undefined) return false;
const val = String(permissionVal).toLowerCase();
return val === '1' || val === 'true' || val === 'iya' || val === 'yes';
return val === '1' || val === 'true' || val === 'ya' || val === 'yes';
}
let currentFile = null;

View File

@ -1,96 +0,0 @@
<div class="modal fade" id="modalCreateFile" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content">
<!-- Modal Header -->
<div class="modal-header">
<h1 class="modal-title fs-5">Aksi </h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<!-- Modal Form -->
<form id="formFile" action="/uploadv2" enctype="multipart/form-data" method="POST" >
@csrf
<div class="modal-body">
<div class="container" style="max-height: 70vh; overflow-y:auto;">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label fw-semibold">Unit <span class="text-danger">*</span></label>
<select class="form-control unit_kerja" name="data[0][id_unit_kerja]" id="select_id_unit_kerja_0" required>
<option value="" disable>Select Choose</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Sub Unit <span class="text-danger">*</span></label>
<select class="form-control" name="data[0][id_sub_unit_kerja]" id="select_id_sub_unit_kerja_0" required>
<option value="" disable selected>Select Choose</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Kategori Dokumen <span class="text-danger">*</span></label>
<select class="form-control" name="data[0][master_kategori_directory_id]" id="select_kategori_0" required>
<option value="" disable>Select Choose</option>
@foreach ($katDok as $kat)
<option value="{{ $kat->master_kategori_directory_id }}/{{ $kat->nama_kategori_directory }}">{{ $kat->nama_kategori_directory }}</option>
@endforeach
</select>
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">Nomor Dokumen</label>
<div class="input-group">
<span class="input-group-text">#</span>
<input type="text" class="form-control" name="data[0][no_dokumen]" placeholder="Contoh: 001/RS/IT/I/2026">
</div>
</div>
<div class="col-md-3">
<label class="form-label fw-semibold">Tanggal Terbit</label>
<input class="form-control" type="date" name="data[0][date_active]">
</div>
<div class="col-md-3">
<label class="form-label fw-semibold">Boleh dilihat unit lain? <span class="text-danger">*</span></label>
<div class="border rounded-3 p-2 bg-light">
<div class="form-check">
<input class="form-check-input" type="radio" name="data[0][is_permission]" id="perm_yes" value="1" required>
<label class="form-check-label" for="perm_yes">
Iya
</label>
</div>
<div class="form-check mt-1">
<input class="form-check-input" type="radio" name="data[0][is_permission]" id="perm_no" value="2" required>
<label class="form-check-label" for="perm_no">
Tidak
</label>
</div>
</div>
</div>
<div class="col-md-12 mb-2">
<label for="fileUpload0" class="form-label fw-semibold">📂 Upload Dokumen (PDF)</label>
<div class="border rounded-3 p-3 bg-white shadow-sm">
<input class="form-control file-input" type="file" id="fileUpload0" accept=".pdf" name="data[0][file]">
<div class="mt-2 text-success fw-semibold d-none file-name"></div>
</div>
<div class="form-text text-muted">
Format yang didukung: <b>PDF</b> Maksimal <b>10mb</b>.
</div>
</div>
<div id="col_add_file" class="col-12"></div>
</div>
<button type="button" class="btn btn-outline-primary btn-sm mt-3" onclick="addForm()">
+ Tambah Form
</button>
</div>
</div>
<!-- Modal Footer -->
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Tutup</button>
<button type="submit" class="btn btn-primary">Simpan</button>
</div>
</form>
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@ -35,6 +35,37 @@
border: 1px solid #dee2e6 !important;
color: #111 !important;
}
.select2-container--default .select2-selection--single .select2-selection__clear {
display: inline-block !important;
font-size: 16px;
font-weight: bold;
color: #999;
margin-right: 8px;
cursor: pointer;
}
.table-header-filter .dropdown-menu {
z-index: 1080;
}
.table-fixed-height {
min-height: 70vh;
}
/* --- Warna kategori baris --- */
.row-shade {
background-color: var(--row-bg, transparent) !important;
transition: background-color 0.2s ease;
}
.legend-dot {
width: 14px;
height: 14px;
border-radius: 4px;
display: inline-block;
border: 1px solid rgba(0,0,0,0.08);
vertical-align: middle;
margin-right: 6px;
}
</style>
@section('body_main')
@ -88,12 +119,11 @@
<div class="d-flex flex-column flex-md-row align-items-md-center gap-2 mb-3">
<div class="d-flex flex-column flex-md-row align-items-md-center gap-2 flex-grow-1">
<select id="tableUnit" class="form-select form-select-sm unit_kerja_filter" style="max-width: 260px;" multiple></select>
<select id="tableKategori" class="form-select form-select-sm kategori_kerja_filter" style="max-width: 260px;" multiple>
@foreach ($katDok as $kat)
<option value="{{ $kat->master_kategori_directory_id }}">
{{ $kat->nama_kategori_directory }}
</option>
@endforeach
<select id="tableKategori" class="form-select form-select-sm kategori_kerja_filter" style="max-width: 260px;">
<option value="">Kategori (Semua)</option>
<option value="akreditasi">Kategori Akreditasi</option>
<option value="hukum">Kategori Hukum</option>
<option value="lainnya">Kategori Lainnya</option>
</select>
<input type="search"
id="tableSearch"
@ -106,7 +136,7 @@
</div>
</div>
<div class="table-responsive" style="max-height: 70vh; overflow-y:auto;">
<div class="table-responsive table-fixed-height" style="max-height: 70vh; overflow-y:auto;">
<table class="table table-sm table-hover align-middle mb-0 table-fixed" id="lastUpdatedTable">
<thead>
<tr>
@ -116,7 +146,21 @@
<th>Aksi</th>
<th>No Dokumen</th>
<th>Nama Dokumen</th>
<th>Kategori</th>
<th>
<div class="d-flex align-items-center gap-2 table-header-filter">
<span>Kategori</span>
<div class="dropdown">
<button class="btn btn-light btn-sm border" type="button" id="tableKategoriHeaderBtn" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false">
<i class="ti ti-filter"></i>
</button>
<div class="dropdown-menu p-2" id="tableKategoriHeaderMenu" style="min-width: 220px;">
<div class="small text-muted px-1">Filter kategori (BETA)</div>
<div class="dropdown-divider"></div>
<div class="kategori-header-list"></div>
</div>
</div>
</div>
</th>
<th>Unit</th>
<th>Tanggal Unggah</th>
</tr>
@ -126,6 +170,7 @@
</tbody>
</table>
</div>
<div class="d-flex flex-wrap align-items-center gap-2 mt-3">
<div class="d-flex align-items-center gap-1">
<span class="small text-muted">Tampilkan</span>
@ -153,7 +198,6 @@
</div>
</div>
</div>
@include('dataUmum.modal.create')
@include('dataUmum.modal.view')
<script>
const katDok = @json($katDok);
@ -163,19 +207,24 @@
const authPegawai = @json(auth()->user()->objectpegawaifk);
const formCreate = $("#formFile")
const modalCreate = document.getElementById('modalCreateFile')
const tableState = { data: [], page: 1, pageSize: 8, search: '', unit: [], kategori: [], lastPage: 1, total: 0 };
const tableState = { data: [], page: 1, pageSize: 8, search: '', unit: [], kategori: [], kategoriType: [], kategoriHeader: [], lastPage: 1, total: 0 };
let kategoriOptionCache = [];
const tbody = document.getElementById('tableDataUmum');
const paginationEl = document.getElementById('paginationControls');
const summaryEl = document.getElementById('tableSummary');
const legendEl = document.getElementById('tableLegend');
const pageSizeSelect = document.getElementById('tablePageSize');
const unitSelect = document.getElementById('tableUnit');
const kategoriSelect = document.getElementById('tableKategori');
const kategoriHeaderMenu = document.getElementById('tableKategoriHeaderMenu');
const searchInput = document.getElementById('tableSearch');
const searchBtn = document.getElementById('btnTableSearch');
const downloadBtn = document.getElementById('btnDownloadMultiple');
const selectedCountEl = document.getElementById('selectedCount');
const checkAllEl = document.getElementById('checkAllRows');
const selectedIds = new Set();
const colorCache = {};
const colorPalette = ['#e8f4ff', '#fff6e5', '#e9f7ef', '#f3e8ff', '#ffe6ea', '#e6f5f3'];
document.addEventListener('change', function(e){
if(!e.target.classList.contains('toggle-expired')) return;
@ -235,16 +284,35 @@
}
if (kategoriSelect) {
$('#tableKategori').select2({
placeholder: 'Pilih Kategori',
placeholder: 'Kategori (Semua)',
allowClear: true,
width: '100%',
closeOnSelect: false
width: '100%'
});
$('#tableKategori').on('change', function () {
tableState.kategori = $(this).val() || [];
const val = $(this).val() || '';
console.log(val);
tableState.kategoriType = val ? [val] : [];
tableState.page = 1;
fetchData();
});
}
}
if (kategoriHeaderMenu) {
kategoriHeaderMenu.addEventListener('change', function(e){
const checkbox = e.target.closest('input[type="checkbox"]');
if (!checkbox) return;
const selected = Array.from(kategoriHeaderMenu.querySelectorAll('input[type="checkbox"]:checked'))
.map(el => el.value);
tableState.kategoriHeader = selected;
if (kategoriSelect && window.$ && $.fn.select2) {
const single = selected.length === 1 ? selected[0] : '';
$('#tableKategori').val(single).trigger('change');
}
tableState.page = 1;
fetchData();
});
}
function resetCreateForm(){
colCount = 1;
@ -258,7 +326,48 @@
function isPublic(permissionVal){
if(permissionVal === null || permissionVal === undefined) return false;
const val = String(permissionVal).toLowerCase();
return val === '1' || val === 'true' || val === 'iya' || val === 'yes';
return val === '1' || val === 'true' || val === 'ya' || val === 'yes';
}
function resolveKategoriFlag(item){
if(Number(item.is_akre) === 1 || item.is_akre === true || String(item.is_akre).toLowerCase() === 'true'){
return { key: 'akre', label: 'Kategori Akreditasi' };
}
if(item.kategori_hukum){
return { key: 'hukum', label: 'Kategori Hukum' };
}
const label = (item.nama_kategori || item.nama_kategori_directory || item.kategori || '').trim() || 'Kategori Lainnya';
const key = String(item.master_kategori_directory_id || label || 'lainnya');
return { key, label };
}
function pickColor(key, label){
if(colorCache[key]) return colorCache[key];
const index = Object.keys(colorCache).length % colorPalette.length;
colorCache[key] = colorPalette[index];
return colorCache[key];
}
function renderLegend(items){
if(!legendEl) return;
const map = new Map();
(items || []).forEach(item => {
const flag = resolveKategoriFlag(item);
const color = pickColor(flag.key, flag.label);
if(!map.has(flag.key)){
map.set(flag.key, { label: flag.label, color });
}
});
if(map.size === 0){
legendEl.textContent = '';
return;
}
legendEl.innerHTML = Array.from(map.values()).map(entry => `
<span class="me-3">
<span class="legend-dot" style="background:${entry.color};"></span>
<span>${entry.label}</span>
</span>
`).join('');
}
function getExpiryStatus(dateStr){
@ -294,12 +403,19 @@
statusClass = 'bg-secondary';
}
const checked = selectedIds.has(String(item.file_directory_id)) ? 'checked' : '';
const rowClass = expiryStatus === 'expired' ? 'table-danger' : (expiryStatus === 'soon' ? 'table-warning' : '');
const kategoriFlag = resolveKategoriFlag(item);
const rowColor = pickColor(kategoriFlag.key, kategoriFlag.label);
const isAkre = kategoriFlag.key === 'akre';
const rowClass = isAkre ? 'table-info' : (expiryStatus === 'expired' ? 'table-danger' : (expiryStatus === 'soon' ? 'table-warning' : 'row-shade'));
const expiryBadge = expiryStatus === 'expired'
? `<span class="badge bg-danger" style="font-size:10px;">Expired</span>`
: (expiryStatus === 'soon' ? `<span class="badge bg-warning text-dark" style="font-size:10px;">Akan Expired</span>` : '');
const akreBadge = isAkre
? `<span class="badge bg-primary text-white" style="font-size:10px;">Akreditasi</span>`
: '';
return `
<tr class="${rowClass}">
<tr class="${rowClass}" style="--row-bg:${rowColor};">
<td class="text-center">
<input type="checkbox"
@ -358,7 +474,7 @@
word-break:break-word;
"
>
${item.nama_dokumen || '-'}
${item.nama_dokumen || '-'} ${akreBadge}
</a>
${expiryBadge}
@ -367,10 +483,10 @@
</td>
<td>
${kategoriName}
${item.nama_kategori || '-'}
</td>
<td>
${unitName}
${item.unit?.name || '-'}
</td>
<td class="text-nowrap">${formatTanggal(item.entry_at)}</td>
</tr>
@ -421,12 +537,12 @@
}
function renderTable(){
const pageData = tableState.data || [];
const pageData = filterByKategoriType(tableState.data || []);
if(pageData.length === 0){
tbody.innerHTML = `
<tr>
<td colspan="7" class="text-center text-muted py-4">
<td colspan="8" class="text-center text-muted py-4">
Tidak ada data yang cocok
</td>
</tr>
@ -444,17 +560,85 @@
renderPagination(tableState.lastPage || 1);
syncCheckAllState();
updateSelectedCount();
renderLegend(pageData);
}
function applyTableSearch(){
const value = searchInput ? searchInput.value : '';
tableState.search = (value || '').trim();
tableState.unit = unitSelect && window.$ ? ($('#tableUnit').val() || []) : (tableState.unit || []);
tableState.kategori = kategoriSelect && window.$ ? ($('#tableKategori').val() || []) : (tableState.kategori || []);
const katVal = kategoriSelect && window.$ ? ($('#tableKategori').val() || '') : (kategoriSelect?.value || '');
tableState.kategoriType = katVal ? [katVal] : (tableState.kategoriType || []);
tableState.page = 1;
fetchData();
}
function getKategoriLabel(item){
const parts = String(item?.file || '').split('/');
return (parts[2] || item?.nama_kategori_directory || item?.kategori || '').trim();
}
function getKategoriId(item){
const label = getKategoriLabel(item);
return String(item?.master_kategori_directory_id || label);
}
function getKategoriOptionsFromData(){
const seen = new Map();
(tableState.data || []).forEach(item => {
const label = getKategoriLabel(item);
if(!label) return;
const id = getKategoriId(item);
if(!seen.has(id)){
seen.set(id, { id, label });
}
});
return Array.from(seen.values()).sort((a,b) => a.label.localeCompare(b.label));
}
function isKategoriMatch(item, types){
if (!types.length) return true;
const lowerTypes = types.map(t => String(t).toLowerCase());
const isAkre = (item.is_akre === true) || String(item.is_akre).toLowerCase() === 'true' || Number(item.is_akre) === 1;
const isHukum = !!item.kategori_hukum;
const hasKategoriId = !!item.master_kategori_directory_id;
if (isAkre && lowerTypes.includes('akreditasi')) return true;
if (isHukum && lowerTypes.includes('hukum')) return true;
if (!isAkre && !isHukum && hasKategoriId && lowerTypes.includes('lainnya')) return true;
const catId = String(getKategoriId(item)).toLowerCase();
const catLabel = String(getKategoriLabel(item)).toLowerCase();
return lowerTypes.includes(catId) || lowerTypes.includes(catLabel);
}
function filterByKategoriType(items){
const types = (tableState.kategoriType || []).map(v => String(v));
if (!types.length) return items;
return items.filter(item => isKategoriMatch(item, types));
}
function renderKategoriHeaderOptions(){
if (!kategoriHeaderMenu) return;
const list = kategoriHeaderMenu.querySelector('.kategori-header-list');
if (!list) return;
const options = kategoriOptionCache.length ? kategoriOptionCache : getKategoriOptionsFromData();
const selected = (tableState.kategoriHeader || []).map(v => String(v));
if(options.length === 0){
list.innerHTML = '<div class=\"dropdown-item text-muted\">Tidak ada kategori</div>';
return;
}
list.innerHTML = options.map(opt => {
const checked = selected.includes(opt.id) ? 'checked' : '';
return `
<label class="dropdown-item d-flex align-items-center gap-2">
<input type="checkbox" class="form-check-input m-0" value="${opt.id}" ${checked}>
<span>${opt.label}</span>
</label>
`;
}).join('');
}
function fetchData(){
if(summaryEl) summaryEl.textContent = 'Memuat data...';
const params = new URLSearchParams({
@ -465,15 +649,20 @@
if (tableState.unit && tableState.unit.length > 0) {
tableState.unit.forEach(id => params.append('unit[]', id));
}
if (tableState.kategori && tableState.kategori.length > 0) {
tableState.kategori.forEach(kat => params.append('kategori[]', kat));
if (tableState.kategoriType && tableState.kategoriType.length > 0) {
tableState.kategoriType.forEach(id => params.append('kategori[]', id));
}
if (tableState.kategoriHeader && tableState.kategoriHeader.length > 0) {
tableState.kategoriHeader.forEach(id => params.append('kategori_header[]', id));
}
fetch(`/datatable-umum?${params.toString()}`)
.then(response => response.json())
.then(data => {
tableState.data = data?.data || [];
kategoriOptionCache = data?.kategori_list || kategoriOptionCache;
tableState.lastPage = data?.pagination?.last_page || 1;
tableState.total = data?.pagination?.total || 0;
renderKategoriHeaderOptions();
renderTable();
})
.catch(error => {
@ -503,6 +692,7 @@
year: 'numeric'
});
}
renderKategoriHeaderOptions();
fetchData()
function updateSelectedCount(){
@ -679,144 +869,6 @@
});
}
function addFormV2(){
let col = $("#col_add_fileV2");
let html = '';
html += `
<div class="row g-3 align-items-start" id="col-${colCount}">
<hr class="my-3" />
<div class="col-12 d-flex justify-content-end">
<button type="button"
class="btn btn-sm btn-danger"
onclick="removeCol(${colCount})">
<i class="fa-solid fa-trash"></i> Hapus
</button>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Unit <span class="text-danger">*</span></label>
<select class="form-select"
name="data[${colCount}][id_unit_kerja]"
id="select_id_unit_kerja_${colCount}"
required>
<option value="" disabled selected>Pilih Unit</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Sub Unit <span class="text-danger">*</span></label>
<select class="form-select"
name="data[${colCount}][id_sub_unit_kerja]"
id="select_id_sub_unit_kerja_${colCount}"
required>
<option value="" disabled selected>Pilih Sub Unit</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Kategori Dokumen <span class="text-danger">*</span></label>
<select class="form-select"
name="data[${colCount}][master_kategori_directory_id]"
id="select_kategori_${colCount}"
required>
<option value="" disabled selected>Pilih Kategori</option>
@foreach ($katDok as $kat)
<option value="{{ $kat->master_kategori_directory_id }}/{{ $kat->nama_kategori_directory }}">
{{ $kat->nama_kategori_directory }}
</option>
@endforeach
</select>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Nomor Dokumen</label>
<div class="input-group">
<span class="input-group-text">#</span>
<input type="text"
class="form-control"
name="data[${colCount}][no_dokumen]"
placeholder="Contoh: 001/RS/IT/I/2026">
</div>
<div class="form-text text-muted"></div>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Nama Dokumen<span class="text-danger">*</span></label>
<input type="text" class="form-control"
name="data[${colCount}][nama_dokumen]"
placeholder="Contoh: 001/RS/IT/I/2026" required>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Tanggal Terbit</label>
<input class="form-control"
type="date"
name="data[${colCount}][date_active]">
</div>
<div class="col-md-2 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input toggle-expired"
type="checkbox"
id="hasExpired_${colCount}"
data-target="expiredField_${colCount}">
<label class="form-check-label" for="hasExpired_${colCount}">Ada Expired?</label>
</div>
</div>
<div class="col-md-5 d-none" id="expiredField_${colCount}">
<label class="form-label fw-semibold">Tanggal Kedaluwarsa Dokumen</label>
<input class="form-control" type="date" name="data[${colCount}][tgl_expired]">
</div>
<div class="col-md-5">
<label class="form-label fw-semibold">Boleh dilihat unit lain? <span class="text-danger">*</span></label>
<div class="border rounded-3 p-2 bg-light">
<div class="form-check">
<input class="form-check-input"
type="radio"
name="data[${colCount}][is_permission]"
id="perm_yes_${colCount}"
value="1"
required>
<label class="form-check-label" for="perm_yes_${colCount}">Iya</label>
</div>
<div class="form-check mt-1">
<input class="form-check-input"
type="radio"
name="data[${colCount}][is_permission]"
id="perm_no_${colCount}"
value="2"
required>
<label class="form-check-label" for="perm_no_${colCount}">Tidak</label>
</div>
</div>
</div>
<div class="col-md-12">
<label for="fileUpload_${colCount}" class="form-label fw-semibold">📂 Upload Dokumen (PDF)</label>
<div class="border rounded-3 p-3 bg-white shadow-sm">
<input class="form-control"
type="file"
id="fileUpload_${colCount}"
accept=".pdf"
name="data[${colCount}][file]">
<div class="mt-2 text-success fw-semibold d-none file-name" id="fileName_${colCount}"></div>
</div>
<div class="form-text text-muted">
Format yang didukung: <b>PDF</b>.
</div>
</div>
</div>
`;
col.append(html)
selectOptionUnitKerjaV1(colCount)
colCount++;
}
function removeCol(count){
$(`#col-${count}`).remove()
}

View File

@ -1,119 +0,0 @@
<div class="modal fade" id="modalCreateFile" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content">
<!-- Modal Header -->
<div class="modal-header">
<h1 class="modal-title fs-5">Aksi </h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<!-- Modal Form -->
<form id="formFile" action="/uploadv2" enctype="multipart/form-data" method="POST">
@csrf
<div class="modal-body">
<div class="container" style="max-height: 70vh; overflow-y:auto;">
<div class="row g-3">
<div class="col-12">
<div class="p-2 rounded-3 bg-light border">
<span class="fw-semibold">Informasi Dokumen</span>
<div class="small text-muted">Lengkapi detail dokumen sebelum upload.</div>
</div>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Unit <span class="text-danger">*</span></label>
<select class="form-control unit_kerja" name="data[0][id_unit_kerja]" id="select_id_unit_kerja_0" required>
<option value="" disable>Pilih Unit</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Sub Unit <span class="text-danger">*</span></label>
<select class="form-control" name="data[0][id_sub_unit_kerja]" id="select_id_sub_unit_kerja_0" required>
<option value="" disable selected>Pilih Sub Unit</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Kategori Dokumen <span class="text-danger">*</span></label>
<select class="form-control" name="data[0][master_kategori_directory_id]" id="select_kategori_0" required>
<option value="" disable>Pilih Kategori</option>
@foreach ($katDok as $kat)
<option value="{{ $kat->master_kategori_directory_id }}/{{ $kat->nama_kategori_directory }}">{{ $kat->nama_kategori_directory }}</option>
@endforeach
</select>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Nomor Dokumen</label>
<div class="input-group">
<span class="input-group-text">#</span>
<input type="text" class="form-control" name="data[0][no_dokumen]" placeholder="Contoh: 001/RS/IT/I/2026">
</div>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Nama Dokumen<span class="text-danger">*</span></label>
<input type="text" class="form-control" name="data[0][nama_dokumen]" placeholder="Contoh: Panduan Mencuci Tangan" required>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Tanggal Terbit</label>
<input class="form-control" type="date" name="data[0][date_active]">
</div>
<div class="col-md-2 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input toggle-expired" type="checkbox" id="hasExpired0" data-target="expiredField_0">
<label class="form-check-label" for="hasExpired0">Ada Expired?</label>
</div>
</div>
<div class="col-md-5 d-none" id="expiredField_0">
<label class="form-label fw-semibold">Tanggal Kedaluwarsa Dokumen</label>
<input class="form-control" type="date" name="data[0][tgl_expired]">
</div>
<div class="col-md-5">
<label class="form-label fw-semibold">Boleh dilihat unit lain? <span class="text-danger">*</span></label>
<div class="border rounded-3 p-2 bg-light">
<div class="form-check">
<input class="form-check-input" type="radio" name="data[0][is_permission]" id="perm_yes" value="1" required>
<label class="form-check-label" for="perm_yes">
Iya
</label>
</div>
<div class="form-check mt-1">
<input class="form-check-input" type="radio" name="data[0][is_permission]" id="perm_no" value="2" required>
<label class="form-check-label" for="perm_no">
Tidak
</label>
</div>
</div>
</div>
<div class="col-md-12 mb-2">
<label for="fileUpload0" class="form-label fw-semibold">📂 Upload Dokumen (PDF)</label>
<div class="border rounded-3 p-3 bg-white shadow-sm">
<input class="form-control" type="file" id="fileUpload0" accept=".pdf" name="data[0][file]">
<div class="mt-2 text-success fw-semibold d-none file-name"></div>
</div>
<div class="form-text text-muted">
Format yang didukung: <b>PDF</b> Maksimal <b>10mb</b>.
</div>
</div>
<div id="col_add_fileV2" class="col-12"></div>
</div>
<button type="button" class="btn btn-outline-primary btn-sm mt-3" onclick="addFormV2()">
+ Tambah Form
</button>
</div>
</div>
<!-- Modal Footer -->
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Tutup</button>
<button type="submit" class="btn btn-primary">Simpan</button>
</div>
</form>
</div>
</div>
</div>

View File

@ -1,10 +1,10 @@
@extends('layout.main')
<style>
/* --- SELECT2: teks terlihat (hitam) --- */
.select2-container--default .select2-selection--multiple {
background: #fff !important;
border: 1px solid #ced4da !important;
min-height: 31px; /* cocok form-select-sm */
border: 1px solid rgb(206, 212, 218) !important;
}
.select2-container--default .select2-selection--multiple .select2-selection__rendered {
@ -35,6 +35,35 @@
border: 1px solid #dee2e6 !important;
color: #111 !important;
}
.select2-container--default .select2-selection--single .select2-selection__clear {
display: inline-block !important;
font-size: 16px;
font-weight: bold;
color: #999;
margin-right: 8px;
cursor: pointer;
}
.table-header-filter .dropdown-menu {
z-index: 1080;
}
.table-fixed-height {
min-height: 70vh;
}
/* --- Warna kategori baris --- */
.row-shade {
background-color: var(--row-bg, transparent) !important;
transition: background-color 0.2s ease;
}
.legend-dot {
width: 14px;
height: 14px;
border-radius: 4px;
display: inline-block;
border: 1px solid rgba(0,0,0,0.08);
vertical-align: middle;
margin-right: 6px;
}
</style>
@section('body_main')
<div class="row">
@ -72,6 +101,7 @@
</span>
</div>
<!-- Tambah Dokumen -->
@if(!Auth::guard('admin')->check())
<button
type="button"
class="btn btn-success btn-sm"
@ -81,17 +111,19 @@
<i class="ti ti-plus me-1"></i>
Tambah Dokumen
</button>
@endif
</div>
</div>
<div class="d-flex flex-column flex-md-row align-items-md-center gap-2 mb-3">
<div class="d-flex flex-column flex-md-row align-items-md-center gap-2 flex-grow-1">
<select id="tableUnit" class="form-select form-select-sm unit_kerja_filter" style="max-width: 260px;" multiple></select>
<select id="tableKategori" class="form-select form-select-sm kategori_kerja_filter" style="max-width: 260px;" multiple>
@foreach ($katDok as $kat)
<option value="{{ $kat->master_kategori_directory_id }}">
{{ $kat->nama_kategori_directory }}
</option>
@endforeach
<select id="tableUnit" style="max-width: 260px;">
<option value=""></option>
</select>
<select id="tableKategori" class="form-select form-select-sm" style="max-width: 260px;">
<option value="">Kategori (Semua)</option>
<option value="akreditasi">Kategori Akreditasi</option>
<option value="hukum">Kategori Hukum</option>
<option value="lainnya">Kategori Lainnya</option>
</select>
<input type="search"
id="tableSearch"
@ -101,7 +133,7 @@
</div>
<button class="btn btn-primary" type="button" id="btnTableSearch">Cari</button>
</div>
<div class="table-responsive" style="max-height: 70vh; overflow-y:auto;">
<div class="table-responsive table-fixed-height" style="max-height: 70vh; overflow-y:auto;">
<table class="table table-sm table-hover align-middle mb-0 table-fixed" id="lastUpdatedTable">
<thead>
<tr>
@ -111,7 +143,21 @@
<th>Aksi</th>
<th>No Dokumen</th>
<th>Nama Dokumen</th>
<th>Kategori</th>
<th>
<div class="d-flex align-items-center gap-2 table-header-filter">
<span>Kategori</span>
<div class="dropdown">
<button class="btn btn-light btn-sm border" type="button" id="tableKategoriHeaderBtn" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false">
<i class="ti ti-filter"></i>
</button>
<div class="dropdown-menu p-2" id="tableKategoriHeaderMenu" style="min-width: 220px;">
<div class="small text-muted px-1">Filter kategori (BETA)</div>
<div class="dropdown-divider"></div>
<div class="kategori-header-list"></div>
</div>
</div>
</div>
</th>
<th>Unit</th>
<th>Tanggal Unggah</th>
<th>Pengunggah</th>
@ -122,6 +168,7 @@
</tbody>
</table>
</div>
<div class="d-flex flex-wrap align-items-center gap-2 mt-3">
<div class="d-flex align-items-center gap-1">
<span class="small text-muted">Tampilkan</span>
@ -164,19 +211,32 @@
const authPegawai = @json(auth()->user()->objectpegawaifk);
const formCreate = $("#formFile");
const modalCreate = document.getElementById('modalCreateFile');
const tableState = { data: [], page: 1, pageSize: 8, search: '', unit: [], kategori: [], lastPage: 1, total: 0 };
const tableState = { data: [], page: 1, pageSize: 8, search: '', unit: [], kategori: [], kategoriType: [], kategoriHeader: [], lastPage: 1, total: 0 };
let kategoriOptionCache = [];
const tbody = document.getElementById('tableDataUnit');
const paginationEl = document.getElementById('paginationControls');
const summaryEl = document.getElementById('tableSummary');
const legendEl = document.getElementById('tableLegend');
const pageSizeSelect = document.getElementById('tablePageSize');
const unitSelect = document.getElementById('tableUnit');
const kategoriSelect = document.getElementById('tableKategori');
const kategoriHeaderMenu = document.getElementById('tableKategoriHeaderMenu');
const searchInput = document.getElementById('tableSearch');
const searchBtn = document.getElementById('btnTableSearch');
const downloadBtn = document.getElementById('btnDownloadMultiple');
const selectedCountEl = document.getElementById('selectedCount');
const checkAllEl = document.getElementById('checkAllRows');
const selectedIds = new Set();
const colorCache = {};
const colorPalette = ['#e8f4ff', '#fff6e5', '#e9f7ef', '#f3e8ff', '#ffe6ea', '#e6f5f3'];
function normalizeToArray(value){
if (Array.isArray(value)) {
return value.filter(v => v !== null && v !== undefined && v !== '');
}
if (value === null || value === undefined || value === '') return [];
return [value];
}
document.addEventListener('change', function(e){
if(!e.target.classList.contains('toggle-expired')) return;
@ -206,8 +266,6 @@
allowClear: true,
width: '100%',
closeOnSelect: false,
selectionCssClass: 'select2-filter-selection',
dropdownCssClass: 'select2-filter-dropdown',
ajax: {
url: '/select-unit-kerja-mapping',
dataType: 'json',
@ -226,24 +284,39 @@
cache: true
}
});
$('#tableUnit').on('change', function () {
tableState.unit = $(this).val() || [];
});
}
if (kategoriSelect) {
$('#tableKategori').select2({
placeholder: 'Pilih Kategori',
placeholder: 'Kategori (Semua)',
allowClear: true,
width: '100%',
closeOnSelect: false,
selectionCssClass: 'select2-filter-selection',
dropdownCssClass: 'select2-filter-dropdown'
});
$('#tableKategori').on('change', function () {
tableState.kategori = $(this).val() || [];
const val = $(this).val() || '';
tableState.kategoriType = val ? [val] : [];
tableState.page = 1;
fetchData();
});
}
}
if (kategoriHeaderMenu) {
kategoriHeaderMenu.addEventListener('change', function(e){
const checkbox = e.target.closest('input[type="checkbox"]');
if (!checkbox) return;
const selected = Array.from(kategoriHeaderMenu.querySelectorAll('input[type="checkbox"]:checked'))
.map(el => el.value);
tableState.kategoriHeader = selected;
if (kategoriSelect && window.$ && $.fn.select2) {
const single = selected.length === 1 ? selected[0] : '';
$('#tableKategori').val(single).trigger('change');
}
tableState.page = 1;
fetchData();
});
}
function resetCreateForm(){
colCount = 1;
@ -252,12 +325,186 @@
formCreate.find('select').val(null).trigger('change');
formCreate.find('input[type="file"]').val('');
formCreate.find('.file-name').addClass('d-none').text('');
resetAkreFields(0);
enableAkreFields(0);
}
let akreData = [];
let akreLoaded = false;
let akreFlat = [];
function loadAkreData(){
if(akreLoaded) return Promise.resolve(akreData);
return fetch('/json/akreditasi.jff')
.then(res => res.json())
.then(data => {
akreData = Array.isArray(data) ? data : [];
akreFlat = [];
akreLoaded = true;
return akreData;
})
.catch(() => {
akreData = [];
akreFlat = [];
akreLoaded = true;
return akreData;
});
}
function getAkreFlat(){
if(akreFlat.length) return akreFlat;
akreFlat = (akreData || []).flatMap(type => {
const segments = Array.isArray(type.segment) ? type.segment : [];
return segments.flatMap(seg => {
const children = Array.isArray(seg.turunan) ? seg.turunan : [];
return children.map(child => ({
value: `${type.name}/${seg.name}/${child.name}`,
label: `${type.name} / ${child.name}`,
type: type.name,
segment: seg.name,
item: child.name
}));
});
});
return akreFlat;
}
function fillAkreSelect(selectEl){
if(!selectEl) return;
selectEl.innerHTML = '<option value="" disabled selected>Pilih Instrumen</option>';
getAkreFlat().forEach(optData => {
const opt = document.createElement('option');
opt.value = optData.value;
opt.textContent = optData.label;
selectEl.appendChild(opt);
});
}
function setKategoriRequired(index, isRequired){
const katSelect = document.getElementById(`select_kategori_${index}`);
if (!katSelect) return;
if (isRequired) {
katSelect.setAttribute('required', 'required');
} else {
katSelect.removeAttribute('required');
}
}
function resetAkreFields(index){
const selectEl = document.getElementById(`akre_select_${index}`);
const typeInput = document.getElementById(`akre_type_${index}`);
const segmentInput = document.getElementById(`akre_segment_${index}`);
const itemInput = document.getElementById(`akre_item_${index}`);
if(selectEl){
selectEl.value = '';
if(window.$ && $.fn.select2) $(selectEl).val(null).trigger('change');
}
if(typeInput) typeInput.value = '';
if(segmentInput) segmentInput.value = '';
if(itemInput) itemInput.value = '';
setKategoriRequired(index, false);
}
function enableAkreFields(index){
const selectEl = document.getElementById(`akre_select_${index}`);
// if(selectEl){
// selectEl.disabled = false;
// selectEl.required = true;
// }
setKategoriRequired(index, false);
loadAkreData().then(() => {
fillAkreSelect(selectEl);
if(window.$ && $.fn.select2){
$(selectEl).select2({
dropdownParent: $('#modalCreateFile'),
placeholder: 'Pilih Instrumen',
allowClear: true
});
}
});
}
function initKategoriSelect2(index){
if(!window.$ || !$.fn.select2) return;
const katSelect = $(`#select_kategori_${index}`);
const hukumSelect = $(`#select_kategori_hukum_${index}`);
if(katSelect.length){
katSelect.select2({
dropdownParent: $('#modalCreateFile'),
placeholder:'Pilih Kategori',
allowClear: true
});
}
if(hukumSelect.length){
hukumSelect.select2({
dropdownParent: $('#modalCreateFile'),
placeholder:'Pilih Kategori Hukum',
allowClear:true
});
}
}
document.addEventListener('change', function(e){
if(e.target.classList.contains('akre-select')){
const id = e.target.id || '';
const idx = id.split('_').pop();
const [typeVal = '', segmentVal = '', itemVal = ''] = (e.target.value || '').split('/');
const typeInput = document.getElementById(`akre_type_${idx}`);
const segmentInput = document.getElementById(`akre_segment_${idx}`);
const itemInput = document.getElementById(`akre_item_${idx}`);
if(typeInput) typeInput.value = typeVal;
if(segmentInput) segmentInput.value = segmentVal;
if(itemInput) itemInput.value = itemVal;
return;
}
});
function isPublic(permissionVal){
if(permissionVal === null || permissionVal === undefined) return false;
const val = String(permissionVal).toLowerCase();
return val === '1' || val === 'true' || val === 'iya' || val === 'yes';
return val === '1' || val === 'true' || val === 'ya' || val === 'yes';
}
function resolveKategoriFlag(item){
if(Number(item.is_akre) === 1 || item.is_akre === true || String(item.is_akre).toLowerCase() === 'true'){
return { key: 'akre', label: 'Kategori Akreditasi' };
}
if(item.kategori_hukum){
return { key: 'hukum', label: 'Kategori Hukum' };
}
const label = (item.nama_kategori || item.nama_kategori_directory || item.kategori || '').trim() || 'Kategori Lainnya';
const key = String(item.master_kategori_directory_id || label || 'lainnya');
return { key, label };
}
function pickColor(key, label){
if(colorCache[key]) return colorCache[key];
const index = Object.keys(colorCache).length % colorPalette.length;
colorCache[key] = colorPalette[index];
return colorCache[key];
}
function renderLegend(items){
if(!legendEl) return;
const map = new Map();
(items || []).forEach(item => {
const flag = resolveKategoriFlag(item);
const color = pickColor(flag.key, flag.label);
if(!map.has(flag.key)){
map.set(flag.key, { label: flag.label, color });
}
});
if(map.size === 0){
legendEl.textContent = '';
return;
}
legendEl.innerHTML = Array.from(map.values()).map(entry => `
<span class="me-3">
<span class="legend-dot" style="background:${entry.color};"></span>
<span>${entry.label}</span>
</span>
`).join('');
}
function getExpiryStatus(dateStr){
@ -293,12 +540,18 @@
statusClass = 'bg-secondary';
}
const checked = selectedIds.has(String(item.file_directory_id)) ? 'checked' : '';
const rowClass = expiryStatus === 'expired' ? 'table-danger' : (expiryStatus === 'soon' ? 'table-warning' : '');
const kategoriFlag = resolveKategoriFlag(item);
const rowColor = pickColor(kategoriFlag.key, kategoriFlag.label);
const isAkre = kategoriFlag.key === 'akre';
const rowClass = isAkre ? 'table-info' : (expiryStatus === 'expired' ? 'table-danger' : (expiryStatus === 'soon' ? 'table-warning' : 'row-shade'));
const expiryBadge = expiryStatus === 'expired'
? `<span class="badge bg-danger" style="font-size:10px;">Expired</span>`
: (expiryStatus === 'soon' ? `<span class="badge bg-warning text-dark" style="font-size:10px;">Akan Expired</span>` : '');
const akreBadge = isAkre
? `<span class="badge bg-primary text-white ms-2" style="font-size:10px;">Akreditasi</span>`
: '';
return `
<tr class="${rowClass}">
<tr class="${rowClass}" style="--row-bg:${rowColor};">
<td class="text-center">
<input type="checkbox"
class="form-check-input row-check"
@ -347,17 +600,17 @@
>
${item.nama_dokumen || '-'}
</a>
${expiryBadge}
${akreBadge}${expiryBadge}
</div>
</td>
<td>
${kategoriName}
${item.nama_kategori || '-'}
</td>
<td>
${unitName}
${item.unit?.name || '-'}
</td>
<td class="text-nowrap">${formatTanggal(item.entry_at)}</td>
<td class="text-nowrap">${item.pegawai_nama_entry || '-'}</td>
<td style="max-width: 200px; white-space: normal; word-wrap: break-word;">${item.pegawai_nama_entry || '-'}</td>
</tr>
`;
}
@ -406,12 +659,12 @@
}
function renderTable(){
const pageData = tableState.data || [];
const pageData = filterByKategoriType(tableState.data || []);
if(pageData.length === 0){
tbody.innerHTML = `
<tr>
<td colspan="7" class="text-center text-muted py-4">
<td colspan="8" class="text-center text-muted py-4">
Tidak ada data yang cocok
</td>
</tr>
@ -429,17 +682,110 @@
renderPagination(tableState.lastPage || 1);
syncCheckAllState();
updateSelectedCount();
renderLegend(pageData);
}
function applyTableSearch(){
const value = searchInput ? searchInput.value : '';
tableState.search = (value || '').trim();
tableState.unit = unitSelect && window.$ ? ($('#tableUnit').val() || []) : (tableState.unit || []);
tableState.kategori = kategoriSelect && window.$ ? ($('#tableKategori').val() || []) : (tableState.kategori || []);
tableState.unit = normalizeToArray(
unitSelect && window.$ ? $('#tableUnit').val() : tableState.unit
);
const katVal = kategoriSelect && window.$ ? ($('#tableKategori').val() || '') : (kategoriSelect?.value || '');
tableState.kategoriType = katVal ? [katVal] : (tableState.kategoriType || []);
tableState.page = 1;
fetchData();
}
function getKategoriLabel(item){
const parts = String(item?.file || '').split('/');
return (item?.nama_kategori || parts[2] || item?.nama_kategori_directory || item?.kategori || '').trim();
}
function getKategoriId(item){
const label = getKategoriLabel(item);
return String(item?.master_kategori_directory_id || label);
}
function getKategoriOptionsFromData(){
const seen = new Map();
(tableState.data || []).forEach(item => {
const label = getKategoriLabel(item);
if(!label) return;
const id = getKategoriId(item);
if(!seen.has(id)){
seen.set(id, { id, label });
}
});
return Array.from(seen.values()).sort((a,b) => a.label.localeCompare(b.label));
}
function isKategoriMatch(item, types){
if (!types.length) return true;
const lowerTypes = types.map(t => String(t).toLowerCase());
const isAkre = (item.is_akre === true) || String(item.is_akre).toLowerCase() === 'true' || Number(item.is_akre) === 1;
const isHukum = !!item.kategori_hukum;
const hasKategoriId = !!item.master_kategori_directory_id;
// special buckets
if (isAkre && lowerTypes.includes('akreditasi')) return true;
if (isHukum && lowerTypes.includes('hukum')) return true;
if (!isAkre && !isHukum && hasKategoriId && lowerTypes.includes('lainnya')) return true;
// fallback to id/label comparison
const catId = String(getKategoriId(item)).toLowerCase();
const catLabel = String(getKategoriLabel(item)).toLowerCase();
return lowerTypes.includes(catId) || lowerTypes.includes(catLabel);
}
function filterByKategoriType(items){
const types = (tableState.kategoriType || []).map(v => String(v));
if (!types.length) return items;
return items.filter(item => isKategoriMatch(item, types));
}
function renderKategoriHeaderOptions(){
if (!kategoriHeaderMenu) return;
const list = kategoriHeaderMenu.querySelector('.kategori-header-list');
if (!list) return;
const options = kategoriOptionCache.length ? kategoriOptionCache : getKategoriOptionsFromData();
const selected = (tableState.kategoriHeader || []).map(v => String(v));
if(options.length === 0){
list.innerHTML = '<div class=\"dropdown-item text-muted\">Tidak ada kategori</div>';
return;
}
list.innerHTML = options.map(opt => {
const checked = selected.includes(opt.id) ? 'checked' : '';
return `
<label class="dropdown-item d-flex align-items-center gap-2 kategori-option" data-kat="${opt.id}">
<input type="checkbox" class="form-check-input m-0" value="${opt.id}" ${checked}>
<span>${opt.label}</span>
</label>
`;
}).join('');
}
if (kategoriHeaderMenu) {
kategoriHeaderMenu.addEventListener('click', function(e){
const option = e.target.closest('.kategori-option');
if (!option) return;
const id = option.getAttribute('data-kat');
const checkbox = option.querySelector('input[type="checkbox"]');
if(checkbox){
checkbox.checked = !checkbox.checked;
const event = new Event('change', { bubbles: true });
checkbox.dispatchEvent(event);
}else{
tableState.kategoriHeader = [id];
if (kategoriSelect && window.$ && $.fn.select2) {
$('#tableKategori').val(id).trigger('change');
}
tableState.page = 1;
fetchData();
}
}, true);
}
function fetchData(){
if(summaryEl) summaryEl.textContent = 'Memuat data...';
const params = new URLSearchParams({
@ -447,18 +793,24 @@
per_page: tableState.pageSize,
keyword: tableState.search
});
if (tableState.unit && tableState.unit.length > 0) {
tableState.unit.forEach(id => params.append('unit[]', id));
const unitValues = normalizeToArray(tableState.unit);
if (unitValues.length > 0) {
unitValues.forEach(id => params.append('unit[]', id));
}
if (tableState.kategori && tableState.kategori.length > 0) {
tableState.kategori.forEach(kat => params.append('kategori[]', kat));
if (tableState.kategoriType && tableState.kategoriType.length > 0) {
tableState.kategoriType.forEach(id => params.append('kategori[]', id));
}
if (tableState.kategoriHeader && tableState.kategoriHeader.length > 0) {
tableState.kategoriHeader.forEach(id => params.append('kategori_header[]', id));
}
fetch(`/data-internal?${params.toString()}`)
.then(response => response.json())
.then(data => {
tableState.data = data?.data || [];
kategoriOptionCache = data?.kategori_list || kategoriOptionCache;
tableState.lastPage = data?.pagination?.last_page || 1;
tableState.total = data?.pagination?.total || 0;
renderKategoriHeaderOptions();
renderTable();
})
.catch(error => {
@ -467,17 +819,13 @@
})
}
if (searchBtn) {
searchBtn.addEventListener('click', applyTableSearch);
}
if (searchInput) {
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
applyTableSearch();
}
});
}
function formatTanggal(dateString) {
const d = new Date(dateString);
@ -487,6 +835,7 @@
year: 'numeric'
});
}
renderKategoriHeaderOptions();
fetchData()
function updateSelectedCount(){
@ -636,6 +985,8 @@
});
selectOptionUnitKerjaV1(0);
initKategoriSelect2(0);
enableAkreFields(0);
});
function loadSubUnitKerja(unitId){
@ -673,7 +1024,7 @@
</button>
</div>
<div class="col-md-4">
<div class="col-md-6">
<label class="form-label fw-semibold">Unit <span class="text-danger">*</span></label>
<select class="form-select"
name="data[${colCount}][id_unit_kerja]"
@ -683,7 +1034,7 @@
</select>
</div>
<div class="col-md-4">
<div class="col-md-6">
<label class="form-label fw-semibold">Sub Unit <span class="text-danger">*</span></label>
<select class="form-select"
name="data[${colCount}][id_sub_unit_kerja]"
@ -693,20 +1044,7 @@
</select>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Kategori Dokumen <span class="text-danger">*</span></label>
<select class="form-select"
name="data[${colCount}][master_kategori_directory_id]"
id="select_kategori_${colCount}"
required>
<option value="" disabled selected>Pilih Kategori</option>
@foreach ($katDok as $kat)
<option value="{{ $kat->master_kategori_directory_id }}/{{ $kat->nama_kategori_directory }}">
{{ $kat->nama_kategori_directory }}
</option>
@endforeach
</select>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Nomor Dokumen</label>
@ -733,7 +1071,7 @@
type="date"
name="data[${colCount}][date_active]">
</div>
<div class="col-md-2">
<div class="col-md-3">
<div class="form-check">
<input class="form-check-input toggle-expired"
type="checkbox"
@ -750,7 +1088,7 @@
name="data[${colCount}][tgl_expired]" disabled>
</div>
<div class="col-md-5">
<div class="col-md-4">
<label class="form-label fw-semibold">Boleh dilihat unit lain? <span class="text-danger">*</span></label>
<div class="border rounded-3 p-2 bg-light">
@ -761,7 +1099,7 @@
id="perm_yes_${colCount}"
value="1"
required>
<label class="form-check-label" for="perm_yes_${colCount}">Iya</label>
<label class="form-check-label" for="perm_yes_${colCount}">Ya</label>
</div>
<div class="form-check mt-1">
@ -776,6 +1114,44 @@
</div>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Instrumen Akreditasi </label>
<select class="form-select akre-select" id="akre_select_${colCount}" name="data[${colCount}][akre]" style="width: 350px;">
<option value="" disabled selected>Pilih Instrumen</option>
</select>
<div class="form-text text-muted">Isi form ini bila dokumen yang diunggah merupakan dokumen <strong>akreditasi</strong>.</div>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Kategori Hukum</label>
<select class="form-select select-kat-hukum" name="data[${colCount}][kategori_hukum]" id="select_kategori_hukum_${colCount}" style="width: 350px;">
<option value="" disabled selected>Pilih Kategori Hukum</option>
<option value="Kebijakan - Peraturan Direktur">Kebijakan - Peraturan Direktur</option>
<option value="Kebijakan - Keputusan Direktur Utama">Kebijakan - Keputusan Direktur Utama</option>
<option value="Kebijakan - Surat Edaran">Kebijakan - Surat Edaran</option>
<option value="Kebijakan - Pengumuman">Kebijakan - Pengumuman</option>
<option value="Kerjasama - Pelayanan Kesehatan">Kerjasama - Pelayanan Kesehatan</option>
<option value="Kerjasama - Management">Kerjasama - Management</option>
<option value="Kerjasama - Pemeliharan">Kerjasama - Pemeliharan</option>
<option value="Kerjasama - Diklat">Kerjasama - Diklat</option>
<option value="Kerjasama - Luar Negeri">Kerjasama - Luar Negeri</option>
<option value="Kerjasama - Area Bisnis">Kerjasama - Area Bisnis</option>
<option value="Kerjasama - Pendidikan">Kerjasama - Pendidikan</option>
<option value="Kerjasama - Pengampuan KIA">Kerjasama- Pengampuan KIA</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Kategori Lainnya</label>
<select class="form-select"
name="data[${colCount}][master_kategori_directory_id]"
id="select_kategori_${colCount}" style="width: 350px;">
<option value="" disabled selected>Pilih Kategori</option>
@foreach ($katDok as $kat)
<option value="{{ $kat->master_kategori_directory_id }}/{{ $kat->nama_kategori_directory }}">
{{ $kat->nama_kategori_directory }}
</option>
@endforeach
</select>
</div>
<div class="col-md-12">
<label for="fileUpload_${colCount}" class="form-label fw-semibold">📂 Upload Dokumen (PDF)</label>
@ -798,8 +1174,11 @@
`;
col.append(html)
selectOptionUnitKerjaV1(colCount)
initKategoriSelect2(colCount)
enableAkreFields(colCount)
setKategoriRequired(colCount, false)
colCount++;
}
}
function removeCol(count){
$(`#col-${count}`).remove()

View File

@ -20,28 +20,19 @@
<div class="small text-muted">Lengkapi detail dokumen sebelum upload.</div>
</div>
</div>
<div class="col-md-4">
<div class="col-md-6">
<label class="form-label fw-semibold">Unit <span class="text-danger">*</span></label>
<select class="form-control unit_kerja" name="data[0][id_unit_kerja]" id="select_id_unit_kerja_0" required>
<option value="" disable>Pilih Unit</option>
</select>
</div>
<div class="col-md-4">
<div class="col-md-6">
<label class="form-label fw-semibold">Sub Unit <span class="text-danger">*</span></label>
<select class="form-control" name="data[0][id_sub_unit_kerja]" id="select_id_sub_unit_kerja_0" required>
<option value="" disable selected>Pilih Sub Unit</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Kategori Dokumen <span class="text-danger">*</span></label>
<select class="form-control" name="data[0][master_kategori_directory_id]" id="select_kategori_0" required>
<option value="" disable>Pilih Kategori</option>
@foreach ($katDok as $kat)
<option value="{{ $kat->master_kategori_directory_id }}/{{ $kat->nama_kategori_directory }}">{{ $kat->nama_kategori_directory }}</option>
@endforeach
</select>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Nomor Dokumen</label>
<div class="input-group">
@ -57,27 +48,26 @@
<label class="form-label fw-semibold">Tanggal Terbit</label>
<input class="form-control" type="date" name="data[0][date_active]">
</div>
<div class="col-md-2">
<div class="col-md-4">
<div class="form-check">
<input class="form-check-input toggle-expired" type="checkbox" id="hasExpired0" data-target="expiredField_0">
<label class="form-check-label" for="hasExpired0">Masa Berlaku Dokumen?</label>
</div>
</div>
<div class="col-md-5" id="expiredField_0">
<div class="col-md-4" id="expiredField_0">
<label class="form-label fw-semibold">Tanggal Kedaluwarsa Dokumen</label>
<input class="form-control" type="date" name="data[0][tgl_expired]" disabled>
</div>
<div class="col-md-5">
<div class="col-md-4">
<label class="form-label fw-semibold">Boleh dilihat unit lain? <span class="text-danger">*</span></label>
<div class="border rounded-3 p-2 bg-light">
<div class="form-check">
<input class="form-check-input" type="radio" name="data[0][is_permission]" id="perm_yes" value="1" required>
<label class="form-check-label" for="perm_yes">
Iya
Ya
</label>
</div>
<div class="form-check mt-1">
<input class="form-check-input" type="radio" name="data[0][is_permission]" id="perm_no" value="2" required>
<label class="form-check-label" for="perm_no">
@ -85,9 +75,41 @@
</label>
</div>
</div>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Instrumen Akreditasi</label>
<select class="form-select akre-select" name="data[0][akre]" id="akre_select_0" style="width: 350px;">
<option value="">Pilih Instrumen</option>
</select>
<div class="form-text text-muted">Isi form ini bila dokumen yang diunggah merupakan dokumen <strong>akreditasi</strong>.</div>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Kategori Hukum</label>
<select class="form-select select-kat-hukum" name="data[0][kategori_hukum]" id="select_kategori_hukum_0" style="width: 350px;">
<option value="">Pilih Kategori Hukum</option>
<option value="Kebijakan - Peraturan Direktur">Kebijakan - Peraturan Direktur</option>
<option value="Kebijakan - Keputusan Direktur Utama">Kebijakan - Keputusan Direktur Utama</option>
<option value="Kebijakan - Surat Edaran">Kebijakan - Surat Edaran</option>
<option value="Kebijakan - Pengumuman">Kebijakan - Pengumuman</option>
<option value="Kerjasama - Pelayanan Kesehatan">Kerjasama - Pelayanan Kesehatan</option>
<option value="Kerjasama - Management">Kerjasama - Management</option>
<option value="Kerjasama - Pemeliharan">Kerjasama - Pemeliharan</option>
<option value="Kerjasama - Diklat">Kerjasama - Diklat</option>
<option value="Kerjasama - Luar Negeri">Kerjasama - Luar Negeri</option>
<option value="Kerjasama - Area Bisnis">Kerjasama - Area Bisnis</option>
<option value="Kerjasama - Pendidikan">Kerjasama - Pendidikan</option>
<option value="Kerjasama - Pengampuan KIA">Kerjasama- Pengampuan KIA</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Kategori lainnya</label>
<select class="form-select select-kat-lain" name="data[0][master_kategori_directory_id]" id="select_kategori_0" style="width: 350px;">
<option value="">Pilih Kategori</option>
@foreach ($katDok as $kat)
<option value="{{ $kat->master_kategori_directory_id }}/{{ $kat->nama_kategori_directory }}">{{ $kat->nama_kategori_directory }}</option>
@endforeach
</select>
</div>
<div class="col-md-12 mb-2">
<label for="fileUpload0" class="form-label fw-semibold">📂 Upload Dokumen (PDF)</label>
<div class="border rounded-3 p-3 bg-white shadow-sm">

View File

@ -30,7 +30,7 @@
<thead class="table-light shadow-sm">
<tr>
<th style="width:5%;" class="text-center">#</th>
<th style="width:30%;">Unit</th>
<th style="width:30%;">Unit / Akreditasi</th>
<th style="width:20%;">Kategori</th>
<th style="width:15%;" class="text-center">Jumlah File</th>
</tr>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,152 @@
@php($showRecapTitle = $showRecapTitle ?? true)
<div class="d-flex flex-column flex-md-row align-items-md-center gap-2 mb-3">
@if ($showRecapTitle)
<div>
<h4 class="mb-0">Rekap Dokumen Expired</h4>
<small class="text-muted">Ringkasan jumlah file per Unit dan Kategori</small>
</div>
@endif
<div class="{{ $showRecapTitle ? 'ms-md-auto' : '' }} d-flex gap-2 align-items-center">
<div class="input-group input-group-sm" style="max-width:320px;">
<span class="input-group-text bg-white border-end-0">
<i class="fa fa-search text-muted"></i>
</span>
<input type="search" id="recapSearch" class="form-control border-start-0" placeholder="Cari unit atau folder" oninput="debouncedRecapSearch(this.value)">
</div>
<select id="recapPerPage" class="form-select form-select-sm" style="width:auto;" onchange="changePerPage(this.value)">
<option value="5">5</option>
<option value="10" selected>10</option>
<option value="20">20</option>
<option value="50">50</option>
</select>
<button class="btn btn-outline-secondary btn-sm d-flex align-items-center gap-1" onclick="fetchRecap()">
<i class="fa fa-rotate"></i>
<span>Refresh</span>
</button>
</div>
</div>
<div class="table-responsive" style="max-height: 55vh; overflow-y:auto;">
<table class="table table-sm table-hover align-middle">
<thead class="table-light shadow-sm">
<tr>
<th style="width:5%;" class="text-center">#</th>
<th style="width:30%;">Unit / Akreditasi</th>
<th style="width:20%;">Kategori</th>
<th style="width:15%;" class="text-center">Jumlah File</th>
</tr>
</thead>
<tbody id="recapBody">
<tr>
<td colspan="4" class="text-center text-muted py-4">Memuat data...</td>
</tr>
</tbody>
</table>
</div>
<div class="d-flex flex-column flex-md-row align-items-center justify-content-between gap-2 mt-3" id="recapPagination"></div>
<script>
document.addEventListener('DOMContentLoaded', () => fetchRecap());
let recapDebounce;
const recapState = { page:1, perPage:10, keyword:'', lastPage:1 };
function debouncedRecapSearch(val){
clearTimeout(recapDebounce);
recapDebounce = setTimeout(() => {
recapState.keyword = val;
recapState.page = 1;
fetchRecap();
}, 250);
}
function changePerPage(val){
recapState.perPage = parseInt(val) || 10;
recapState.page = 1;
fetchRecap();
}
function fetchRecap(){
const tbody = document.getElementById('recapBody');
const pager = document.getElementById('recapPagination');
if(!tbody) return;
tbody.innerHTML = `<tr><td colspan="4" class="text-center text-muted py-4">Memuat data...</td></tr>`;
if(pager) pager.innerHTML = '';
const params = new URLSearchParams({
page: recapState.page,
per_page: recapState.perPage,
keyword: recapState.keyword || ''
});
fetch('/data/recapExp?' + params.toString())
.then(res => res.json())
.then(json => {
const rows = json?.data || [];
recapState.lastPage = json?.pagination?.last_page || 1;
if(!rows.length){
tbody.innerHTML = `<tr><td colspan="4" class="text-center text-muted py-4">Tidak ada data</td></tr>`;
return;
}
let grandTotal = 0;
const html = rows.map((row, idx) => {
const folderRows = (row.data || []).map((f, i) => `
<tr>
${i === 0 ? `<td rowspan="${row.data.length}" class="text-center align-middle fw-semibold">${idx+1}</td>` : ''}
${i === 0 ? `<td rowspan="${row.data.length}" class="fw-semibold">${row.unit || '-'}</td>` : ''}
<td>${f.folder || '-'}</td>
<td class="text-center fw-bold">${f.count || 0}</td>
</tr>
`).join('');
(row.data || []).forEach(f => { grandTotal += (parseInt(f.count, 10) || 0); });
return folderRows;
}).join('');
tbody.innerHTML = html + `
<tr class="table-light">
<td colspan="3" class="text-end fw-semibold">Total File</td>
<td class="text-center fw-bold">${grandTotal}</td>
</tr>
`;
renderRecapPagination();
})
.catch(err => {
console.error(err);
tbody.innerHTML = `<tr><td colspan="4" class="text-center text-danger py-4">Gagal memuat data</td></tr>`;
});
}
function renderRecapPagination(){
const pager = document.getElementById('recapPagination');
if(!pager) return;
if(recapState.lastPage <= 1){
pager.innerHTML = '';
return;
}
const maxButtons = 5;
let start = Math.max(1, recapState.page - Math.floor(maxButtons/2));
let end = Math.min(recapState.lastPage, start + maxButtons - 1);
start = Math.max(1, end - maxButtons + 1);
let buttons = '';
buttons += `<button class="btn btn-outline-secondary btn-sm" data-page="prev" ${recapState.page === 1 ? 'disabled' : ''}></button>`;
for(let i=start; i<=end; i++){
buttons += `<button class="btn btn-sm ${i === recapState.page ? 'btn-primary' : 'btn-outline-secondary'}" data-page="${i}">${i}</button>`;
}
buttons += `<button class="btn btn-outline-secondary btn-sm" data-page="next" ${recapState.page === recapState.lastPage ? 'disabled' : ''}></button>`;
pager.innerHTML = `
<div class="d-flex align-items-center gap-2 flex-wrap">
<div class="btn-group" role="group">${buttons}</div>
<span class="small text-muted">Halaman ${recapState.page} dari ${recapState.lastPage}</span>
</div>
`;
pager.querySelectorAll('button[data-page]').forEach(btn => {
btn.addEventListener('click', () => {
const page = btn.getAttribute('data-page');
if(page === 'prev' && recapState.page > 1) recapState.page--;
else if(page === 'next' && recapState.page < recapState.lastPage) recapState.page++;
else if(!isNaN(parseInt(page))) recapState.page = parseInt(page);
fetchRecap();
});
});
}
</script>

View File

@ -41,15 +41,21 @@
<span class="hide-menu">Dokumen Umum</span>
</a>
</li>
@if(Auth::guard('admin')->check() || (Auth::check() && auth()->user()->dataUser->mappingUnitKerjaPegawai()->whereIn('objectunitkerjapegawaifk', [51, 22])->exists()))
<li class="sidebar-item">
<a class="sidebar-link" href="{{ url('/data-akreditasi') }}" aria-expanded="false">
<i class="fa-solid fa-sliders"></i>
<span class="hide-menu">Dokumen Akreditasi</span>
</a>
</li>
@endif
{{-- AKTIVITAS --}}
<li class="nav-small-cap"><span class="hide-menu">Aktivitas</span></li>
@php
$isAtasan = \App\Models\MappingUnitKerjaPegawai::where('statusenabled', true)->where('objectatasanlangsungfk', auth()->user()->objectpegawaifk)->exists();
@endphp
@if($isAtasan)
@if(!Auth::guard('admin')->check())
<li class="sidebar-item">
<a class="sidebar-link d-flex align-items-center justify-content-between"
href="{{ url('/pending-file') }}" aria-expanded="false">
@ -62,7 +68,9 @@
<span class="badge bg-danger rounded-pill d-none" id="pendingCountBadge">0</span>
</a>
</li>
@endif
@else
@if(!Auth::guard('admin')->check())
<li class="sidebar-item">
<a class="sidebar-link d-flex align-items-center justify-content-between"
href="{{ url('/pengajuan-file') }}" aria-expanded="false">
@ -74,6 +82,7 @@
</a>
</li>
@endif
@endif
<li class="sidebar-item">
<a class="sidebar-link d-flex align-items-center justify-content-between"
href="{{ url('/log-activity') }}" aria-expanded="false">
@ -97,6 +106,14 @@
</li> --}}
{{-- MASTER --}}
<li class="nav-small-cap"><span class="hide-menu">History</span></li>
<li class="sidebar-item">
<a class="sidebar-link" href="{{ url('/expired-dokumen') }}" aria-expanded="false">
<i class="ti ti-clock"></i>
<span class="hide-menu">Expired Dokumen</span>
</a>
</li>
@if(!Auth::guard('admin')->check())
@if(auth()->user()->dataUser->mappingUnitKerjaPegawai()->where('objectunitkerjapegawaifk', 43)->exists())
<li class="nav-small-cap"><span class="hide-menu">Master</span></li>
@ -137,6 +154,7 @@
</ul>
</li>
@endif
@endif
</ul>
</nav>
</div>

View File

@ -1,41 +1,50 @@
<style>
/* ===== NOTIFIKASI STYLE FACEBOOK ===== */
<style>
.message-body {
max-height: 360px;
overflow-y: auto;
}
/* item notif */
.message-body .dropdown-item {
padding: 8px 12px;
font-size: 13px;
line-height: 1.35;
display: flex;
align-items: flex-start;
gap: 1px;
}
.message-body .dropdown-item div {
white-space: normal;
border-radius: 6px;
word-break: break-word;
}
/* jarak antar notif */
.message-body .dropdown-item + .dropdown-item {
margin-top: 2px;
}
/* judul teks */
.message-body .notif-text {
color: #212529;
}
/* waktu */
.message-body .notif-time {
font-size: 11px;
color: #6c757d;
margin-top: 2px;
}
/* hover ala Facebook */
.message-body .dropdown-item:hover {
background-color: #f0f2f5;
}
/* DOT */
.nav-link {
position: relative;
}
.notif-dot {
position: absolute;
top: 8px;
right: 8px;
width: 10px;
height: 10px;
background-color: red;
border-radius: 50%;
z-index: 10;
animation: pulse 1.5s infinite;
}
#expiredDot {
background-color: orange;
}
@keyframes pulse {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.5); opacity: 0.6; }
100% { transform: scale(1); opacity: 1; }
}
</style>
<header class="app-header">
@ -50,7 +59,27 @@
<div class="navbar-collapse justify-content-end px-0" id="navbarNav">
<ul class="navbar-nav flex-row ms-auto align-items-center justify-content-end">
<li class="nav-item dropdown">
<a class="nav-link position-relative" href="javascript:void(0)" id="drop1" data-bs-toggle="dropdown" aria-expanded="false">
<a class="nav-link position-relative" href="javascript:void(0)" id="dropExpired" data-bs-toggle="dropdown" aria-expanded="false">
<span class="d-inline-flex align-items-center justify-content-center rounded-circle bg-light" style="width:46px;height:46px;">
<i class="ti ti-alert-circle text-primary"></i>
</span>
<span id="expiredDot" class="notif-dot d-none">
</span>
</a>
<div class="dropdown-menu dropdown-menu-end dropdown-menu-animate-up shadow" aria-labelledby="dropExpired" style="min-width: 320px;">
<div class="d-flex align-items-center justify-content-between px-3 border-bottom">
<span class="fw-semibold">Notifikasi Expired</span>
<button type="button" class="btn btn-sm btn-outline-primary my-2" id="expiredOpenDetailBtn">
Detail
</button>
</div>
<div class="message-body" id="expiredNotifList">
<div class="dropdown-item text-muted small">Memuat...</div>
</div>
</div>
</li>
<li class="nav-item dropdown">
<a class="nav-link position-relative" href="javascript:void(0)" id="dropNotif" data-bs-toggle="dropdown" aria-expanded="false">
<span class="d-inline-flex align-items-center justify-content-center rounded-circle bg-light" style="width:46px;height:46px;">
<i class="ti ti-bell text-primary"></i>
</span>
@ -58,7 +87,7 @@
0
</span>
</a>
<div class="dropdown-menu dropdown-menu-end dropdown-menu-animate-up shadow" aria-labelledby="drop1" style="min-width: 320px;">
<div class="dropdown-menu dropdown-menu-end dropdown-menu-animate-up shadow" aria-labelledby="dropNotif" style="min-width: 320px;">
<div class="d-flex align-items-center justify-content-between px-3 border-bottom">
<span class="fw-semibold">Notifikasi</span>
<span class="small text-muted" id="notifCountText">0 baru</span>
@ -78,7 +107,7 @@
<div class="message-body">
<a href="javascript:void(0)" class="d-flex align-items-center gap-2 dropdown-item">
<i class="ti ti-user fs-6"></i>
<p class="mb-0 fs-3">{{ auth()->user()->namauser }}</p>
<p class="mb-0 fs-3">{{ auth()->user()->namauser ?? 'admin' }}</p>
</a>
<form action="/logout" method="POST">
@csrf
@ -91,68 +120,619 @@
</div>
</nav>
</header>
<script>
document.addEventListener('DOMContentLoaded', function() {
const listEl = document.getElementById('notifList');
const countTextEl = document.getElementById('notifCountText');
const badgeEl = document.getElementById('notifCountBadge');
function setList(items) {
if (!listEl) return;
if (!items.length) {
listEl.innerHTML = '<div class="dropdown-item text-muted small">Tidak ada notifikasi</div>';
<!-- Modal: Detail Dokumen Akan Expired -->
<div class="modal fade" id="expiredDetailModal" tabindex="-1" aria-labelledby="expiredDetailModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="expiredDetailModalLabel">Detail Notifikasi Dokumen Akan Expired</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row g-2 align-items-end mb-3">
<div class="col-12 col-md-4">
<label class="form-label mb-1">Akan expired (hari)</label>
<input type="number" class="form-control" id="expiredFilterDaysMax" min="0" step="1" value="30">
</div>
<div class="col-12 col-md-4 d-flex gap-2">
<button type="button" class="btn btn-primary flex-grow-1" id="expiredApplyFilterBtn">Terapkan</button>
<button type="button" class="btn btn-outline-secondary" id="expiredResetFilterBtn">Reset</button>
</div>
</div>
<div class="d-flex align-items-center justify-content-between mb-2">
<div class="small text-muted" id="expiredDetailInfo">-</div>
<div class="d-flex align-items-center gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary" id="expiredDetailPrevBtn">Prev</button>
<span class="small" id="expiredDetailPageText">1</span>
<button type="button" class="btn btn-sm btn-outline-secondary" id="expiredDetailNextBtn">Next</button>
</div>
</div>
<div class="table-responsive">
<table class="table table-sm table-striped align-middle mb-0">
<thead>
<tr>
<th style="width: 56px;">#</th>
<th>Unit</th>
<th>No Dokumen</th>
<th>Nama Dokumen</th>
<th style="width: 140px;">Tgl Expired</th>
<th style="width: 140px;">Sisa (hari)</th>
<th style="width: 140px;">Aksi</th>
</tr>
</thead>
<tbody id="expiredDetailTbody">
<tr>
<td colspan="7" class="text-muted small">Memuat...</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Tutup</button>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
/*
|--------------------------------------------------------------------------
| HELPER
|--------------------------------------------------------------------------
*/
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function ajaxGet(url) {
return fetch(url, {
method: 'GET',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
}
});
}
function ajaxPost(url) {
return fetch(url, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
}
});
}
/*
|--------------------------------------------------------------------------
| NOTIFIKASI BIASA
|--------------------------------------------------------------------------
*/
const notifListEl = document.getElementById('notifList');
const notifCountTextEl = document.getElementById('notifCountText');
const notifBadgeEl = document.getElementById('notifCountBadge');
const notifToggle = document.getElementById('dropNotif') || document.getElementById('drop1');
function setNotifList(items) {
if (!notifListEl) return;
if (!items || !items.length) {
notifListEl.innerHTML = `
<div class="dropdown-item text-muted small">
Tidak ada notifikasi
</div>
`;
return;
}
listEl.innerHTML = items.map(item => `
<a href="${item.url || '#'}" class="dropdown-item d-flex align-items-start gap-2">
<span class="badge ${item.is_read ? 'bg-secondary' : 'bg-primary'} rounded-circle" style="width:10px;height:10px;margin-top:6px;"></span>
notifListEl.innerHTML = items.map(item => {
const url = item.url || '#';
const text = escapeHtml(item.text_notifikasi || '-');
const createdAt = escapeHtml(item.created_at || '');
const isRead = item.is_read ? true : false;
return `
<a href="${url}" class="dropdown-item d-flex align-items-start gap-2">
<span class="badge ${isRead ? 'bg-secondary' : 'bg-primary'} rounded-circle"
style="width:10px;height:10px;margin-top:6px;"></span>
<div>
<div class="fw-semibold">${item.text_notifikasi || '-'}</div>
<div class="small text-muted">${item.created_at || ''}</div>
<div class="fw-semibold">${text}</div>
<div class="small text-muted">${createdAt}</div>
</div>
</a>
`).join('');
`;
}).join('');
}
fetch('/data/notifications')
function setNotifBadge(unread) {
unread = Number(unread || 0);
if (notifCountTextEl) {
notifCountTextEl.textContent = `${unread} baru`;
}
if (!notifBadgeEl) return;
if (unread > 0) {
notifBadgeEl.classList.remove('d-none');
notifBadgeEl.textContent = unread > 99 ? '99+' : unread;
} else {
notifBadgeEl.classList.add('d-none');
notifBadgeEl.textContent = '0';
}
}
function loadNotifications() {
ajaxGet('/data/notifications')
.then(r => r.json())
.then(res => {
const unread = res?.status ? Number(res.unread || 0) : 0;
const items = res?.status ? (res.data || []) : [];
if (countTextEl) countTextEl.textContent = `${unread} baru`;
if (badgeEl) {
if (unread > 0) {
badgeEl.classList.remove('d-none');
badgeEl.textContent = unread;
} else {
badgeEl.classList.add('d-none');
}
}
setList(items);
setNotifBadge(unread);
setNotifList(items);
})
.catch(() => {
if (listEl) listEl.innerHTML = '<div class="dropdown-item text-muted small">Gagal memuat notifikasi</div>';
if (notifListEl) {
notifListEl.innerHTML = `
<div class="dropdown-item text-muted small">
Gagal memuat notifikasi
</div>
`;
}
setNotifBadge(0);
});
}
function markNotificationsAsRead() {
ajaxPost('/data/notifications/read')
.then(() => {
setNotifBadge(0);
if (notifListEl) {
notifListEl.querySelectorAll('.badge').forEach(badge => {
badge.classList.remove('bg-primary');
badge.classList.add('bg-secondary');
});
}
})
.catch(() => {});
}
if (notifToggle) {
notifToggle.addEventListener('show.bs.dropdown', function() {
markNotificationsAsRead();
});
}
/*
|--------------------------------------------------------------------------
| NOTIFIKASI EXPIRED
|--------------------------------------------------------------------------
*/
const expiredListEl = document.getElementById('expiredNotifList');
const expiredDotEl = document.getElementById('expiredDot');
const expiredToggle = document.getElementById('dropExpired');
function setExpiredList(items) {
if (!expiredListEl) return;
if (!items || !items.length) {
expiredListEl.innerHTML = `
<div class="dropdown-item text-muted small">
Tidak ada notifikasi expired
</div>
`;
return;
}
expiredListEl.innerHTML = items.map(item => {
const url = item.url || '#';
const text = escapeHtml(item.text_notifikasi || '-');
const createdAt = escapeHtml(item.created_at || '');
const isRead = item.is_read ? true : false;
const docId = item.doc_id || item.document_id || item.dokumen_id || item.id || '';
return `
<a href="${url}"
class="dropdown-item d-flex align-items-start gap-2 js-expired-notif-item"
data-doc-id="${docId}">
<span class="badge ${isRead ? 'bg-secondary' : 'bg-warning'} rounded-circle"
style="width:10px;height:10px;margin-top:6px;"></span>
<div>
<div class="fw-semibold">${text}</div>
<div class="small text-muted">${createdAt}</div>
</div>
</a>
`;
}).join('');
}
function setExpiredDot(unread) {
unread = Number(unread || 0);
if (!expiredDotEl) return;
if (unread > 0) {
expiredDotEl.classList.remove('d-none');
} else {
expiredDotEl.classList.add('d-none');
}
}
function loadExpiredNotifications() {
ajaxGet('/data/expired-notifications')
.then(r => r.json())
.then(res => {
const unread = res?.status ? Number(res.unread || 0) : 0;
const items = res?.status ? (res.data || []) : [];
setExpiredDot(unread);
setExpiredList(items);
})
.catch(() => {
if (expiredListEl) {
expiredListEl.innerHTML = `
<div class="dropdown-item text-muted small">
Gagal memuat notifikasi expired
</div>
`;
}
setExpiredDot(0);
});
}
function markExpiredNotificationsAsRead() {
ajaxPost('/data/expired-notifications/read')
.then(() => {
setExpiredDot(0);
if (expiredListEl) {
expiredListEl.querySelectorAll('.badge').forEach(badge => {
badge.classList.remove('bg-warning');
badge.classList.add('bg-secondary');
});
}
})
.catch(() => {});
}
if (expiredToggle) {
expiredToggle.addEventListener('show.bs.dropdown', function() {
markExpiredNotificationsAsRead();
});
}
/*
|--------------------------------------------------------------------------
| MODAL DETAIL EXPIRED
|--------------------------------------------------------------------------
*/
const expiredDetailModalEl = document.getElementById('expiredDetailModal');
let expiredDetailModal = null;
if (expiredDetailModalEl && window.bootstrap && window.bootstrap.Modal) {
expiredDetailModal = new bootstrap.Modal(expiredDetailModalEl);
}
const expiredState = {
docId: null,
page: 1,
perPage: 10
};
const expiredDetailTbody = document.getElementById('expiredDetailTbody');
const expiredDetailInfo = document.getElementById('expiredDetailInfo');
const expiredDetailPageText = document.getElementById('expiredDetailPageText');
const expiredOpenDetailBtn = document.getElementById('expiredOpenDetailBtn');
const expiredFilterDaysMax = document.getElementById('expiredFilterDaysMax');
const expiredApplyFilterBtn = document.getElementById('expiredApplyFilterBtn');
const expiredResetFilterBtn = document.getElementById('expiredResetFilterBtn');
const expiredDetailPrevBtn = document.getElementById('expiredDetailPrevBtn');
const expiredDetailNextBtn = document.getElementById('expiredDetailNextBtn');
function showExpiredModal() {
if (expiredDetailModal) {
expiredDetailModal.show();
return;
}
/**
* Fallback kalau bootstrap.Modal tidak terbaca.
* Ini berguna kalau Bootstrap JS belum include.
*/
if (expiredDetailModalEl) {
expiredDetailModalEl.classList.add('show');
expiredDetailModalEl.style.display = 'block';
expiredDetailModalEl.removeAttribute('aria-hidden');
expiredDetailModalEl.setAttribute('aria-modal', 'true');
document.body.classList.add('modal-open');
let backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop fade show';
backdrop.id = 'manualExpiredBackdrop';
document.body.appendChild(backdrop);
} else {
alert('Modal expiredDetailModal tidak ditemukan di HTML.');
}
}
function hideExpiredModalFallback() {
if (!expiredDetailModalEl) return;
expiredDetailModalEl.classList.remove('show');
expiredDetailModalEl.style.display = 'none';
expiredDetailModalEl.setAttribute('aria-hidden', 'true');
expiredDetailModalEl.removeAttribute('aria-modal');
document.body.classList.remove('modal-open');
const backdrop = document.getElementById('manualExpiredBackdrop');
if (backdrop) {
backdrop.remove();
}
}
expiredDetailModalEl?.querySelectorAll('[data-bs-dismiss="modal"]').forEach(btn => {
btn.addEventListener('click', function() {
if (!expiredDetailModal) {
hideExpiredModalFallback();
}
});
});
const notifToggle = document.getElementById('drop1');
if (notifToggle) {
notifToggle.addEventListener('show.bs.dropdown', () => {
const unread = Number(badgeEl?.textContent || 0);
if (unread > 0) {
fetch('/data/notifications/read', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
'X-Requested-With': 'XMLHttpRequest'
function setExpiredDetailLoading(text = 'Memuat...') {
if (!expiredDetailTbody) return;
expiredDetailTbody.innerHTML = `
<tr>
<td colspan="7" class="text-muted small">${escapeHtml(text)}</td>
</tr>
`;
}
}).then(() => {
if (countTextEl) countTextEl.textContent = '0 baru';
if (badgeEl) badgeEl.classList.add('d-none');
}).catch(() => {});
function buildExpiredDetailParams() {
const params = new URLSearchParams();
params.set('page', String(expiredState.page));
params.set('per_page', String(expiredState.perPage));
if (expiredState.docId) {
params.set('doc_id', String(expiredState.docId));
}
const daysMax = expiredFilterDaysMax?.value ?? '';
if (daysMax !== '' && daysMax !== null) {
params.set('days_left_max', String(daysMax));
}
return params;
}
function renderExpiredDetail(rows, meta) {
const total = Number(meta?.total || 0);
const perPage = Number(meta?.per_page || expiredState.perPage);
const page = Number(meta?.page || expiredState.page);
const maxPage = total ? Math.ceil(total / perPage) : 1;
if (expiredDetailPageText) {
expiredDetailPageText.textContent = String(page);
}
if (expiredDetailInfo) {
const start = total ? ((page - 1) * perPage + 1) : 0;
const end = Math.min(page * perPage, total);
expiredDetailInfo.textContent = total
? `Menampilkan ${start}-${end} dari ${total} data`
: 'Tidak ada data';
}
if (expiredDetailPrevBtn) {
expiredDetailPrevBtn.disabled = page <= 1;
}
if (expiredDetailNextBtn) {
expiredDetailNextBtn.disabled = page >= maxPage;
}
if (!expiredDetailTbody) return;
if (!rows || !rows.length) {
expiredDetailTbody.innerHTML = `
<tr>
<td colspan="7" class="text-muted small">Tidak ada data</td>
</tr>
`;
return;
}
expiredDetailTbody.innerHTML = rows.map((row, idx) => {
const no = (page - 1) * perPage + (idx + 1);
const unit = escapeHtml(row.unit_name || row.nama_unit || '-');
const noDok = escapeHtml(row.no_dokumen || row.nomor_dokumen || '-');
const nama = escapeHtml(row.nama_dokumen || row.nama_document || row.nama || '-');
const tgl = escapeHtml(row.tgl_expired_label || row.tgl_expired || row.expired_date || '-');
const daysLeft = row.days_left === null || row.days_left === undefined
? '-'
: escapeHtml(row.days_left);
const previewUrl = row.preview_url || row.url || '#';
return `
<tr>
<td>${no}</td>
<td>${unit}</td>
<td>${noDok}</td>
<td>${nama}</td>
<td>${tgl}</td>
<td>${daysLeft}</td>
<td>
<a class="btn btn-sm btn-outline-primary"
href="${previewUrl}"
target="_blank"
rel="noopener">
Preview
</a>
</td>
</tr>
`;
}).join('');
}
function loadExpiredDetail() {
setExpiredDetailLoading();
const params = buildExpiredDetailParams();
ajaxGet(`/data/expired-notifications/detail?${params.toString()}`)
.then(r => r.json())
.then(res => {
if (!res?.status) {
setExpiredDetailLoading(res?.message || 'Gagal memuat data');
return;
}
renderExpiredDetail(res.data || [], res.meta || {});
})
.catch(() => {
setExpiredDetailLoading('Gagal memuat data detail expired');
});
}
function openExpiredDetail(docId = null) {
expiredState.docId = docId ? Number(docId) : null;
expiredState.page = 1;
/**
* Kalau klik dari item expired tertentu,
* filter hari dikosongkan supaya data berdasarkan doc_id tetap muncul.
*/
if (expiredState.docId && expiredFilterDaysMax) {
expiredFilterDaysMax.value = '';
}
showExpiredModal();
loadExpiredDetail();
}
/**
* Tombol "Detail" di header expired.
*/
if (expiredOpenDetailBtn) {
expiredOpenDetailBtn.addEventListener('click', function(e) {
e.preventDefault();
expiredState.docId = null;
expiredState.page = 1;
if (expiredFilterDaysMax) {
expiredFilterDaysMax.value = '30';
}
openExpiredDetail(null);
});
</script>
}
/**
* Klik salah satu item expired dari dropdown.
* Nanti langsung buka modal detail sesuai dokumen.
*/
if (expiredListEl) {
expiredListEl.addEventListener('click', function(e) {
const item = e.target.closest('a.js-expired-notif-item');
if (!item) return;
e.preventDefault();
const docId = item.dataset.docId || null;
openExpiredDetail(docId);
});
}
if (expiredApplyFilterBtn) {
expiredApplyFilterBtn.addEventListener('click', function() {
expiredState.docId = null;
expiredState.page = 1;
loadExpiredDetail();
});
}
if (expiredResetFilterBtn) {
expiredResetFilterBtn.addEventListener('click', function() {
expiredState.docId = null;
expiredState.page = 1;
if (expiredFilterDaysMax) {
expiredFilterDaysMax.value = '30';
}
loadExpiredDetail();
});
}
if (expiredDetailPrevBtn) {
expiredDetailPrevBtn.addEventListener('click', function() {
if (expiredState.page <= 1) return;
expiredState.page -= 1;
loadExpiredDetail();
});
}
if (expiredDetailNextBtn) {
expiredDetailNextBtn.addEventListener('click', function() {
expiredState.page += 1;
loadExpiredDetail();
});
}
/*
|--------------------------------------------------------------------------
| LOAD PERTAMA
|--------------------------------------------------------------------------
*/
loadNotifications();
loadExpiredNotifications();
/*
|--------------------------------------------------------------------------
| REFRESH OTOMATIS
|--------------------------------------------------------------------------
*/
setInterval(function() {
loadNotifications();
loadExpiredNotifications();
}, 60000);
});
</script>

View File

@ -139,7 +139,7 @@ document.addEventListener('DOMContentLoaded', () => {
const paginationBtns = document.getElementById('logPaginationBtns');
const summaryText = document.getElementById('logSummaryText');
if(tbody) tbody.innerHTML = '<tr><td colspan="4" class="text-center text-muted py-3">Memuat...</td></tr>';
if(tbody) tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Memuat...</td></tr>';
if(summaryText) summaryText.textContent = 'Memuat data...';
if(searchInput) searchInput.value = keyword || '';
@ -160,11 +160,12 @@ document.addEventListener('DOMContentLoaded', () => {
<td>${((currentPage - 1) * (pagination.per_page || 10)) + idx + 1}</td>
<td>${row.pegawai_nama_entry || '-'}</td>
<td>${row.total_open || 0}</td>
<td>${row.total_download || 0}</td>
<td>${formatTanggal(row.last_open)}</td>
</tr>
`).join('');
const emptyState = logs.length === 0 ? '<tr><td colspan="4" class="text-center text-muted py-3">Belum ada aktivitas</td></tr>' : '';
const emptyState = logs.length === 0 ? '<tr><td colspan="6" class="text-center text-muted py-3">Belum ada aktivitas</td></tr>' : '';
if(tbody) tbody.innerHTML = logs.length ? rows : emptyState;
if(summaryText){
@ -255,7 +256,7 @@ document.addEventListener('DOMContentLoaded', () => {
if(pageData.length === 0){
tbody.innerHTML = `
<tr>
<td colspan="6" class="text-center text-muted py-4">
<td colspan="7" class="text-center text-muted py-4">
Tidak ada data
</td>
</tr>
@ -341,6 +342,7 @@ document.addEventListener('DOMContentLoaded', () => {
<th>#</th>
<th>Nama</th>
<th>Jumlah Membuka</th>
<th>Jumlah Mengunduh</th>
<th>Terakhir Dilihat</th>
</tr>
</thead>

View File

@ -18,27 +18,18 @@
<div class="small text-muted">Perbarui detail dokumen sebelum mengirim ulang.</div>
</div>
</div>
<div class="col-md-4">
<div class="col-md-6">
<label class="form-label fw-semibold">Unit <span class="text-danger">*</span></label>
<select class="form-control unit_kerja" name="id_unit_kerja" id="edit_id_unit_kerja" required>
<option value="" disabled selected>Pilih Unit</option>
</select>
</div>
<div class="col-md-4">
<div class="col-md-6">
<label class="form-label fw-semibold">Sub Unit <span class="text-danger">*</span></label>
<select class="form-control sub_unit_kerja" name="id_sub_unit_kerja" id="edit_id_sub_unit_kerja" required>
<option value="" disabled selected>Pilih Sub Unit</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Kategori Dokumen <span class="text-danger">*</span></label>
<select class="form-control" name="master_kategori_directory_id" id="edit_kategori" required>
<option value="" disabled selected>Pilih Kategori</option>
@foreach ($katDok as $kat)
<option value="{{ $kat->master_kategori_directory_id }}/{{ $kat->nama_kategori_directory }}">{{ $kat->nama_kategori_directory }}</option>
@endforeach
</select>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Nomor Dokumen</label>
@ -55,22 +46,22 @@
<label class="form-label fw-semibold">Tanggal Terbit</label>
<input class="form-control" type="date" name="tanggal_terbit" id="edit_tanggal_terbit">
</div>
<div class="col-md-2 d-flex align-items-end">
<div class="col-md-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="edit_has_expired" data-target="edit_expired_field">
<label class="form-check-label" for="edit_has_expired">Ada Expired?</label>
<input class="form-check-input toggle-expired" type="checkbox" id="edit_has_expired" data-target="edit_expired_field">
<label class="form-check-label" for="edit_has_expired">Masa Berlaku Dokumen?</label>
</div>
</div>
<div class="col-md-5" id="edit_expired_field">
<div class="col-md-4" id="edit_expired_field">
<label class="form-label fw-semibold">Tanggal Kedaluwarsa Dokumen</label>
<input class="form-control" type="date" name="tgl_expired" id="edit_tgl_expired" disabled>
</div>
<div class="col-md-5">
<div class="col-md-4">
<label class="form-label fw-semibold">Boleh dilihat unit lain? <span class="text-danger">*</span></label>
<div class="border rounded-3 p-2 bg-light">
<div class="form-check">
<input class="form-check-input" type="radio" name="permission_file" id="edit_perm_yes" value="1" required>
<label class="form-check-label" for="edit_perm_yes">Iya</label>
<label class="form-check-label" for="edit_perm_yes">Ya</label>
</div>
<div class="form-check mt-1">
<input class="form-check-input" type="radio" name="permission_file" id="edit_perm_no" value="0" required>
@ -79,6 +70,41 @@
</div>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Instrumen Akreditasi</label>
<select class="form-select akre-select" id="edit_akre_select" name="akre" style="width: 350px;">
<option value="">Pilih Instrumen</option>
</select>
<div class="form-text text-muted">Isi form ini bila dokumen yang diunggah merupakan dokumen <strong>akreditasi</strong>.</div>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Kategori Hukum</label>
<select class="form-select select-kat-hukum" name="kategori_hukum" id="edit_kategori_hukum" style="width: 350px;">
<option value="">Pilih Kategori Hukum</option>
<option value="Kebijakan - Peraturan Direktur">Kebijakan - Peraturan Direktur</option>
<option value="Kebijakan - Keputusan Direktur Utama">Kebijakan - Keputusan Direktur Utama</option>
<option value="Kebijakan - Surat Edaran">Kebijakan - Surat Edaran</option>
<option value="Kebijakan - Pengumuman">Kebijakan - Pengumuman</option>
<option value="Kerjasama - Pelayanan Kesehatan">Kerjasama - Pelayanan Kesehatan</option>
<option value="Kerjasama - Management">Kerjasama - Management</option>
<option value="Kerjasama - Pemeliharan">Kerjasama - Pemeliharan</option>
<option value="Kerjasama - Diklat">Kerjasama - Diklat</option>
<option value="Kerjasama - Luar Negeri">Kerjasama - Luar Negeri</option>
<option value="Kerjasama - Area Bisnis">Kerjasama - Area Bisnis</option>
<option value="Kerjasama - Pendidikan">Kerjasama - Pendidikan</option>
<option value="Kerjasama - Pengampuan KIA">Kerjasama- Pengampuan KIA</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Kategori lainnya</label>
<select class="form-select" name="master_kategori_directory_id" id="edit_kategori" style="width: 350px;">
<option value="">Pilih Kategori</option>
@foreach ($katDok as $kat)
<option value="{{ $kat->master_kategori_directory_id }}/{{ $kat->nama_kategori_directory }}">{{ $kat->nama_kategori_directory }}</option>
@endforeach
</select>
</div>
<div class="col-md-12 mb-2">
<label for="edit_file_upload" class="form-label fw-semibold">📂 Upload Dokumen (PDF)</label>
<div class="border rounded-3 p-3 bg-white shadow-sm">

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;
@ -9,13 +10,19 @@ use App\Http\Controllers\LogActivityController;
use App\Http\Controllers\masterPersetujuanController;
use Illuminate\Support\Facades\Route;
Route::middleware(['auth'])->group(function(){
Route::middleware(['auth:admin,web'])->group(function(){
Route::get('/', [DashboardController::class, 'index']);
Route::get('/data-internal', [DashboardController::class, 'dataUnitInternal']);
Route::get('/download-excel/data-unit', [DashboardController::class, 'downloadDataUnitExcel']);
Route::get('/data-umum', [DashboardController::class, 'dataUmum']);
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']);
@ -53,7 +60,7 @@ Route::middleware(['auth'])->group(function(){
Route::get('/log-activity', [LogActivityController::class, 'index']);
Route::get('/datatable/log-activity', [LogActivityController::class, 'datatable']);
Route::get('/datatable/log-activity/{fileDirectoryId}', [LogActivityController::class, 'detailByFile']);
Route::get('/datatable/log-activity-pengajuan', [LogActivityController::class, 'datatableHistoryPengajuan']);
Route::get('/datatable/log-activity-pengajuapn', [LogActivityController::class, 'datatableHistoryPengajuan']);
Route::get('/recap', [DashboardController::class, 'recapView']);
Route::get('/data/recap', [DashboardController::class, 'recapData']);
@ -74,10 +81,18 @@ Route::middleware(['auth'])->group(function(){
// });
Route::get('/data/notifications', [DashboardController::class, 'notifkasiList']);
Route::post('/data/notifications/read', [DashboardController::class, 'notifkasiMarkRead']);
Route::get('/data/expired-notifications', [DashboardController::class, 'expiredNotifkasiList']);
Route::post('/data/expired-notifications/read', [DashboardController::class, 'expiredNotifkasiMarkRead']);
Route::get('/data/expired-notifications/detail', [DashboardController::class, 'expiredNotifkasiDetail']);
Route::get('/data/log-dokumen', [DashboardController::class, 'logDokumen']);
Route::get('/expired-dokumen', [DashboardController::class, 'expDokumen']);
Route::get('/data/expired-dokumen', [DashboardController::class, 'dataUnitExp']);
Route::get('/data/recapExp', [DashboardController::class, 'recapDataExp']);
});
Route::get('/login', [AuthController::class, 'index'])->name('login');
Route::get('/captcha/login', [AuthController::class, 'captcha'])->name('captcha.login');
Route::post('/login', [AuthController::class, 'login']);
Route::post('/logout', [AuthController::class, 'logout']);