Compare commits

...

10 Commits

Author SHA1 Message Date
root 1c1418f3e0 Initial commit - expense
Deploy to VPS (PAT over HTTPS) / deploy (push) Has been cancelled
2026-06-01 15:01:03 +07:00
Fiqh Pratama 9e9fab4a3f fixing OutstandingHelper Role Account Manager Cabang 2025-10-27 11:27:51 +07:00
Fiqh Pratama 1a090c8292 fixing OutstandingHelper 2025-10-26 16:20:13 +07:00
Fiqh Pratama e918e1e8a1 update fixing by CODEX 2025-10-26 14:14:06 +07:00
Fiqh Pratama 6f9f0d34a3 update by CODEX 2025-10-23 17:21:46 +07:00
Fiqh Pratama 4e0ab80520 Delete .env 2025-10-14 08:39:39 +07:00
Fiqh Pratama 8321df6d2b final upload form 2025-10-13 17:28:34 +07:00
Fiqh Pratama e8acdd3303 update form others 2025-10-13 16:42:05 +07:00
Fiqh Pratama 602fc68836 stage Form Vehicle Running Cost 2025-10-13 15:51:53 +07:00
Fiqh Pratama 76d4aa9b90 stage file upload an 2025-10-13 14:55:46 +07:00
141 changed files with 23256 additions and 10244 deletions
@@ -0,0 +1,80 @@
= Illuminate\Database\Query\Builder {#6493
+connection: Illuminate\Database\MySqlConnection {#6495},
+grammar: Illuminate\Database\Query\Grammars\MySqlGrammar {#6477},
+processor: Illuminate\Database\Query\Processors\MySqlProcessor {#6476},
+bindings: [
"select" => [],
"from" => [],
"join" => [],
"where" => [],
"groupBy" => [],
"having" => [],
"order" => [],
"union" => [],
"unionOrder" => [],
],
+aggregate: null,
+columns: null,
+distinct: false,
+from: "budget_control",
+indexHint: null,
+joins: null,
+wheres: [],
+groups: null,
+havings: null,
+orders: null,
+limit: null,
+groupLimit: null,
+offset: null,
+unions: null,
+unionLimit: null,
+unionOffset: null,
+unionOrders: null,
+lock: null,
+beforeQueryCallbacks: [],
+operators: [
"=",
"<",
">",
"<=",
">=",
"<>",
"!=",
"<=>",
"like",
"like binary",
"not like",
"ilike",
"&",
"|",
"^",
"<<",
">>",
"&~",
"is",
"is not",
"rlike",
"not rlike",
"regexp",
"not regexp",
"~",
"~*",
"!~",
"!~*",
"similar to",
"not similar to",
"not ilike",
"~~*",
"!~~*",
],
+bitwiseOperators: [
"&",
"|",
"^",
"<<",
">>",
"&~",
],
+useWritePdo: false,
}
-81
View File
@@ -1,81 +0,0 @@
APP_NAME=Xpendify
APP_ENV=local
APP_KEY=base64:nkxevDI1XtmExvpntHABxwR+rjZ5TMIcXAUxgntuKdM=
APP_DEBUG=true
APP_TIMEZONE=UTC
APP_URL=http://localhost
APP_VERSION = "1.0.0"
APP_COMPANY = "Tunggal Idaman Abadi"
APP_NAME = "Xpendify"
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST=mysql.upsense.co.id
DB_PORT=3306
DB_DATABASE=xpendify
DB_USERNAME=root
DB_PASSWORD=Hesoyam88
DB_TIMEZONE='+07:00'
SESSION_DRIVER=file
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=smtp
MAIL_HOST=mail.tia-pharma.com
MAIL_PORT=465
MAIL_USERNAME="mkt.alert@tia-pharma.com"
MAIL_PASSWORD="alert!2024"
MAIL_ENCRYPTION=ssl
MAIL_FROM_ADDRESS="mkt.alert@tia-pharma.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
NEXT_CLOUD_URL=https://mkt.tunggal-pharma.com
NEXT_CLOUD_USERNAME=user_dev
NEXT_CLOUD_PASSWORD=userdev12345
NEXT_CLOUD_PATHPREFIX=""
WABLAS_HOST=https://bdg.wablas.com
WABLAS_TOKEN=5iCaiP0nEgfl3lzrdsZGb9TvNYuwFZF8s4rp8AYwCx5OrYeiKXgixFMo9nDWNrJN.xvAwySaa
STARTING_DATE='12'
CLOSING_DATE='10'
+7 -14
View File
@@ -1,15 +1,8 @@
/node_modules
/public/hot
/public/storage
/storage/*.key
/vendor /vendor
.env.backup /node_modules
.phpunit.result.cache /.env
docker-compose.override.yml /storage/framework
Homestead.json /storage/logs
Homestead.yaml /storage/*.key
npm-debug.log /public/storage
yarn-error.log *.log
/.idea
/.vscode
.env
View File
@@ -0,0 +1,50 @@
= Illuminate\Support\Collection {#6508
all: [
1 => 3,
7 => 1,
8 => 5,
9 => 4,
11 => 1,
12 => 1,
13 => 3,
14 => 3,
15 => 4,
16 => 4,
17 => 5,
18 => 5,
19 => 4,
20 => 4,
21 => 4,
22 => 4,
23 => 4,
24 => 4,
25 => 4,
26 => 4,
27 => 5,
28 => 5,
29 => 5,
30 => 5,
31 => 5,
32 => 5,
33 => 5,
34 => 5,
35 => 5,
36 => 1,
37 => 1,
38 => 1,
39 => 1,
40 => 1,
41 => 1,
42 => 1,
43 => 1,
44 => 1,
45 => 3,
46 => 3,
47 => 3,
48 => 3,
49 => 3,
50 => 3,
51 => 3,
],
}
+34
View File
@@ -0,0 +1,34 @@
= Illuminate\Database\Eloquent\Collection {#8545
all: [
App\Models\FormUpCountry {#8380
id: 17,
expense_number: "RG-4-BGR-UPC-25040001",
tanggal: "2025-04-29",
status: "Rejected",
},
App\Models\FormUpCountry {#8372
id: 18,
expense_number: "RG-3-MDO-UPC-25050001",
tanggal: "2025-05-15",
status: "Rejected",
},
App\Models\FormUpCountry {#8381
id: 19,
expense_number: "RG-3-MDO-UPC-25050002",
tanggal: "2025-05-16",
status: "Rejected",
},
App\Models\FormUpCountry {#8382
id: 20,
expense_number: "RG-3-MDO-UPC-25050003",
tanggal: "2025-05-16",
status: "Rejected",
},
App\Models\FormUpCountry {#8383
id: 21,
expense_number: "RG-3-MDO-UPC-25050004",
tanggal: "2025-05-22",
status: "Rejected",
},
],
}
@@ -0,0 +1,97 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use App\Models\FormUpCountry;
use App\Models\FormVehicleRunningCost;
use App\Models\FormEntertaimentPresentation;
use App\Models\FormMeetingSeminar;
use App\Models\FormOthers;
use App\Helpers\AuditTrailHelper;
class AutoRejectExpense extends Command
{
/**
* Nama perintah yang akan dijalankan di terminal/cron
*/
protected $signature = 'expense:auto-reject';
/**
* Deskripsi perintah
*/
protected $description = 'Otomatis reject expense yang melewati batas waktu final approve (Tgl 13)';
public function handle()
{
$now = Carbon::now();
$closingDay = (int) env('CLOSING_DATE', 13);
$startDay = (int) env('STARTING_DATE', 11);
// Hanya jalankan eksekusi reject pada H+1 dari Closing Day (Misal: Tanggal 14 jam 00:01)
if ($now->day !== ($closingDay + 1)) {
$this->info("Hari ini bukan jadwal auto-reject. Jadwal berikutnya: Tanggal " . ($closingDay + 1));
return;
}
// Batas maksimal tanggal expense yang harus direject adalah H-1 dari Start Day (Misal: Tanggal 10 bulan ini)
// Karena tanggal 11 sudah masuk ke periode bulan depannya.
$batasTanggalPeriodeLalu = Carbon::create($now->year, $now->month, $startDay)->subDay()->endOfDay();
$models = [
FormUpCountry::class,
FormVehicleRunningCost::class,
FormEntertaimentPresentation::class,
FormMeetingSeminar::class,
FormOthers::class
];
// Daftar status yang dianggap menggantung / belum selesai
$pendingStatuses = ['On Progress', 'Approved 1', 'Approved 2', 'Waiting Approval', 'Validated'];
$totalRejected = 0;
DB::beginTransaction();
try {
foreach ($models as $model) {
// Tentukan kolom tanggal yang digunakan
$dateColumn = ($model === FormMeetingSeminar::class) ? 'created_at' : 'tanggal';
// Ambil semua data yang masih menggantung dari periode lalu
$pendingExpenses = $model::whereIn('status', $pendingStatuses)
->where($dateColumn, '<=', $batasTanggalPeriodeLalu)
->get();
foreach ($pendingExpenses as $expense) {
// Update status dan berikan alasan penolakan sesuai instruksi core business
$expense->update([
'status' => 'Rejected',
'remarks' => 'auto reject by system - lewat periode cut off',
'rejected_at' => Carbon::now(),
'rejected_by' => 0 // Diisi 0 sebagai flag penanda sistem otomatis
]);
// Tambahkan ke Audit Trail
$noExpense = $expense->expense_number ?? 'Unknown';
AuditTrailHelper::AddAuditTrail('System Update', "Auto Reject by System for Expense ({$noExpense})");
$totalRejected++;
}
}
DB::commit();
$msg = "Auto Reject berhasil dijalankan. Total direject: {$totalRejected} dokumen.";
$this->info($msg);
Log::info($msg);
} catch (\Exception $e) {
DB::rollBack();
$this->error("Gagal menjalankan Auto Reject: " . $e->getMessage());
Log::error("Cron Auto Reject Failed: " . $e->getMessage());
}
}
}
@@ -0,0 +1,111 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class MigrateOldExpenseData extends Command
{
// Perintah yang akan dieksekusi di terminal
protected $signature = 'expense:migrate-old';
protected $description = 'Migrasi data transaksional paling update dari expense-old ke database expense (v2.0) secara aman';
public function handle()
{
$this->info('Memulai Proses Sinkronisasi: Memindahkan data update dari [expense-old] ke [expense]...');
// Daftar tabel yang akan disinkronkan (Nama tabel diasumsikan sama pada kedua versi)
$tablesToMigrate = [
'form_up_country' => 'form_up_country',
'form_vehicle_running_cost' => 'form_vehicle_running_cost',
'form_entertainment_presentation' => 'form_entertainment_presentation',
'form_meeting_seminar' => 'form_meeting_seminar',
'form_others' => 'form_others',
];
foreach ($tablesToMigrate as $oldTable => $newTable) {
$this->comment("--------------------------------------------------");
$this->comment("Memproses Tabel Target: {$newTable}");
// 1. Validasi eksistensi tabel di database tujuan (expense)
if (!Schema::hasTable($newTable)) {
$this->error("Tabel [{$newTable}] tidak ditemukan di database 'expense'. Melewati tabel ini.");
continue;
}
// 2. Ambil list kolom lengkap yang ada di database tujuan (expense)
$destinationColumns = Schema::getColumnListing($newTable);
// 3. Ambil data transaksional paling update dari database lama (expense-old)
$oldData = DB::connection('mysql_lama')->table($oldTable)->get();
$insertedCount = 0;
$skippedCount = 0;
foreach ($oldData as $row) {
// 4. PROTEKSI ANTI-OVERWRITE: Cek apakah data sudah ada di database 'expense'
$isExists = DB::table($newTable)
->where('expense_number', $row->expense_number)
->exists();
if (!$isExists) {
$insertData = [];
// 5. MAPPING DATA SECARA DINAMIS
foreach ($destinationColumns as $column) {
// KUNCI PENYEMPURNAAN: Jangan bawa ID lama agar tidak tabrakan dengan Auto-Increment di DB baru
if ($column === 'id') {
continue;
}
// Jika kolom dari database baru tersedia di data 'expense-old', ambil nilainya
if (property_exists($row, $column) && !is_null($row->$column)) {
$insertData[$column] = $row->$column;
} else {
// Jika kolom baru versi 2.0 tidak ada di database lama / bernilai NULL, isi dengan nilai default aman
$insertData[$column] = $this->getDefaultValueForColumn($newTable, $column);
}
}
// 6. Jalankan amunisi Insert ke database utama (expense)
DB::table($newTable)->insert($insertData);
$insertedCount++;
} else {
$skippedCount++;
}
}
$this->info("Tabel [{$newTable}] Selesai: Masuk {$insertedCount} data baru. Diabaikan (Sudah ada) {$skippedCount} data.");
}
$this->info('==================================================');
$this->info('Sukses! Seluruh data dari [expense-old] telah bermigrasi ke [expense] dengan aman.');
}
/**
* Fallback Nilai Default untuk Kolom Baru Struktur Aplikasi 2.0
*/
private function getDefaultValueForColumn($tableName, $columnName)
{
// POINT 1: Solusi otomatisasi penanganan cabang_id yang NULL pada tabel entertainment
if ($tableName === 'form_entertainment_presentation' && $columnName === 'cabang_id') {
return 1; // Ganti angka 1 dengan ID Cabang default existing Anda
}
// Pengondisian khusus untuk kolom-kolom baru krusial di versi 2.0 Anda
if ($columnName === 'account_number') {
return '-';
}
if ($columnName === 'approved_total') {
return 0;
}
if ($columnName === 'approved_at' || $columnName === 'approved2_at' || $columnName === 'final_approved_at') {
return null;
}
// Nilai bawaan jika tidak masuk kategori spesifik di atas
return null;
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
namespace App\Exports;
use App\Models\AuditTrail;
use Maatwebsite\Excel\Concerns\FromQuery;
use Maatwebsite\Excel\Concerns\WithMapping;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithColumnWidths;
use Maatwebsite\Excel\Concerns\WithStyles;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
class AuditTrailExport implements FromQuery, WithMapping, WithHeadings, WithColumnWidths, WithStyles
{
protected $filters;
public function __construct($filters)
{
$this->filters = $filters;
}
/**
* Menggunakan FromQuery untuk efisiensi memori pada data besar
*/
public function query()
{
$f = $this->filters;
$query = AuditTrail::with(['user.cabang.cabang.region']);
// Filter Region & Cabang
if (!empty($f['region']) || !empty($f['cabang'])) {
$query->whereHas('user.cabang.cabang', function($q) use ($f) {
if (!empty($f['cabang'])) {
$q->where('id', $f['cabang']);
}
if (!empty($f['region'])) {
$q->whereHas('region', function($rq) use ($f) {
// PERBAIKAN: Gunakan 'code' sesuai image_63aac6.png
$rq->where('code', $f['region']);
});
}
});
}
// Filter User & Tanggal...
if (!empty($f['admin_id'])) {
$query->where('user_id', $f['admin_id']);
}
return $query->latest();
}
/**
* Header Tabel Excel
*/
public function headings(): array
{
return [
'Waktu',
'User',
'IP Address',
'Aksi',
'Aktivitas',
'Browser'
];
}
/**
* Transformasi Data per baris
*/
public function map($audit): array
{
return [
$audit->created_at->format('d-m-Y H:i:s'),
$audit->user->name ?? 'System',
$audit->ip_address,
$audit->action,
$audit->activity,
$audit->browser,
];
}
/**
* Atur Lebar Kolom agar rapi secara otomatis
*/
public function columnWidths(): array
{
return [
'A' => 20,
'B' => 20,
'C' => 15,
'D' => 15,
'E' => 50,
'F' => 20,
];
}
/**
* Styling Header agar lebih profesional
*/
public function styles(Worksheet $sheet)
{
return [
1 => ['font' => ['bold' => true]],
];
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace App\Exports;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithMapping;
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
class PendingDocumentExport implements FromCollection, WithHeadings, WithMapping, ShouldAutoSize
{
protected $cabangs;
public function __construct($cabangs)
{
$this->cabangs = $cabangs;
}
public function collection()
{
return $this->cabangs;
}
public function headings(): array
{
return [
'Region',
'Nama Cabang',
'Up Country',
'Vehicle',
'Entertainment',
'Meeting',
'Others',
'Total Pending',
'Budget Aktif'
];
}
public function map($cabang): array
{
return [
$cabang->region->name ?? '-',
$cabang->name,
$cabang->pending_details['up_country'] ?? 0,
$cabang->pending_details['vehicle'] ?? 0,
$cabang->pending_details['entertainment'] ?? 0,
$cabang->pending_details['meeting'] ?? 0,
$cabang->pending_details['others'] ?? 0,
$cabang->total_pending,
$cabang->available_budget
];
}
}
+37 -7
View File
@@ -4,29 +4,59 @@ namespace App\Helpers;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use App\Models\AuditTrail; use App\Models\AuditTrail;
use Illuminate\Support\Facades\Request;
class AuditTrailHelper class AuditTrailHelper
{ {
/**
* Mencatat aktivitas umum (Insert, Update, Delete, Approve, Reject)
*/
public static function AddAuditTrail($action, $activity) public static function AddAuditTrail($action, $activity)
{ {
// Menggunakan guard 'admin' sesuai spesifikasi login aplikasi Anda
$user = Auth::guard('admin')->user();
$audit = new AuditTrail(); $audit = new AuditTrail();
$audit->user_id = Auth::user()->id; $audit->user_id = $user ? $user->id : null;
$audit->ip_address = request()->ip(); $audit->ip_address = Request::ip();
$audit->activity = $activity; $audit->activity = $activity;
$audit->action = $action; $audit->action = $action;
$audit->user_agent = request()->userAgent(); $audit->user_agent = Request::userAgent();
$audit->browser = substr($audit->user_agent, 0, strpos($audit->user_agent, '/')); $audit->browser = self::getBrowserName(Request::userAgent());
$audit->save(); $audit->save();
} }
/**
* Mencatat percobaan otentikasi (Login/Logout)
*/
public static function AuthAttempt($action, $activity) public static function AuthAttempt($action, $activity)
{ {
$audit = new AuditTrail(); $audit = new AuditTrail();
$audit->ip_address = request()->ip(); $audit->user_id = Auth::guard('admin')->id(); // Tetap coba ambil ID jika ada (misal saat logout)
$audit->ip_address = Request::ip();
$audit->activity = $activity; $audit->activity = $activity;
$audit->action = $action; $audit->action = $action;
$audit->user_agent = request()->userAgent(); $audit->user_agent = Request::userAgent();
$audit->browser = substr($audit->user_agent, 0, strpos($audit->user_agent, '/')); $audit->browser = self::getBrowserName(Request::userAgent());
$audit->save(); $audit->save();
} }
/**
* Helper internal untuk deteksi browser yang lebih bersih
*/
private static function getBrowserName($userAgent)
{
if (!$userAgent) return 'Unknown';
if (strpos($userAgent, 'Opera') || strpos($userAgent, 'OPR')) return 'Opera';
if (strpos($userAgent, 'Edge')) return 'Edge';
if (strpos($userAgent, 'Chrome')) return 'Chrome';
if (strpos($userAgent, 'Safari')) return 'Safari';
if (strpos($userAgent, 'Firefox')) return 'Firefox';
if (strpos($userAgent, 'MSIE') || strpos($userAgent, 'Trident/7')) return 'Internet Explorer';
return explode('/', $userAgent)[0] ?? 'Unknown';
}
} }
+300 -53
View File
@@ -2,97 +2,344 @@
namespace App\Helpers; namespace App\Helpers;
use GuzzleHttp\Client; use App\Models\Kategori;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Str;
use App\Models\BudgetControl; use App\Models\BudgetControl;
use App\Models\FormUpCountry; use App\Models\FormUpCountry;
use App\Models\FormMeetingSeminar; use App\Models\FormMeetingSeminar;
use App\Models\FormOthers; use App\Models\FormOthers;
use App\Models\FormEntertaimentPresentation; use App\Models\FormEntertaimentPresentation;
use App\Models\FormVehicleRunningCost; use App\Models\FormVehicleRunningCost;
use App\Helpers\FormHelper;
use App\Models\UserHasCabang; use App\Models\UserHasCabang;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class BudgetHelper class BudgetHelper
{ {
/**
* Cache memori sementara untuk fungsi jurnal agar loading halaman riwayat tidak berat.
*/
private static $jurnalCache = [];
/**
* Daftar status yang akan memotong (menge-hold) sisa budget secara real-time.
*/
const HOLD_STATUSES = [
'Approved 1',
'Approved 2',
'Waiting Approval',
'Validated',
'Approved',
'Closed',
'Completed'
];
public static function getBulkAvailableBudget($budgetControls)
{
if ($budgetControls->isEmpty()) return [];
$cabangIds = $budgetControls->pluck('cabang_id')->unique()->toArray();
// 1. Mapping User per Cabang
$branchUsers = UserHasCabang::whereIn('cabang_id', $cabangIds)
->get()
->groupBy('cabang_id')
->map(fn($item) => $item->pluck('user_id')->toArray());
$allUserIds = $branchUsers->flatten()->unique()->toArray();
$resultsMap = [];
if (empty($allUserIds)) return [];
$formModels = [
'tanggal' => [
FormUpCountry::class,
FormVehicleRunningCost::class,
FormEntertaimentPresentation::class,
FormOthers::class
],
'created_at' => [
FormMeetingSeminar::class
]
];
// 2. Tarik data pengeluaran sekaligus dari semua tabel form
foreach ($formModels as $dateColumn => $models) {
foreach ($models as $model) {
$query = $model::whereIn('user_id', $allUserIds)
->whereIn('status', self::HOLD_STATUSES)
->select(
'user_id',
DB::raw("MONTH($dateColumn) as month"),
DB::raw("YEAR($dateColumn) as year"),
DB::raw("SUM(approved_total) as total")
)
->groupBy('user_id', 'month', 'year')
->get();
// 3. Masukkan ke Map berdasarkan Cabang_Bulan_Tahun
foreach ($query as $row) {
foreach ($branchUsers as $cabangId => $userIds) {
if (in_array($row->user_id, $userIds)) {
$monthName = strtolower(date('F', mktime(0, 0, 0, $row->month, 10)));
$key = "{$cabangId}_{$monthName}_{$row->year}";
$resultsMap[$key] = ($resultsMap[$key] ?? 0) + $row->total;
}
}
}
}
}
return $resultsMap;
}
/**
* Menghitung sisa budget (Pagu - Total Hold/Terpakai).
* Tetap menggunakan kolom tanggal/created_at karena pengajuan yang baru
* dibuat (Approved 1) belum memiliki final_approved_at.
*/
public static function getAvailableBudget($cabang_id, $periode_month, $periode_year) public static function getAvailableBudget($cabang_id, $periode_month, $periode_year)
{ {
// Validasi & normalisasi
$periode_month = strtolower($periode_month); $periode_month = strtolower($periode_month);
$periode_year = (int) $periode_year; $periode_year = (int) $periode_year;
// Ambil budget sesuai cabang dan periode $totalPagu = BudgetControl::where('cabang_id', $cabang_id)
$budget = BudgetControl::where('cabang_id', $cabang_id)
->where('periode_month', $periode_month) ->where('periode_month', $periode_month)
->where('periode_year', $periode_year) ->where('periode_year', $periode_year)
->with(['user.cabang']) ->sum('amount');
->get();
if ($totalPagu <= 0) return 0;
$userIds = UserHasCabang::where('cabang_id', $cabang_id)->pluck('user_id')->toArray();
if (empty($userIds)) return $totalPagu;
$usedBudget = 0; $usedBudget = 0;
$monthNumeric = date('m', strtotime($periode_month));
$users = UserHasCabang::where('cabang_id', $cabang_id)->get(); $formModels = [
$userIds = $users->pluck('user_id')->toArray(); FormUpCountry::class,
FormVehicleRunningCost::class,
FormEntertaimentPresentation::class,
FormMeetingSeminar::class,
FormOthers::class
];
$forms = FormHelper::getTotalAmount($userIds, $periode_month, $periode_year) foreach ($formModels as $model) {
->sortByDesc('tanggal') $query = $model::whereIn('user_id', $userIds)
->sortByDesc('created_at'); ->whereIn('status', self::HOLD_STATUSES);
$usedBudget += $forms->sum('approved_total'); if ($model === FormMeetingSeminar::class) {
$query->whereMonth('created_at', $monthNumeric)
$availableBudget = $budget->sum('amount') - $usedBudget; ->whereYear('created_at', $periode_year);
} else {
return $availableBudget; $query->whereMonth('tanggal', $monthNumeric)
->whereYear('tanggal', $periode_year);
} }
$usedBudget += $query->sum('approved_total');
}
return $totalPagu - $usedBudget;
}
/**
* Memvalidasi apakah tanggal expense yang diinput user berada dalam
* rentang periode aktif (Tanggal 11 s/d 10).
*/
public static function checkExpenseInputWindow($tanggal_expense)
{
$inputDate = \Carbon\Carbon::parse($tanggal_expense)->startOfDay();
$today = \Carbon\Carbon::now();
$startDay = 11;
$endDay = 10;
// Menentukan jendela periode yang sedang aktif hari ini
if ($today->day >= $startDay) {
// Jika hari ini tgl 11 atau lebih: Jendela aktif adalah Tgl 11 bulan ini s/d Tgl 10 bulan depan
$periodStart = \Carbon\Carbon::create($today->year, $today->month, $startDay)->startOfDay();
$periodEnd = \Carbon\Carbon::create($today->year, $today->month, $endDay)->addMonth()->endOfDay();
} else {
// Jika hari ini tgl 1-10: Jendela aktif adalah Tgl 11 bulan lalu s/d Tgl 10 bulan ini
$periodStart = \Carbon\Carbon::create($today->year, $today->month, $startDay)->subMonth()->startOfDay();
$periodEnd = \Carbon\Carbon::create($today->year, $today->month, $endDay)->endOfDay();
}
// Jika tanggal form yang diinput user berada di luar jendela aktif
if ($inputDate->lt($periodStart) || $inputDate->gt($periodEnd)) {
return [
'is_valid' => false,
'message' => "Gagal! Batas input expense untuk periode aktif saat ini adalah {$periodStart->format('d M Y')} s/d {$periodEnd->format('d M Y')}. Tanggal {$inputDate->format('d M Y')} sudah ditutup atau belum dibuka."
];
}
return ['is_valid' => true];
}
/**
* Mengambil nominal per tipe form.
*/
public static function getNominalByFormType($userIds, $type, $type_id, $month, $year) public static function getNominalByFormType($userIds, $type, $type_id, $month, $year)
{ {
$monthNumeric = date('m', strtotime($month));
$used = 0;
if ($type == 'Up Country') { if ($type == 'Up Country') {
$form = FormUpCountry::select('approved_total') $used = FormUpCountry::whereIn('user_id', $userIds)
->whereIn('user_id', $userIds) ->whereMonth('tanggal', $monthNumeric)->whereYear('tanggal', $year)
->whereMonth('tanggal', '=', date('m', strtotime($month))) ->whereIn('status', self::HOLD_STATUSES)->sum('approved_total');
->whereYear('tanggal', '=', $year)
->whereIn('status', ['Approved', 'Closed'])
->get();
} elseif ($type == 'Vehicle Running Cost') { } elseif ($type == 'Vehicle Running Cost') {
$form = FormVehicleRunningCost::select('approved_total') $used = FormVehicleRunningCost::whereIn('user_id', $userIds)
->whereIn('user_id', $userIds) ->whereMonth('tanggal', $monthNumeric)->whereYear('tanggal', $year)
->whereMonth('tanggal', '=', date('m', strtotime($month))) ->whereIn('status', self::HOLD_STATUSES)->sum('approved_total');
->whereYear('tanggal', '=', $year)
->whereIn('status', ['Approved', 'Closed'])
->get();
} elseif ($type == 'Entertainment') { } elseif ($type == 'Entertainment') {
$form = FormEntertaimentPresentation::select('approved_total') $used = FormEntertaimentPresentation::whereIn('user_id', $userIds)
->whereIn('user_id', $userIds) ->whereMonth('tanggal', $monthNumeric)->whereYear('tanggal', $year)
->whereMonth('tanggal', '=', date('m', strtotime($month))) ->whereIn('status', self::HOLD_STATUSES)->sum('approved_total');
->whereYear('tanggal', '=', $year)
->whereIn('status', ['Approved', 'Closed'])
->get();
} elseif ($type == 'Meeting / Seminar') { } elseif ($type == 'Meeting / Seminar') {
$form = FormMeetingSeminar::select('approved_total') $used = FormMeetingSeminar::whereIn('user_id', $userIds)
->whereIn('user_id', $userIds) ->whereMonth('created_at', $monthNumeric)->whereYear('created_at', $year)
->whereMonth('created_at', '=', date('m', strtotime($month))) ->whereIn('status', self::HOLD_STATUSES)->sum('approved_total');
->whereYear('created_at', '=', $year)
->whereIn('status', ['Approved', 'Closed'])
->get();
} else { } else {
$form = FormOthers::select('approved_total') $used = FormOthers::whereIn('user_id', $userIds)
->whereIn('user_id', $userIds)
->whereMonth('tanggal', '=', date('m', strtotime($month)))
->whereYear('tanggal', '=', $year)
->where('kategori_id', $type_id) ->where('kategori_id', $type_id)
->whereIn('status', ['Approved', 'Closed']) ->whereMonth('tanggal', $monthNumeric)->whereYear('tanggal', $year)
->get(); ->whereIn('status', self::HOLD_STATUSES)->sum('approved_total');
} }
return $form->sum('approved_total'); return $used;
} }
/**
* Mengambil tanggal closing.
*/
public static function getClosingDate($cabang_id) public static function getClosingDate($cabang_id)
{ {
$budget = BudgetControl::where('cabang_id', $cabang_id)->orderBy('created_at', 'desc')->first(); return BudgetControl::where('cabang_id', $cabang_id)
->orderBy('created_at', 'desc')
return $budget->closing_at; ->value('closing_at');
} }
/**
* KALKULASI JURNAL AKUNTANSI
* Berlaku seragam menggunakan kolom final_approved_at untuk semua form
* Cut-off Date: Tgl 11 s/d Tgl 13 Bulan Berikutnya
*/
/**
* KALKULASI JURNAL AKUNTANSI (Log & Report)
* Sinkron dengan 12 Kategori Utama & Akun 800xxx
*/
// public static function getKalkulasiJurnal($cabang_id, $periode_month, $periode_year)
// {
// $periode_month = strtolower($periode_month);
// $periode_year = (int) $periode_year;
// $cacheKey = "{$cabang_id}_{$periode_month}_{$periode_year}";
// if (isset(self::$jurnalCache[$cacheKey])) return self::$jurnalCache[$cacheKey];
// $userIds = UserHasCabang::where('cabang_id', $cabang_id)->pluck('user_id')->toArray();
// $totalDebet = 0;
// if (!empty($userIds)) {
// // Ambil Range Tanggal (Contoh: 11 Maret - 13 April)
// $startDay = env('STARTING_DATE', 11);
// $closeDay = env('CLOSING_DATE', 13);
// $monthNumeric = date('m', strtotime($periode_month));
// $startDate = Carbon::create($periode_year, $monthNumeric, $startDay)->startOfDay();
// $endDate = Carbon::create($periode_year, $monthNumeric, $closeDay)->addMonth()->endOfDay();
// // Mapping Model dengan pengamanan Typo
// $formModels = [
// 'Up Country' => FormUpCountry::class,
// 'Vehicle Running Cost' => FormVehicleRunningCost::class,
// 'Entertainment' => FormEntertaimentPresentation::class,
// 'Entertaiment' => FormEntertaimentPresentation::class,
// 'Presentation' => FormEntertaimentPresentation::class,
// 'Meeting / Seminar' => FormMeetingSeminar::class,
// ];
// // Ambil kategori yang punya account_number dan tidak dikecualikan
// $kategoris = \App\Models\Kategori::whereNotIn('name', ['Cash in Bank BCA', 'Donasi/Gift', 'PPn Masukan', 'Sponsorship'])
// ->whereNotNull('account_number')
// ->where('account_number', '!=', '')
// ->get();
// foreach ($kategoris as $kat) {
// $katName = trim($kat->name);
// if (isset($formModels[$katName])) {
// $model = $formModels[$katName];
// $query = $model::whereIn('user_id', $userIds)
// ->whereIn('status', ['Closed', 'Final Approved'])
// ->whereBetween('final_approved_at', [$startDate, $endDate]);
// // PENTING: Filter berdasarkan kategori_id agar data tidak tertukar di tabel yang sama
// if (\Illuminate\Support\Facades\Schema::hasColumn((new $model)->getTable(), 'kategori_id')) {
// $query->where('kategori_id', $kat->id);
// }
// $totalDebet += $query->sum('approved_total');
// } else {
// // Masuk ke Form Others
// $totalDebet += FormOthers::whereIn('user_id', $userIds)
// ->where('kategori_id', $kat->id)
// ->whereIn('status', ['Closed', 'Final Approved'])
// ->whereBetween('final_approved_at', [$startDate, $endDate])
// ->sum('approved_total');
// }
// }
// }
// self::$jurnalCache[$cacheKey] = $totalDebet;
// return $totalDebet;
// }
public static function getBulkKalkulasiJurnal(array $lookups)
{
if (empty($lookups)) return [];
$resultsMap = [];
$startDay = 11; // Batas awal periode (Contoh: tgl 11)
$closeDay = 13; // Batas akhir approval (Contoh: tgl 13 bulan depan)
foreach ($lookups as $item) {
$cabId = $item['cabang_id'];
$monthNumeric = date('m', strtotime($item['month']));
$year = (int) $item['year'];
$key = $cabId . '_' . strtolower($item['month']) . '_' . $year;
// Penentuan Rentang Tanggal Cut-off Akuntansi
$startDate = \Illuminate\Support\Carbon::create($year, $monthNumeric, $startDay)->startOfDay();
$endDate = $startDate->copy()->addMonth()->day($closeDay)->endOfDay();
$userIds = \App\Models\UserHasCabang::where('cabang_id', $cabId)->pluck('user_id')->toArray();
if (empty($userIds)) {
$resultsMap[$key] = 0;
continue;
}
// Menghitung Total Pengeluaran Riil (Status: Closed / Final Approved)
$tables = ['form_up_country', 'form_vehicle_running_cost', 'form_entertainment_presentation', 'form_meeting_seminar', 'form_others'];
$totalExpense = 0;
foreach ($tables as $table) {
$totalExpense += \Illuminate\Support\Facades\DB::table($table)
->whereIn('user_id', $userIds)
->whereIn('status', ['Closed', 'Final Approved'])
->whereBetween('final_approved_at', [$startDate, $endDate])
->sum('approved_total');
}
$resultsMap[$key] = $totalExpense;
}
return $resultsMap;
}
} }
+366
View File
@@ -0,0 +1,366 @@
<?php
namespace App\Helpers;
use App\Models\Admin;
use App\Models\FormEntertaimentPresentation;
use App\Models\FormMeetingSeminar;
use App\Models\FormOthers;
use App\Models\FormUpCountry;
use App\Models\FormVehicleRunningCost;
use Illuminate\Support\Facades\DB;
use App\Models\UserHasCabang;
class FormHelper
{
public static function getFormsByUserIds($userIds, $month, $year) {
$monthNumber = date('m', strtotime($month . ' 1'));
$startDate = "$year-$monthNumber-" . env('STARTING_DATE');
$nextMonthTimestamp = strtotime("first day of next month", strtotime("$year-$monthNumber-01"));
$closingDateNextMonth = date('Y-m-', $nextMonthTimestamp) . env('CLOSING_DATE');
$users = Admin::with(['region', 'cabang'])
->whereIn('id', $userIds)
->get();
$formUpCountryQuery = FormUpCountry::select(
'expense_number',
'user_id',
'total',
'approved_total',
'status',
'account_number',
'created_at',
DB::raw("'Up Country' as type")
)->whereIn('user_id', $userIds)
->whereBetween('tanggal', [$startDate, $closingDateNextMonth]);
$formVehicleRunningCostQuery = FormVehicleRunningCost::select(
'expense_number',
'user_id',
'total',
'approved_total',
'status',
'account_number',
'created_at',
DB::raw("'Vehicle Running Cost' as type")
)->whereIn('user_id', $userIds)
->whereBetween('tanggal', [$startDate, $closingDateNextMonth]);
$formEntertaimentPresentationQuery = FormEntertaimentPresentation::select(
'expense_number',
'user_id',
'total',
'approved_total',
'status',
'account_number',
'created_at',
DB::raw("'Entertainment Presentation' as type")
)->whereIn('user_id', $userIds)
->whereBetween('tanggal', [$startDate, $closingDateNextMonth]);
$formMeetingSeminarQuery = FormMeetingSeminar::select(
'expense_number',
'user_id',
'total',
'approved_total',
'status',
'account_number',
'created_at',
DB::raw("'Meeting Seminar' as type")
)->whereIn('user_id', $userIds)
->whereBetween('created_at', [$startDate, $closingDateNextMonth]);
$formOthersQuery = FormOthers::select(
'expense_number',
'user_id',
'total',
'approved_total',
'status',
'account_number',
'created_at',
DB::raw("'Others' as type")
)->whereIn('user_id', $userIds)
->whereBetween('tanggal', [$startDate, $closingDateNextMonth]);
// Use union to combine queries
$forms = $formUpCountryQuery
->union($formVehicleRunningCostQuery)
->union($formEntertaimentPresentationQuery)
->union($formMeetingSeminarQuery)
->union($formOthersQuery)
->get();
// Return forms with eager loaded user details
return $forms->map(function ($form) use ($users) {
$user = $users->firstWhere('id', $form->user_id);
$form->user = $user;
return $form;
});
}
public static function getAllForms($month, $year) {
$monthNumber = date('m', strtotime($month . ' 1'));
$startDate = "$year-$monthNumber-" . env('STARTING_DATE');
$nextMonthTimestamp = strtotime("first day of next month", strtotime("$year-$monthNumber-01"));
$closingDateNextMonth = date('Y-m-', $nextMonthTimestamp) . env('CLOSING_DATE');
$users = Admin::with(['region', 'cabang'])
->get();
$formUpCountryQuery = FormUpCountry::select(
'expense_number',
'user_id',
'total',
'approved_total',
'status',
'account_number',
'created_at',
DB::raw("'Up Country' as type")
)->whereBetween('tanggal', [$startDate, $closingDateNextMonth]);
$formVehicleRunningCostQuery = FormVehicleRunningCost::select(
'expense_number',
'user_id',
'total',
'approved_total',
'status',
'account_number',
'created_at',
DB::raw("'Vehicle Running Cost' as type")
)->whereBetween('tanggal', [$startDate, $closingDateNextMonth]);
$formEntertaimentPresentationQuery = FormEntertaimentPresentation::select(
'expense_number',
'user_id',
'total',
'approved_total',
'status',
'account_number',
'created_at',
DB::raw("'Entertainment Presentation' as type")
)->whereBetween('tanggal', [$startDate, $closingDateNextMonth]);
$formMeetingSeminarQuery = FormMeetingSeminar::select(
'expense_number',
'user_id',
'total',
'approved_total',
'status',
'account_number',
'created_at',
DB::raw("'Meeting Seminar' as type")
)->whereBetween('created_at', [$startDate, $closingDateNextMonth]);
$formOthersQuery = FormOthers::select(
'expense_number',
'user_id',
'total',
'approved_total',
'status',
'account_number',
'created_at',
DB::raw("'Others' as type")
)->whereBetween('tanggal', [$startDate, $closingDateNextMonth]);
// Combine queries using union
$forms = $formUpCountryQuery
->union($formVehicleRunningCostQuery)
->union($formEntertaimentPresentationQuery)
->union($formMeetingSeminarQuery)
->union($formOthersQuery)
->get();
// Map users to the forms with eager loaded data
return $forms->map(function ($form) use ($users) {
$user = $users->firstWhere('id', $form->user_id);
$form->user = $user;
return $form;
});
}
public static function getAllForms2() {
// Get all users with eager loading for 'region' and 'cabang'
$users = Admin::with(['region', 'cabang'])
->get();
$formUpCountryQuery = FormUpCountry::select(
'expense_number',
'user_id',
'total',
'approved_total',
'status',
'account_number',
'created_at',
DB::raw("'Up Country' as type")
);
$formVehicleRunningCostQuery = FormVehicleRunningCost::select(
'expense_number',
'user_id',
'total',
'approved_total',
'status',
'account_number',
'created_at',
DB::raw("'Vehicle Running Cost' as type")
);
$formEntertaimentPresentationQuery = FormEntertaimentPresentation::select(
'expense_number',
'user_id',
'total',
'approved_total',
'status',
'account_number',
'created_at',
DB::raw("'Entertainment Presentation' as type")
);
$formMeetingSeminarQuery = FormMeetingSeminar::select(
'expense_number',
'user_id',
'total',
'approved_total',
'status',
'account_number',
'created_at',
DB::raw("'Meeting Seminar' as type")
);
$formOthersQuery = FormOthers::select(
'expense_number',
'user_id',
'total',
'approved_total',
'status',
'account_number',
'created_at',
DB::raw("'Others' as type")
);
// Combine queries using union
$forms = $formUpCountryQuery
->union($formVehicleRunningCostQuery)
->union($formEntertaimentPresentationQuery)
->union($formMeetingSeminarQuery)
->union($formOthersQuery)
->get();
// Map users to the forms with eager loaded data
return $forms->map(function ($form) use ($users) {
$user = $users->firstWhere('id', $form->user_id);
$form->user = $user;
return $form;
});
}
public static function getNominalByFormType($cabang_id, $type, $type_id, $month, $year) {
$users = UserHasCabang::where('cabang_id', $cabang_id)->get();
$userIds = $users->pluck('user_id')->toArray();
if($type == 'Up Country') {
$form = FormUpCountry::select('approved_total')->whereIn('user_id', $userIds)->whereMonth('tanggal', '=', date('m', strtotime($month)))->whereYear('tanggal', '=', $year)->where('status', ['Approved', 'Closed'])->get();
} else if($type == 'Vehicle Running Cost') {
$form = FormVehicleRunningCost::select('approved_total')->whereIn('user_id', $userIds)->whereMonth('tanggal', '=', date('m', strtotime($month)))->whereYear('tanggal', '=', $year)->where('status', ['Approved', 'Closed'])->get();
} else if($type == 'Entertainment') {
$form = FormEntertaimentPresentation::select('approved_total')->whereIn('user_id', $userIds)->whereMonth('tanggal', '=', date('m', strtotime($month)))->whereYear('tanggal', '=', $year)->where('status', ['Approved', 'Closed'])->get();
} else if($type == 'Meeting / Seminar') {
$form = FormMeetingSeminar::select('approved_total')->whereIn('user_id', $userIds)->whereMonth('created_at', '=', date('m', strtotime($month)))->whereYear('created_at', '=', $year)->where('status', ['Approved', 'Closed'])->get();
} else {
$form = FormOthers::select('approved_total')->whereIn('user_id', $userIds)->whereMonth('tanggal', '=', date('m', strtotime($month)))->whereYear('tanggal', '=', $year)->where('kategori_id', $type_id)->where('status', ['Approved', 'Closed'])->get();
}
$nominal = $form->sum('approved_total');
return $nominal;
}
public static function getTotalAmount($userIds, $month, $year) {
$monthNumber = date('m', strtotime($month . ' 1'));
$startDate = "$year-$monthNumber-" . env('STARTING_DATE');
$nextMonthTimestamp = strtotime("first day of next month", strtotime("$year-$monthNumber-01"));
$closingDateNextMonth = date('Y-m-', $nextMonthTimestamp) . env('CLOSING_DATE');
$users = Admin::with(['region', 'cabang'])
->whereIn('id', $userIds)
->get();
$formUpCountryQuery = FormUpCountry::select(
'expense_number',
'user_id',
'total',
'approved_total',
'status',
'account_number',
'created_at',
DB::raw("'Up Country' as type")
)->whereIn('user_id', $userIds)
->whereBetween('final_approved_at', [$startDate, $closingDateNextMonth]);
$formVehicleRunningCostQuery = FormVehicleRunningCost::select(
'expense_number',
'user_id',
'total',
'approved_total',
'status',
'account_number',
'created_at',
DB::raw("'Vehicle Running Cost' as type")
)->whereIn('user_id', $userIds)
->whereBetween('final_approved_at', [$startDate, $closingDateNextMonth]);
$formEntertaimentPresentationQuery = FormEntertaimentPresentation::select(
'expense_number',
'user_id',
'total',
'approved_total',
'status',
'account_number',
'created_at',
DB::raw("'Entertainment Presentation' as type")
)->whereIn('user_id', $userIds)
->whereBetween('final_approved_at', [$startDate, $closingDateNextMonth]);
$formMeetingSeminarQuery = FormMeetingSeminar::select(
'expense_number',
'user_id',
'total',
'approved_total',
'status',
'account_number',
'created_at',
DB::raw("'Meeting Seminar' as type")
)->whereIn('user_id', $userIds)
->whereBetween('final_approved_at', [$startDate, $closingDateNextMonth]);
$formOthersQuery = FormOthers::select(
'expense_number',
'user_id',
'total',
'approved_total',
'status',
'account_number',
'created_at',
DB::raw("'Others' as type")
)->whereIn('user_id', $userIds)
->whereBetween('final_approved_at', [$startDate, $closingDateNextMonth]);
// Use union to combine queries
$forms = $formUpCountryQuery
->union($formVehicleRunningCostQuery)
->union($formEntertaimentPresentationQuery)
->union($formMeetingSeminarQuery)
->union($formOthersQuery)
->get();
// Return forms with eager loaded user details
return $forms->map(function ($form) use ($users) {
$user = $users->firstWhere('id', $form->user_id);
$form->user = $user;
return $form;
});
}
}
+130 -284
View File
@@ -10,81 +10,81 @@ use App\Models\FormUpCountry;
use App\Models\FormVehicleRunningCost; use App\Models\FormVehicleRunningCost;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use App\Models\UserHasCabang; use App\Models\UserHasCabang;
use Illuminate\Support\Carbon;
class FormHelper class FormHelper
{ {
public static function getFormsByUserIds($userIds, $month, $year) { /**
$monthNumber = date('m', strtotime($month . ' 1')); * Helper internal untuk menghitung rentang siklus tanggal
$startDate = "$year-$monthNumber-" . env('STARTING_DATE'); * Menghasilkan: Start (11), Max Input (10 bln depan), Closing (13 bln depan)
*/
private static function getCycleRange($month, $year)
{
// Pastikan di-cast ke (int) agar Carbon tidak error
$startDay = (int) env('STARTING_DATE', 11);
$maxInputDay = (int) env('INPUT_MAX_DATE', 10);
$closingDay = (int) env('CLOSING_DATE', 13);
$nextMonthTimestamp = strtotime("first day of next month", strtotime("$year-$monthNumber-01")); // Mengambil nomor bulan sebagai integer
$closingDateNextMonth = date('Y-m-', $nextMonthTimestamp) . env('CLOSING_DATE'); $monthNumber = (int) date('m', strtotime($month . ' 1'));
// Pastikan $year juga integer
$year = (int) $year;
// Carbon::createFromDate membutuhkan integer
$startDate = \Illuminate\Support\Carbon::createFromDate($year, $monthNumber, $startDay)->startOfDay();
// Input maksimal dan Closing menggunakan method ->day() yang butuh integer
$maxInputDate = $startDate->copy()->addMonth()->day($maxInputDay)->endOfDay();
$closingDate = $startDate->copy()->addMonth()->day($closingDay)->endOfDay();
return [
'start' => $startDate->toDateTimeString(),
'input_max' => $maxInputDate->toDateTimeString(),
'closing' => $closingDate->toDateTimeString(),
'display_label' => $startDate->format('d M') . ' - ' . $maxInputDate->format('d M Y')
];
}
public static function getFormsByUserIds($userIds, $month, $year) {
$dates = self::getCycleRange($month, $year);
$startDate = $dates['start'];
$closingDate = $dates['closing']; // Mengacu ke Closing Date (13)
$users = Admin::with(['region', 'cabang']) $users = Admin::with(['region', 'cabang'])
->whereIn('id', $userIds) ->whereIn('id', $userIds)
->get(); ->get();
$formUpCountryQuery = FormUpCountry::select( $formUpCountryQuery = FormUpCountry::select(
'expense_number', 'expense_number', 'user_id', 'total', 'approved_total', 'status', 'account_number', 'created_at',
'user_id',
'total',
'approved_total',
'status',
'account_number',
'created_at',
DB::raw("'Up Country' as type") DB::raw("'Up Country' as type")
)->whereIn('user_id', $userIds) )->whereIn('user_id', $userIds)
->whereBetween('tanggal', [$startDate, $closingDateNextMonth]); ->whereBetween('tanggal', [$startDate, $closingDate]);
$formVehicleRunningCostQuery = FormVehicleRunningCost::select( $formVehicleRunningCostQuery = FormVehicleRunningCost::select(
'expense_number', 'expense_number', 'user_id', 'total', 'approved_total', 'status', 'account_number', 'created_at',
'user_id',
'total',
'approved_total',
'status',
'account_number',
'created_at',
DB::raw("'Vehicle Running Cost' as type") DB::raw("'Vehicle Running Cost' as type")
)->whereIn('user_id', $userIds) )->whereIn('user_id', $userIds)
->whereBetween('tanggal', [$startDate, $closingDateNextMonth]); ->whereBetween('tanggal', [$startDate, $closingDate]);
$formEntertaimentPresentationQuery = FormEntertaimentPresentation::select( $formEntertaimentPresentationQuery = FormEntertaimentPresentation::select(
'expense_number', 'expense_number', 'user_id', 'total', 'approved_total', 'status', 'account_number', 'created_at',
'user_id',
'total',
'approved_total',
'status',
'account_number',
'created_at',
DB::raw("'Entertainment Presentation' as type") DB::raw("'Entertainment Presentation' as type")
)->whereIn('user_id', $userIds) )->whereIn('user_id', $userIds)
->whereBetween('tanggal', [$startDate, $closingDateNextMonth]); ->whereBetween('tanggal', [$startDate, $closingDate]);
$formMeetingSeminarQuery = FormMeetingSeminar::select( $formMeetingSeminarQuery = FormMeetingSeminar::select(
'expense_number', 'expense_number', 'user_id', 'total', 'approved_total', 'status', 'account_number', 'created_at',
'user_id',
'total',
'approved_total',
'status',
'account_number',
'created_at',
DB::raw("'Meeting Seminar' as type") DB::raw("'Meeting Seminar' as type")
)->whereIn('user_id', $userIds) )->whereIn('user_id', $userIds)
->whereBetween('created_at', [$startDate, $closingDateNextMonth]); ->whereBetween('created_at', [$startDate, $closingDate]);
$formOthersQuery = FormOthers::select( $formOthersQuery = FormOthers::select(
'expense_number', 'expense_number', 'user_id', 'total', 'approved_total', 'status', 'account_number', 'created_at',
'user_id',
'total',
'approved_total',
'status',
'account_number',
'created_at',
DB::raw("'Others' as type") DB::raw("'Others' as type")
)->whereIn('user_id', $userIds) )->whereIn('user_id', $userIds)
->whereBetween('tanggal', [$startDate, $closingDateNextMonth]); ->whereBetween('tanggal', [$startDate, $closingDate]);
// Use union to combine queries
$forms = $formUpCountryQuery $forms = $formUpCountryQuery
->union($formVehicleRunningCostQuery) ->union($formVehicleRunningCostQuery)
->union($formEntertaimentPresentationQuery) ->union($formEntertaimentPresentationQuery)
@@ -92,7 +92,6 @@ class FormHelper
->union($formOthersQuery) ->union($formOthersQuery)
->get(); ->get();
// Return forms with eager loaded user details
return $forms->map(function ($form) use ($users) { return $forms->map(function ($form) use ($users) {
$user = $users->firstWhere('id', $form->user_id); $user = $users->firstWhere('id', $form->user_id);
$form->user = $user; $form->user = $user;
@@ -101,79 +100,90 @@ class FormHelper
} }
public static function getAllForms($month, $year) { public static function getAllForms($month, $year) {
$monthNumber = date('m', strtotime($month . ' 1')); $dates = self::getCycleRange($month, $year);
$startDate = "$year-$monthNumber-" . env('STARTING_DATE'); $startDate = $dates['start'];
$closingDate = $dates['closing'];
$nextMonthTimestamp = strtotime("first day of next month", strtotime("$year-$monthNumber-01")); $users = Admin::with(['region', 'cabang'])->get();
$closingDateNextMonth = date('Y-m-', $nextMonthTimestamp) . env('CLOSING_DATE');
$users = Admin::with(['region', 'cabang']) $commonSelect = ['expense_number', 'user_id', 'total', 'approved_total', 'status', 'account_number', 'created_at'];
->get();
$formUpCountryQuery = FormUpCountry::select( $queries = [
'expense_number', FormUpCountry::select(array_merge($commonSelect, [DB::raw("'Up Country' as type")]))->whereBetween('tanggal', [$startDate, $closingDate]),
'user_id', FormVehicleRunningCost::select(array_merge($commonSelect, [DB::raw("'Vehicle Running Cost' as type")]))->whereBetween('tanggal', [$startDate, $closingDate]),
'total', FormEntertaimentPresentation::select(array_merge($commonSelect, [DB::raw("'Entertainment Presentation' as type")]))->whereBetween('tanggal', [$startDate, $closingDate]),
'approved_total', FormMeetingSeminar::select(array_merge($commonSelect, [DB::raw("'Meeting Seminar' as type")]))->whereBetween('created_at', [$startDate, $closingDate]),
'status', FormOthers::select(array_merge($commonSelect, [DB::raw("'Others' as type")]))->whereBetween('tanggal', [$startDate, $closingDate]),
'account_number', ];
'created_at',
DB::raw("'Up Country' as type")
)->whereBetween('tanggal', [$startDate, $closingDateNextMonth]);
$formVehicleRunningCostQuery = FormVehicleRunningCost::select( $mainQuery = array_shift($queries);
'expense_number', foreach ($queries as $q) { $mainQuery->union($q); }
'user_id',
'total',
'approved_total',
'status',
'account_number',
'created_at',
DB::raw("'Vehicle Running Cost' as type")
)->whereBetween('tanggal', [$startDate, $closingDateNextMonth]);
$formEntertaimentPresentationQuery = FormEntertaimentPresentation::select( $forms = $mainQuery->get();
'expense_number',
'user_id',
'total',
'approved_total',
'status',
'account_number',
'created_at',
DB::raw("'Entertainment Presentation' as type")
)->whereBetween('tanggal', [$startDate, $closingDateNextMonth]);
$formMeetingSeminarQuery = FormMeetingSeminar::select( return $forms->map(function ($form) use ($users) {
'expense_number', $user = $users->firstWhere('id', $form->user_id);
'user_id', $form->user = $user;
'total', return $form;
'approved_total', });
'status', }
'account_number',
'created_at',
DB::raw("'Meeting Seminar' as type")
)->whereBetween('created_at', [$startDate, $closingDateNextMonth]);
$formOthersQuery = FormOthers::select( /**
'expense_number', * Dashboard Info: Menghasilkan label periode input (11-10)
'user_id', */
'total', public static function getDashboardPeriodLabel($month, $year) {
'approved_total', return self::getCycleRange($month, $year)['display_label'];
'status', }
'account_number',
'created_at',
DB::raw("'Others' as type")
)->whereBetween('tanggal', [$startDate, $closingDateNextMonth]);
// Combine queries using union public static function getNominalByFormType($cabang_id, $type, $type_id, $month, $year) {
$forms = $formUpCountryQuery $users = UserHasCabang::where('cabang_id', $cabang_id)->get();
->union($formVehicleRunningCostQuery) $userIds = $users->pluck('user_id')->toArray();
->union($formEntertaimentPresentationQuery)
->union($formMeetingSeminarQuery) // Menggunakan logika cycle agar nominal yang muncul sinkron dengan cutoff
->union($formOthersQuery) $dates = self::getCycleRange($month, $year);
->get(); $start = $dates['start'];
$end = $dates['closing'];
$query = null;
if($type == 'Up Country') {
$query = FormUpCountry::whereIn('user_id', $userIds)->whereBetween('tanggal', [$start, $end]);
} else if($type == 'Vehicle Running Cost') {
$query = FormVehicleRunningCost::whereIn('user_id', $userIds)->whereBetween('tanggal', [$start, $end]);
} else if($type == 'Entertainment') {
$query = FormEntertaimentPresentation::whereIn('user_id', $userIds)->whereBetween('tanggal', [$start, $end]);
} else if($type == 'Meeting / Seminar') {
$query = FormMeetingSeminar::whereIn('user_id', $userIds)->whereBetween('created_at', [$start, $end]);
} else {
$query = FormOthers::whereIn('user_id', $userIds)->whereBetween('tanggal', [$start, $end])->where('kategori_id', $type_id);
}
return $query->whereIn('status', ['Approved', 'Closed'])->sum('approved_total');
}
public static function getTotalAmount($userIds, $month, $year) {
$dates = self::getCycleRange($month, $year);
$startDate = $dates['start'];
$closingDate = $dates['closing'];
$users = Admin::with(['region', 'cabang'])->whereIn('id', $userIds)->get();
$formUpCountryQuery = FormUpCountry::select('expense_number', 'user_id', 'total', 'approved_total', 'status', 'account_number', 'created_at', DB::raw("'Up Country' as type"))
->whereIn('user_id', $userIds)->whereBetween('final_approved_at', [$startDate, $closingDate]);
$formVehicleRunningCostQuery = FormVehicleRunningCost::select('expense_number', 'user_id', 'total', 'approved_total', 'status', 'account_number', 'created_at', DB::raw("'Vehicle Running Cost' as type"))
->whereIn('user_id', $userIds)->whereBetween('final_approved_at', [$startDate, $closingDate]);
$formEntertaimentPresentationQuery = FormEntertaimentPresentation::select('expense_number', 'user_id', 'total', 'approved_total', 'status', 'account_number', 'created_at', DB::raw("'Entertainment Presentation' as type"))
->whereIn('user_id', $userIds)->whereBetween('final_approved_at', [$startDate, $closingDate]);
$formMeetingSeminarQuery = FormMeetingSeminar::select('expense_number', 'user_id', 'total', 'approved_total', 'status', 'account_number', 'created_at', DB::raw("'Meeting Seminar' as type"))
->whereIn('user_id', $userIds)->whereBetween('final_approved_at', [$startDate, $closingDate]);
$formOthersQuery = FormOthers::select('expense_number', 'user_id', 'total', 'approved_total', 'status', 'account_number', 'created_at', DB::raw("'Others' as type"))
->whereIn('user_id', $userIds)->whereBetween('final_approved_at', [$startDate, $closingDate]);
$forms = $formUpCountryQuery->union($formVehicleRunningCostQuery)->union($formEntertaimentPresentationQuery)->union($formMeetingSeminarQuery)->union($formOthersQuery)->get();
// Map users to the forms with eager loaded data
return $forms->map(function ($form) use ($users) { return $forms->map(function ($form) use ($users) {
$user = $users->firstWhere('id', $form->user_id); $user = $users->firstWhere('id', $form->user_id);
$form->user = $user; $form->user = $user;
@@ -182,181 +192,17 @@ class FormHelper
} }
public static function getAllForms2() { public static function getAllForms2() {
// Get all users with eager loading for 'region' and 'cabang' // Fungsi ini tetap dipertahankan sesuai request (tanpa filter tanggal)
$users = Admin::with(['region', 'cabang']) $users = Admin::with(['region', 'cabang'])->get();
$commonSelect = ['expense_number', 'user_id', 'total', 'approved_total', 'status', 'account_number', 'created_at'];
$forms = FormUpCountry::select(array_merge($commonSelect, [DB::raw("'Up Country' as type")]))
->union(FormVehicleRunningCost::select(array_merge($commonSelect, [DB::raw("'Vehicle Running Cost' as type")])))
->union(FormEntertaimentPresentation::select(array_merge($commonSelect, [DB::raw("'Entertainment Presentation' as type")])))
->union(FormMeetingSeminar::select(array_merge($commonSelect, [DB::raw("'Meeting Seminar' as type")])))
->union(FormOthers::select(array_merge($commonSelect, [DB::raw("'Others' as type")])))
->get(); ->get();
$formUpCountryQuery = FormUpCountry::select(
'expense_number',
'user_id',
'total',
'approved_total',
'status',
'account_number',
'created_at',
DB::raw("'Up Country' as type")
);
$formVehicleRunningCostQuery = FormVehicleRunningCost::select(
'expense_number',
'user_id',
'total',
'approved_total',
'status',
'account_number',
'created_at',
DB::raw("'Vehicle Running Cost' as type")
);
$formEntertaimentPresentationQuery = FormEntertaimentPresentation::select(
'expense_number',
'user_id',
'total',
'approved_total',
'status',
'account_number',
'created_at',
DB::raw("'Entertainment Presentation' as type")
);
$formMeetingSeminarQuery = FormMeetingSeminar::select(
'expense_number',
'user_id',
'total',
'approved_total',
'status',
'account_number',
'created_at',
DB::raw("'Meeting Seminar' as type")
);
$formOthersQuery = FormOthers::select(
'expense_number',
'user_id',
'total',
'approved_total',
'status',
'account_number',
'created_at',
DB::raw("'Others' as type")
);
// Combine queries using union
$forms = $formUpCountryQuery
->union($formVehicleRunningCostQuery)
->union($formEntertaimentPresentationQuery)
->union($formMeetingSeminarQuery)
->union($formOthersQuery)
->get();
// Map users to the forms with eager loaded data
return $forms->map(function ($form) use ($users) {
$user = $users->firstWhere('id', $form->user_id);
$form->user = $user;
return $form;
});
}
public static function getNominalByFormType($cabang_id, $type, $type_id, $month, $year) {
$users = UserHasCabang::where('cabang_id', $cabang_id)->get();
$userIds = $users->pluck('user_id')->toArray();
if($type == 'Up Country') {
$form = FormUpCountry::select('approved_total')->whereIn('user_id', $userIds)->whereMonth('tanggal', '=', date('m', strtotime($month)))->whereYear('tanggal', '=', $year)->where('status', ['Approved', 'Closed'])->get();
} else if($type == 'Vehicle Running Cost') {
$form = FormVehicleRunningCost::select('approved_total')->whereIn('user_id', $userIds)->whereMonth('tanggal', '=', date('m', strtotime($month)))->whereYear('tanggal', '=', $year)->where('status', ['Approved', 'Closed'])->get();
} else if($type == 'Entertainment') {
$form = FormEntertaimentPresentation::select('approved_total')->whereIn('user_id', $userIds)->whereMonth('tanggal', '=', date('m', strtotime($month)))->whereYear('tanggal', '=', $year)->where('status', ['Approved', 'Closed'])->get();
} else if($type == 'Meeting / Seminar') {
$form = FormMeetingSeminar::select('approved_total')->whereIn('user_id', $userIds)->whereMonth('created_at', '=', date('m', strtotime($month)))->whereYear('created_at', '=', $year)->where('status', ['Approved', 'Closed'])->get();
} else {
$form = FormOthers::select('approved_total')->whereIn('user_id', $userIds)->whereMonth('tanggal', '=', date('m', strtotime($month)))->whereYear('tanggal', '=', $year)->where('kategori_id', $type_id)->where('status', ['Approved', 'Closed'])->get();
}
$nominal = $form->sum('approved_total');
return $nominal;
}
public static function getTotalAmount($userIds, $month, $year) {
$monthNumber = date('m', strtotime($month . ' 1'));
$startDate = "$year-$monthNumber-" . env('STARTING_DATE');
$nextMonthTimestamp = strtotime("first day of next month", strtotime("$year-$monthNumber-01"));
$closingDateNextMonth = date('Y-m-', $nextMonthTimestamp) . env('CLOSING_DATE');
$users = Admin::with(['region', 'cabang'])
->whereIn('id', $userIds)
->get();
$formUpCountryQuery = FormUpCountry::select(
'expense_number',
'user_id',
'total',
'approved_total',
'status',
'account_number',
'created_at',
DB::raw("'Up Country' as type")
)->whereIn('user_id', $userIds)
->whereBetween('final_approved_at', [$startDate, $closingDateNextMonth]);
$formVehicleRunningCostQuery = FormVehicleRunningCost::select(
'expense_number',
'user_id',
'total',
'approved_total',
'status',
'account_number',
'created_at',
DB::raw("'Vehicle Running Cost' as type")
)->whereIn('user_id', $userIds)
->whereBetween('final_approved_at', [$startDate, $closingDateNextMonth]);
$formEntertaimentPresentationQuery = FormEntertaimentPresentation::select(
'expense_number',
'user_id',
'total',
'approved_total',
'status',
'account_number',
'created_at',
DB::raw("'Entertainment Presentation' as type")
)->whereIn('user_id', $userIds)
->whereBetween('final_approved_at', [$startDate, $closingDateNextMonth]);
$formMeetingSeminarQuery = FormMeetingSeminar::select(
'expense_number',
'user_id',
'total',
'approved_total',
'status',
'account_number',
'created_at',
DB::raw("'Meeting Seminar' as type")
)->whereIn('user_id', $userIds)
->whereBetween('final_approved_at', [$startDate, $closingDateNextMonth]);
$formOthersQuery = FormOthers::select(
'expense_number',
'user_id',
'total',
'approved_total',
'status',
'account_number',
'created_at',
DB::raw("'Others' as type")
)->whereIn('user_id', $userIds)
->whereBetween('final_approved_at', [$startDate, $closingDateNextMonth]);
// Use union to combine queries
$forms = $formUpCountryQuery
->union($formVehicleRunningCostQuery)
->union($formEntertaimentPresentationQuery)
->union($formMeetingSeminarQuery)
->union($formOthersQuery)
->get();
// Return forms with eager loaded user details
return $forms->map(function ($form) use ($users) { return $forms->map(function ($form) use ($users) {
$user = $users->firstWhere('id', $form->user_id); $user = $users->firstWhere('id', $form->user_id);
$form->user = $user; $form->user = $user;
+5
View File
@@ -135,6 +135,11 @@ class NextCloudHelper
return route('admin.nextcloud.download', ['file' => base64_encode($filePath)]); return route('admin.nextcloud.download', ['file' => base64_encode($filePath)]);
} }
public static function getPreviewUrl($filePath)
{
return route('admin.nextcloud.preview', ['file' => base64_encode($filePath)]);
}
public static function getNextcloudFile($filePath) public static function getNextcloudFile($filePath)
{ {
// Check if the file exists // Check if the file exists
+16 -2
View File
@@ -28,7 +28,13 @@ class OutstandingHelper
return false; return false;
} }
$status = $form->status; $status = (string) $form->status;
$normalizedStatus = strtolower($status);
if (in_array($normalizedStatus, ['rejected', 'closed'], true)) {
return false;
}
switch ($role) { switch ($role) {
case 'Admin Region': case 'Admin Region':
if ($status !== 'On Progress') { if ($status !== 'On Progress') {
@@ -43,7 +49,11 @@ class OutstandingHelper
if ($status !== 'Approved 1') { if ($status !== 'Approved 1') {
return false; return false;
} }
if ($cabangId && optional($form->rayon)->cabang_id !== $cabangId) {
$formCabangId = optional($form->rayon)->cabang_id
?? optional(optional($form->user)->cabang)->cabang_id
?? optional(optional(optional($form->user)->cabang)->cabang)->id;
if ($cabangId !== null && (string) $formCabangId !== (string) $cabangId) {
return false; return false;
} }
return true; return true;
@@ -70,6 +80,10 @@ class OutstandingHelper
return is_null($form->final_approved_at) || is_null($form->final_approved_by); return is_null($form->final_approved_at) || is_null($form->final_approved_by);
default: default:
if (!in_array($normalizedStatus, ['on progress', 'approved 1', 'approved 2'], true)) {
return false;
}
return is_null($form->final_approved_at) || is_null($form->final_approved_by); return is_null($form->final_approved_at) || is_null($form->final_approved_by);
} }
}); });
+11 -7
View File
@@ -1,19 +1,23 @@
<?php <?php
namespace App\Helpers; namespace App\Helpers;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
class PeriodeHelper class PeriodeHelper
{ {
public static function getPeriodeRange($month, $year) /**
* Mendapatkan rentang tanggal fisik pengakuan untuk sebuah periode akuntansi (Bulan & Tahun)
* Contoh: Periode "april 2026" -> Start: 11 April 2026, End: 13 Mei 2026 (Closing Date)
*/
public static function getPeriodeRange(string $month, int $year): array
{ {
$month = (int) date('m', strtotime($month)); // 1. Tentukan tanggal 11 di bulan dan tahun yang dipilih
$year = (int) $year; $startDate = Carbon::parse("11 $month $year")->startOfDay()->toDateTimeString();
// Jika Januari, periode dimulai dari 11 Januari hingga 10 Februari // 2. Tentukan tanggal 13 di bulan berikutnya (Bulan berjalan + 1)
$start = Carbon::create($year, $month, 11)->startOfDay(); $endDate = Carbon::parse("13 $month $year")->addMonth()->endOfDay()->toDateTimeString();
$end = $start->copy()->addMonth()->subDay()->endOfDay();
return [$start, $end]; return [$startDate, $endDate];
} }
} }
+23 -10
View File
@@ -106,7 +106,7 @@ class WhatsappHelper
} }
} }
public static function rejectExpense($phones, $expense_number, $url = null, $tanggal, $total, $name) public static function rejectExpense($phones, $expense_number, $url = null, $tanggal, $total, $name, ?string $remarks = null)
{ {
$client = new Client(); $client = new Client();
@@ -115,15 +115,28 @@ class WhatsappHelper
$url = 'https://' . $url; $url = 'https://' . $url;
} }
// Format the message like the email example using Markdown for WhatsApp $formattedDate = \Carbon\Carbon::parse($tanggal)->locale('id')->isoFormat('D MMMM Y');
$message = " $formattedTotal = 'Rp ' . number_format($total, 0, ',', '.');
Halo *{$name}*,
Pengajuan pengeluaran baru dengan detail berikut telah ditolak: $messageLines = [
*Expense Number:* {$expense_number} "Halo *{$name}*,",
*Expense Date:* " . \Carbon\Carbon::parse($tanggal)->locale('id')->isoFormat('D MMMM Y') . " 'Pengajuan pengeluaran baru dengan detail berikut telah ditolak:',
*Total:* Rp " . number_format($total, 0, ',', '.') . " "*Expense Number:* {$expense_number}",
Silahkan buka link berikut untuk melihat detail expense Anda: {$url} "*Expense Date:* {$formattedDate}",
Terima kasih."; "*Total:* {$formattedTotal}",
];
if (!empty($remarks)) {
$messageLines[] = "*Remarks:* {$remarks}";
}
if (!empty($url)) {
$messageLines[] = "Silahkan buka link berikut untuk melihat detail expense Anda: {$url}";
}
$messageLines[] = 'Terima kasih.';
$message = implode("\n", $messageLines);
// Prepare the message for each phone number // Prepare the message for each phone number
$data = []; $data = [];
@@ -6,26 +6,108 @@ namespace App\Http\Controllers\Backend;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\AuditTrail; use App\Models\AuditTrail;
use Spatie\Permission\Models\Permission; use App\Models\Admin; // Pastikan Model Admin diimport
use Spatie\Permission\Models\Role; use App\Models\Region; // Pastikan Model Region diimport
use App\Models\Cabang; // Pastikan Model Cabang diimport
use App\Helpers\AuditTrailHelper;
use Illuminate\Http\Request; // BARIS INI YANG WAJIB ADA
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use App\Exports\AuditTrailExport;
use Maatwebsite\Excel\Facades\Excel;
class AuditTrailController extends Controller class AuditTrailController extends Controller
{ {
public function index() public function index(Request $request)
{ {
$pageInfo = [ $pageInfo = [
'title' => 'Audit Trails', 'title' => 'Audit Trails',
'sub_title' => 'List of Audit Trails System', 'sub_title' => 'List of Audit Trails System',
'path' => url()->current() 'path' => url()->current()
]; ];
return view( $query = AuditTrail::with(['user.cabang.cabang.region']);
'backend.pages.audit_trail.index',
[ // Filter: User
// sort by newest if ($request->filled('admin_id')) {
'data' => AuditTrail::with('user')->orderBy('created_at', 'desc')->get(), $query->where('user_id', $request->admin_id);
}
// Filter: Region & Cabang (Sesuai relasi hasOne UserHasCabang)
if ($request->filled('region') || $request->filled('cabang')) {
$query->whereHas('user.cabang.cabang', function($q) use ($request) {
if ($request->filled('cabang')) {
$q->where('id', $request->cabang);
}
if ($request->filled('region')) {
$q->whereHas('region', function($rq) use ($request) {
// PERBAIKAN: Gunakan 'code' sesuai image_63aac6.png
$rq->where('code', $request->region);
});
}
});
}
// Filter: Rentang Waktu
if ($request->filled('start_date') && $request->filled('end_date')) {
$query->whereBetween('created_at', [
$request->start_date . ' 00:00:00',
$request->end_date . ' 23:59:59'
]);
}
$data = $query->latest()->paginate(50)->appends($request->all());
return view('backend.pages.audit_trail.index', [
'data' => $data,
'pageInfo' => $pageInfo, 'pageInfo' => $pageInfo,
] 'admins' => \App\Models\Admin::select('id', 'name')->orderBy('name')->get(),
); // PERBAIKAN: Select 'code' sesuai image_63aac6.png
'regions' => \App\Models\Region::select('code', 'name')->get(),
'cabangs' => \App\Models\Cabang::select('id', 'name')->orderBy('name')->get(),
]);
}
public function exportExcel(Request $request)
{
AuditTrailHelper::AddAuditTrail('Export', 'Melakukan export data Audit Trail dengan filter tertentu');
// Mengirimkan parameter filter ke class Export
return Excel::download(new AuditTrailExport($request->all()), 'Audit_Trail_' . date('Y-m-d') . '.xlsx');
}
/**
* Membersihkan semua data audit trail (Truncate)
*/
public function clearAll()
{
try {
// Catat siapa yang menghapus log sebelum data dihapus
AuditTrailHelper::AddAuditTrail('Delete', 'Membersihkan (Truncate) seluruh data Audit Trail');
// Menggunakan truncate untuk performa maksimal pada tabel besar
DB::table('audit_trail')->truncate();
return redirect()->back()->with('success', 'All audit logs have been cleared.');
} catch (\Exception $e) {
return redirect()->back()->with('error', 'Failed to clear logs: ' . $e->getMessage());
}
}
/**
* Fungsi untuk membersihkan cache sistem
*/
public function clearCache()
{
try {
Artisan::call('optimize:clear');
AuditTrailHelper::AddAuditTrail('System', 'Membersihkan cache sistem (Optimize Clear)');
return redirect()->back()->with('success', 'System cache cleared successfully!');
} catch (\Exception $e) {
return redirect()->back()->with('error', 'Failed to clear cache: ' . $e->getMessage());
}
} }
} }
@@ -11,164 +11,617 @@ use App\Models\BudgetControlLog;
use App\Models\Admin; use App\Models\Admin;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Maatwebsite\Excel\Facades\Excel;
use Maatwebsite\Excel\Concerns\FromArray;
use Maatwebsite\Excel\Concerns\WithHeadings;
use App\Helpers\PeriodeHelper;
class BudgetControlController extends Controller class BudgetControlController extends Controller
{ {
public function index() /**
{ * Halaman Utama Budget Control (Termasuk Data Grafik)
*/
public function index(Request $request)
{
$this->checkAuthorization(auth()->user(), ['budget_control.view']); $this->checkAuthorization(auth()->user(), ['budget_control.view']);
session()->put('redirect_url', route('budget_control'));
$role = auth()->user()->getRoleNames()[0]; $user = auth()->user();
$budget = null; $role = $user->getRoleNames()->first();
if ($role == 'Admin Region' || $role == 'Marketing Operational Manager Region') { // --- 1. IDENTIFIKASI WILAYAH USER (Sama seperti Dashboard) ---
$region_id = auth()->user()->getMyCabangAndRegionId()['region']; // Mengambil semua cabang yang di-assign ke user ini
$userCabangIds = \App\Models\UserHasCabang::where('user_id', $user->id)
->pluck('cabang_id')
->toArray();
if ($region_id) { // Mengambil semua ID Region dari cabang-cabang tersebut
$budget = BudgetControl::whereHas('cabang', function ($query) use ($region_id) { $userRegionIds = \App\Models\Cabang::whereIn('id', $userCabangIds)
$query->where('region_id', $region_id); ->pluck('region_id')
})->with(['receiver', 'sender', 'cabang'])->get(); ->unique()
} ->toArray();
} elseif ($role == 'Area Manager Cabang') {
$cabang_id = auth()->user()->getMyCabangAndRegionId()['cabang']; // --- 2. KOLEKSI DROPDOWN FILTER BERDASARKAN ROLE ---
if (in_array($role, ['Admin Region', 'Marketing Operational Manager Region'])) {
// Dropdown Region HANYA muncul region miliknya (Menghilangkan Blank)
$regions = \App\Models\Region::whereIn('id', $userRegionIds)->get();
// Dropdown Cabang HANYA muncul cabang-cabang di bawah region tersebut
$cabangOptions = \App\Models\Cabang::whereIn('region_id', $userRegionIds)->get();
} elseif (in_array($role, ['Area Manager Cabang', 'Medical Representatif'])) {
$regions = \App\Models\Region::whereIn('id', $userRegionIds)->get();
$cabangOptions = \App\Models\Cabang::whereIn('id', $userCabangIds)->get();
if ($cabang_id) {
$budget = BudgetControl::where('cabang_id', $cabang_id)
->with(['receiver', 'sender', 'cabang'])
->get();
}
} else { } else {
$budget = BudgetControl::with(['receiver', 'sender', 'cabang'])->get(); // Superadmin & Accounting: Muncul Semua
$regions = \App\Models\Region::all();
$cabangOptions = \App\Models\Cabang::when($request->region_id, function($q) use ($request) {
return $q->where('region_id', $request->region_id);
})->get();
} }
// Tentukan periode berdasarkan start dan closing date // --- 3. QUERY UTAMA DENGAN PROTEKSI DATA ---
$startDay = (int) env('STARTING_DATE', 11); $query = \App\Models\BudgetControl::with(['receiver', 'sender', 'cabang.region']);
$closingDay = (int) env('CLOSING_DATE', 10);
$now = Carbon::now();
if ($now->day >= $startDay) { // Jika bukan Superadmin/Accounting, kunci kueri ke wilayahnya saja
$startDate = Carbon::create($now->year, $now->month, $startDay)->startOfDay(); if (!in_array($role, ['Superadmin', 'Super Admin', 'Accounting', 'accounting'])) {
} else { $query->whereHas('cabang', function($q) use ($userRegionIds) {
$startDate = Carbon::create($now->copy()->subMonth()->year, $now->copy()->subMonth()->month, $startDay)->startOfDay(); $q->whereIn('region_id', $userRegionIds);
});
} }
// Hitung available budget berdasarkan periode // --- 4. FILTER DARI INPUT USER ---
$budget->map(function ($b) { if ($request->filled('region_id')) {
$b->availableBudget = BudgetHelper::getAvailableBudget($b->cabang_id, $b->periode_month, $b->periode_year); $query->whereHas('cabang', fn($q) => $q->where('region_id', $request->region_id));
}
if ($request->filled('cabang_id')) {
$query->where('cabang_id', $request->cabang_id);
}
if ($request->filled('month')) {
$query->where('periode_month', strtolower($request->month));
}
if ($request->filled('year')) {
$query->where('periode_year', $request->year);
}
$data = $query->latest()->paginate(50)->withQueryString();
// --- 5. OPTIMASI PERHITUNGAN REALISASI ---
$expenseMap = \App\Helpers\BudgetHelper::getBulkAvailableBudget($data);
$data->getCollection()->transform(function ($b) use ($expenseMap) {
$key = $b->cabang_id . '_' . strtolower($b->periode_month) . '_' . $b->periode_year;
$b->availableBudget = isset($expenseMap[$key]) ? ($b->amount - $expenseMap[$key]) : $b->amount;
return $b; return $b;
}); });
return view('backend.pages.budget_control.index', [ return view('backend.pages.budget_control.index', compact('data', 'regions', 'cabangOptions'));
'data' => $budget, }
]); /**
} * Menampilkan Log Riwayat Budget (Disempurnakan dengan Total, Realisasi, DR, CR)
*/
public function log() /**
{ * Menampilkan Log Riwayat Budget (Menampilkan Budget & Realisasi Cash BCA)
*/
/**
* Menampilkan Log Riwayat Budget dengan Integrasi Tabel Transaksi
*/
public function log(Request $request)
{
$this->checkAuthorization(auth()->user(), ['budget_control.view']); $this->checkAuthorization(auth()->user(), ['budget_control.view']);
session()->put('redirect_url', route('budget_control'));
return view( // Data untuk Dropdown Filter
'backend.pages.budget_control.log', $regions = \App\Models\Region::all();
[ $cabangOptions = \App\Models\Cabang::when($request->region_id, function($q) use ($request) {
'data' => BudgetControlLog::orderBy('created_at', 'desc')->with(['receiver', 'sender', 'cabang'])->get(), return $q->where('region_id', $request->region_id);
] })->get();
);
// Query Utama dengan Filter Dinamis
$query = \App\Models\BudgetControlLog::with(['receiver', 'sender', 'cabang.region']);
if ($request->filled('region_id')) {
$query->whereHas('cabang', fn($q) => $q->where('region_id', $request->region_id));
}
if ($request->filled('cabang_id')) {
$query->where('cabang_id', $request->cabang_id);
}
if ($request->filled('month')) {
$query->where('periode_month', strtolower($request->month));
}
if ($request->filled('year')) {
$query->where('periode_year', $request->year);
} }
$data = $query->orderBy('created_at', 'desc')->paginate(50)->withQueryString();
// Sinkronisasi Kalkulasi Realisasi
$lookups = $data->map(fn($l) => [
'cabang_id' => $l->cabang_id,
'month' => $l->periode_month,
'year' => $l->periode_year
])->unique()->toArray();
$debetMap = \App\Helpers\BudgetHelper::getBulkKalkulasiJurnal($lookups);
$data->getCollection()->transform(function ($log) use ($debetMap) {
$key = $log->cabang_id . '_' . strtolower($log->periode_month) . '_' . $log->periode_year;
$log->total_budget = (float) $log->amount;
$log->total_credit = (float) $log->amount; // Pagu/Transfer
$log->total_debet = (float) ($debetMap[$key] ?? 0); // Pengeluaran riil
$log->realisasi = $log->total_credit - $log->total_debet; // Sisa Saldo
return $log;
});
// Menyiapkan Data Grafik (Top 10 hasil filter)
$chartData = [
'labels' => $data->getCollection()->take(10)->map(fn($l) => $l->cabang->name)->toArray(),
'budget' => $data->getCollection()->take(10)->pluck('total_budget')->toArray(),
'realisasi' => $data->getCollection()->take(10)->pluck('realisasi')->toArray(),
];
return view('backend.pages.budget_control.log', compact('data', 'regions', 'cabangOptions', 'chartData'));
}
private function prepareLogChartData($collection)
{
// Mengelompokkan data berdasarkan periode untuk grafik
$grouped = $collection->groupBy(function($item) {
return strtoupper($item->periode_month) . ' ' . $item->periode_year;
})->take(6); // Ambil 6 periode terbaru yang ada di list
return [
'labels' => $grouped->keys()->toArray(),
'budget' => $grouped->map(fn($group) => $group->sum('total_budget'))->values()->toArray(),
'realisasi' => $grouped->map(fn($group) => $group->sum('realisasi'))->values()->toArray(),
];
}
/**
* Download Template untuk Bulk Upload
*/
public function downloadTemplate()
{
$fileName = 'Template_Bulk_Upload_Budget.csv';
$headers = [
"Content-type" => "text/csv",
"Content-Disposition" => "attachment; filename=$fileName",
"Pragma" => "no-cache",
"Cache-Control" => "must-revalidate, post-check=0, pre-check=0",
"Expires" => "0"
];
$columns = ['Kode Cabang', 'Bulan', 'Nominal Budget', 'Keterangan'];
$callback = function() use($columns) {
$file = fopen('php://output', 'w');
// Baris 1: Header
fputcsv($file, $columns);
// Baris 2 & 3: Contoh data agar user paham
fputcsv($file, ['JKT-01', 'January', '15000000', 'Budget Ops JKT-01']);
fputcsv($file, ['BDG-01', 'January', '12500000', 'Budget Ops BDG-01']);
fclose($file);
};
return response()->stream($callback, 200, $headers);
}
public static function generateRefNumber($cabang)
{
// Mengambil 3 huruf kode cabang (Pastikan Anda menggunakan kolom yang tepat, misalnya $cabang->code atau $cabang->name)
$kodeCabang = strtoupper(substr($cabang->code ?? $cabang->name, 0, 3));
// Format ym (Contoh: 2605 untuk Mei 2026)
$ym = date('ym');
$prefix = "JRN-{$kodeCabang}-{$ym}";
// Menghitung urutan transaksi untuk cabang dan bulan ini
$count = \Illuminate\Support\Facades\DB::table('transactions')
->where('cabang_id', $cabang->id)
->whereRaw("DATE_FORMAT(created_at, '%y%m') = ?", [$ym])
->count();
$sequence = str_pad((string)($count + 1), 3, '0', STR_PAD_LEFT);
// Hasil akhir: JRN-PDG-2605-001
return "{$prefix}-{$sequence}";
}
/**
* Form Create Budget
*/
public function create() public function create()
{ {
$this->checkAuthorization(auth()->user(), ['budget_control.create']); $this->checkAuthorization(auth()->user(), ['budget_control.create']);
$role = auth()->user()->getRoleNames()[0]; $user = auth()->user();
$role = $user->getRoleNames()[0];
if ($role == 'Admin Region' || $role == 'Marketing Operational Manager Region') { if (in_array($role, ['Admin Region', 'Marketing Operational Manager Region'])) {
$region_id = auth()->user()->getMyCabangAndRegionId()['region']; $region_id = $user->getMyCabangAndRegionId()['region'];
$users = $region_id
if ($region_id) { ? Admin::where('id', '!=', $user->id)->whereHas('cabang', fn($q) => $q->where('region_id', $region_id))->get()
// only show users from the same region : Admin::getUserByRoleName('Area Manager Cabang');
$users = Admin::where('id', '!=', auth()->user()->id)
->whereHas('cabang', function ($query) use ($region_id) {
$query->where('region_id', $region_id);
})
->get();
} else {
$users = Admin::getUserByRoleName('Area Manager Cabang');
}
} elseif ($role == 'Area Manager Cabang') { } elseif ($role == 'Area Manager Cabang') {
$cabang_id = auth()->user()->getMyCabangAndRegionId()['cabang']; $cabang_id = $user->getMyCabangAndRegionId()['cabang'];
$users = $cabang_id
if ($cabang_id) { ? Admin::where('id', '!=', $user->id)->whereHas('cabang', fn($q) => $q->where('id', $cabang_id))->get()
// only show users from the same cabang : collect();
$users = Admin::where('id', '!=', auth()->user()->id)
->whereHas('cabang', function ($query) use ($cabang_id) {
$query->where('id', $cabang_id);
})
->get();
}
} else { } else {
$users = Admin::getUserByRoleName('Area Manager Cabang'); $users = Admin::getUserByRoleName('Area Manager Cabang');
} }
return view('backend.pages.budget_control.create', [ return view('backend.pages.budget_control.create', ['users' => $users]);
'users' => $users, }
/**
* Menampilkan Halaman Filter Generate Report Jurnal
*/
public function jurnal()
{
$user = auth()->user();
$role = $user->getRoleNames()[0] ?? null;
$userData = $user->getMyCabangAndRegionId();
$regions = Region::all();
$cabangs = Cabang::all();
// Filter data berdasarkan hak akses (Role)
if (in_array($role, ['Admin Region', 'Marketing Operational Manager Region'])) {
$regions = Region::where('id', $userData['region'])->get();
$cabangs = Cabang::where('region_id', $userData['region'])->get();
} elseif ($role == 'Area Manager Cabang') {
$cabangs = Cabang::where('id', $userData['cabang'])->get();
$regions = Region::where('id', $cabangs->first()->region_id ?? 0)->get();
}
return view('backend.pages.report.jurnal', [
'pageInfo' => ['title' => 'Generate Reports For Jurnal'],
'cabang' => $cabangs,
'regions' => $regions
]); ]);
} }
/**
* Proses Menghitung Data Jurnal (Debet, Kredit, & Realisasi)
*/
public function generateJurnal()
{
$params = request()->all();
$accounting_period = $params['accounting_period'] ?? '-';
// 1. Ambil kategori yang valid (Wajib punya Account Number & tidak di-exclude)
$excluded = ['Cash in Bank BCA', 'Donasi/Gift', 'PPn Masukan', 'Vehicle Maintenance', 'Bahan Promosi', 'Sponsorship'];
$kategoris = Kategori::whereNotIn('name', $excluded)
->whereNotNull('account_number')
->where('account_number', '!=', '')
->where('account_number', '!=', '0')
->get();
$kategori_bca = Kategori::where('name', 'Cash in Bank BCA')->first();
$cabang = Cabang::where('code', $params['cabang'])->firstOrFail();
$year = $params['year'];
$month = $params['month'];
// 2. Ambil Range Tanggal Cut-off (Misal: 11 Maret - 13 April)
[$startDate, $endDate] = PeriodeHelper::getPeriodeRange($month, $year);
// 3. KREDIT: Total Petty Cash yang diterima cabang (Pagu)
$pettyCashAmount = BudgetControl::where('cabang_id', $cabang->id)
->where('periode_year', $year)
->where('periode_month', $month)
->sum('amount');
// Log budget untuk display di view
$budget = BudgetControlLog::where('cabang_id', $cabang->id)
->where('periode_year', $year)
->where('periode_month', $month)
->orderByDesc('created_at')->first();
$userIds = UserHasCabang::where('cabang_id', $cabang->id)->pluck('user_id');
// Mapping Model & Alias
$formModels = [
'Up Country' => FormUpCountry::class,
'Vehicle Running Cost' => FormVehicleRunningCost::class,
'Entertainment' => FormEntertaimentPresentation::class,
'Entertaiment' => FormEntertaimentPresentation::class,
'Presentation' => FormEntertaimentPresentation::class,
'Meeting / Seminar' => FormMeetingSeminar::class,
];
$nominals = [];
foreach ($kategoris as $kategori) {
$sum = 0;
$katName = trim($kategori->name);
if (isset($formModels[$katName])) {
$model = $formModels[$katName];
$sum = $model::whereIn('user_id', $userIds)
->where('expense_number', 'LIKE', "%-{$cabang->code}-%") // Filter Cabang
->where('kategori_id', $kategori->id) // Filter Kategori WAJIB
->whereIn('status', ['Closed', 'Final Approved'])
->whereBetween('final_approved_at', [$startDate, $endDate])
->sum('approved_total');
} else {
$sum = FormOthers::whereIn('user_id', $userIds)
->where('expense_number', 'LIKE', "%-{$cabang->code}-%") // Filter Cabang
->where('kategori_id', $kategori->id) // Filter Kategori WAJIB
->whereIn('status', ['Closed', 'Final Approved'])
->whereBetween('final_approved_at', [$startDate, $endDate])
->sum('approved_total');
}
$nominals[$kategori->id] = $sum;
}
$totalDebet = array_sum($nominals); // Total Pengeluaran riil
return view('backend.pages.report.generate_jurnal', [
'pageInfo' => ['title' => 'Generate Reports For Jurnal'],
'kategori' => $kategoris,
'cabang' => $cabang,
'year' => $year,
'month' => $month,
'kategori_bca' => $kategori_bca,
'nominals' => $nominals,
'budget' => $budget,
'totalKategori' => $totalDebet, // Total Debet Jurnal
'pettyCashAmount' => $pettyCashAmount, // Total Kredit Jurnal
'cashInBca' => $pettyCashAmount - $totalDebet, // Sisa Uang BCA
'accounting_period' => $accounting_period,
]);
}
/**
* Simpan Budget Baru & Log
*/
public function store(Request $request) public function store(Request $request)
{ {
$this->checkAuthorization(auth()->user(), ['budget_control.create']); $this->checkAuthorization(auth()->user(), ['budget_control.create']);
$validated = $request->validate([ $validated = $request->validate([
'receiver_id' => 'required|exists:admins,id', 'receiver_id' => 'required|exists:admins,id',
'periode_month' => 'required|string|in:january,february,march,april,may,june,july,august,september,october,november,december', 'periode_month' => 'required|string',
'periode_year' => 'required|numeric', 'periode_year' => 'required|numeric',
'amount' => 'required|numeric', 'amount' => 'required|numeric',
'remarks' => 'nullable|string', 'remarks' => 'nullable|string',
]); ]);
$cabang_id = Admin::find($validated['receiver_id'])->getMyCabangAndRegionId()['cabang']; return DB::transaction(function () use ($validated) {
$periode = [ $receiver = Admin::find($validated['receiver_id']);
'periode_month' => $validated['periode_month'], $cabang_id = $receiver->getMyCabangAndRegionId()['cabang'];
'periode_year' => $validated['periode_year'],
$existing = BudgetControl::where('cabang_id', $cabang_id)
->where('periode_month', $validated['periode_month'])
->where('periode_year', $validated['periode_year'])
->first();
if ($existing && strtolower($existing->status) == 'assigned') {
return back()->with('error', 'Gagal: Budget periode ini sudah Assigned.');
}
$budgetData = [
'sender_id' => auth()->user()->id,
'receiver_id' => $validated['receiver_id'],
'amount' => $validated['amount'],
'remarks' => $validated['remarks'],
'status' => 'Assigned',
]; ];
BudgetControl::updateOrCreate( BudgetControl::updateOrCreate(
array_merge(['cabang_id' => $cabang_id], $periode), ['cabang_id' => $cabang_id, 'periode_month' => $validated['periode_month'], 'periode_year' => $validated['periode_year']],
array_merge($periode, [ $budgetData
'sender_id' => auth()->user()->id,
'receiver_id' => $validated['receiver_id'],
'amount' => $validated['amount'],
'remarks' => $validated['remarks'],
'closing_at' => null,
'status' => 'Assigned',
])
); );
BudgetControlLog::create([ BudgetControlLog::create(array_merge(['cabang_id' => $cabang_id], $validated, $budgetData));
'cabang_id' => $cabang_id,
'sender_id' => auth()->user()->id,
'receiver_id' => $validated['receiver_id'],
'periode_month' => $validated['periode_month'],
'periode_year' => $validated['periode_year'],
'amount' => $validated['amount'],
'remarks' => $validated['remarks'],
'closing_at' => null,
'status' => 'Assigned',
]);
session()->flash('success', 'Budget has been added or updated successfully'); return redirect()->route('budget_control')->with('success', 'Budget Assigned successfully');
return redirect()->route('budget_control'); });
} }
/**
* Proses Upload Bukti Transfer (Disempurnakan untuk dukung PDF & Gambar)
*/
public function uploadProof(Request $request)
{
// PERBAIKAN: Ganti 'image' menjadi 'file', karena aturan 'image' akan menolak PDF
$request->validate([
'budget_id' => 'required|exists:budget_control,id',
'transfer_proof' => 'required|file|mimes:jpeg,png,jpg,pdf|max:5120', // Maks 5MB
], [
'transfer_proof.mimes' => 'Format file harus berupa JPG, JPEG, PNG, atau PDF.',
'transfer_proof.max' => 'Ukuran file maksimal 5MB.'
]);
$budget = BudgetControl::find($request->budget_id);
if ($request->hasFile('transfer_proof')) {
$file = $request->file('transfer_proof');
$filename = time() . '_' . str_replace(' ', '_', $file->getClientOriginalName());
// Simpan file
$file->move(public_path('uploads/transfer_proofs'), $filename);
$budget->update([
'transfer_proof' => 'uploads/transfer_proofs/' . $filename,
'transfer_status' => 'Waiting Approval'
]);
return back()->with('success', 'Bukti transfer berhasil diunggah.');
}
return back()->with('error', 'File gagal diunggah.');
}
/**
* Konfirmasi Accounting (Validated)
*/
public function approveTransfer($id)
{
BudgetControl::where('id', $id)->update([
'transfer_status' => 'Validated',
'updated_at' => now()
]);
return back()->with('success', 'Transfer divalidasi.');
}
/**
* Penolakan Accounting (Rejected)
*/
public function rejectTransfer($id)
{
BudgetControl::where('id', $id)->update([
'transfer_status' => 'Rejected',
'updated_at' => now()
]);
return back()->with('error', 'Transfer ditolak.');
}
/**
* Proses Bulk Upload Budget via Excel/CSV
*/
public function bulkUpload(Request $request)
{
$this->checkAuthorization(auth()->user(), ['budget_control.create']);
// Tambahkan 'csv' pada mimes
$request->validate([
'upload_type' => 'required|in:region,cabang',
'periode_year' => 'required|numeric',
'file_excel' => 'required|file|mimes:xlsx,xls,csv|max:5120',
], [
'file_excel.mimes' => 'Format file wajib berupa Microsoft Excel (.xls / .xlsx) atau CSV.'
]);
// 1. Tentukan Cabang ID mana saja yang diizinkan untuk di-upload pada proses ini
$cabangIds = [];
if ($request->upload_type === 'region') {
$request->validate(['region_id' => 'required|exists:region,id']);
$cabangIds = \App\Models\Cabang::where('region_id', $request->region_id)->pluck('id')->toArray();
} else {
$request->validate(['cabang_id' => 'required|array', 'cabang_id.*' => 'exists:cabang,id']);
$cabangIds = $request->cabang_id;
}
try {
// 2. Baca file Excel menggunakan Maatwebsite (otomatis jadi Array)
// Parameter pertama new \stdClass() digunakan sebagai dummy import class
$data = Excel::toArray(new \stdClass(), $request->file('file_excel'));
$rows = $data[0] ?? [];
DB::beginTransaction();
$count = 0;
// 3. Looping data Excel
foreach ($rows as $index => $row) {
// Skip baris pertama (Asumsi baris 1 adalah Header)
if ($index === 0) continue;
$cabangCode = $row[0] ?? null;
$month = $row[1] ?? null;
$amount = (float) ($row[2] ?? 0);
$remarks = $row[3] ?? null;
if (!$cabangCode || !$month || $amount <= 0) continue;
// Cari cabang berdasarkan Kode Cabang di Excel
$cabang = \App\Models\Cabang::where('code', $cabangCode)->first();
// Validasi Keamanan: Pastikan cabang ditemukan DAN termasuk dalam scope filter modal
if (!$cabang || !in_array($cabang->id, $cabangIds)) continue;
$budgetData = [
'sender_id' => auth()->user()->id,
'receiver_id' => $cabang->head_id ?? auth()->user()->id, // Assign ke Head Cabang
'amount' => $amount,
'remarks' => $remarks,
'status' => 'Assigned',
];
// Simpan atau Update Budget
BudgetControl::updateOrCreate(
[
'cabang_id' => $cabang->id,
'periode_month' => $month,
'periode_year' => $request->periode_year
],
$budgetData
);
// Catat di Log
BudgetControlLog::create(array_merge([
'cabang_id' => $cabang->id,
'periode_month' => $month,
'periode_year' => $request->periode_year
], $budgetData));
$count++;
}
DB::commit();
return back()->with('success', "Bulk upload berhasil! Sebanyak {$count} data budget telah diproses.");
} catch (\Exception $e) {
DB::rollBack();
\Illuminate\Support\Facades\Log::error("Bulk Upload Error: " . $e->getMessage());
return back()->with('error', 'Gagal memproses file Excel. Pastikan format tabel sudah sesuai.');
}
}
/**
* Hapus Data Budget
*/
public function destroy($id) public function destroy($id)
{ {
$this->checkAuthorization(auth()->user(), ['budget_control.delete']); $this->checkAuthorization(auth()->user(), ['budget_control.delete']);
BudgetControl::findOrFail($id)->delete();
return redirect()->route('budget_control')->with('success', 'Deleted successfully');
}
$budgetRequest = BudgetControl::findOrfail($id); /**
$budgetRequest->delete(); * PRIVATE METHOD: Logika Grafik Trend Petty Cash vs Realisasi
*/
private function getTrendChartData(array $cabangIds): array
{
$currentYear = \Carbon\Carbon::now()->year;
$months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
return redirect()->route('budget_control'); $pettyCashData = [];
$realisasiData = [];
foreach ($months as $month) {
$pettyCash = \App\Models\BudgetControl::whereIn('cabang_id', $cabangIds)
->where('periode_month', strtolower($month))
->where('periode_year', $currentYear)
->sum('amount');
// Realisasi Sementara Di-set 0
$realisasi = 0;
$pettyCashData[] = (float) $pettyCash;
$realisasiData[] = (float) $realisasi;
}
return [
'labels' => $months,
'datasets' => [
[
'label' => 'Petty Cash (Budget)',
'data' => $pettyCashData,
'backgroundColor' => 'rgba(54, 162, 235, 0.6)',
'borderColor' => 'rgba(54, 162, 235, 1)',
],
[
'label' => 'Realisasi',
'data' => $realisasiData,
'backgroundColor' => 'rgba(255, 99, 132, 0.6)',
'borderColor' => 'rgba(255, 99, 132, 1)',
]
]
];
} }
} }
@@ -5,144 +5,243 @@ declare(strict_types=1);
namespace App\Http\Controllers\Backend; namespace App\Http\Controllers\Backend;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Admin; use App\Models\Region;
use Spatie\Permission\Models\Permission; use App\Models\Cabang;
use Spatie\Permission\Models\Role; use App\Models\Kategori;
use App\Models\FormUpCountry; use Illuminate\Http\Request;
use App\Models\FormEntertaimentPresentation; use Illuminate\Support\Facades\DB;
use App\Models\FormVehicleRunningCost;
use App\Models\FormMeetingSeminar;
use App\Models\FormOthers;
use App\Models\UserHasCabang;
use App\Models\Notifications;
use Carbon\Carbon; use Carbon\Carbon;
class DashboardController extends Controller class DashboardController extends Controller
{ {
public function index() private array $tables = [
{ 'up_country' => 'form_up_country',
$role = auth()->user()->getRoleNames()[0]; 'vehicle' => 'form_vehicle_running_cost',
$startDay = (int) env('STARTING_DATE', 11); 'entertainment' => 'form_entertainment_presentation',
$closingDay = (int) env('CLOSING_DATE', 10); 'meeting' => 'form_meeting_seminar',
'others' => 'form_others'
// Referensi waktu sekarang
$now = Carbon::now();
// Tentukan periode
if ($now->day >= $startDay) {
// Mulai dari tanggal START bulan ini
$startDate = Carbon::create($now->year, $now->month, $startDay)->startOfDay();
// Sampai tanggal CLOSING bulan depan
$closingDate = Carbon::create($now->copy()->addMonth()->year, $now->copy()->addMonth()->month, $closingDay)->endOfDay();
} else {
// Mulai dari tanggal START bulan lalu
$startDate = Carbon::create($now->copy()->subMonth()->year, $now->copy()->subMonth()->month, $startDay)->startOfDay();
// Sampai tanggal CLOSING bulan ini
$closingDate = Carbon::create($now->year, $now->month, $closingDay)->endOfDay();
}
$forms = [
'vehicle' => FormVehicleRunningCost::whereMonth('tanggal', date('m'))
->whereBetween('tanggal', [$startDate, $closingDate])
->orderBy('tanggal', 'desc')
->get(),
'upcountry' => FormUpCountry::whereMonth('tanggal', date('m'))
->whereBetween('tanggal', [$startDate, $closingDate])
->orderBy('tanggal', 'desc')
->get(),
'entertainment' => FormEntertaimentPresentation::whereMonth('tanggal', date('m'))
->whereBetween('tanggal', [$startDate, $closingDate])
->orderBy('tanggal', 'desc')
->get(),
'meeting' => FormMeetingSeminar::whereMonth('created_at', date('m'))
->whereBetween('created_at', [$startDate, $closingDate])
->orderBy('created_at', 'desc')
->get(),
'others' => FormOthers::whereMonth('tanggal', date('m'))
->whereBetween('tanggal', [$startDate, $closingDate])
->orderBy('tanggal', 'desc')
->get(),
]; ];
if ($role == 'Admin Region' || $role == 'Marketing Operational Manager Region') { public function index(Request $request)
$region_id = auth()->user()->getMyCabangAndRegionId()['region']; {
$user = auth()->user();
$roleName = trim($user->getRoleNames()->first() ?? '');
$now = Carbon::now();
if ($region_id) { // 1. FILTER WAKTU (Default ke bulan berjalan)
$users = UserHasCabang::whereHas('cabang', function ($query) use ($region_id) { $chartMonth = $request->chart_month ?? $now->format('m');
$query->where('region_id', $region_id); $chartYear = $request->chart_year ?? $now->format('Y');
})->pluck('user_id'); $monthName = Carbon::createFromFormat('m', $chartMonth)->translatedFormat('F');
foreach ($forms as $key => $query) { // --- KALKULASI PERIODE PENILAIAN (11 di bulan terpilih s/d 13 di bulan berikutnya) ---
$forms[$key] = $query->whereIn('user_id', $users)->whereIn('status', ['Approved', 'Closed']); $displayStart = Carbon::create((int)$chartYear, (int)$chartMonth, 11)->startOfDay();
} $displayEnd = $displayStart->copy()->addMonth()->day(13)->endOfDay(); // Dikunci aman ke tanggal 13 penutupan closing laporan
}
} elseif ($role == 'Area Manager Cabang') {
$cabang_id = auth()->user()->getMyCabangAndRegionId()['cabang'];
if ($cabang_id) { $periodeLabelFormatted = $displayStart->translatedFormat('d F') . ' - ' . $displayEnd->translatedFormat('d F Y');
$users = UserHasCabang::where('cabang_id', $cabang_id)->pluck('user_id');
foreach ($forms as $key => $query) { // 2. FILTER OTORISASI & DATA
$forms[$key] = $query->whereIn('user_id', $users)->whereIn('status', ['Approved', 'Closed']); $allowedCabangIds = $this->getScopedCabangIds($user, $roleName);
if (empty($allowedCabangIds)) {
return view('backend.pages.dashboard.index', $this->emptyData());
} }
$availableRegions = $this->getAvailableRegions($user, $roleName, $allowedCabangIds);
$cabangOptions = Cabang::whereIn('id', $allowedCabangIds)
->when($request->region_id, fn($q) => $q->where('region_id', $request->region_id))
->get();
// 3. AMBIL DATA PENDING & PERFORMA (Passing Variable Waktu Siklus ke Chart Method)
$cabangDetails = $this->getPendingExpenseTable($allowedCabangIds, $request);
$performance = $this->getPerformanceCharts($allowedCabangIds, $displayStart, $displayEnd);
$totalKategori = Kategori::whereNotNull('account_number')->where('account_number', '!=', '')->count();
$data = [
'periodeLabel' => $periodeLabelFormatted,
'totalRegions' => Region::count(),
'totalCabangs' => count($allowedCabangIds),
'totalKategori' => $totalKategori,
'regions' => $availableRegions,
'cabangOptions' => $cabangOptions,
'cabangDetails' => $cabangDetails,
'pendingCount' => $this->getTotalPendingGlobal($allowedCabangIds),
];
return view('backend.pages.dashboard.index', array_merge($data, $performance));
} }
} elseif ($role == 'Medical Representatif') {
foreach ($forms as $key => $query) { // ========================================================================
$forms[$key] = $query->where('user_id', auth()->user()->id)->whereIn('status', ['Approved', 'Closed']); // PRIVATE METHODS - SEPARATION OF CONCERNS
} // ========================================================================
/**
* MENGAMBIL DATA UNTUK TABEL MONITORING PENDING
*/
private function getPendingExpenseTable($allowedCabangIds, Request $request)
{
$query = Cabang::whereIn('id', $allowedCabangIds)->with('region');
if ($request->region_id) $query->where('region_id', $request->region_id);
if ($request->cabang_id) $query->where('id', $request->cabang_id);
if ($request->filled('search')) $query->where('name', 'like', "%{$request->search}%");
$sortField = $request->get('sort', 'name');
$sortOrder = $request->get('direction', 'asc');
if ($sortField == 'region') {
$query->leftJoin('region', 'cabang.region_id', '=', 'region.id')
->select('cabang.*')->orderBy('region.name', $sortOrder);
} else { } else {
foreach ($forms as $key => $query) { $query->orderBy('cabang.' . ($sortField == 'total_pending' ? 'name' : $sortField), $sortOrder);
$forms[$key] = $query->whereIn('status', ['Approved', 'Closed']);
}
} }
// Sum the filtered queries $cabangDetails = $query->paginate(100)->withQueryString()->through(function($cabang) {
foreach ($forms as $key => $query) { $statuses = ['Pending', 'On Progress', 'Approved 1', 'Approved 2', 'Waiting Approval'];
$forms[$key] = $query->sum('approved_total'); $details = []; $total = 0;
foreach ($this->tables as $key => $table) {
$count = DB::table($table)->where('cabang_id', $cabang->id)->whereIn('status', $statuses)->count();
$details[$key] = $count;
$total += $count;
} }
return view( $cabang->pending_details = $details;
'backend.pages.dashboard.index', $cabang->total_pending = $total;
[ return $cabang;
'forms' => $forms, });
if ($sortField == 'total_pending') {
$sortedCollection = $cabangDetails->getCollection()->sortBy('total_pending', SORT_REGULAR, $sortOrder == 'desc')->values();
$cabangDetails->setCollection($sortedCollection);
}
return $cabangDetails;
}
/**
* MENGHITUNG PERFORMA KECEPATAN APPROVE CABANG (Fokus Akurat Jendela Siklus Transaksi Tanggal 11 - 13)
*/
private function getPerformanceCharts($cabangIds, Carbon $startDate, Carbon $endDate): array
{
$queryUnion = null;
foreach ($this->tables as $table) {
$q = DB::table($table)
->select('cabang_id', 'created_at', 'final_approved_at', 'updated_at')
->where('status', 'Closed')
->whereIn('cabang_id', $cabangIds)
// KUNCI UTAMA: Hanya membaca pengajuan yang dibuat murni di dalam rentang tanggal siklus 11 s/d 13 berjalan
->whereBetween('tanggal', [$startDate->format('Y-m-d H:i:s'), $endDate->format('Y-m-d H:i:s')]);
$queryUnion = is_null($queryUnion) ? $q : $queryUnion->unionAll($q);
}
if ($queryUnion && DB::table(DB::raw("({$queryUnion->toSql()}) as t"))->mergeBindings($queryUnion)->exists()) {
// Kalkulasi selisih waktu rata-rata penyelesaian berkas menggunakan fondasi TIMESTAMPDIFF detik secara objektif
$res = DB::table(DB::raw("({$queryUnion->toSql()}) as combined"))
->mergeBindings($queryUnion)
->select('cabang_id', DB::raw('AVG(TIMESTAMPDIFF(SECOND, created_at, COALESCE(final_approved_at, updated_at))) as avg_sec'))
->groupBy('cabang_id')
->get();
$cabangNames = Cabang::whereIn('id', $res->pluck('cabang_id'))->pluck('name', 'id');
$fastest = $res->sortBy('avg_sec')->take(5);
$slowest = $res->sortByDesc('avg_sec')->take(5);
return [
'chartFastest' => [
'labels' => $fastest->map(fn($i) => $cabangNames[$i->cabang_id] ?? 'N/A')->values(),
'data' => $fastest->map(fn($i) => round($i->avg_sec / 3600, 2))->values(),
'raw' => $fastest->map(fn($i) => $this->formatDuration((int)$i->avg_sec))->values()
],
'chartSlowest' => [
'labels' => $slowest->map(fn($i) => $cabangNames[$i->cabang_id] ?? 'N/A')->values(),
'data' => $slowest->map(fn($i) => round($i->avg_sec / 3600, 2))->values(),
'raw' => $slowest->map(fn($i) => $this->formatDuration((int)$i->avg_sec))->values()
] ]
); ];
} }
public function notifications() return [
{ 'chartFastest' => ['labels'=>[], 'data'=>[], 'raw'=>[]],
$this->checkAuthorization(auth()->user(), ['notification.view']); 'chartSlowest' => ['labels'=>[], 'data'=>[], 'raw'=>[]]
];
// Mulai dengan query dasar
$notifications = Notifications::orderBy('created_at', 'desc')
->where('receiver_id', auth()->user()->id)
->where('is_read', 0);
// Ambil data dan kembalikan sebagai JSON
return response()->json([
'count' => $notifications->count(),
'data' => $notifications->take(7)->get(),
]);
} }
public function mark_read($id) /**
* GET AVAILABLE REGIONS BERDASARKAN ROLE
*/
private function getAvailableRegions($user, $roleName, $allowedCabangIds)
{ {
$this->checkAuthorization(auth()->user(), ['notification.delete']); $regionsQuery = Region::query();
$receiver_id = auth()->user()->id; if (in_array($roleName, ['Admin Region', 'Marketing Operational Manager Region'])) {
$regionsQuery->where('id', $user->region_id);
Notifications::where('receiver_id', $receiver_id)->where('id', $id)->update(['is_read' => 1]); } elseif (in_array($roleName, ['Area Manager Cabang', 'Medical Representatif'])) {
$myRegionIds = Cabang::whereIn('id', $allowedCabangIds)->pluck('region_id')->unique();
return response()->json(['message' => 'Notification marked as read.']); $regionsQuery->whereIn('id', $myRegionIds);
}
return $regionsQuery->get();
} }
public function mark_all_read() private function getScopedCabangIds($user, $roleName): array
{ {
$this->checkAuthorization(auth()->user(), ['notification.delete']); $central = ['Superadmin', 'Head of Sales Marketing', 'Marketing Information System', 'Accounting'];
$receiver_id = auth()->user()->id; if (in_array($roleName, $central)) return Cabang::pluck('id')->toArray();
Notifications::where('receiver_id', $receiver_id)->update(['is_read' => 1]); if (in_array($roleName, ['Admin Region', 'Marketing Operational Manager Region'])) {
return $user->region_id ? Cabang::where('region_id', $user->region_id)->pluck('id')->toArray() : [];
}
session()->flash('success', 'All notifications marked as read.'); if (in_array($roleName, ['Area Manager Cabang', 'Medical Representatif'])) {
return redirect()->back(); $cabangId = $user->cabang->cabang_id ?? \App\Models\Rayon::where('id', $user->rayon_id)->value('cabang_id');
return $cabangId ? [(int)$cabangId] : [];
}
return [];
}
private function getTotalPendingGlobal($cabangIds): int
{
$total = 0;
foreach ($this->tables as $table) {
$total += DB::table($table)->whereIn('cabang_id', $cabangIds)->whereIn('status', ['Pending', 'On Progress', 'Approved 1', 'Approved 2', 'Waiting Approval'])->count();
}
return $total;
}
private function formatDuration(int $sec): string
{
if ($sec <= 0) return "0 Jam";
$d = floor($sec / 86400); $h = floor(($sec % 86400) / 3600);
return $d > 0 ? "{$d} Hari {$h} Jam" : "{$h} Jam";
}
private function emptyData(): array
{
return [
'periodeLabel' => '-', 'totalRegions' => 0, 'totalCabangs' => 0, 'totalKategori' => 0, 'pendingCount' => 0,
'cabangDetails' => collect(), 'chartFastest' => ['labels'=>[], 'data'=>[], 'raw'=>[]],
'chartSlowest' => ['labels'=>[], 'data'=>[], 'raw'=>[]], 'regions' => collect(), 'cabangOptions' => collect()
];
}
public function notifications() { return response()->json(['status' => 'success', 'data' => []]); }
public function show($id)
{
try {
$user = auth()->user();
$allowedIds = $this->getScopedCabangIds($user, $user->getRoleNames()->first());
if (!in_array((int)$id, $allowedIds)) abort(403);
$cabang = Cabang::with('region')->findOrFail($id);
$statuses = ['Pending', 'On Progress', 'Approved 1', 'Approved 2', 'Waiting Approval'];
$data = [];
foreach (['Up Country' => 'form_up_country', 'Vehicle' => 'form_vehicle_running_cost', 'Entertainment' => 'form_entertainment_presentation', 'Meeting' => 'form_meeting_seminar', 'Others' => 'form_others'] as $label => $table) {
$data[$label] = DB::table($table)->where('cabang_id', $id)->whereIn('status', $statuses)->get();
}
return view('backend.pages.dashboard.show', compact('cabang', 'data'));
} catch (\Exception $e) {
return redirect()->route('admin.dashboard')->with('error', 'Cabang tidak ditemukan.');
}
} }
} }
@@ -68,4 +68,60 @@ class NextCloudController extends Controller
->header('Pragma', 'public') ->header('Pragma', 'public')
->header('Content-Length', strlen($response)); ->header('Content-Length', strlen($response));
} }
public function preview($file)
{
$filePath = base64_decode($file);
$baseUrl = rtrim(env('NEXT_CLOUD_URL'), '/');
$username = env('NEXT_CLOUD_USERNAME');
$password = env('NEXT_CLOUD_PASSWORD');
$filePath = trim((string) $filePath);
$segments = explode('/', ltrim($filePath, '/'));
$encodedPath = implode('/', array_map('rawurlencode', $segments));
$relativePath = "remote.php/dav/files/{$username}/" . $encodedPath;
$url = $baseUrl . '/' . $relativePath;
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_USERPWD, "{$username}:{$password}");
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
$response = curl_exec($ch);
if ($response === false) {
curl_close($ch);
abort(404);
}
$statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
curl_close($ch);
if ($statusCode >= 400 || !$response) {
abort(404);
}
if (!$contentType) {
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
$contentType = match ($extension) {
'jpg', 'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'pdf' => 'application/pdf',
default => 'application/octet-stream',
};
}
$filename = basename($filePath);
return response($response, 200)
->header('Content-Type', $contentType)
->header('Content-Disposition', "inline; filename=\"{$filename}\"")
->header('Content-Length', strlen($response));
}
} }
File diff suppressed because it is too large Load Diff
@@ -7,7 +7,6 @@ use App\Http\Controllers\Controller;
use App\Models\FormEntertaimentPresentation; use App\Models\FormEntertaimentPresentation;
use App\Models\Rayon; use App\Models\Rayon;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use App\Models\Region; use App\Models\Region;
use App\Models\Cabang; use App\Models\Cabang;
use App\Models\UserHasCabang; use App\Models\UserHasCabang;
@@ -23,23 +22,61 @@ use App\Mail\ExpenseCreated;
use App\Helpers\WhatsappHelper; use App\Helpers\WhatsappHelper;
use App\Helpers\AuditTrailHelper; use App\Helpers\AuditTrailHelper;
use App\Models\Admin; use App\Models\Admin;
use App\Rules\FileType;
use App\Helpers\BudgetHelper; use App\Helpers\BudgetHelper;
use App\Helpers\NotificationHelper; use App\Helpers\NotificationHelper;
use App\Helpers\OutstandingHelper; use App\Helpers\OutstandingHelper;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use App\Services\BrevoService; use App\Services\BrevoService;
use App\Services\AttachmentService;
use App\Enums\AttachmentTableName;
use App\Models\AttachmentForm;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Carbon\Carbon; use Carbon\Carbon;
class FormEntertainmentPresentationController extends Controller class FormEntertainmentPresentationController extends Controller
{ {
protected AttachmentService $attachmentService;
/**
* Attachment categories for Entertainment & Presentation.
*
* @var array<string,string>
*/
protected array $attachmentCategories = [
'bukti_total' => 'Bukti Total',
];
/**
* Blocked extensions to avoid dangerous uploads.
*
* @var array<int,string>
*/
protected array $blockedExtensions = ['exe', 'dll', 'sh', 'bat', 'cmd', 'msi'];
/**
* Supported image extensions for inline preview.
*
* @var array<int,string>
*/
protected array $imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'];
public function __construct(AttachmentService $attachmentService)
{
$this->attachmentService = $attachmentService;
view()->share('entertainmentAttachmentCategories', $this->attachmentCategories);
view()->share('entertainmentAttachmentCategoryKeys', array_keys($this->attachmentCategories));
view()->share('entertainmentBlockedExtensions', $this->blockedExtensions);
}
public function index() public function index()
{ {
$this->checkAuthorization(auth()->user(), ['forms.entertainment.view']); $this->checkAuthorization(auth()->user(), ['forms.entertainment.view']);
$role = auth()->user()->getRoleNames()[0]; $role = auth()->user()->getRoleNames()[0];
$startDay = (int) env('STARTING_DATE', 11); $startDay = (int) env('STARTING_DATE', 11);
$closingDay = (int) env('CLOSING_DATE', 10); $closingDay = (int) env('CLOSING_DATE', 13);
$userRegionData = auth()->user()->getMyCabangAndRegionId(); $userRegionData = auth()->user()->getMyCabangAndRegionId();
$regionContextId = $userRegionData['region'] !== '-' ? $userRegionData['region'] : null; $regionContextId = $userRegionData['region'] !== '-' ? $userRegionData['region'] : null;
$cabangContextId = $userRegionData['cabang'] !== '-' ? $userRegionData['cabang'] : null; $cabangContextId = $userRegionData['cabang'] !== '-' ? $userRegionData['cabang'] : null;
@@ -60,7 +97,7 @@ class FormEntertainmentPresentationController extends Controller
$forms = FormEntertaimentPresentation::whereBetween('tanggal', [$dataRetrievalStartDate, $currentPeriodClosingDate]) $forms = FormEntertaimentPresentation::whereBetween('tanggal', [$dataRetrievalStartDate, $currentPeriodClosingDate])
->orderBy('tanggal', 'desc') ->orderBy('tanggal', 'desc')
->with('user') ->with(['user', 'attachments'])
->get(); ->get();
$availableBudget = null; $availableBudget = null;
@@ -105,6 +142,11 @@ class FormEntertainmentPresentationController extends Controller
); );
session()->put('redirect_url', route('forms.entertainment')); session()->put('redirect_url', route('forms.entertainment'));
$forms->each(function (FormEntertaimentPresentation $form) {
$form->setAttribute('formatted_attachments', $this->formatAttachmentCollection($form));
});
return view('backend.pages.forms.entertainment.index', [ return view('backend.pages.forms.entertainment.index', [
'forms' => $forms, 'forms' => $forms,
'availableBudget' => $availableBudget, 'availableBudget' => $availableBudget,
@@ -118,8 +160,10 @@ class FormEntertainmentPresentationController extends Controller
{ {
$this->checkAuthorization(auth()->user(), ['forms.entertainment.view']); $this->checkAuthorization(auth()->user(), ['forms.entertainment.view']);
$form = FormEntertaimentPresentation::with('user')->findOrFail($id); $form = FormEntertaimentPresentation::with(['user', 'attachments'])->findOrFail($id);
$form->bukti_total = $form->bukti_total ? NextCloudHelper::getFileUrl($form->bukti_total) : null; $attachments = $this->formatAttachmentCollection($form);
$form->setAttribute('formatted_attachments', $attachments);
$form->bukti_total = $this->getAttachmentDownloadUrlByCategory($attachments, 'bukti_total');
return response()->json($form); return response()->json($form);
} }
@@ -128,11 +172,13 @@ class FormEntertainmentPresentationController extends Controller
{ {
$this->checkAuthorization(auth()->user(), ['forms.entertainment.view']); $this->checkAuthorization(auth()->user(), ['forms.entertainment.view']);
$form = FormEntertaimentPresentation::with('user')->findOrFail($id); $form = FormEntertaimentPresentation::with(['user', 'attachments'])->findOrFail($id);
$form->bukti_total = $form->bukti_total ? NextCloudHelper::getFileUrl($form->bukti_total) : null; $attachments = $this->formatAttachmentCollection($form);
$form->setAttribute('formatted_attachments', $attachments);
return view('backend.pages.forms.entertainment.view', [ return view('backend.pages.forms.entertainment.view', [
'form' => $form, 'form' => $form,
'attachments' => $attachments,
]); ]);
} }
@@ -147,37 +193,46 @@ class FormEntertainmentPresentationController extends Controller
{ {
$this->checkAuthorization(auth()->user(), ['forms.entertainment.create']); $this->checkAuthorization(auth()->user(), ['forms.entertainment.create']);
$request->validate([ $validator = Validator::make($request->all(), [
'tanggal' => 'required', 'tanggal' => 'required|date',
'jenis' => 'required|in:entertainment,presentation,sponsorship', 'jenis' => 'required|in:entertainment,presentation,sponsorship',
'keterangan' => 'required', 'keterangan' => 'required|string',
'total' => 'required|numeric|min:1', 'total' => 'required|numeric|min:1',
'name' => 'required', 'name' => 'required|string',
'alamat' => 'required', 'alamat' => 'required|string',
'nik_or_npwp' => 'required', 'nik_or_npwp' => 'required|string',
'bukti_total' => ['nullable', 'file', 'max:51200'], 'jabatan' => 'required|string',
'nama_perusahaan' => 'required|string',
'jenis_usaha' => 'required|string',
'attachments' => 'nullable|array',
'attachments.*.file_category' => 'nullable|string|in:' . implode(',', array_keys($this->attachmentCategories)),
'attachments.*.file_path' => 'nullable|file|max:10240',
]); ]);
$this->addAttachmentValidationRules($validator, $request);
$validator->validate();
$tanggal = Carbon::parse($request->tanggal); $tanggal = Carbon::parse($request->tanggal);
// PEMBATASAN INPUT MENGGUNAKAN STARTING_DATE & INPUT_MAX_DATE
$startDay = (int) env('STARTING_DATE', 11); $startDay = (int) env('STARTING_DATE', 11);
$closingDay = (int) env('CLOSING_DATE', 10); $inputMaxDay = (int) env('INPUT_MAX_DATE', 10);
// Ambil tanggal sekarang untuk tentukan periode berjalan // Ambil tanggal sekarang untuk tentukan periode berjalan
$now = Carbon::now(); $now = Carbon::now();
if ($now->day >= $startDay) { if ($now->day >= $startDay) {
// Periode berjalan dari bulan ini sampai bulan depan // Jendela Aktif: Tgl 11 Bulan Ini s/d Tgl 10 Bulan Depan
$startDate = Carbon::create($now->year, $now->month, $startDay); $startDate = Carbon::create($now->year, $now->month, $startDay)->startOfDay();
$closingDate = Carbon::create($now->copy()->addMonth()->year, $now->copy()->addMonth()->month, $closingDay); $maxInputDate = Carbon::create($now->copy()->addMonth()->year, $now->copy()->addMonth()->month, $inputMaxDay)->endOfDay();
} else { } else {
// Periode berjalan dari bulan lalu sampai bulan ini // Jendela Aktif: Tgl 11 Bulan Lalu s/d Tgl 10 Bulan Ini
$startDate = Carbon::create($now->copy()->subMonth()->year, $now->copy()->subMonth()->month, $startDay); $startDate = Carbon::create($now->copy()->subMonth()->year, $now->copy()->subMonth()->month, $startDay)->startOfDay();
$closingDate = Carbon::create($now->year, $now->month, $closingDay); $maxInputDate = Carbon::create($now->year, $now->month, $inputMaxDay)->endOfDay();
} }
if ($tanggal->lt($startDate) || $tanggal->gt($closingDate)) { if ($tanggal->lt($startDate) || $tanggal->gt($maxInputDate)) {
session()->flash('error', "Gagal, tanggal tidak berada dalam periode input: {$startDate->format('d M')} - {$closingDate->format('d M')}"); session()->flash('error', "Gagal, tanggal expense tidak berada dalam batas waktu input yang diizinkan: {$startDate->format('d M Y')} - {$maxInputDate->format('d M Y')}");
return redirect()->back(); return redirect()->back()->withInput();
} }
// Hitung periode budget dari tanggal form // Hitung periode budget dari tanggal form
@@ -187,47 +242,45 @@ class FormEntertainmentPresentationController extends Controller
$cabang = UserHasCabang::with('cabang')->where('user_id', auth()->user()->id)->first()->cabang; $cabang = UserHasCabang::with('cabang')->where('user_id', auth()->user()->id)->first()->cabang;
$availableBudget = BudgetHelper::getAvailableBudget($cabang->id, $periodeMonth, $periodeYear); $availableBudget = BudgetHelper::getAvailableBudget($cabang->id, $periodeMonth, $periodeYear);
if($request->total > $availableBudget) { if ($request->total > $availableBudget) {
session()->flash('error', 'Alokasi budget bulanan cabang tidak mencukupi,silahkan ajukan pada periode expense berikutnya'); session()->flash('error', 'Alokasi budget bulanan cabang tidak mencukupi,silahkan ajukan pada periode expense berikutnya');
return redirect()->back(); return redirect()->back()->withInput();
} }
$region = Region::where('id', $cabang->region_id)->first(); $region = Region::where('id', $cabang->region_id)->first();
$kategori = ""; $kategori = null;
if (strtolower($request->jenis) == 'entertainment' || $request->jenis == 'entertainment') { if (strtolower($request->jenis) == 'entertainment' || $request->jenis == 'entertainment') {
$kategori = Kategori::where('name', 'Entertainment')->first(); $kategori = Kategori::where('name', 'LIKE', '%Entertainment%')->first();
}elseif (strtolower($request->jenis) == 'presentation' || $request->jenis == 'presentation') { } elseif (strtolower($request->jenis) == 'presentation' || $request->jenis == 'presentation') {
$kategori = Kategori::where('name', 'Presentation')->first(); $kategori = Kategori::where('name', 'LIKE', '%Presentation%')->first();
}elseif (strtolower($request->jenis) == 'sponsorship' || $request->jenis == 'sponsorship') { } elseif (strtolower($request->jenis) == 'sponsorship' || $request->jenis == 'sponsorship') {
$kategori = Kategori::where('name', 'Sponsorship')->first(); $kategori = Kategori::where('name', 'LIKE', '%Sponsorship%')->first();
} }
$filePaths = [];
$folderPath = '/expense/' . $region->code . '/' . $cabang->code . '/entertainment'; $folderPath = '/expense/' . $region->code . '/' . $cabang->code . '/entertainment';
if ($request->hasFile('bukti_total')) {
$bukti_total = $request->file('bukti_total');
$filename = Str::uuid() . '.' . $bukti_total->extension();
$filePaths['bukti_total'] = $folderPath . '/' . $filename;
NextCloudHelper::uploadFile(
$folderPath,
$bukti_total->getContent(),
$filename
);
}
// Generate sequence number and expense number // Generate sequence number and expense number
$sequence_number = FormEntertaimentPresentation::withTrashed()->where('expense_number', 'like', $region->code . '-' . $cabang->code . '-ETY-' . date('ym') . '%')->count() + 1; $sequence_number = FormEntertaimentPresentation::withTrashed()->where('expense_number', 'like', $region->code . '-' . $cabang->code . '-ETY-' . date('ym') . '%')->count() + 1;
$formatted_sequence_number = str_pad($sequence_number, 4, '0', STR_PAD_LEFT); $formatted_sequence_number = str_pad($sequence_number, 4, '0', STR_PAD_LEFT);
$expense_number = $region->code . '-' . $cabang->code . '-ETY-' . date('ym') . $formatted_sequence_number; $expense_number = $region->code . '-' . $cabang->code . '-ETY-' . date('ym') . $formatted_sequence_number;
// Save the form data DB::beginTransaction();
try {
$attachmentsPayload = $this->buildAttachmentPayload($request, $folderPath);
if (empty($attachmentsPayload)) {
throw ValidationException::withMessages([
'attachments.0.file_path' => 'Minimal satu lampiran diperlukan.',
]);
}
$primaryAttachmentPath = $attachmentsPayload[0]['file_path'] ?? null;
$form = FormEntertaimentPresentation::create([ $form = FormEntertaimentPresentation::create([
'expense_number' => $expense_number, 'expense_number' => $expense_number,
'user_id' => auth()->user()->id, 'user_id' => auth()->user()->id,
'cabang_id' => $cabang->id, // <--- REPARASI SUNTIK CABANG_ID BARU
'tanggal' => $request->tanggal, 'tanggal' => $request->tanggal,
'jenis' => $request->jenis, 'jenis' => $request->jenis,
'keterangan' => $request->keterangan, 'keterangan' => $request->keterangan,
@@ -235,11 +288,38 @@ class FormEntertainmentPresentationController extends Controller
'name' => $request->name, 'name' => $request->name,
'alamat' => $request->alamat, 'alamat' => $request->alamat,
'nik_or_npwp' => $request->nik_or_npwp, 'nik_or_npwp' => $request->nik_or_npwp,
'bukti_total' => $filePaths['bukti_total'] ?? null, 'jabatan' => $request->jabatan,
'account_number' => $kategori->account_number, 'nama_perusahaan' => $request->nama_perusahaan,
'jenis_usaha' => $request->jenis_usaha,
'bukti_total' => $primaryAttachmentPath,
'account_number' => $kategori->account_number ?? null,
'kategori_id' => $kategori->id ?? null,
'status' => 'On Progress', 'status' => 'On Progress',
]); ]);
$this->attachmentService->addMultipleAttachments(
$form->id,
AttachmentTableName::FORM_ENTERTAINMENT_PRESENTATION,
$attachmentsPayload
);
DB::commit();
} catch (ValidationException $exception) {
DB::rollBack();
throw $exception;
} catch (\Throwable $th) {
DB::rollBack();
Log::error('Failed to create Entertainment Presentation form', [
'user_id' => auth()->id(),
'message' => $th->getMessage(),
'trace' => $th->getTraceAsString(),
]);
session()->flash('error', 'Terjadi kesalahan saat menyimpan data, silakan coba lagi.');
return redirect()->back()->withInput();
}
$name = auth()->user()->name; $name = auth()->user()->name;
$tanggal = $form->created_at; $tanggal = $form->created_at;
$total = $form->total; $total = $form->total;
@@ -325,14 +405,17 @@ class FormEntertainmentPresentationController extends Controller
$this->checkAuthorization(auth()->user(), ['forms.entertainment.edit']); $this->checkAuthorization(auth()->user(), ['forms.entertainment.edit']);
$role = auth()->user()->getRoleNames()[0]; $role = auth()->user()->getRoleNames()[0];
$form = FormEntertaimentPresentation::findOrfail($id); $form = FormEntertaimentPresentation::with('attachments')->findOrFail($id);
if($form->status != 'On Progress' && $role == 'Medical Representatif') { if($form->status != 'On Progress' && $role == 'Medical Representatif') {
session()->flash('error', 'You cannot edit this form because it has been approved or rejected.'); session()->flash('error', 'You cannot edit this form because it has been approved or rejected.');
return redirect()->back(); return redirect()->back();
} }
$attachments = $this->formatAttachmentCollection($form);
return view('backend.pages.forms.entertainment.edit', [ return view('backend.pages.forms.entertainment.edit', [
'form' => $form 'form' => $form,
'attachments' => $attachments,
]); ]);
} }
@@ -340,76 +423,85 @@ class FormEntertainmentPresentationController extends Controller
{ {
$this->checkAuthorization(auth()->user(), ['forms.entertainment.edit']); $this->checkAuthorization(auth()->user(), ['forms.entertainment.edit']);
$role = auth()->user()->getRoleNames()[0]; $role = auth()->user()->getRoleNames()[0];
$form = FormEntertaimentPresentation::findOrFail($id); $form = FormEntertaimentPresentation::with('attachments')->findOrFail($id);
$cabang_id = UserHasCabang::where('user_id', $form->user_id)->first()?->cabang_id; $cabang = UserHasCabang::with('cabang')->where('user_id', $form->user_id)->first()?->cabang;
if (!$cabang_id) { if (!$cabang) {
return redirect()->back()->with('error', 'Cabang tidak ditemukan.'); return redirect()->back()->with('error', 'Cabang tidak ditemukan.');
} }
// Hitung periode berdasarkan tanggal form
$tanggal = Carbon::parse($request->tanggal); $tanggal = Carbon::parse($request->tanggal);
$startDay = (int) env('STARTING_DATE', 11);
$closingDay = (int) env('CLOSING_DATE', 10);
// Periode aktif berdasarkan tanggal input // PEMBATASAN INPUT MENGGUNAKAN STARTING_DATE & INPUT_MAX_DATE (Konsisten dengan fungsi Store)
if ($tanggal->day >= $startDay) { $startDay = (int) env('STARTING_DATE', 11);
$periodeDate = Carbon::create($tanggal->year, $tanggal->month, $startDay); $inputMaxDay = (int) env('INPUT_MAX_DATE', 10);
$now = Carbon::now();
if ($now->day >= $startDay) {
$startDate = Carbon::create($now->year, $now->month, $startDay)->startOfDay();
$maxInputDate = Carbon::create($now->copy()->addMonth()->year, $now->copy()->addMonth()->month, $inputMaxDay)->endOfDay();
} else { } else {
$periodeDate = Carbon::create($tanggal->copy()->subMonth()->year, $tanggal->copy()->subMonth()->month, $startDay); $startDate = Carbon::create($now->copy()->subMonth()->year, $now->copy()->subMonth()->month, $startDay)->startOfDay();
$maxInputDate = Carbon::create($now->year, $now->month, $inputMaxDay)->endOfDay();
} }
$periodeMonth = strtolower($periodeDate->format('F')); if ($tanggal->lt($startDate) || $tanggal->gt($maxInputDate)) {
$periodeYear = (int) $periodeDate->format('Y'); session()->flash('error', "Gagal, tanggal expense tidak berada dalam batas waktu input yang diizinkan: {$startDate->format('d M Y')} - {$maxInputDate->format('d M Y')}");
return redirect()->back()->withInput();
}
$availableBudget = BudgetHelper::getAvailableBudget($cabang_id, $periodeMonth, $periodeYear); // Hitung periode berdasarkan tanggal form untuk validasi budget
$periodeMonth = strtolower($startDate->format('F'));
$periodeYear = (int) $startDate->format('Y');
$request->validate([ $availableBudget = BudgetHelper::getAvailableBudget($cabang->id, $periodeMonth, $periodeYear);
'tanggal' => 'required',
$validator = Validator::make($request->all(), [
'tanggal' => 'required|date',
'jenis' => 'required|in:entertainment,presentation,sponsorship', 'jenis' => 'required|in:entertainment,presentation,sponsorship',
'keterangan' => 'required', 'keterangan' => 'required|string',
'total' => 'required|numeric|min:1|max:' . $availableBudget, 'total' => 'required|numeric|min:1|max:' . $availableBudget,
'name' => 'required', 'name' => 'required|string',
'alamat' => 'required', 'alamat' => 'required|string',
'nik_or_npwp' => 'required', 'nik_or_npwp' => 'required|string',
'bukti_total' => ['nullable', 'file', 'max:51200'], 'jabatan' => 'required|string',
'nama_perusahaan' => 'required|string',
'jenis_usaha' => 'required|string',
'attachments' => 'nullable|array',
'attachments.*.file_category' => 'nullable|string|in:' . implode(',', array_keys($this->attachmentCategories)),
'attachments.*.file_path' => 'nullable|file|max:10240',
]); ]);
$this->addAttachmentValidationRules($validator, $request);
$validator->validate();
if($form->status != 'On Progress' && $role == 'Medical Representatif') { if ($form->status != 'On Progress' && $role == 'Medical Representatif') {
session()->flash('error', 'You cannot edit this form because it has been approved or rejected.'); session()->flash('error', 'You cannot edit this form because it has been approved or rejected.');
return redirect()->back(); return redirect()->back();
} }
$cabang = UserHasCabang::with('cabang')->where('user_id', $form->user_id)->first()->cabang;
$region = Region::where('id', $cabang->region_id)->first(); $region = Region::where('id', $cabang->region_id)->first();
$kategori = ""; $kategori = null;
if (strtolower($request->jenis) == 'entertainment' || $request->jenis == 'entertainment') { if (strtolower($request->jenis) == 'entertainment' || $request->jenis == 'entertainment') {
$kategori = Kategori::where('name', 'Entertainment')->first(); $kategori = Kategori::where('name', 'LIKE', '%Entertainment%')->first();
}elseif (strtolower($request->jenis) == 'presentation' || $request->jenis == 'presentation') { } elseif (strtolower($request->jenis) == 'presentation' || $request->jenis == 'presentation') {
$kategori = Kategori::where('name', 'Presentation')->first(); $kategori = Kategori::where('name', 'LIKE', '%Presentation%')->first();
}elseif (strtolower($request->jenis) == 'sponsorship' || $request->jenis == 'sponsorship') { } elseif (strtolower($request->jenis) == 'sponsorship' || $request->jenis == 'sponsorship') {
$kategori = Kategori::where('name', 'Sponsorship')->first(); $kategori = Kategori::where('name', 'LIKE', '%Sponsorship%')->first();
} }
$filePaths = [];
$folderPath = '/expense/' . $region->code . '/' . $cabang->code . '/entertainment'; $folderPath = '/expense/' . $region->code . '/' . $cabang->code . '/entertainment';
if ($request->hasFile('bukti_total')) { DB::beginTransaction();
$bukti_total = $request->file('bukti_total'); try {
$filename = Str::uuid() . '.' . $bukti_total->extension(); $attachmentsPayload = $this->buildAttachmentPayload($request, $folderPath);
$primaryAttachmentPath = !empty($attachmentsPayload)
? $attachmentsPayload[0]['file_path']
: $form->bukti_total;
$filePaths['bukti_total'] = $folderPath . '/' . $filename;
NextCloudHelper::uploadFile(
$folderPath,
$bukti_total->getContent(),
$filename
);
}
// Save the form data
$form->update([ $form->update([
'cabang_id' => $cabang->id, // <--- REPARASI SUNTIK CABANG_ID TERBARU
'tanggal' => $request->tanggal, 'tanggal' => $request->tanggal,
'jenis' => $request->jenis, 'jenis' => $request->jenis,
'keterangan' => $request->keterangan, 'keterangan' => $request->keterangan,
@@ -417,15 +509,71 @@ class FormEntertainmentPresentationController extends Controller
'name' => $request->name, 'name' => $request->name,
'alamat' => $request->alamat, 'alamat' => $request->alamat,
'nik_or_npwp' => $request->nik_or_npwp, 'nik_or_npwp' => $request->nik_or_npwp,
'bukti_total' => $filePaths['bukti_total'] ?? $form->bukti_total, 'jabatan' => $request->jabatan,
'account_number' => $kategori->account_number, 'nama_perusahaan' => $request->nama_perusahaan,
'jenis_usaha' => $request->jenis_usaha,
'bukti_total' => $primaryAttachmentPath,
'account_number' => $kategori->account_number ?? $form->account_number,
'kategori_id' => $kategori->id ?? null,
]); ]);
if (!empty($attachmentsPayload)) {
$this->attachmentService->addMultipleAttachments(
$form->id,
AttachmentTableName::FORM_ENTERTAINMENT_PRESENTATION,
$attachmentsPayload
);
}
DB::commit();
} catch (ValidationException $exception) {
DB::rollBack();
throw $exception;
} catch (\Throwable $th) {
DB::rollBack();
Log::error('Failed to update Entertainment Presentation form', [
'form_id' => $form->id,
'message' => $th->getMessage(),
'trace' => $th->getTraceAsString(),
]);
session()->flash('error', 'Terjadi kesalahan saat memperbarui data, silakan coba lagi.');
return redirect()->back()->withInput();
}
AuditTrailHelper::AddAuditTrail('Update', 'Update Expense at Form Entertainment Presentation (' . $form->expense_number . ')'); AuditTrailHelper::AddAuditTrail('Update', 'Update Expense at Form Entertainment Presentation (' . $form->expense_number . ')');
session()->flash('success', 'Form has been updated.'); session()->flash('success', 'Form has been updated.');
return redirect()->back(); return redirect()->back();
} }
public function deleteAttachment(Request $request, $formId, $attachmentId)
{
$this->checkAuthorization(auth()->user(), ['forms.entertainment.edit']);
$form = FormEntertaimentPresentation::findOrFail($formId);
$role = auth()->user()->getRoleNames()[0];
if (($form->status !== 'On Progress' && $form->status !== 'Rejected') && $role === 'Medical Representatif') {
return response()->json(['message' => 'You cannot modify attachments once the form is approved or closed.'], 403);
}
$attachment = AttachmentForm::where('id', $attachmentId)
->where('form_id', $form->id)
->where('table_name', AttachmentTableName::FORM_ENTERTAINMENT_PRESENTATION)
->first();
if (!$attachment) {
return response()->json(['message' => 'Attachment not found.'], 404);
}
$this->attachmentService->deleteAttachment($attachment->id);
AuditTrailHelper::AddAuditTrail('Delete Attachment', 'Delete Attachment at Form Entertainment Presentation (' . $form->expense_number . ')');
return response()->json(['message' => 'Attachment deleted successfully.']);
}
public function approve($id) public function approve($id)
{ {
$this->checkAuthorization(auth()->user(), ['approval.approve']); $this->checkAuthorization(auth()->user(), ['approval.approve']);
@@ -610,7 +758,8 @@ class FormEntertainmentPresentationController extends Controller
return redirect()->back(); return redirect()->back();
} }
public function finalApprove($id) // <--- REPARASI SUNTIKAN REQUEST DARI JAVASCRIPT MODAL AGAR DATA BISA DIAMBIL
public function finalApprove(Request $request, $id)
{ {
$this->checkAuthorization(auth()->user(), ['final_approval.approve']); $this->checkAuthorization(auth()->user(), ['final_approval.approve']);
$form = FormEntertaimentPresentation::findOrFail($id); $form = FormEntertaimentPresentation::findOrFail($id);
@@ -627,10 +776,9 @@ class FormEntertainmentPresentationController extends Controller
return redirect()->back(); return redirect()->back();
} }
// Hitung periode dari tanggal form // Hitung periode berdasarkan tanggal form
$tanggal = Carbon::parse($form->tanggal); $tanggal = Carbon::parse($form->tanggal);
$startDay = (int) env('STARTING_DATE', 11); $startDay = (int) env('STARTING_DATE', 11);
$closingDay = (int) env('CLOSING_DATE', 10);
if ($tanggal->day >= $startDay) { if ($tanggal->day >= $startDay) {
$periodeDate = Carbon::create($tanggal->year, $tanggal->month, $startDay); $periodeDate = Carbon::create($tanggal->year, $tanggal->month, $startDay);
@@ -641,13 +789,17 @@ class FormEntertainmentPresentationController extends Controller
$periodeMonth = strtolower($periodeDate->format('F')); $periodeMonth = strtolower($periodeDate->format('F'));
$periodeYear = (int) $periodeDate->format('Y'); $periodeYear = (int) $periodeDate->format('Y');
// <--- REPARASI: Penangkapan nilai payload dari checkbox JavaScript
$approvedTotal = $request->input('approved_total', $form->total);
$availableBudget = BudgetHelper::getAvailableBudget($cabang_id, $periodeMonth, $periodeYear); $availableBudget = BudgetHelper::getAvailableBudget($cabang_id, $periodeMonth, $periodeYear);
if($form->approved_total > $availableBudget) { if($approvedTotal > $availableBudget) { // <--- DISINKRONKAN DENGAN NILAI BARU HASIL CHECKLIST
session()->flash('error', 'Budget cabang untuk periode ini tidak mencukupi, silahkan ajukan pada periode expense berikutnya'); session()->flash('error', 'Budget cabang untuk periode ini tidak mencukupi, silahkan ajukan pada periode expense berikutnya');
return redirect()->back(); return redirect()->back();
} }
$form->update([ $form->update([
'approved_total' => $approvedTotal, // <--- SUNTIK DATA UPDATE HASIL CHECKLIST KE DATABASE
'final_approved_at' => now(), 'final_approved_at' => now(),
'final_approved_by' => auth()->user()->id, 'final_approved_by' => auth()->user()->id,
'status' => 'Closed', 'status' => 'Closed',
@@ -656,7 +808,7 @@ class FormEntertainmentPresentationController extends Controller
$name = $form->user->name; $name = $form->user->name;
$expense_number = $form->expense_number; $expense_number = $form->expense_number;
$tanggal = $form->created_at; $tanggal = $form->created_at;
$total = $form->approved_total; $total = $form->approved_total; // <--- TOTAL BARU YANG DIKIRIMKAN KE EMAIL / WHATSAPP
$roles = ['Head of Sales Marketing', 'Marketing Operational Manager Region']; $roles = ['Head of Sales Marketing', 'Marketing Operational Manager Region'];
$recipients = [$form->user->email, auth()->user()->email]; $recipients = [$form->user->email, auth()->user()->email];
@@ -729,25 +881,31 @@ class FormEntertainmentPresentationController extends Controller
public function reject(Request $request, $id) public function reject(Request $request, $id)
{ {
$this->checkAuthorization(auth()->user(), ['approval.reject']); // Otorisasi khusus MOM Region dan Atasan
if (!auth()->user()->hasAnyPermission(['approval.reject', 'approval2.reject', 'final_approval.approve'])) {
abort(403, 'Akses Ditolak: Anda tidak memiliki izin untuk melakukan Reject.');
}
$request->validate([ $request->validate([
'remarks' => 'required|string|max:500', 'remarks' => 'required|string|max:500',
]); ]);
// Menggunakan Model Entertainment & Presentation
$form = FormEntertaimentPresentation::findOrfail($id); $form = FormEntertaimentPresentation::findOrfail($id);
$form->update([ $form->update([
'status' => 'Rejected', 'status' => 'Rejected',
'remarks' => $request->remarks, 'remarks' => $request->remarks,
'rejected_by' => auth()->id(), // Kolom baru
'rejected_at' => now(), // Kolom baru
]); ]);
$heads = $form->user->getCabangAndRegionHead(); $heads = $form->user->getCabangAndRegionHead();
$name = $form->user->name; $name = $form->user->name;
$expense_number = $form->expense_number; $expense_number = $form->expense_number;
$tanggal = $form->created_at; $tanggal = $form->tanggal;
$total = $form->total; $total = $form->total;
// Collect all recipients
$recipients = [$form->user->email, auth()->user()->email]; $recipients = [$form->user->email, auth()->user()->email];
$phoneNumbers = [$form->user->phone, auth()->user()->phone]; $phoneNumbers = [$form->user->phone, auth()->user()->phone];
@@ -761,40 +919,37 @@ class FormEntertainmentPresentationController extends Controller
$phoneNumbers[] = $heads['region_head']['phone']; $phoneNumbers[] = $heads['region_head']['phone'];
} }
// Remove duplicate email addresses, if any
$recipients = array_unique($recipients); $recipients = array_unique($recipients);
$phoneNumbers = array_unique($phoneNumbers); $phoneNumbers = array_unique($phoneNumbers);
// send whatsapp message // Route khusus Entertainment
$url = route('forms.entertainment.view', $form->id); $url = route('forms.entertainment.view', $form->id);
WhatsappHelper::rejectExpense($phoneNumbers, $expense_number, $url, $tanggal, $total, $name);
// Send bulk email // Notifikasi WhatsApp
$this->rejectExpenseWithNotification($phoneNumbers, $expense_number, $url, $tanggal, $total, $name, $request->remarks, $recipients);
// Audit Trail Khusus Form Entertainment
AuditTrailHelper::AddAuditTrail('Reject', 'Reject Expense at Form Entertainment Presentation (' . $form->expense_number . ') oleh ' . auth()->user()->name);
session()->flash('success', 'Form has been rejected.');
return redirect()->back();
}
private function rejectExpenseWithNotification($phoneNumbers, $expense_number, $url, $tanggal, $total, $name, $remarks, $recipients)
{
WhatsappHelper::rejectExpense($phoneNumbers, $expense_number, $url, $tanggal, $total, $name, $remarks);
// Notifikasi Email (Brevo)
$brevoService = app(BrevoService::class); $brevoService = app(BrevoService::class);
foreach ($recipients as $recipient) { foreach ($recipients as $recipient) {
try { try {
$mail = new ExpenseRejected( $mail = new ExpenseRejected($name, $expense_number, $tanggal, $total, $url, $remarks);
$name, $brevoService->expenseRejected($recipient, $name, $mail);
$expense_number,
$tanggal,
$total,
$url
);
$response = $brevoService->expenseRejected($recipient, $name, $mail);
if (isset($response['success']) && $response['success'] === false) {
Log::warning("Email to {$recipient} failed: " . ($response['message'] ?? 'Unknown error'));
}
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error("Failed to send email to {$recipient}: " . $e->getMessage()); Log::error("Failed to send email to {$recipient}: " . $e->getMessage());
continue; continue;
} }
} }
AuditTrailHelper::AddAuditTrail('Reject', 'Reject Expense at Form Entertainment (' . $form->expense_number . ')');
session()->flash('success', 'Form has been rejected.');
return redirect()->back();
} }
public function open($id) public function open($id)
@@ -830,4 +985,190 @@ class FormEntertainmentPresentationController extends Controller
session()->flash('success', 'Form has been deleted.'); session()->flash('success', 'Form has been deleted.');
return redirect()->back(); return redirect()->back();
} }
protected function addAttachmentValidationRules(\Illuminate\Validation\Validator $validator, Request $request): void
{
$validator->after(function ($validator) use ($request) {
$inputAttachments = $request->input('attachments', []);
$fileAttachments = $request->file('attachments', []);
foreach ($inputAttachments as $index => $input) {
$category = $input['file_category'] ?? null;
$file = $fileAttachments[$index]['file_path'] ?? null;
if ($file && !$category) {
$validator->errors()->add("attachments.{$index}.file_category", 'Attachment category is required.');
}
if ($category && !$file) {
$validator->errors()->add("attachments.{$index}.file_path", 'Attachment file is required.');
continue;
}
if (!$file) {
continue;
}
$extension = strtolower((string) $file->getClientOriginalExtension());
if ($this->isBlockedExtension($extension)) {
$validator->errors()->add("attachments.{$index}.file_path", 'File type is not allowed.');
continue;
}
$mimeType = (string) $file->getMimeType();
if (in_array($extension, $this->imageExtensions, true) && strpos($mimeType, 'image/') !== 0) {
$validator->errors()->add("attachments.{$index}.file_path", 'Invalid image file.');
}
if ($extension === 'pdf' && $mimeType !== 'application/pdf') {
$validator->errors()->add("attachments.{$index}.file_path", 'Invalid PDF file.');
}
}
foreach ($fileAttachments as $index => $files) {
$file = $files['file_path'] ?? null;
if ($file && !isset($inputAttachments[$index])) {
$validator->errors()->add("attachments.{$index}.file_category", 'Attachment category is required.');
}
}
});
}
protected function buildAttachmentPayload(Request $request, string $folderPath): array
{
$payload = [];
$inputs = $request->input('attachments', []);
$files = $request->file('attachments', []);
foreach ($inputs as $index => $input) {
$category = $input['file_category'] ?? null;
$file = $files[$index]['file_path'] ?? null;
if (!$category || !$file) {
continue;
}
$extension = strtolower((string) $file->getClientOriginalExtension());
if ($this->isBlockedExtension($extension)) {
throw ValidationException::withMessages([
"attachments.{$index}.file_path" => 'File type is not allowed.',
]);
}
$filename = Str::uuid() . '.' . $extension;
$filePath = rtrim($folderPath, '/') . '/' . $filename;
$uploadResponse = NextCloudHelper::uploadFile(
$folderPath,
$file->getContent(),
$filename
);
if (is_array($uploadResponse) && isset($uploadResponse['success']) && $uploadResponse['success'] === false) {
throw ValidationException::withMessages([
"attachments.{$index}.file_path" => $uploadResponse['message'] ?? 'Failed to upload attachment.',
]);
}
$payload[] = [
'file_category' => $category,
'file_path' => $filePath,
];
}
return $payload;
}
protected function formatAttachmentCollection(FormEntertaimentPresentation $form): array
{
if (!$form->relationLoaded('attachments')) {
$form->load('attachments');
}
$attachments = $form->attachments->map(function (AttachmentForm $attachment) {
if (!$attachment->file_path) {
return null;
}
return $this->formatAttachmentData(
$attachment->id,
$attachment->file_category,
$attachment->file_path
);
})->filter()->values()->all();
$hasLegacyPath = collect($attachments)->contains(function ($attachment) use ($form) {
return isset($attachment['file_path']) && $attachment['file_path'] === $form->bukti_total;
});
if ($form->bukti_total && !$hasLegacyPath) {
$attachments[] = $this->formatAttachmentData(null, 'bukti_total', $form->bukti_total, false);
}
return $attachments;
}
protected function formatAttachmentData(?int $id, ?string $category, string $filePath, bool $canDelete = true): array
{
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
$previewType = $this->determinePreviewType($extension);
return [
'id' => $id,
'file_category' => $category,
'category_label' => $this->getCategoryLabel($category),
'file_path' => $filePath,
'download_url' => NextCloudHelper::getFileUrl($filePath),
'preview_url' => in_array($previewType, ['image', 'pdf'], true) ? NextCloudHelper::getPreviewUrl($filePath) : null,
'preview_type' => $previewType,
'filename' => basename($filePath),
'extension' => $extension,
'can_delete' => $canDelete && !is_null($id),
];
}
protected function determinePreviewType(?string $extension): string
{
$extension = strtolower((string) $extension);
if (in_array($extension, $this->imageExtensions, true)) {
return 'image';
}
if ($extension === 'pdf') {
return 'pdf';
}
return 'other';
}
protected function isBlockedExtension(?string $extension): bool
{
return in_array(strtolower((string) $extension), $this->blockedExtensions, true);
}
protected function getAttachmentDownloadUrlByCategory(array $attachments, string $category): ?string
{
foreach ($attachments as $attachment) {
if (($attachment['file_category'] ?? null) === $category) {
return $attachment['download_url'] ?? null;
}
}
return null;
}
protected function getCategoryLabel(?string $category): string
{
if (!$category) {
return 'Lampiran';
}
if (isset($this->attachmentCategories[$category])) {
return $this->attachmentCategories[$category];
}
return ucwords(str_replace('_', ' ', $category));
}
} }
@@ -7,7 +7,6 @@ use App\Http\Controllers\Controller;
use App\Models\FormMeetingSeminar; use App\Models\FormMeetingSeminar;
use App\Models\Rayon; use App\Models\Rayon;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use App\Models\Region; use App\Models\Region;
use App\Models\Cabang; use App\Models\Cabang;
use App\Models\UserHasCabang; use App\Models\UserHasCabang;
@@ -22,24 +21,61 @@ use App\Mail\ExpenseCreated;
use App\Mail\FinalApprove; use App\Mail\FinalApprove;
use App\Helpers\WhatsappHelper; use App\Helpers\WhatsappHelper;
use App\Helpers\AuditTrailHelper; use App\Helpers\AuditTrailHelper;
use App\Rules\FileType;
use App\Models\Admin; use App\Models\Admin;
use App\Helpers\BudgetHelper; use App\Helpers\BudgetHelper;
use App\Helpers\NotificationHelper; use App\Helpers\NotificationHelper;
use App\Helpers\OutstandingHelper; use App\Helpers\OutstandingHelper;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use App\Services\BrevoService; use App\Services\BrevoService;
use App\Services\AttachmentService;
use App\Enums\AttachmentTableName;
use App\Models\AttachmentForm;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Carbon\Carbon; use Carbon\Carbon;
class FormMeetingSeminarController extends Controller class FormMeetingSeminarController extends Controller
{ {
protected AttachmentService $attachmentService;
/**
* @var array<string,string>
*/
protected array $attachmentCategories = [
'bukti_allowance' => 'Bukti Allowance',
'bukti_transport_ankot' => 'Bukti Transport Antar Kota',
'bukti_hotel' => 'Bukti Hotel',
];
/**
* Blocked file extensions (case insensitive).
*
* @var array<int,string>
*/
protected array $blockedExtensions = ['exe', 'dll', 'sh', 'bat', 'cmd', 'msi'];
/**
* Image extensions that can be previewed inline.
*
* @var array<int,string>
*/
protected array $imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'];
public function __construct(AttachmentService $attachmentService)
{
$this->attachmentService = $attachmentService;
view()->share('meetingAttachmentCategories', $this->attachmentCategories);
view()->share('meetingAttachmentCategoryKeys', array_keys($this->attachmentCategories));
view()->share('meetingBlockedExtensions', $this->blockedExtensions);
}
public function index() public function index()
{ {
$this->checkAuthorization(auth()->user(), ['forms.meeting.view']); $this->checkAuthorization(auth()->user(), ['forms.meeting.view']);
$role = auth()->user()->getRoleNames()[0]; $role = auth()->user()->getRoleNames()[0];
$startDay = (int) env('STARTING_DATE', 11); $startDay = (int) env('STARTING_DATE', 11);
$closingDay = (int) env('CLOSING_DATE', 10); $closingDay = (int) env('CLOSING_DATE', 13);
$userRegionData = auth()->user()->getMyCabangAndRegionId(); $userRegionData = auth()->user()->getMyCabangAndRegionId();
$regionContextId = $userRegionData['region'] !== '-' ? $userRegionData['region'] : null; $regionContextId = $userRegionData['region'] !== '-' ? $userRegionData['region'] : null;
$cabangContextId = $userRegionData['cabang'] !== '-' ? $userRegionData['cabang'] : null; $cabangContextId = $userRegionData['cabang'] !== '-' ? $userRegionData['cabang'] : null;
@@ -120,10 +156,13 @@ class FormMeetingSeminarController extends Controller
{ {
$this->checkAuthorization(auth()->user(), ['forms.meeting.view']); $this->checkAuthorization(auth()->user(), ['forms.meeting.view']);
$form = FormMeetingSeminar::with('user')->findOrFail($id); $form = FormMeetingSeminar::with(['user', 'attachments'])->findOrFail($id);
$form->bukti_allowance = $form->bukti_allowance ? NextCloudHelper::getFileUrl($form->bukti_allowance) : null; $attachments = $this->formatAttachmentCollection($form);
$form->bukti_transport_ankot = $form->bukti_transport_ankot ? NextCloudHelper::getFileUrl($form->bukti_transport_ankot) : null;
$form->bukti_hotel = $form->bukti_hotel ? NextCloudHelper::getFileUrl($form->bukti_hotel) : null; $form->bukti_allowance = $this->getAttachmentDownloadUrlByCategory($attachments, 'bukti_allowance');
$form->bukti_transport_ankot = $this->getAttachmentDownloadUrlByCategory($attachments, 'bukti_transport_ankot');
$form->bukti_hotel = $this->getAttachmentDownloadUrlByCategory($attachments, 'bukti_hotel');
$form->setAttribute('formatted_attachments', $attachments);
return response()->json($form); return response()->json($form);
} }
@@ -132,13 +171,12 @@ class FormMeetingSeminarController extends Controller
{ {
$this->checkAuthorization(auth()->user(), ['forms.meeting.view']); $this->checkAuthorization(auth()->user(), ['forms.meeting.view']);
$form = FormMeetingSeminar::with('user')->findOrFail($id); $form = FormMeetingSeminar::with(['user', 'attachments'])->findOrFail($id);
$form->bukti_allowance = $form->bukti_allowance ? NextCloudHelper::getFileUrl($form->bukti_allowance) : null; $attachments = $this->formatAttachmentCollection($form);
$form->bukti_transport_ankot = $form->bukti_transport_ankot ? NextCloudHelper::getFileUrl($form->bukti_transport_ankot) : null;
$form->bukti_hotel = $form->bukti_hotel ? NextCloudHelper::getFileUrl($form->bukti_hotel) : null;
return view('backend.pages.forms.meeting.view', [ return view('backend.pages.forms.meeting.view', [
'form' => $form, 'form' => $form,
'attachments' => $attachments,
]); ]);
} }
@@ -153,34 +191,41 @@ class FormMeetingSeminarController extends Controller
{ {
$this->checkAuthorization(auth()->user(), ['forms.meeting.create']); $this->checkAuthorization(auth()->user(), ['forms.meeting.create']);
$request->validate([ $validator = Validator::make($request->all(), [
'tanggal' => 'required|date', 'tanggal' => 'required|date',
'allowance' => 'nullable|numeric', 'allowance' => 'nullable|numeric|min:0',
'transport_ankot' => 'nullable|numeric', 'transport_ankot' => 'nullable|numeric|min:0',
'hotel' => 'nullable|numeric', 'hotel' => 'nullable|numeric|min:0',
'bukti_allowance' => ['nullable', 'file', 'max:51200'], 'attachments' => 'nullable|array',
'bukti_transport_ankot' => ['nullable', 'file', 'max:51200'], 'attachments.*.file_category' => 'nullable|string|in:' . implode(',', array_keys($this->attachmentCategories)),
'bukti_hotel' => ['nullable', 'file', 'max:51200'], 'attachments.*.file_path' => 'nullable|file', // 10 MB
]); ]);
$this->addAttachmentValidationRules($validator, $request);
$validator->validate();
$tanggal = Carbon::parse($request->tanggal); $tanggal = Carbon::parse($request->tanggal);
// PEMBATASAN INPUT MENGGUNAKAN STARTING_DATE & INPUT_MAX_DATE
$startDay = (int) env('STARTING_DATE', 11); $startDay = (int) env('STARTING_DATE', 11);
$closingDay = (int) env('CLOSING_DATE', 10); $inputMaxDay = (int) env('INPUT_MAX_DATE', 10);
$now = Carbon::now(); $now = Carbon::now();
// Tentukan periode aktif berdasarkan tanggal saat ini // Tentukan periode aktif berdasarkan tanggal saat ini
if ($now->day >= $startDay) { if ($now->day >= $startDay) {
$startDate = Carbon::create($now->year, $now->month, $startDay); // Jendela Aktif: Tgl 11 Bulan Ini s/d Tgl 10 Bulan Depan
$closingDate = Carbon::create($now->copy()->addMonth()->year, $now->copy()->addMonth()->month, $closingDay); $startDate = Carbon::create($now->year, $now->month, $startDay)->startOfDay();
$maxInputDate = Carbon::create($now->copy()->addMonth()->year, $now->copy()->addMonth()->month, $inputMaxDay)->endOfDay();
} else { } else {
$startDate = Carbon::create($now->copy()->subMonth()->year, $now->copy()->subMonth()->month, $startDay); // Jendela Aktif: Tgl 11 Bulan Lalu s/d Tgl 10 Bulan Ini
$closingDate = Carbon::create($now->year, $now->month, $closingDay); $startDate = Carbon::create($now->copy()->subMonth()->year, $now->copy()->subMonth()->month, $startDay)->startOfDay();
$maxInputDate = Carbon::create($now->year, $now->month, $inputMaxDay)->endOfDay();
} }
// Validasi apakah tanggal pengajuan masuk ke periode saat ini // Validasi apakah tanggal pengajuan masuk ke periode input saat ini
if ($tanggal->lt($startDate) || $tanggal->gt($closingDate)) { if ($tanggal->lt($startDate) || $tanggal->gt($maxInputDate)) {
session()->flash('error', "Gagal, tanggal tidak berada dalam periode input: {$startDate->format('d M')} - {$closingDate->format('d M')}"); session()->flash('error', "Gagal, tanggal expense tidak berada dalam batas waktu input yang diizinkan: {$startDate->format('d M Y')} - {$maxInputDate->format('d M Y')}");
return redirect()->back(); return redirect()->back()->withInput();
} }
$cabang = UserHasCabang::with('cabang')->where('user_id', auth()->user()->id)->first()?->cabang; $cabang = UserHasCabang::with('cabang')->where('user_id', auth()->user()->id)->first()?->cabang;
@@ -190,7 +235,9 @@ class FormMeetingSeminarController extends Controller
} }
$region = Region::find($cabang->region_id); $region = Region::find($cabang->region_id);
$kategori = Kategori::where('name', 'Meeting / Seminar')->first();
// Optimasi pencarian kategori menggunakan LIKE fleksibel untuk menjamin ID ditemukan
$kategori = Kategori::where('name', 'LIKE', '%Meeting / Seminar%')->first();
// Hitung periode dari tanggal input (bukan dari today) // Hitung periode dari tanggal input (bukan dari today)
if ($tanggal->day >= $startDay) { if ($tanggal->day >= $startDay) {
@@ -202,31 +249,8 @@ class FormMeetingSeminarController extends Controller
$periodeMonth = strtolower($periodeDate->format('F')); $periodeMonth = strtolower($periodeDate->format('F'));
$periodeYear = (int) $periodeDate->format('Y'); $periodeYear = (int) $periodeDate->format('Y');
// Upload bukti file ke Nextcloud
$filePaths = [];
$folderPath = '/expense/' . $region->code . '/' . $cabang->code . '/meeting'; $folderPath = '/expense/' . $region->code . '/' . $cabang->code . '/meeting';
if ($request->hasFile('bukti_allowance')) {
$bukti_allowance = $request->file('bukti_allowance');
$filename = Str::uuid() . '.' . $bukti_allowance->extension();
$filePaths['bukti_allowance'] = $folderPath . '/' . $filename;
NextCloudHelper::uploadFile($folderPath, $bukti_allowance->getContent(), $filename);
}
if ($request->hasFile('bukti_transport_ankot')) {
$bukti_transport_ankot = $request->file('bukti_transport_ankot');
$filename = Str::uuid() . '.' . $bukti_transport_ankot->extension();
$filePaths['bukti_transport_ankot'] = $folderPath . '/' . $filename;
NextCloudHelper::uploadFile($folderPath, $bukti_transport_ankot->getContent(), $filename);
}
if ($request->hasFile('bukti_hotel')) {
$bukti_hotel = $request->file('bukti_hotel');
$filename = Str::uuid() . '.' . $bukti_hotel->extension();
$filePaths['bukti_hotel'] = $folderPath . '/' . $filename;
NextCloudHelper::uploadFile($folderPath, $bukti_hotel->getContent(), $filename);
}
// Generate nomor expense // Generate nomor expense
$sequence_number = FormMeetingSeminar::withTrashed() $sequence_number = FormMeetingSeminar::withTrashed()
->where('expense_number', 'like', $region->code . '-' . $cabang->code . '-MTG-' . date('ym') . '%') ->where('expense_number', 'like', $region->code . '-' . $cabang->code . '-MTG-' . date('ym') . '%')
@@ -237,36 +261,67 @@ class FormMeetingSeminarController extends Controller
// Hitung nominal // Hitung nominal
$data_nominal = [ $data_nominal = [
'allowance' => $request->allowance ?? 0, 'allowance' => (float) ($request->allowance ?? 0),
'transport_ankot' => $request->transport_ankot ?? 0, 'transport_ankot' => (float) ($request->transport_ankot ?? 0),
'hotel' => $request->hotel ?? 0, 'hotel' => (float) ($request->hotel ?? 0),
]; ];
$totalNominal = array_sum($data_nominal);
// Validasi budget // Validasi budget
$availableBudget = BudgetHelper::getAvailableBudget($cabang->id, $periodeMonth, $periodeYear); $availableBudget = BudgetHelper::getAvailableBudget($cabang->id, $periodeMonth, $periodeYear);
// Check if the total nominal exceeds the available budget // Check if the total nominal exceeds the available budget
if(array_sum($data_nominal) > $availableBudget) { if ($totalNominal > $availableBudget) {
session()->flash('error', 'Alokasi budget bulanan cabang tidak mencukupi,silahkan ajukan pada periode expense berikutnya'); session()->flash('error', 'Alokasi budget bulanan cabang tidak mencukupi,silahkan ajukan pada periode expense berikutnya');
return redirect()->back(); return redirect()->back()->withInput();
} }
// Save the form data DB::beginTransaction();
try {
$attachmentsPayload = $this->buildAttachmentPayload($request, $folderPath);
$form = FormMeetingSeminar::create([ $form = FormMeetingSeminar::create([
'expense_number' => $expense_number, 'expense_number' => $expense_number,
'user_id' => auth()->user()->id, 'user_id' => auth()->user()->id,
'cabang_id' => $cabang->id, // <--- REPARASI SUNTIK CABANG_ID BARU
'tanggal' => $request->tanggal, 'tanggal' => $request->tanggal,
'allowance' => $data_nominal['allowance'], 'allowance' => $data_nominal['allowance'],
'transport_ankot' => $data_nominal['transport_ankot'], 'transport_ankot' => $data_nominal['transport_ankot'],
'hotel' => $data_nominal['hotel'], 'hotel' => $data_nominal['hotel'],
'total' => array_sum($data_nominal), 'total' => $totalNominal,
'bukti_allowance' => $filePaths['bukti_allowance'] ?? null, 'bukti_allowance' => null,
'bukti_transport_ankot' => $filePaths['bukti_transport_ankot'] ?? null, 'bukti_transport_ankot' => null,
'bukti_hotel' => $filePaths['bukti_hotel'] ?? null, 'bukti_hotel' => null,
'account_number' => $kategori->account_number, 'account_number' => $kategori?->account_number,
'kategori_id' => $kategori->id ?? null,
'status' => 'On Progress', 'status' => 'On Progress',
]); ]);
if (!empty($attachmentsPayload)) {
$this->attachmentService->addMultipleAttachments(
$form->id,
AttachmentTableName::FORM_MEETING_SEMINAR,
$attachmentsPayload
);
}
DB::commit();
} catch (ValidationException $exception) {
DB::rollBack();
throw $exception;
} catch (\Throwable $th) {
DB::rollBack();
Log::error('Failed to create Meeting Seminar form', [
'user_id' => auth()->user()->id,
'message' => $th->getMessage(),
'trace' => $th->getTraceAsString(),
]);
session()->flash('error', 'Terjadi kesalahan saat menyimpan data, silakan coba lagi.');
return redirect()->back()->withInput();
}
$name = auth()->user()->name; $name = auth()->user()->name;
$tanggal = $form->tanggal; $tanggal = $form->tanggal;
$total = $form->total; $total = $form->total;
@@ -352,14 +407,17 @@ class FormMeetingSeminarController extends Controller
$this->checkAuthorization(auth()->user(), ['forms.meeting.edit']); $this->checkAuthorization(auth()->user(), ['forms.meeting.edit']);
$role = auth()->user()->getRoleNames()[0]; $role = auth()->user()->getRoleNames()[0];
$form = FormMeetingSeminar::findOrfail($id); $form = FormMeetingSeminar::with('attachments')->findOrFail($id);
if($form->status != 'On Progress' && $role == 'Medical Representatif') { if($form->status != 'On Progress' && $role == 'Medical Representatif') {
session()->flash('error', 'You cannot edit this form because it has been approved or rejected.'); session()->flash('error', 'You cannot edit this form because it has been approved or rejected.');
return redirect()->back(); return redirect()->back();
} }
$attachments = $this->formatAttachmentCollection($form);
return view('backend.pages.forms.meeting.edit', [ return view('backend.pages.forms.meeting.edit', [
'form' => $form 'form' => $form,
'attachments' => $attachments,
]); ]);
} }
@@ -367,23 +425,47 @@ class FormMeetingSeminarController extends Controller
{ {
$this->checkAuthorization(auth()->user(), ['forms.meeting.edit']); $this->checkAuthorization(auth()->user(), ['forms.meeting.edit']);
$role = auth()->user()->getRoleNames()[0]; $role = auth()->user()->getRoleNames()[0];
$form = FormMeetingSeminar::findOrFail($id); $form = FormMeetingSeminar::with('attachments')->findOrFail($id);
$request->validate([ $validator = Validator::make($request->all(), [
'tanggal' => 'required|date', 'tanggal' => 'required|date',
'allowance' => 'nullable|numeric', 'allowance' => 'nullable|numeric|min:0',
'transport_ankot' => 'nullable|numeric', 'transport_ankot' => 'nullable|numeric|min:0',
'hotel' => 'nullable|numeric', 'hotel' => 'nullable|numeric|min:0',
'bukti_allowance' => ['nullable', 'file', 'max:51200'], 'attachments' => 'nullable|array',
'bukti_transport_ankot' => ['nullable', 'file', 'max:51200'], 'attachments.*.file_category' => 'nullable|string|in:' . implode(',', array_keys($this->attachmentCategories)),
'bukti_hotel' => ['nullable', 'file', 'max:51200'], 'attachments.*.file_path' => 'nullable|file', // 10 MB
]); ]);
$this->addAttachmentValidationRules($validator, $request);
$validator->validate();
if ($form->status != 'On Progress' && $role == 'Medical Representatif') { if ($form->status != 'On Progress' && $role == 'Medical Representatif') {
session()->flash('error', 'You cannot edit this form because it has been approved or rejected.'); session()->flash('error', 'You cannot edit this form because it has been approved or rejected.');
return redirect()->back(); return redirect()->back();
} }
$tanggal = Carbon::parse($request->tanggal);
// PEMBATASAN INPUT MENGGUNAKAN STARTING_DATE & INPUT_MAX_DATE
$startDay = (int) env('STARTING_DATE', 11);
$inputMaxDay = (int) env('INPUT_MAX_DATE', 10);
$now = Carbon::now();
if ($now->day >= $startDay) {
$startDate = Carbon::create($now->year, $now->month, $startDay)->startOfDay();
$maxInputDate = Carbon::create($now->copy()->addMonth()->year, $now->copy()->addMonth()->month, $inputMaxDay)->endOfDay();
} else {
$startDate = Carbon::create($now->copy()->subMonth()->year, $now->copy()->subMonth()->month, $startDay)->startOfDay();
$maxInputDate = Carbon::create($now->year, $now->month, $inputMaxDay)->endOfDay();
}
// Validasi apakah tanggal pengajuan masuk ke periode input saat ini
if ($tanggal->lt($startDate) || $tanggal->gt($maxInputDate)) {
session()->flash('error', "Gagal, tanggal expense tidak berada dalam batas waktu input yang diizinkan: {$startDate->format('d M Y')} - {$maxInputDate->format('d M Y')}");
return redirect()->back()->withInput();
}
$cabang = UserHasCabang::with('cabang')->where('user_id', $form->user_id)->first()?->cabang; $cabang = UserHasCabang::with('cabang')->where('user_id', $form->user_id)->first()?->cabang;
if (!$cabang) { if (!$cabang) {
session()->flash('error', 'Cabang tidak ditemukan.'); session()->flash('error', 'Cabang tidak ditemukan.');
@@ -391,44 +473,22 @@ class FormMeetingSeminarController extends Controller
} }
$region = Region::find($cabang->region_id); $region = Region::find($cabang->region_id);
$kategori = Kategori::where('name', 'Meeting / Seminar')->first(); $kategori = Kategori::where('name', 'LIKE', '%Meeting / Seminar%')->first();
$filePaths = [];
$folderPath = '/expense/' . $region->code . '/' . $cabang->code . '/meeting'; $folderPath = '/expense/' . $region->code . '/' . $cabang->code . '/meeting';
if ($request->hasFile('bukti_allowance')) { $attachmentsPayload = $this->buildAttachmentPayload($request, $folderPath);
$bukti_allowance = $request->file('bukti_allowance');
$filename = Str::uuid() . '.' . $bukti_allowance->extension();
$filePaths['bukti_allowance'] = $folderPath . '/' . $filename;
NextCloudHelper::uploadFile($folderPath, $bukti_allowance->getContent(), $filename);
}
if ($request->hasFile('bukti_transport_ankot')) {
$bukti_transport_ankot = $request->file('bukti_transport_ankot');
$filename = Str::uuid() . '.' . $bukti_transport_ankot->extension();
$filePaths['bukti_transport_ankot'] = $folderPath . '/' . $filename;
NextCloudHelper::uploadFile($folderPath, $bukti_transport_ankot->getContent(), $filename);
}
if ($request->hasFile('bukti_hotel')) {
$bukti_hotel = $request->file('bukti_hotel');
$filename = Str::uuid() . '.' . $bukti_hotel->extension();
$filePaths['bukti_hotel'] = $folderPath . '/' . $filename;
NextCloudHelper::uploadFile($folderPath, $bukti_hotel->getContent(), $filename);
}
// Hitung nominal total // Hitung nominal total
$data_nominal = [ $data_nominal = [
'allowance' => $request->allowance ?? 0, 'allowance' => (float) ($request->allowance ?? 0),
'transport_ankot' => $request->transport_ankot ?? 0, 'transport_ankot' => (float) ($request->transport_ankot ?? 0),
'hotel' => $request->hotel ?? 0, 'hotel' => (float) ($request->hotel ?? 0),
]; ];
$totalNominal = array_sum($data_nominal); $totalNominal = array_sum($data_nominal);
// Hitung periode dari tanggal form yang diinput // Hitung periode dari tanggal form yang diinput
$tanggal = Carbon::parse($request->tanggal);
$startDay = (int) env('STARTING_DATE', 11);
if ($tanggal->day >= $startDay) { if ($tanggal->day >= $startDay) {
$periodeDate = Carbon::create($tanggal->year, $tanggal->month, $startDay); $periodeDate = Carbon::create($tanggal->year, $tanggal->month, $startDay);
} else { } else {
@@ -440,29 +500,78 @@ class FormMeetingSeminarController extends Controller
// Ambil budget tersedia // Ambil budget tersedia
$availableBudget = BudgetHelper::getAvailableBudget($cabang->id, $periodeMonth, $periodeYear); $availableBudget = BudgetHelper::getAvailableBudget($cabang->id, $periodeMonth, $periodeYear);
if(array_sum($data_nominal) > $availableBudget) { if ($totalNominal > $availableBudget) {
session()->flash('error', 'The total nominal exceeds the available budget.'); session()->flash('error', 'The total nominal exceeds the available budget.');
return redirect()->back(); return redirect()->back()->withInput();
} }
// Save the form data DB::beginTransaction();
try {
$form->update([ $form->update([
'cabang_id' => $cabang->id, // <--- REPARASI SUNTIK CABANG_ID TERBARU SAAT PROSES UPDATE
'tanggal' => $request->tanggal, 'tanggal' => $request->tanggal,
'allowance' => $data_nominal['allowance'], 'allowance' => $data_nominal['allowance'],
'transport_ankot' => $data_nominal['transport_ankot'], 'transport_ankot' => $data_nominal['transport_ankot'],
'hotel' => $data_nominal['hotel'], 'hotel' => $data_nominal['hotel'],
'total' => $request->allowance + $request->transport_ankot + $request->hotel, 'total' => $totalNominal,
'bukti_allowance' => $filePaths['bukti_allowance'] ?? $form->bukti_allowance, 'account_number' => $kategori?->account_number,
'bukti_transport_ankot' => $filePaths['bukti_transport_ankot'] ?? $form->bukti_transport_ankot, 'kategori_id' => $kategori->id ?? null,
'bukti_hotel' => $filePaths['bukti_hotel'] ?? $form->bukti_hotel,
'account_number' => $kategori->account_number,
]); ]);
if (!empty($attachmentsPayload)) {
$this->attachmentService->addMultipleAttachments(
$form->id,
AttachmentTableName::FORM_MEETING_SEMINAR,
$attachmentsPayload
);
}
DB::commit();
} catch (\Throwable $th) {
DB::rollBack();
Log::error('Failed to update Meeting Seminar form', [
'form_id' => $form->id,
'message' => $th->getMessage(),
'trace' => $th->getTraceAsString(),
]);
session()->flash('error', 'Terjadi kesalahan saat memperbarui data, silakan coba lagi.');
return redirect()->back()->withInput();
}
AuditTrailHelper::AddAuditTrail('Update', 'Update Expense at Form Meeting Seminar (' . $form->expense_number . ')'); AuditTrailHelper::AddAuditTrail('Update', 'Update Expense at Form Meeting Seminar (' . $form->expense_number . ')');
session()->flash('success', 'Form has been updated.'); session()->flash('success', 'Form has been updated.');
return redirect()->back(); return redirect()->back();
} }
public function deleteAttachment(Request $request, $formId, $attachmentId)
{
$this->checkAuthorization(auth()->user(), ['forms.meeting.edit']);
$form = FormMeetingSeminar::findOrFail($formId);
$role = auth()->user()->getRoleNames()[0];
if (($form->status !== 'On Progress' && $form->status !== 'Rejected') && $role === 'Medical Representatif') {
return response()->json(['message' => 'You cannot modify attachments once the form is approved or closed.'], 403);
}
$attachment = AttachmentForm::where('id', $attachmentId)
->where('form_id', $form->id)
->where('table_name', AttachmentTableName::FORM_MEETING_SEMINAR)
->first();
if (!$attachment) {
return response()->json(['message' => 'Attachment not found.'], 404);
}
$this->attachmentService->deleteAttachment($attachment->id);
AuditTrailHelper::AddAuditTrail('Delete Attachment', 'Delete Attachment at Form Meeting Seminar (' . $form->expense_number . ')');
return response()->json(['message' => 'Attachment deleted successfully.']);
}
public function approve(Request $request, $id) public function approve(Request $request, $id)
{ {
$this->checkAuthorization(auth()->user(), ['approval.approve']); $this->checkAuthorization(auth()->user(), ['approval.approve']);
@@ -657,7 +766,8 @@ class FormMeetingSeminarController extends Controller
return redirect()->back(); return redirect()->back();
} }
public function finalApprove($id) // <--- REPARASI SUNTIKAN OBJECT REQUEST HASIL CHECKLIST JAVASCRIPT MODAL DILUAR
public function finalApprove(Request $request, $id)
{ {
$this->checkAuthorization(auth()->user(), ['final_approval.approve']); $this->checkAuthorization(auth()->user(), ['final_approval.approve']);
$form = FormMeetingSeminar::findOrFail($id); $form = FormMeetingSeminar::findOrFail($id);
@@ -686,13 +796,18 @@ class FormMeetingSeminarController extends Controller
$periodeMonth = strtolower($periodeDate->format('F')); $periodeMonth = strtolower($periodeDate->format('F'));
$periodeYear = (int) $periodeDate->format('Y'); $periodeYear = (int) $periodeDate->format('Y');
// <--- REPARASI: Penangkapan nilai payload dari checkbox JavaScript
$approvedTotal = $request->input('approved_total', $form->total);
// Ambil sisa budget bulanan dan sinkronkan dengan nominal dinamis terbaru
$availableBudget = BudgetHelper::getAvailableBudget($cabang_id, $periodeMonth, $periodeYear); $availableBudget = BudgetHelper::getAvailableBudget($cabang_id, $periodeMonth, $periodeYear);
if($form->approved_total > $availableBudget) { if($approvedTotal > $availableBudget) {
session()->flash('error', 'Budget cabang untuk periode ini tidak mencukupi, silahkan ajukan pada periode expense berikutnya'); session()->flash('error', 'Budget cabang untuk periode ini tidak mencukupi, silahkan ajukan pada periode expense berikutnya');
return redirect()->back(); return redirect()->back();
} }
$form->update([ $form->update([
'approved_total' => $approvedTotal, // <--- SUNTIK NOMINAL BARU HASIL CHECKLIST DARI JAVASCRIPT KE DB
'final_approved_at' => now(), 'final_approved_at' => now(),
'final_approved_by' => auth()->user()->id, 'final_approved_by' => auth()->user()->id,
'status' => 'Closed', 'status' => 'Closed',
@@ -701,7 +816,7 @@ class FormMeetingSeminarController extends Controller
$name = $form->user->name; $name = $form->user->name;
$expense_number = $form->expense_number; $expense_number = $form->expense_number;
$tanggal = $form->tanggal; $tanggal = $form->tanggal;
$total = $form->approved_total; $total = $form->approved_total; // <--- TOTAL BARU YANG DIKIRIMKAN KE EMAIL / WHATSAPP
$roles = ['Head of Sales Marketing', 'Marketing Operational Manager Region']; $roles = ['Head of Sales Marketing', 'Marketing Operational Manager Region'];
$recipients = [$form->user->email, auth()->user()->email]; $recipients = [$form->user->email, auth()->user()->email];
@@ -744,16 +859,18 @@ class FormMeetingSeminarController extends Controller
$url = route('forms.meeting.view', $form->id); $url = route('forms.meeting.view', $form->id);
WhatsappHelper::finalApprove($phoneNumbers, $expense_number, $url, $tanggal, $total, $name); WhatsappHelper::finalApprove($phoneNumbers, $expense_number, $url, $tanggal, $total, $name);
// Send bulk email // Send bulk email (Optimasi menggunakan BrevoService)
$brevoService = app(BrevoService::class);
foreach ($recipients as $recipient) { foreach ($recipients as $recipient) {
try { try {
Mail::to($recipient)->send(new FinalApprove( $mail = new FinalApprove(
$name, $name,
$expense_number, $expense_number,
$tanggal, $tanggal,
$total, $total,
$url $url
)); );
$brevoService->expenseFinalApproved($recipient, $name, $mail);
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error("Failed to send email to {$recipient}: " . $e->getMessage()); Log::error("Failed to send email to {$recipient}: " . $e->getMessage());
continue; continue;
@@ -767,16 +884,22 @@ class FormMeetingSeminarController extends Controller
public function reject(Request $request, $id) public function reject(Request $request, $id)
{ {
$this->checkAuthorization(auth()->user(), ['approval.reject']); if (!auth()->user()->hasAnyPermission(['approval.reject', 'approval2.reject', 'final_approval.approve'])) {
abort(403, 'Akses Ditolak: Anda tidak memiliki izin untuk melakukan Reject.');
}
$request->validate([ $request->validate([
'remarks' => 'required|string|max:500', 'remarks' => 'required|string|max:500',
]); ]);
$form = FormMeetingSeminar::findOrfail($id); $form = FormMeetingSeminar::findOrfail($id);
// Update Metadata Rejeksi
$form->update([ $form->update([
'status' => 'Rejected', 'status' => 'Rejected',
'remarks' => $request->remarks, 'remarks' => $request->remarks,
'rejected_by' => auth()->id(), // Kolom baru
'rejected_at' => now(), // Kolom baru
]); ]);
$heads = $form->user->getCabangAndRegionHead(); $heads = $form->user->getCabangAndRegionHead();
@@ -785,7 +908,6 @@ class FormMeetingSeminarController extends Controller
$tanggal = $form->tanggal; $tanggal = $form->tanggal;
$total = $form->total; $total = $form->total;
// Collect all recipients
$recipients = [$form->user->email, auth()->user()->email]; $recipients = [$form->user->email, auth()->user()->email];
$phoneNumbers = [$form->user->phone, auth()->user()->phone]; $phoneNumbers = [$form->user->phone, auth()->user()->phone];
@@ -799,38 +921,24 @@ class FormMeetingSeminarController extends Controller
$phoneNumbers[] = $heads['region_head']['phone']; $phoneNumbers[] = $heads['region_head']['phone'];
} }
// Remove duplicate email addresses, if any
$recipients = array_unique($recipients); $recipients = array_unique($recipients);
$phoneNumbers = array_unique($phoneNumbers); $phoneNumbers = array_unique($phoneNumbers);
// send whatsapp message
$url = route('forms.meeting.view', $form->id); $url = route('forms.meeting.view', $form->id);
WhatsappHelper::rejectExpense($phoneNumbers, $expense_number, $url, $tanggal, $total, $name); WhatsappHelper::rejectExpense($phoneNumbers, $expense_number, $url, $tanggal, $total, $name, $request->remarks);
// Send bulk email
$brevoService = app(BrevoService::class); $brevoService = app(BrevoService::class);
foreach ($recipients as $recipient) { foreach ($recipients as $recipient) {
try { try {
$mail = new ExpenseRejected( $mail = new ExpenseRejected($name, $expense_number, $tanggal, $total, $url, $request->remarks);
$name, $brevoService->expenseRejected($recipient, $name, $mail);
$expense_number,
$tanggal,
$total,
$url
);
$response = $brevoService->expenseRejected($recipient, $name, $mail);
if (isset($response['success']) && $response['success'] === false) {
Log::warning("Email to {$recipient} failed: " . ($response['message'] ?? 'Unknown error'));
}
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error("Failed to send email to {$recipient}: " . $e->getMessage()); Log::error("Failed to send email to {$recipient}: " . $e->getMessage());
continue; continue;
} }
} }
AuditTrailHelper::AddAuditTrail('Reject', 'Reject Expense at Form Meeting Seminar (' . $form->expense_number . ')'); AuditTrailHelper::AddAuditTrail('Reject', 'Reject Expense at Form Meeting Seminar (' . $form->expense_number . ') oleh ' . auth()->user()->name);
session()->flash('success', 'Form has been rejected.'); session()->flash('success', 'Form has been rejected.');
return redirect()->back(); return redirect()->back();
} }
@@ -869,4 +977,194 @@ class FormMeetingSeminarController extends Controller
session()->flash('success', 'Form has been deleted.'); session()->flash('success', 'Form has been deleted.');
return redirect()->back(); return redirect()->back();
} }
protected function addAttachmentValidationRules(\Illuminate\Validation\Validator $validator, Request $request): void
{
$validator->after(function ($validator) use ($request) {
$inputAttachments = $request->input('attachments', []);
$fileAttachments = $request->file('attachments', []);
foreach ($inputAttachments as $index => $input) {
$category = $input['file_category'] ?? null;
$file = $fileAttachments[$index]['file_path'] ?? null;
if ($file && !$category) {
$validator->errors()->add("attachments.{$index}.file_category", 'Attachment category is required.');
}
if ($category && !$file) {
$validator->errors()->add("attachments.{$index}.file_path", 'Attachment file is required.');
continue;
}
if (!$file) {
continue;
}
$extension = strtolower((string) $file->getClientOriginalExtension());
if ($this->isBlockedExtension($extension)) {
$validator->errors()->add("attachments.{$index}.file_path", 'File type is not allowed.');
continue;
}
$mimeType = (string) $file->getMimeType();
if (in_array($extension, $this->imageExtensions, true) && strpos($mimeType, 'image/') !== 0) {
$validator->errors()->add("attachments.{$index}.file_path", 'Invalid image file.');
}
if ($extension === 'pdf' && $mimeType !== 'application/pdf') {
$validator->errors()->add("attachments.{$index}.file_path", 'Invalid PDF file.');
}
}
foreach ($fileAttachments as $index => $files) {
$file = $files['file_path'] ?? null;
if ($file && !isset($inputAttachments[$index])) {
$validator->errors()->add("attachments.{$index}.file_category", 'Attachment category is required.');
}
}
});
}
protected function buildAttachmentPayload(Request $request, string $folderPath): array
{
$payload = [];
$inputs = $request->input('attachments', []);
$files = $request->file('attachments', []);
foreach ($inputs as $index => $input) {
$category = $input['file_category'] ?? null;
$file = $files[$index]['file_path'] ?? null;
if (!$category || !$file) {
continue;
}
$extension = strtolower((string) $file->getClientOriginalExtension());
if ($this->isBlockedExtension($extension)) {
throw ValidationException::withMessages([
"attachments.{$index}.file_path" => 'File type is not allowed.',
]);
}
$filename = Str::uuid() . '.' . $extension;
$filePath = rtrim($folderPath, '/') . '/' . $filename;
$uploadResponse = NextCloudHelper::uploadFile(
$folderPath,
$file->getContent(),
$filename
);
if (is_array($uploadResponse) && isset($uploadResponse['success']) && $uploadResponse['success'] === false) {
throw ValidationException::withMessages([
"attachments.{$index}.file_path" => $uploadResponse['message'] ?? 'Failed to upload attachment.',
]);
}
$payload[] = [
'file_category' => $category,
'file_path' => $filePath,
];
}
return $payload;
}
protected function formatAttachmentCollection(FormMeetingSeminar $form): array
{
if (!$form->relationLoaded('attachments')) {
$form->load('attachments');
}
$attachments = $form->attachments->map(function (AttachmentForm $attachment) {
if (!$attachment->file_path) {
return null;
}
return $this->formatAttachmentData(
$attachment->id,
$attachment->file_category,
$attachment->file_path
);
})->filter()->values()->all();
$legacySources = [
'bukti_allowance' => $form->bukti_allowance,
'bukti_transport_ankot' => $form->bukti_transport_ankot,
'bukti_hotel' => $form->bukti_hotel,
];
foreach ($legacySources as $category => $path) {
if ($path) {
$attachments[] = $this->formatAttachmentData(null, $category, $path, false);
}
}
return $attachments;
}
protected function formatAttachmentData(?int $id, ?string $category, string $filePath, bool $canDelete = true): array
{
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
$previewType = $this->determinePreviewType($extension);
return [
'id' => $id,
'file_category' => $category,
'category_label' => $this->getCategoryLabel($category),
'file_path' => $filePath,
'download_url' => NextCloudHelper::getFileUrl($filePath),
'preview_url' => in_array($previewType, ['image', 'pdf'], true) ? NextCloudHelper::getPreviewUrl($filePath) : null,
'preview_type' => $previewType,
'filename' => basename($filePath),
'extension' => $extension,
'can_delete' => $canDelete && !is_null($id),
];
}
protected function determinePreviewType(?string $extension): string
{
$extension = strtolower((string) $extension);
if (in_array($extension, $this->imageExtensions, true)) {
return 'image';
}
if ($extension === 'pdf') {
return 'pdf';
}
return 'other';
}
protected function isBlockedExtension(?string $extension): bool
{
return in_array(strtolower((string) $extension), $this->blockedExtensions, true);
}
protected function getAttachmentDownloadUrlByCategory(array $attachments, string $category): ?string
{
foreach ($attachments as $attachment) {
if (($attachment['file_category'] ?? null) === $category) {
return $attachment['download_url'] ?? null;
}
}
return null;
}
protected function getCategoryLabel(?string $category): string
{
if (!$category) {
return 'Lampiran';
}
if (isset($this->attachmentCategories[$category])) {
return $this->attachmentCategories[$category];
}
return ucwords(str_replace('_', ' ', $category));
}
} }
@@ -18,7 +18,6 @@ use App\Mail\FinalApprove;
use App\Mail\ExpenseCreated; use App\Mail\ExpenseCreated;
use App\Helpers\WhatsappHelper; use App\Helpers\WhatsappHelper;
use App\Helpers\AuditTrailHelper; use App\Helpers\AuditTrailHelper;
use App\Rules\FileType;
use App\Models\Admin; use App\Models\Admin;
use App\Helpers\BudgetHelper; use App\Helpers\BudgetHelper;
use App\Helpers\NotificationHelper; use App\Helpers\NotificationHelper;
@@ -26,17 +25,56 @@ use App\Models\Cabang;
use App\Helpers\OutstandingHelper; use App\Helpers\OutstandingHelper;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use App\Services\BrevoService; use App\Services\BrevoService;
use App\Services\AttachmentService;
use App\Enums\AttachmentTableName;
use App\Models\AttachmentForm;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Carbon\Carbon; use Carbon\Carbon;
class FormOtherController extends Controller class FormOtherController extends Controller
{ {
protected AttachmentService $attachmentService;
/**
* Kategori yang tidak boleh dipilih pada Form Others.
*
* @var array<int,string>
*/
protected array $excludedKategoriNames = [
'Up Country',
'Vehicle Running Cost',
'Entertainment',
'Meeting / Seminar',
'Presentation',
'Sponsorship',
'Cash in Bank BCA',
'Donasi/Gift',
'PPn Masukan',
'Vehicle Maintenance',
'Bahan Promosi',
];
protected array $attachmentCategories = [
'bukti_total',
];
protected array $blockedExtensions = ['exe', 'dll', 'sh', 'bat', 'cmd', 'msi'];
public function __construct(AttachmentService $attachmentService)
{
$this->attachmentService = $attachmentService;
view()->share('otherAttachmentCategories', $this->attachmentCategories);
}
public function index() public function index()
{ {
$this->checkAuthorization(auth()->user(), ['forms.other.view']); $this->checkAuthorization(auth()->user(), ['forms.other.view']);
$role = auth()->user()->getRoleNames()[0]; $role = auth()->user()->getRoleNames()[0];
$startDay = (int) env('STARTING_DATE', 11); $startDay = (int) env('STARTING_DATE', 11);
$closingDay = (int) env('CLOSING_DATE', 10); $closingDay = (int) env('CLOSING_DATE', 13);
$userRegionData = auth()->user()->getMyCabangAndRegionId(); $userRegionData = auth()->user()->getMyCabangAndRegionId();
$regionContextId = $userRegionData['region'] !== '-' ? $userRegionData['region'] : null; $regionContextId = $userRegionData['region'] !== '-' ? $userRegionData['region'] : null;
$cabangContextId = $userRegionData['cabang'] !== '-' ? $userRegionData['cabang'] : null; $cabangContextId = $userRegionData['cabang'] !== '-' ? $userRegionData['cabang'] : null;
@@ -117,22 +155,27 @@ class FormOtherController extends Controller
{ {
$this->checkAuthorization(auth()->user(), ['forms.other.view']); $this->checkAuthorization(auth()->user(), ['forms.other.view']);
$form = FormOthers::with(['user', 'kategori'])->findOrFail($id); $form = FormOthers::with(['user', 'kategori', 'attachments'])->findOrFail($id);
$attachments = $this->formatAttachmentCollection($form);
$form->bukti_total = $form->bukti_total ? NextCloudHelper::getFileUrl($form->bukti_total) : null; $form->bukti_total = $form->bukti_total ? NextCloudHelper::getFileUrl($form->bukti_total) : null;
return response()->json($form); return response()->json(array_merge($form->toArray(), [
'attachments' => $attachments,
]));
} }
public function view($id) public function view($id)
{ {
$this->checkAuthorization(auth()->user(), ['forms.other.view']); $this->checkAuthorization(auth()->user(), ['forms.other.view']);
$form = FormOthers::with(['user', 'kategori'])->findOrFail($id); $form = FormOthers::with(['user', 'kategori', 'attachments'])->findOrFail($id);
$attachments = $this->formatAttachmentCollection($form);
$form->bukti_total = $form->bukti_total ? NextCloudHelper::getFileUrl($form->bukti_total) : null; $form->bukti_total = $form->bukti_total ? NextCloudHelper::getFileUrl($form->bukti_total) : null;
return view('backend.pages.forms.other.view', [ return view('backend.pages.forms.other.view', [
'form' => $form, 'form' => $form,
'kategori' => Kategori::whereNotIn('name', ['Up Country', 'Vehicle Running Cost', 'Entertainment', 'Meeting / Seminar'])->get() 'kategori' => Kategori::whereNotIn('name', $this->excludedKategoriNames)->get(),
'attachments' => $attachments,
]); ]);
} }
@@ -141,39 +184,72 @@ class FormOtherController extends Controller
$this->checkAuthorization(auth()->user(), ['forms.other.create']); $this->checkAuthorization(auth()->user(), ['forms.other.create']);
return view('backend.pages.forms.other.create', [ return view('backend.pages.forms.other.create', [
'kategori' => Kategori::whereNotIn('name', ['Up Country', 'Vehicle Running Cost', 'Entertainment','Presentation','Sponsorship', 'Meeting / Seminar'])->get() 'kategori' => Kategori::whereNotIn('name', $this->excludedKategoriNames)->get(),
'attachmentCategories' => $this->attachmentCategories,
]); ]);
} }
public function store(Request $request) public function store(Request $request)
{ {
$this->checkAuthorization(auth()->user(), ['forms.other.create']); $this->checkAuthorization(auth()->user(), ['forms.other.create']);
$request->validate([ $validator = Validator::make($request->all(), [
'kategori_id' => 'required', 'kategori_id' => 'required',
'tanggal' => 'required|date', 'tanggal' => 'required|date',
'keterangan' => 'required', 'keterangan' => 'required',
'total' => 'required|numeric|min:1', 'total' => 'required|numeric|min:1',
'bukti_total' => ['nullable', 'file', 'max:51200'], 'attachments' => 'nullable|array',
'attachments.*.file_category' => 'required|string|in:' . implode(',', $this->attachmentCategories),
'attachments.*.file_path' => [
'nullable',
'file',
'max:10240',
'mimetypes:image/jpeg,image/png,image/webp,application/pdf,application/msword,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/plain,text/csv,application/zip,application/x-zip-compressed,application/x-7z-compressed,application/vnd.rar,application/octet-stream'
],
]); ]);
$tanggal = Carbon::parse($request->tanggal); $validator->after(function ($validator) use ($request) {
$startDay = (int) env('STARTING_DATE', 11); $inputAttachments = $request->input('attachments', []);
$closingDay = (int) env('CLOSING_DATE', 10); $fileAttachments = $request->file('attachments', []);
$now = Carbon::now();
// Tentukan periode input berdasarkan waktu saat ini foreach ($inputAttachments as $index => $input) {
if ($now->day >= $startDay) { $category = $input['file_category'] ?? null;
$startDate = Carbon::create($now->year, $now->month, $startDay); $file = $fileAttachments[$index]['file_path'] ?? null;
$closingDate = Carbon::create($now->copy()->addMonth()->year, $now->copy()->addMonth()->month, $closingDay);
} else { if ($category && !$file) {
$startDate = Carbon::create($now->copy()->subMonth()->year, $now->copy()->subMonth()->month, $startDay); $validator->errors()->add("attachments.{$index}.file_path", 'Attachment file is required.');
$closingDate = Carbon::create($now->year, $now->month, $closingDay); continue;
} }
// Validasi apakah tanggal berada dalam periode aktif input if ($file && $this->isBlockedExtension($file->getClientOriginalExtension())) {
if ($tanggal->lt($startDate) || $tanggal->gt($closingDate)) { $validator->errors()->add("attachments.{$index}.file_path", 'File type is not allowed.');
session()->flash('error', "Gagal, tanggal tidak berada dalam periode input: {$startDate->format('d M')} - {$closingDate->format('d M')}"); }
return redirect()->back(); }
});
$validator->validate();
$tanggal = Carbon::parse($request->tanggal);
// PEMBATASAN INPUT MENGGUNAKAN STARTING_DATE & INPUT_MAX_DATE
$startDay = (int) env('STARTING_DATE', 11);
$inputMaxDay = (int) env('INPUT_MAX_DATE', 10);
$now = Carbon::now();
// Tentukan periode aktif berdasarkan tanggal saat ini
if ($now->day >= $startDay) {
// Jendela Aktif: Tgl 11 Bulan Ini s/d Tgl 10 Bulan Depan
$startDate = Carbon::create($now->year, $now->month, $startDay)->startOfDay();
$maxInputDate = Carbon::create($now->copy()->addMonth()->year, $now->copy()->addMonth()->month, $inputMaxDay)->endOfDay();
} else {
// Jendela Aktif: Tgl 11 Bulan Lalu s/d Tgl 10 Bulan Ini
$startDate = Carbon::create($now->copy()->subMonth()->year, $now->copy()->subMonth()->month, $startDay)->startOfDay();
$maxInputDate = Carbon::create($now->year, $now->month, $inputMaxDay)->endOfDay();
}
// Validasi apakah tanggal pengajuan masuk ke periode input saat ini
if ($tanggal->lt($startDate) || $tanggal->gt($maxInputDate)) {
session()->flash('error', "Gagal, tanggal expense tidak berada dalam batas waktu input yang diizinkan: {$startDate->format('d M Y')} - {$maxInputDate->format('d M Y')}");
return redirect()->back()->withInput();
} }
// Ambil cabang user // Ambil cabang user
@@ -184,7 +260,7 @@ class FormOtherController extends Controller
return redirect()->back(); return redirect()->back();
} }
// Tentukan periode budget berdasarkan tanggal input (bukan now) // Tentukan periode budget berdasarkan tanggal input
if ($tanggal->day >= $startDay) { if ($tanggal->day >= $startDay) {
$periodeDate = Carbon::create($tanggal->year, $tanggal->month, $startDay); $periodeDate = Carbon::create($tanggal->year, $tanggal->month, $startDay);
} else { } else {
@@ -198,46 +274,58 @@ class FormOtherController extends Controller
$availableBudget = BudgetHelper::getAvailableBudget($cabang->id, $periodeMonth, $periodeYear); $availableBudget = BudgetHelper::getAvailableBudget($cabang->id, $periodeMonth, $periodeYear);
if($request->total > $availableBudget) { if($request->total > $availableBudget) {
session()->flash('error', 'Alokasi budget bulanan cabang tidak mencukupi,silahkan ajukan pada periode expense berikutnya'); session()->flash('error', 'Alokasi budget bulanan cabang tidak mencukupi,silahkan ajukan pada periode expense berikutnya');
return redirect()->back(); return redirect()->back()->withInput();
} }
$region = Region::where('id', $cabang->region_id)->first(); $region = Region::where('id', $cabang->region_id)->first();
$kategori = Kategori::where('id', $request->kategori_id)->first(); $kategori = Kategori::where('id', $request->kategori_id)->first();
$filePaths = [];
$folderPath = '/expense/' . $region->code . '/' . $cabang->code . '/other'; $folderPath = '/expense/' . $region->code . '/' . $cabang->code . '/other';
$attachmentsPayload = $this->buildAttachmentPayload($request, $folderPath);
if ($request->hasFile('bukti_total')) { $primaryAttachmentPath = $attachmentsPayload[0]['file_path'] ?? null;
$bukti_total = $request->file('bukti_total');
$filename = Str::uuid() . '.' . $bukti_total->extension();
$filePaths['bukti_total'] = $folderPath . '/' . $filename;
NextCloudHelper::uploadFile(
$folderPath,
$bukti_total->getContent(),
$filename
);
}
// Generate sequence number and expense number // Generate sequence number and expense number
$sequence_number = FormOthers::withTrashed()->where('expense_number', 'like', $region->code . '-' . $cabang->code . '-OTH-' . date('ym') . '%')->count() + 1; $sequence_number = FormOthers::withTrashed()->where('expense_number', 'like', $region->code . '-' . $cabang->code . '-OTH-' . date('ym') . '%')->count() + 1;
$formatted_sequence_number = str_pad($sequence_number, 4, '0', STR_PAD_LEFT); $formatted_sequence_number = str_pad($sequence_number, 4, '0', STR_PAD_LEFT);
$expense_number = $region->code . '-' . $cabang->code . '-OTH-' . date('ym') . $formatted_sequence_number; $expense_number = $region->code . '-' . $cabang->code . '-OTH-' . date('ym') . $formatted_sequence_number;
// Save the form data DB::beginTransaction();
try {
$form = FormOthers::create([ $form = FormOthers::create([
'expense_number' => $expense_number, 'expense_number' => $expense_number,
'user_id' => auth()->user()->id, 'user_id' => auth()->user()->id,
'cabang_id' => $cabang->id, // <--- REPARASI SUNTIK CABANG_ID BARU
'kategori_id' => $request->kategori_id, 'kategori_id' => $request->kategori_id,
'tanggal' => $request->tanggal, 'tanggal' => $request->tanggal,
'keterangan' => $request->keterangan, 'keterangan' => $request->keterangan,
'total' => $request->total, 'total' => $request->total,
'bukti_total' => $filePaths['bukti_total'] ?? null, 'bukti_total' => $primaryAttachmentPath,
'account_number' => $kategori->account_number, 'account_number' => $kategori->account_number,
'status' => 'On Progress', 'status' => 'On Progress',
]); ]);
if (!empty($attachmentsPayload)) {
$this->attachmentService->addMultipleAttachments(
$form->id,
AttachmentTableName::FORM_OTHERS,
$attachmentsPayload
);
}
DB::commit();
} catch (\Throwable $th) {
DB::rollBack();
Log::error('Failed to store Form Others', [
'message' => $th->getMessage(),
'trace' => $th->getTraceAsString(),
]);
session()->flash('error', 'Terjadi kesalahan saat menyimpan data, silakan coba lagi.');
return redirect()->back()->withInput();
}
$form->load('user');
$name = auth()->user()->name; $name = auth()->user()->name;
$tanggal = $request->tanggal; $tanggal = $request->tanggal;
$total = $request->total; $total = $request->total;
@@ -323,7 +411,7 @@ class FormOtherController extends Controller
$this->checkAuthorization(auth()->user(), ['forms.other.edit']); $this->checkAuthorization(auth()->user(), ['forms.other.edit']);
$role = auth()->user()->getRoleNames()[0]; $role = auth()->user()->getRoleNames()[0];
$form = FormOthers::findOrfail($id); $form = FormOthers::with('attachments')->findOrfail($id);
if($form->status != 'On Progress' && $role == 'Medical Representatif') { if($form->status != 'On Progress' && $role == 'Medical Representatif') {
session()->flash('error', 'You cannot edit this form because it has been approved or rejected.'); session()->flash('error', 'You cannot edit this form because it has been approved or rejected.');
return redirect()->back(); return redirect()->back();
@@ -331,7 +419,9 @@ class FormOtherController extends Controller
return view('backend.pages.forms.other.edit', [ return view('backend.pages.forms.other.edit', [
'form' => $form, 'form' => $form,
'kategori' => Kategori::whereNotIn('name', ['Up Country', 'Vehicle Running Cost', 'Entertainment','Presentation','Sponsorship', 'Meeting / Seminar'])->get() 'kategori' => Kategori::whereNotIn('name', $this->excludedKategoriNames)->get(),
'attachments' => $this->formatAttachmentCollection($form),
'attachmentCategories' => $this->attachmentCategories,
]); ]);
} }
@@ -339,7 +429,7 @@ class FormOtherController extends Controller
{ {
$this->checkAuthorization(auth()->user(), ['forms.other.edit']); $this->checkAuthorization(auth()->user(), ['forms.other.edit']);
$role = auth()->user()->getRoleNames()[0]; $role = auth()->user()->getRoleNames()[0];
$form = FormOthers::findOrFail($id); $form = FormOthers::with('attachments')->findOrFail($id);
if ($form->status !== 'On Progress' && $role === 'Medical Representatif') { if ($form->status !== 'On Progress' && $role === 'Medical Representatif') {
session()->flash('error', 'You cannot edit this form because it has been approved or rejected.'); session()->flash('error', 'You cannot edit this form because it has been approved or rejected.');
@@ -352,10 +442,28 @@ class FormOtherController extends Controller
return redirect()->back(); return redirect()->back();
} }
// Hitung periode dari tanggal input
$tanggal = Carbon::parse($request->tanggal); $tanggal = Carbon::parse($request->tanggal);
$startDay = (int) env('STARTING_DATE', 11);
// PEMBATASAN INPUT MENGGUNAKAN STARTING_DATE & INPUT_MAX_DATE
$startDay = (int) env('STARTING_DATE', 11);
$inputMaxDay = (int) env('INPUT_MAX_DATE', 10);
$now = Carbon::now();
if ($now->day >= $startDay) {
$startDate = Carbon::create($now->year, $now->month, $startDay)->startOfDay();
$maxInputDate = Carbon::create($now->copy()->addMonth()->year, $now->copy()->addMonth()->month, $inputMaxDay)->endOfDay();
} else {
$startDate = Carbon::create($now->copy()->subMonth()->year, $now->copy()->subMonth()->month, $startDay)->startOfDay();
$maxInputDate = Carbon::create($now->year, $now->month, $inputMaxDay)->endOfDay();
}
// Validasi apakah tanggal pengajuan masuk ke periode input saat ini
if ($tanggal->lt($startDate) || $tanggal->gt($maxInputDate)) {
session()->flash('error', "Gagal, tanggal expense tidak berada dalam batas waktu input yang diizinkan: {$startDate->format('d M Y')} - {$maxInputDate->format('d M Y')}");
return redirect()->back()->withInput();
}
// Hitung periode budget dari tanggal input
if ($tanggal->day >= $startDay) { if ($tanggal->day >= $startDay) {
$periodeDate = Carbon::create($tanggal->year, $tanggal->month, $startDay); $periodeDate = Carbon::create($tanggal->year, $tanggal->month, $startDay);
} else { } else {
@@ -368,48 +476,124 @@ class FormOtherController extends Controller
// Ambil available budget // Ambil available budget
$availableBudget = BudgetHelper::getAvailableBudget($cabang->id, $periodeMonth, $periodeYear); $availableBudget = BudgetHelper::getAvailableBudget($cabang->id, $periodeMonth, $periodeYear);
// Validasi input $validator = Validator::make($request->all(), [
$request->validate([
'kategori_id' => 'required', 'kategori_id' => 'required',
'tanggal' => 'required|date', 'tanggal' => 'required|date',
'keterangan' => 'required', 'keterangan' => 'required',
'total' => 'required|numeric|min:1|max:' . $availableBudget, 'total' => 'required|numeric|min:1|max:' . $availableBudget,
'bukti_total' => ['nullable', 'file', 'max:51200'], 'attachments' => 'nullable|array',
'attachments.*.file_category' => 'required|string|in:' . implode(',', $this->attachmentCategories),
'attachments.*.file_path' => [
'nullable',
'file',
'max:10240',
'mimetypes:image/jpeg,image/png,image/webp,application/pdf,application/msword,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/plain,text/csv,application/zip,application/x-zip-compressed,application/x-7z-compressed,application/vnd.rar,application/octet-stream'
],
]); ]);
$validator->after(function ($validator) use ($request) {
$inputAttachments = $request->input('attachments', []);
$fileAttachments = $request->file('attachments', []);
foreach ($inputAttachments as $index => $input) {
$category = $input['file_category'] ?? null;
$file = $fileAttachments[$index]['file_path'] ?? null;
if ($category && !$file) {
$validator->errors()->add("attachments.{$index}.file_path", 'Attachment file is required.');
continue;
}
if ($file && $this->isBlockedExtension($file->getClientOriginalExtension())) {
$validator->errors()->add("attachments.{$index}.file_path", 'File type is not allowed.');
}
}
});
$validator->validate();
$region = Region::find($cabang->region_id); $region = Region::find($cabang->region_id);
$kategori = Kategori::find($request->kategori_id); $kategori = Kategori::find($request->kategori_id);
$filePaths = [];
$folderPath = '/expense/' . $region->code . '/' . $cabang->code . '/other'; $folderPath = '/expense/' . $region->code . '/' . $cabang->code . '/other';
$attachmentsPayload = $this->buildAttachmentPayload($request, $folderPath);
$primaryAttachmentPath = !empty($attachmentsPayload) ? $attachmentsPayload[0]['file_path'] : null;
if ($request->hasFile('bukti_total')) { DB::beginTransaction();
$bukti_total = $request->file('bukti_total'); try {
$filename = Str::uuid() . '.' . $bukti_total->extension();
$filePaths['bukti_total'] = $folderPath . '/' . $filename;
NextCloudHelper::uploadFile(
$folderPath,
$bukti_total->getContent(),
$filename
);
}
// Update form
$form->update([ $form->update([
'cabang_id' => $cabang->id, // <--- REPARASI SUNTIK CABANG_ID TERBARU
'kategori_id' => $request->kategori_id, 'kategori_id' => $request->kategori_id,
'tanggal' => $request->tanggal, 'tanggal' => $request->tanggal,
'keterangan' => $request->keterangan, 'keterangan' => $request->keterangan,
'total' => $request->total, 'total' => $request->total,
'bukti_total' => $filePaths['bukti_total'] ?? $form->bukti_total, 'bukti_total' => $primaryAttachmentPath ?? $form->bukti_total,
'account_number' => $kategori->account_number, 'account_number' => $kategori->account_number,
]); ]);
if (!empty($attachmentsPayload)) {
$this->attachmentService->addMultipleAttachments(
$form->id,
AttachmentTableName::FORM_OTHERS,
$attachmentsPayload
);
}
DB::commit();
} catch (\Throwable $th) {
DB::rollBack();
Log::error('Failed to update Form Others', [
'form_id' => $form->id,
'message' => $th->getMessage(),
'trace' => $th->getTraceAsString(),
]);
session()->flash('error', 'Terjadi kesalahan saat memperbarui data, silakan coba lagi.');
return redirect()->back()->withInput();
}
AuditTrailHelper::AddAuditTrail('Update', 'Update Record at Form Other (' . $form->expense_number . ')'); AuditTrailHelper::AddAuditTrail('Update', 'Update Record at Form Other (' . $form->expense_number . ')');
session()->flash('success', 'Form has been updated.'); session()->flash('success', 'Form has been updated.');
return redirect()->back(); return redirect()->back();
} }
public function deleteAttachment(Request $request, $formId, $attachmentId)
{
$this->checkAuthorization(auth()->user(), ['forms.other.edit']);
$form = FormOthers::findOrFail($formId);
$role = auth()->user()->getRoleNames()[0];
if (($form->status !== 'On Progress' && $form->status !== 'Rejected') && $role === 'Medical Representatif') {
return response()->json(['message' => 'You cannot modify attachments once the form is approved or closed.'], 403);
}
$attachment = AttachmentForm::where('id', $attachmentId)
->where('form_id', $form->id)
->where('table_name', AttachmentTableName::FORM_OTHERS)
->first();
if (!$attachment) {
return response()->json(['message' => 'Attachment not found.'], 404);
}
$this->attachmentService->deleteAttachment($attachment->id);
if ($form->bukti_total === $attachment->file_path) {
$replacement = AttachmentForm::where('form_id', $form->id)
->where('table_name', AttachmentTableName::FORM_OTHERS)
->whereNull('deleted_at')
->orderBy('id')
->first();
$form->update(['bukti_total' => $replacement->file_path ?? null]);
}
AuditTrailHelper::AddAuditTrail('Delete Attachment', 'Delete Attachment at Form Other (' . $form->expense_number . ')');
return response()->json(['message' => 'Attachment deleted successfully.']);
}
public function approve($id) public function approve($id)
{ {
$this->checkAuthorization(auth()->user(), ['approval.approve']); $this->checkAuthorization(auth()->user(), ['approval.approve']);
@@ -592,7 +776,8 @@ class FormOtherController extends Controller
return redirect()->back(); return redirect()->back();
} }
public function finalApprove($id) // <--- REPARASI SUNTIKAN OBJECT REQUEST HASIL CHECKLIST JAVASCRIPT MODAL DILUAR
public function finalApprove(Request $request, $id)
{ {
$this->checkAuthorization(auth()->user(), ['final_approval.approve']); $this->checkAuthorization(auth()->user(), ['final_approval.approve']);
$form = FormOthers::findOrFail($id); $form = FormOthers::findOrFail($id);
@@ -621,14 +806,18 @@ class FormOtherController extends Controller
$periodeMonth = strtolower($periodeDate->format('F')); $periodeMonth = strtolower($periodeDate->format('F'));
$periodeYear = (int) $periodeDate->format('Y'); $periodeYear = (int) $periodeDate->format('Y');
// Ambil sisa budget // <--- REPARASI: Penangkapan nilai payload dari checkbox JavaScript
$approvedTotal = $request->input('approved_total', $form->total);
// Ambil sisa budget dan sinkronkan validasinya dengan nominal dinamis terbaru
$availableBudget = BudgetHelper::getAvailableBudget($cabang_id, $periodeMonth, $periodeYear); $availableBudget = BudgetHelper::getAvailableBudget($cabang_id, $periodeMonth, $periodeYear);
if($form->approved_total > $availableBudget) { if($approvedTotal > $availableBudget) {
session()->flash('error', 'Budget cabang untuk periode ini tidak mencukupi, silahkan ajukan pada periode expense berikutnya'); session()->flash('error', 'Budget cabang untuk periode ini tidak mencukupi, silahkan ajukan pada periode expense berikutnya');
return redirect()->back(); return redirect()->back();
} }
$form->update([ $form->update([
'approved_total' => $approvedTotal, // <--- SUNTIK NOMINAL BARU HASIL CHECKLIST DARI JAVASCRIPT KE DB
'final_approved_at' => now(), 'final_approved_at' => now(),
'final_approved_by' => auth()->user()->id, 'final_approved_by' => auth()->user()->id,
'status' => 'Closed', 'status' => 'Closed',
@@ -637,7 +826,7 @@ class FormOtherController extends Controller
$name = $form->user->name; $name = $form->user->name;
$expense_number = $form->expense_number; $expense_number = $form->expense_number;
$tanggal = $form->created_at; $tanggal = $form->created_at;
$total = $form->approved_total; $total = $form->approved_total; // <--- SEKARANG MENGGUNAKAN DATA UPDATE TERBARU
$roles = ['Head of Sales Marketing', 'Marketing Operational Manager Region']; $roles = ['Head of Sales Marketing', 'Marketing Operational Manager Region'];
$recipients = [$form->user->email, auth()->user()->email]; $recipients = [$form->user->email, auth()->user()->email];
@@ -710,25 +899,29 @@ class FormOtherController extends Controller
public function reject(Request $request, $id) public function reject(Request $request, $id)
{ {
$this->checkAuthorization(auth()->user(), ['approval.reject']); if (!auth()->user()->hasAnyPermission(['approval.reject', 'approval2.reject', 'final_approval.approve'])) {
abort(403, 'Akses Ditolak: Anda tidak memiliki izin untuk melakukan Reject.');
}
$request->validate([ $request->validate([
'remarks' => 'required|string|max:500', 'remarks' => 'required|string|max:500',
]); ]);
$form = FormOthers::findOrfail($id); $form = FormOthers::findOrfail($id);
$form->update([ $form->update([
'status' => 'Rejected', 'status' => 'Rejected',
'remarks' => $request->remarks, 'remarks' => $request->remarks,
'rejected_by' => auth()->id(), // Kolom baru
'rejected_at' => now(), // Kolom baru
]); ]);
$heads = $form->user->getCabangAndRegionHead(); $heads = $form->user->getCabangAndRegionHead();
$name = $form->user->name; $name = $form->user->name;
$expense_number = $form->expense_number; $expense_number = $form->expense_number;
$tanggal = $form->created_at; $tanggal = $form->tanggal;
$total = $form->total; $total = $form->total;
// Collect all recipients
$recipients = [$form->user->email, auth()->user()->email]; $recipients = [$form->user->email, auth()->user()->email];
$phoneNumbers = [$form->user->phone, auth()->user()->phone]; $phoneNumbers = [$form->user->phone, auth()->user()->phone];
@@ -742,38 +935,24 @@ class FormOtherController extends Controller
$phoneNumbers[] = $heads['region_head']['phone']; $phoneNumbers[] = $heads['region_head']['phone'];
} }
// Remove duplicate email addresses, if any
$recipients = array_unique($recipients); $recipients = array_unique($recipients);
$phoneNumbers = array_unique($phoneNumbers); $phoneNumbers = array_unique($phoneNumbers);
// send whatsapp message $url = route('forms.others.view', $form->id);
$url = route('forms.other.view', $form->id); WhatsappHelper::rejectExpense($phoneNumbers, $expense_number, $url, $tanggal, $total, $name, $request->remarks);
WhatsappHelper::rejectExpense($phoneNumbers, $expense_number, $url, $tanggal, $total, $name);
// Send bulk email
$brevoService = app(BrevoService::class); $brevoService = app(BrevoService::class);
foreach ($recipients as $recipient) { foreach ($recipients as $recipient) {
try { try {
$mail = new ExpenseRejected( $mail = new ExpenseRejected($name, $expense_number, $tanggal, $total, $url, $request->remarks);
$name, $brevoService->expenseRejected($recipient, $name, $mail);
$expense_number,
$tanggal,
$total,
$url
);
$response = $brevoService->expenseRejected($recipient, $name, $mail);
if (isset($response['success']) && $response['success'] === false) {
Log::warning("Email to {$recipient} failed: " . ($response['message'] ?? 'Unknown error'));
}
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error("Failed to send email to {$recipient}: " . $e->getMessage()); Log::error("Failed to send email to {$recipient}: " . $e->getMessage());
continue; continue;
} }
} }
AuditTrailHelper::AddAuditTrail('Reject', 'Reject Expense at Form Other (' . $form->expense_number . ')'); AuditTrailHelper::AddAuditTrail('Reject', 'Reject Expense at Form Others (' . $form->expense_number . ') oleh ' . auth()->user()->name);
session()->flash('success', 'Form has been rejected.'); session()->flash('success', 'Form has been rejected.');
return redirect()->back(); return redirect()->back();
} }
@@ -812,4 +991,106 @@ class FormOtherController extends Controller
session()->flash('success', 'Form has been deleted.'); session()->flash('success', 'Form has been deleted.');
return redirect()->back(); return redirect()->back();
} }
protected function formatAttachmentCollection(FormOthers $form): array
{
$attachments = $form->attachments->map(function ($attachment) {
return $this->formatAttachmentData(
$attachment->id,
$attachment->file_category,
$attachment->file_path
);
});
if ($attachments->isEmpty() && $form->bukti_total) {
$attachments->push($this->formatAttachmentData(null, 'bukti_total', $form->bukti_total));
}
return $attachments->values()->all();
}
protected function formatAttachmentData(?int $id, ?string $category, string $filePath): array
{
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
$previewType = $this->determinePreviewType($extension);
return [
'id' => $id,
'file_category' => $category,
'file_path' => $filePath,
'download_url' => NextCloudHelper::getFileUrl($filePath),
'preview_url' => in_array($previewType, ['image', 'pdf'], true) ? NextCloudHelper::getPreviewUrl($filePath) : null,
'preview_type' => $previewType,
'filename' => basename($filePath),
'extension' => $extension,
'can_delete' => !is_null($id),
];
}
protected function determinePreviewType(?string $extension): string
{
$extension = strtolower((string) $extension);
$imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'];
if (in_array($extension, $imageExtensions, true)) {
return 'image';
}
if ($extension === 'pdf') {
return 'pdf';
}
return 'other';
}
protected function isBlockedExtension(?string $extension): bool
{
return in_array(strtolower((string) $extension), $this->blockedExtensions, true);
}
protected function buildAttachmentPayload(Request $request, string $folderPath): array
{
$payload = [];
$inputs = $request->input('attachments', []);
$files = $request->file('attachments', []);
foreach ($inputs as $index => $input) {
$category = $input['file_category'] ?? null;
$file = $files[$index]['file_path'] ?? null;
if (!$category || !$file) {
continue;
}
$extension = strtolower($file->getClientOriginalExtension());
if ($this->isBlockedExtension($extension)) {
throw ValidationException::withMessages([
"attachments.{$index}.file_path" => 'File type is not allowed.',
]);
}
$filename = Str::uuid() . '.' . $extension;
$filePath = $folderPath . '/' . $filename;
$uploadResponse = NextCloudHelper::uploadFile(
$folderPath,
$file->getContent(),
$filename
);
if (is_array($uploadResponse) && isset($uploadResponse['success']) && $uploadResponse['success'] === false) {
throw ValidationException::withMessages([
"attachments.{$index}.file_path" => $uploadResponse['message'] ?? 'Failed to upload attachment.',
]);
}
$payload[] = [
'file_category' => $category,
'file_path' => $filePath,
];
}
return $payload;
}
} }
@@ -24,23 +24,49 @@ use App\Helpers\AuditTrailHelper;
use App\Helpers\NextCloudHelper; use App\Helpers\NextCloudHelper;
use App\Rules\FileType; use App\Rules\FileType;
use App\Models\Admin; use App\Models\Admin;
use App\Models\AttachmentForm;
use App\Helpers\BudgetHelper; use App\Helpers\BudgetHelper;
use App\Helpers\NotificationHelper; use App\Helpers\NotificationHelper;
use App\Helpers\OutstandingHelper; use App\Helpers\OutstandingHelper;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use App\Services\BrevoService; use App\Services\BrevoService;
use App\Services\AttachmentService;
use App\Enums\AttachmentTableName;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Container\Attributes\Auth; use Illuminate\Container\Attributes\Auth;
class FormUpCountryController extends Controller class FormUpCountryController extends Controller
{ {
protected AttachmentService $attachmentService;
/**
* Attachment categories (key => label) for Up Country forms.
*
* @var array<string,string>
*/
protected array $attachmentCategories = [
'bukti_allowance' => 'Bukti Allowance',
'bukti_transport_dalkot' => 'Bukti Transport Dalam Kota',
'bukti_transport_ankot' => 'Bukti Transport Antar Kota',
'bukti_hotel' => 'Bukti Hotel',
'bukti_total' => 'Bukti Total',
'bukti_lainnya' => 'Bukti Lainnya',
];
public function __construct(AttachmentService $attachmentService)
{
$this->attachmentService = $attachmentService;
view()->share('upCountryAttachmentCategories', $this->attachmentCategories);
view()->share('upCountryAttachmentCategoryKeys', array_keys($this->attachmentCategories));
}
public function index() public function index()
{ {
$this->checkAuthorization(auth()->user(), ['forms.country.view']); $this->checkAuthorization(auth()->user(), ['forms.country.view']);
$role = auth()->user()->getRoleNames()[0]; $role = auth()->user()->getRoleNames()[0];
$startDay = (int) env('STARTING_DATE', 11); $startDay = (int) env('STARTING_DATE', 11);
$closingDay = (int) env('CLOSING_DATE', 10); $closingDay = (int) env('CLOSING_DATE', 13);
// Current time reference // Current time reference
$now = Carbon::now(); $now = Carbon::now();
@@ -134,31 +160,50 @@ class FormUpCountryController extends Controller
{ {
$this->checkAuthorization(auth()->user(), ['forms.country.view']); $this->checkAuthorization(auth()->user(), ['forms.country.view']);
$form = FormUpCountry::with(['rayon', 'user'])->findOrFail($id); $form = FormUpCountry::with(['rayon', 'user', 'attachments'])->findOrFail($id);
$form->bukti_allowance = $form->bukti_allowance ? NextCloudHelper::getFileUrl($form->bukti_allowance) : null; $form->bukti_allowance = $form->bukti_allowance ? NextCloudHelper::getFileUrl($form->bukti_allowance) : null;
$form->bukti_transport_dalkot = $form->bukti_transport_dalkot ? NextCloudHelper::getFileUrl($form->bukti_transport_dalkot) : null; $form->bukti_transport_dalkot = $form->bukti_transport_dalkot ? NextCloudHelper::getFileUrl($form->bukti_transport_dalkot) : null;
$form->bukti_transport_ankot = $form->bukti_transport_ankot ? NextCloudHelper::getFileUrl($form->bukti_transport_ankot) : null; $form->bukti_transport_ankot = $form->bukti_transport_ankot ? NextCloudHelper::getFileUrl($form->bukti_transport_ankot) : null;
$form->bukti_hotel = $form->bukti_hotel ? NextCloudHelper::getFileUrl($form->bukti_hotel) : null; $form->bukti_hotel = $form->bukti_hotel ? NextCloudHelper::getFileUrl($form->bukti_hotel) : null;
$form->uc_plan = $form->uc_plan ? NextCloudHelper::getFileUrl($form->uc_plan) : null; $form->uc_plan = $form->uc_plan ? NextCloudHelper::getFileUrl($form->uc_plan) : null;
$attachments = $form->attachments->map(function ($attachment) {
$filePath = $attachment->file_path;
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
return [
'id' => $attachment->id,
'file_category' => $attachment->file_category,
'file_path' => $filePath,
'download_url' => $filePath ? NextCloudHelper::getFileUrl($filePath) : null,
'preview_url' => $filePath ? NextCloudHelper::getPreviewUrl($filePath) : null,
'preview_type' => in_array($extension, ['jpg', 'jpeg', 'png']) ? 'image' : 'pdf',
'filename' => $filePath ? basename($filePath) : null,
'extension' => $extension,
];
})->values();
return view('backend.pages.forms.upcountry.view', [ return view('backend.pages.forms.upcountry.view', [
'form' => $form, 'form' => $form,
'rayons' => Rayon::all(), 'rayons' => Rayon::all(),
'attachments' => $attachments,
]); ]);
} }
public function create() public function create()
{ {
$this->checkAuthorization(auth()->user(), ['forms.country.create']); $this->checkAuthorization(auth()->user(), ['forms.country.create']);
$cabang = UserHasCabang::with('cabang')->where('user_id', auth()->user()->id)->first()->cabang; $rayons = $this->getAccessibleRayonsForCurrentUser();
return view('backend.pages.forms.upcountry.create', [ return view('backend.pages.forms.upcountry.create', [
'rayons' => Rayon::where('cabang_id', $cabang->id)->get() 'rayons' => $rayons,
'attachmentCategories' => array_keys($this->attachmentCategories),
'attachmentCategoryLabels' => $this->attachmentCategories,
]); ]);
} }
public function store(Request $request) public function store(Request $request)
{ {
$this->checkAuthorization(auth()->user(), ['forms.country.create']); $this->checkAuthorization(auth()->user(), ['forms.country.create']);
$request->validate([ $request->validate([
'rayon_id' => 'required|exists:rayon,id', 'rayon_id' => 'required|exists:rayon,id',
@@ -169,31 +214,31 @@ class FormUpCountryController extends Controller
'transport_dalkot' => 'nullable|numeric', 'transport_dalkot' => 'nullable|numeric',
'transport_ankot' => 'nullable|numeric', 'transport_ankot' => 'nullable|numeric',
'hotel' => 'nullable|numeric', 'hotel' => 'nullable|numeric',
'bukti_allowance' => ['nullable', 'file', 'max:51200'], 'attachments' => 'nullable|array',
'bukti_transport_dalkot' => ['nullable', 'file', 'max:51200'], 'attachments.*.file_category' => 'required|string|in:' . implode(',', array_keys($this->attachmentCategories)),
'bukti_transport_ankot' => ['nullable', 'file', 'max:51200'], 'attachments.*.file_path' => ['nullable', 'file', 'mimes:jpg,jpeg,png,pdf', 'max:5120'],
'bukti_hotel' => ['nullable', 'file', 'max:51200'],
'uc_plan' => 'nullable|file|max:51200', 'uc_plan' => 'nullable|file|max:51200',
]); ]);
$tanggal = Carbon::parse($request->tanggal); $tanggal = Carbon::parse($request->tanggal);
// SINKRONISASI JENDELA INPUT ATURAN BISNIS: 11 s/d 10 Bulan Berikutnya
$startDay = (int) env('STARTING_DATE', 11); $startDay = (int) env('STARTING_DATE', 11);
$closingDay = (int) env('CLOSING_DATE', 10); $inputMaxDay = (int) env('INPUT_MAX_DATE', 10);
$now = Carbon::now(); $now = Carbon::now();
// Tentukan periode aktif saat ini
if ($now->day >= $startDay) { if ($now->day >= $startDay) {
$startDate = Carbon::create($now->year, $now->month, $startDay); $startDate = Carbon::create($now->year, $now->month, $startDay)->startOfDay();
$closingDate = Carbon::create($now->copy()->addMonth()->year, $now->copy()->addMonth()->month, $closingDay); $maxInputDate = Carbon::create($now->copy()->addMonth()->year, $now->copy()->addMonth()->month, $inputMaxDay)->endOfDay();
} else { } else {
$startDate = Carbon::create($now->copy()->subMonth()->year, $now->copy()->subMonth()->month, $startDay); $startDate = Carbon::create($now->copy()->subMonth()->year, $now->copy()->subMonth()->month, $startDay)->startOfDay();
$closingDate = Carbon::create($now->year, $now->month, $closingDay); $maxInputDate = Carbon::create($now->year, $now->month, $inputMaxDay)->endOfDay();
} }
// Validasi apakah tanggal input berada di periode aktif // Blokade Sistem: Tolak input jika berada di luar siklus pengisian aktif
if ($tanggal->lt($startDate) || $tanggal->gt($closingDate)) { if ($now->lt($startDate) || $now->gt($maxInputDate)) {
session()->flash('error', "Gagal, tanggal tidak berada dalam periode input: {$startDate->format('d M')} - {$closingDate->format('d M')}"); session()->flash('error', "Gagal, pengajuan saat ini sudah ditutup. Periode pengisian aktif hanya diizinkan dari tanggal {$startDay} sampai tanggal {$inputMaxDay} berikutnya.");
return redirect()->back(); return redirect()->back()->withInput();
} }
$cabang = UserHasCabang::with('cabang')->where('user_id', auth()->user()->id)->first()?->cabang; $cabang = UserHasCabang::with('cabang')->where('user_id', auth()->user()->id)->first()?->cabang;
@@ -203,7 +248,9 @@ class FormUpCountryController extends Controller
} }
$region = Region::find($cabang->region_id); $region = Region::find($cabang->region_id);
$kategori = Kategori::where('name', 'Up Country')->first();
// Optimasi pencarian kategori menggunakan LIKE fleksibel untuk menjamin ID ditemukan
$kategori = Kategori::where('name', 'LIKE', '%Up Country%')->first();
// Hitung periode budget dari tanggal input // Hitung periode budget dari tanggal input
if ($tanggal->day >= $startDay) { if ($tanggal->day >= $startDay) {
@@ -215,54 +262,6 @@ class FormUpCountryController extends Controller
$periodeMonth = strtolower($periodeDate->format('F')); $periodeMonth = strtolower($periodeDate->format('F'));
$periodeYear = (int) $periodeDate->format('Y'); $periodeYear = (int) $periodeDate->format('Y');
// Upload file
$filePaths = [];
$folderPath = '/expense/' . $region->code . '/' . $cabang->code . '/upcountry';
if ($request->hasFile('bukti_allowance')) {
$bukti = $request->file('bukti_allowance');
$filename = Str::uuid() . '.' . $bukti->extension();
$filePaths['bukti_allowance'] = $folderPath . '/' . $filename;
NextCloudHelper::uploadFile($folderPath, $bukti->getContent(), $filename);
}
if ($request->hasFile('bukti_transport_dalkot')) {
$bukti = $request->file('bukti_transport_dalkot');
$filename = Str::uuid() . '.' . $bukti->extension();
$filePaths['bukti_transport_dalkot'] = $folderPath . '/' . $filename;
NextCloudHelper::uploadFile($folderPath, $bukti->getContent(), $filename);
}
if ($request->hasFile('bukti_transport_ankot')) {
$bukti = $request->file('bukti_transport_ankot');
$filename = Str::uuid() . '.' . $bukti->extension();
$filePaths['bukti_transport_ankot'] = $folderPath . '/' . $filename;
NextCloudHelper::uploadFile($folderPath, $bukti->getContent(), $filename);
}
if ($request->hasFile('bukti_hotel')) {
$bukti = $request->file('bukti_hotel');
$filename = Str::uuid() . '.' . $bukti->extension();
$filePaths['bukti_hotel'] = $folderPath . '/' . $filename;
NextCloudHelper::uploadFile($folderPath, $bukti->getContent(), $filename);
}
if ($request->hasFile('uc_plan')) {
$ucPlan = $request->file('uc_plan');
$filename = Str::uuid() . '.' . $ucPlan->extension();
$filePaths['uc_plan'] = $folderPath . '/' . $filename;
NextCloudHelper::uploadFile($folderPath, $ucPlan->getContent(), $filename);
}
// Generate expense number
$sequence_number = FormUpCountry::withTrashed()
->where('expense_number', 'like', $region->code . '-' . $cabang->code . '-UPC-' . date('ym') . '%')
->count() + 1;
$formatted_sequence_number = str_pad($sequence_number, 4, '0', STR_PAD_LEFT);
$expense_number = $region->code . '-' . $cabang->code . '-UPC-' . date('ym') . $formatted_sequence_number;
// Hitung total nominal
$data_nominal = [ $data_nominal = [
'allowance' => $request->allowance ?? 0, 'allowance' => $request->allowance ?? 0,
'transport_dalkot' => $request->transport_dalkot ?? 0, 'transport_dalkot' => $request->transport_dalkot ?? 0,
@@ -274,15 +273,39 @@ class FormUpCountryController extends Controller
// Validasi budget berdasarkan periode // Validasi budget berdasarkan periode
$availableBudget = BudgetHelper::getAvailableBudget($cabang->id, $periodeMonth, $periodeYear); $availableBudget = BudgetHelper::getAvailableBudget($cabang->id, $periodeMonth, $periodeYear);
if(array_sum($data_nominal) > $availableBudget) { if($totalNominal > $availableBudget) {
session()->flash('error', 'Alokasi budget bulanan cabang tidak mencukupi,silahkan ajukan pada periode expense berikutnya'); session()->flash('error', 'Alokasi budget bulanan cabang tidak mencukupi,silahkan ajukan pada periode expense berikutnya');
return redirect()->back(); return redirect()->back();
} }
// Save the form data $folderPath = '/expense/' . $region->code . '/' . $cabang->code . '/upcountry';
$ucPlanPath = null;
if ($request->hasFile('uc_plan')) {
$ucPlan = $request->file('uc_plan');
$filename = Str::uuid() . '.' . $ucPlan->extension();
$ucPlanPath = $folderPath . '/' . $filename;
NextCloudHelper::uploadFile($folderPath, $ucPlan->getContent(), $filename);
}
$attachmentsPayload = $this->buildAttachmentPayload($request, $folderPath);
// Generate expense number
DB::beginTransaction();
try {
$sequence_number = FormUpCountry::withTrashed()
->where('expense_number', 'like', $region->code . '-' . $cabang->code . '-UPC-' . date('ym') . '%')
->count() + 1;
$formatted_sequence_number = str_pad($sequence_number, 4, '0', STR_PAD_LEFT);
$expense_number = $region->code . '-' . $cabang->code . '-UPC-' . date('ym') . $formatted_sequence_number;
$form = FormUpCountry::create([ $form = FormUpCountry::create([
'expense_number' => $expense_number, 'expense_number' => $expense_number,
'user_id' => auth()->user()->id, 'user_id' => auth()->user()->id,
'cabang_id' => $cabang->id, // <--- REPARASI: Memasukkan Cabang ID milik user pengaju secara instan
'kategori_id' => $kategori->id ?? null, // SUNTIK KATEGORI_ID OTOMATIS BERHASIL
'rayon_id' => $request->rayon_id, 'rayon_id' => $request->rayon_id,
'tanggal' => $request->tanggal, 'tanggal' => $request->tanggal,
'tujuan' => $request->tujuan, 'tujuan' => $request->tujuan,
@@ -291,16 +314,36 @@ class FormUpCountryController extends Controller
'transport_dalkot' => $data_nominal['transport_dalkot'], 'transport_dalkot' => $data_nominal['transport_dalkot'],
'transport_ankot' => $data_nominal['transport_ankot'], 'transport_ankot' => $data_nominal['transport_ankot'],
'hotel' => $data_nominal['hotel'], 'hotel' => $data_nominal['hotel'],
'total' => array_sum($data_nominal), 'total' => $totalNominal,
'bukti_allowance' => $filePaths['bukti_allowance'] ?? null, 'bukti_allowance' => null,
'bukti_transport_dalkot' => $filePaths['bukti_transport_dalkot'] ?? null, 'bukti_transport_dalkot' => null,
'bukti_transport_ankot' => $filePaths['bukti_transport_ankot'] ?? null, 'bukti_transport_ankot' => null,
'bukti_hotel' => $filePaths['bukti_hotel'] ?? null, 'bukti_hotel' => null,
'uc_plan' => $filePaths['uc_plan'] ?? null, 'uc_plan' => $ucPlanPath,
'account_number' => $kategori->account_number, 'account_number' => $kategori->account_number ?? null,
'status' => 'On Progress', 'status' => 'On Progress',
]); ]);
if (!empty($attachmentsPayload)) {
$this->attachmentService->addMultipleAttachments(
$form->id,
$dynamicTable = AttachmentTableName::FORM_UP_COUNTRY,
$attachmentsPayload
);
}
DB::commit();
} catch (\Throwable $th) {
DB::rollBack();
Log::error('Failed to store Form Up Country', [
'message' => $th->getMessage(),
'trace' => $th->getTraceAsString(),
]);
session()->flash('error', 'Terjadi kesalahan saat menyimpan data, silakan coba lagi.');
return redirect()->back()->withInput();
}
// Send notification to MIS and Admin Region and Area Manager Cabang // Send notification to MIS and Admin Region and Area Manager Cabang
$roles = ['Marketing Information System', 'Admin Region', 'Area Manager Cabang']; $roles = ['Marketing Information System', 'Admin Region', 'Area Manager Cabang'];
$recipients = [$form->user->email, auth()->user()->email]; $recipients = [$form->user->email, auth()->user()->email];
@@ -321,11 +364,11 @@ class FormUpCountryController extends Controller
foreach ($roles as $role) { foreach ($roles as $role) {
$users = Admin::getUserByRoleName($role)->filter(function ($user) use ($role, $cabang_id, $region_id) { $users = Admin::getUserByRoleName($role)->filter(function ($user) use ($role, $cabang_id, $region_id) {
if ($role === 'Marketing Information System') { if ($role === 'Marketing Information System') {
return true; // Tidak ada filter cabang untuk peran ini return true;
} }
if (!$user->cabang || !$user->cabang->cabang) { if (!$user->cabang || !$user->cabang->cabang) {
return false; // Pastikan user memiliki cabang return false;
} }
if ($role === 'Admin Region' || $role === 'Marketing Operational Manager Region') { if ($role === 'Admin Region' || $role === 'Marketing Operational Manager Region') {
@@ -379,47 +422,84 @@ class FormUpCountryController extends Controller
AuditTrailHelper::AddAuditTrail('Insert', 'Insert New Expense at Form Up Country (' . $expense_number . ')'); AuditTrailHelper::AddAuditTrail('Insert', 'Insert New Expense at Form Up Country (' . $expense_number . ')');
session()->flash('success', 'Form has been created.'); session()->flash('success', 'Form has been created.');
return redirect()->back(); return redirect()->back();
} }
public function edit($id) public function edit($id)
{ {
$this->checkAuthorization(auth()->user(), ['forms.country.edit']); $this->checkAuthorization(auth()->user(), ['forms.country.edit']);
$role = auth()->user()->getRoleNames()[0]; $role = auth()->user()->getRoleNames()[0];
$cabang = UserHasCabang::with('cabang')->where('user_id', auth()->user()->id)->first()->cabang; $rayonData = $this->getAccessibleRayonsForCurrentUser();
$regionId = Cabang::where('id', 1)->value('region_id');
$rayonData = Rayon::where('cabang_id', $cabang->id)->get();
if ($role == "Admin Region") { $form = FormUpCountry::with(['rayon.cabang', 'attachments'])->findOrfail($id);
$rayonData = DB::table('rayon')
->join('cabang', 'rayon.cabang_id', '=', 'cabang.id')
->join('region', 'cabang.region_id', '=', 'region.id')
->where('region.id', $regionId)
->select('rayon.*')
->get();
} elseif ($role == "Marketing Information System" || $role == "Superadmin") {
$rayonData = DB::table('rayon')
->join('cabang', 'rayon.cabang_id', '=', 'cabang.id')
->join('region', 'cabang.region_id', '=', 'region.id')
->whereNotNull('region.id')
->select('rayon.*')
->get();
}
$form = FormUpCountry::with('rayon')->findOrfail($id);
if (($form->status != 'On Progress' && $form->status != 'Rejected') && $role == 'Medical Representatif') { if (($form->status != 'On Progress' && $form->status != 'Rejected') && $role == 'Medical Representatif') {
session()->flash('error', 'You cannot edit this form because it has been approved or closed.'); session()->flash('error', 'You cannot edit this form because it has been approved or closed.');
return redirect()->back(); return redirect()->back();
} }
if ($form->rayon && !$rayonData->contains('id', $form->rayon_id)) {
$rayonData->push($form->rayon);
$rayonData = $rayonData->sortBy('name')->values();
}
$existingAttachments = $form->attachments->map(function ($attachment) {
$filePath = $attachment->file_path;
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
return [
'id' => $attachment->id,
'file_category' => $attachment->file_category,
'file_path' => $filePath,
'download_url' => $filePath ? NextCloudHelper::getFileUrl($filePath) : null,
'preview_url' => $filePath ? NextCloudHelper::getPreviewUrl($filePath) : null,
'preview_type' => in_array($extension, ['jpg', 'jpeg', 'png']) ? 'image' : 'pdf',
'filename' => $filePath ? basename($filePath) : null,
'extension' => $extension,
];
})->values();
return view('backend.pages.forms.upcountry.edit', [ return view('backend.pages.forms.upcountry.edit', [
'rayons' => $rayonData, 'rayons' => $rayonData,
'form' => $form 'form' => $form,
'attachments' => $existingAttachments,
'attachmentCategories' => array_keys($this->attachmentCategories),
'attachmentCategoryLabels' => $this->attachmentCategories,
]); ]);
} }
public function update(Request $request, $id) protected function getAccessibleRayonsForCurrentUser()
{ {
$user = auth()->user();
$role = $user->getRoleNames()->first();
$rayonQuery = Rayon::with('cabang')->orderBy('name');
if (in_array($role, ['Superadmin', 'Marketing Information System'], true)) {
return $rayonQuery->get();
}
$userRegionData = $user->getMyCabangAndRegionId();
$regionContextId = $userRegionData['region'] !== '-' ? $userRegionData['region'] : null;
$cabangContextId = $userRegionData['cabang'] !== '-' ? $userRegionData['cabang'] : null;
if ($role === 'Admin Region' && $regionContextId) {
return $rayonQuery
->whereHas('cabang', function ($query) use ($regionContextId) {
$query->where('region_id', $regionContextId);
})
->get();
}
if ($cabangContextId) {
return $rayonQuery
->where('cabang_id', $cabangContextId)
->get();
}
return collect();
}
public function update(Request $request, $id)
{
$this->checkAuthorization(auth()->user(), ['forms.country.edit']); $this->checkAuthorization(auth()->user(), ['forms.country.edit']);
$role = auth()->user()->getRoleNames()[0]; $role = auth()->user()->getRoleNames()[0];
@@ -432,10 +512,9 @@ class FormUpCountryController extends Controller
'transport_dalkot' => 'nullable|numeric', 'transport_dalkot' => 'nullable|numeric',
'transport_ankot' => 'nullable|numeric', 'transport_ankot' => 'nullable|numeric',
'hotel' => 'nullable|numeric', 'hotel' => 'nullable|numeric',
'bukti_allowance' => ['nullable', 'file', 'max:51200'], 'attachments' => 'nullable|array',
'bukti_transport_dalkot' => ['nullable', 'file', 'max:51200'], 'attachments.*.file_category' => 'required|string|in:' . implode(',', array_keys($this->attachmentCategories)),
'bukti_transport_ankot' => ['nullable', 'file', 'max:51200'], 'attachments.*.file_path' => ['nullable', 'file', 'mimes:jpg,jpeg,png,pdf', 'max:5120'],
'bukti_hotel' => ['nullable', 'file', 'max:51200'],
'uc_plan' => 'nullable|file|max:51200', 'uc_plan' => 'nullable|file|max:51200',
]); ]);
@@ -451,12 +530,31 @@ class FormUpCountryController extends Controller
return redirect()->back(); return redirect()->back();
} }
// PEMBATASAN INPUT MENGGUNAKAN STARTING_DATE & INPUT_MAX_DATE
$startDay = (int) env('STARTING_DATE', 11);
$inputMaxDay = (int) env('INPUT_MAX_DATE', 10);
$now = Carbon::now();
if ($now->day >= $startDay) {
$startDate = Carbon::create($now->year, $now->month, $startDay)->startOfDay();
$maxInputDate = Carbon::create($now->copy()->addMonth()->year, $now->copy()->addMonth()->month, $inputMaxDay)->endOfDay();
} else {
$startDate = Carbon::create($now->copy()->subMonth()->year, $now->copy()->subMonth()->month, $startDay)->startOfDay();
$maxInputDate = Carbon::create($now->year, $now->month, $inputMaxDay)->endOfDay();
}
if ($now->lt($startDate) || $now->gt($maxInputDate)) {
session()->flash('error', "Gagal, pengajuan saat ini sudah ditutup. Periode pengisian aktif hanya diizinkan dari tanggal {$startDay} sampai tanggal {$inputMaxDay} berikutnya.");
return redirect()->back()->withInput();
}
$region = Region::find($cabang->region_id); $region = Region::find($cabang->region_id);
$kategori = Kategori::where('name', 'Up Country')->first();
// Optimasi pencarian kategori menggunakan LIKE fleksibel untuk menjamin ID ditemukan
$kategori = Kategori::where('name', 'LIKE', '%Up Country%')->first();
// Hitung periode dari tanggal input // Hitung periode dari tanggal input
$tanggal = Carbon::parse($request->tanggal); $tanggal = Carbon::parse($request->tanggal);
$startDay = (int) env('STARTING_DATE', 11);
if ($tanggal->day >= $startDay) { if ($tanggal->day >= $startDay) {
$periodeDate = Carbon::create($tanggal->year, $tanggal->month, $startDay); $periodeDate = Carbon::create($tanggal->year, $tanggal->month, $startDay);
} else { } else {
@@ -466,45 +564,19 @@ class FormUpCountryController extends Controller
$periodeMonth = strtolower($periodeDate->format('F')); $periodeMonth = strtolower($periodeDate->format('F'));
$periodeYear = (int) $periodeDate->format('Y'); $periodeYear = (int) $periodeDate->format('Y');
// Upload bukti file
$filePaths = [];
$folderPath = '/expense/' . $region->code . '/' . $cabang->code . '/upcountry'; $folderPath = '/expense/' . $region->code . '/' . $cabang->code . '/upcountry';
if ($request->hasFile('bukti_allowance')) { $ucPlanPath = null;
$file = $request->file('bukti_allowance');
$filename = Str::uuid() . '.' . $file->extension();
$filePaths['bukti_allowance'] = $folderPath . '/' . $filename;
NextCloudHelper::uploadFile($folderPath, $file->getContent(), $filename);
}
if ($request->hasFile('bukti_transport_dalkot')) {
$file = $request->file('bukti_transport_dalkot');
$filename = Str::uuid() . '.' . $file->extension();
$filePaths['bukti_transport_dalkot'] = $folderPath . '/' . $filename;
NextCloudHelper::uploadFile($folderPath, $file->getContent(), $filename);
}
if ($request->hasFile('bukti_transport_ankot')) {
$file = $request->file('bukti_transport_ankot');
$filename = Str::uuid() . '.' . $file->extension();
$filePaths['bukti_transport_ankot'] = $folderPath . '/' . $filename;
NextCloudHelper::uploadFile($folderPath, $file->getContent(), $filename);
}
if ($request->hasFile('bukti_hotel')) {
$file = $request->file('bukti_hotel');
$filename = Str::uuid() . '.' . $file->extension();
$filePaths['bukti_hotel'] = $folderPath . '/' . $filename;
NextCloudHelper::uploadFile($folderPath, $file->getContent(), $filename);
}
if ($request->hasFile('uc_plan')) { if ($request->hasFile('uc_plan')) {
$file = $request->file('uc_plan'); $file = $request->file('uc_plan');
$filename = Str::uuid() . '.' . $file->extension(); $filename = Str::uuid() . '.' . $file->extension();
$filePaths['uc_plan'] = $folderPath . '/' . $filename; $ucPlanPath = $folderPath . '/' . $filename;
NextCloudHelper::uploadFile($folderPath, $file->getContent(), $filename); NextCloudHelper::uploadFile($folderPath, $file->getContent(), $filename);
} }
$attachmentsPayload = $this->buildAttachmentPayload($request, $folderPath);
// Hitung total nominal // Hitung total nominal
$data_nominal = [ $data_nominal = [
'allowance' => $request->allowance ?? 0, 'allowance' => $request->allowance ?? 0,
@@ -516,14 +588,18 @@ class FormUpCountryController extends Controller
// Validasi budget dengan periode // Validasi budget dengan periode
$availableBudget = BudgetHelper::getAvailableBudget($cabang->id, $periodeMonth, $periodeYear); $availableBudget = BudgetHelper::getAvailableBudget($cabang->id, $periodeMonth, $periodeYear);
if(array_sum($data_nominal) > $availableBudget) { if($totalNominal > $availableBudget) {
session()->flash('error', 'The total nominal exceeds the available budget.'); session()->flash('error', 'The total nominal exceeds the available budget.');
return redirect()->back(); return redirect()->back();
} }
// Save the form data // Save the form data
DB::beginTransaction();
try {
$form->update([ $form->update([
'rayon_id' => $request->rayon_id, 'rayon_id' => $request->rayon_id,
'cabang_id' => $cabang->id, // <--- REPARASI: Memastikan Cabang ID tersimpan akurat saat update data
'kategori_id' => $kategori->id ?? null, // RE-SUNTIK KATEGORI_ID SAAT UPDATE
'tanggal' => $request->tanggal, 'tanggal' => $request->tanggal,
'tujuan' => $request->tujuan, 'tujuan' => $request->tujuan,
'jarak' => $request->jarak, 'jarak' => $request->jarak,
@@ -531,18 +607,94 @@ class FormUpCountryController extends Controller
'transport_dalkot' => $data_nominal['transport_dalkot'], 'transport_dalkot' => $data_nominal['transport_dalkot'],
'transport_ankot' => $data_nominal['transport_ankot'], 'transport_ankot' => $data_nominal['transport_ankot'],
'hotel' => $data_nominal['hotel'], 'hotel' => $data_nominal['hotel'],
'total' => array_sum($data_nominal), 'total' => $totalNominal,
'bukti_allowance' => $filePaths['bukti_allowance'] ?? $form->bukti_allowance, 'uc_plan' => $ucPlanPath ?? $form->uc_plan,
'bukti_transport_dalkot' => $filePaths['bukti_transport_dalkot'] ?? $form->bukti_transport_dalkot, 'account_number' => $kategori->account_number ?? $form->account_number,
'bukti_transport_ankot' => $filePaths['bukti_transport_ankot'] ?? $form->bukti_transport_ankot,
'bukti_hotel' => $filePaths['bukti_hotel'] ?? $form->bukti_hotel,
'uc_plan' => $filePaths['uc_plan'] ?? $form->uc_plan,
'account_number' => $kategori->account_number,
]); ]);
if (!empty($attachmentsPayload)) {
$this->attachmentService->addMultipleAttachments(
$form->id,
AttachmentTableName::FORM_UP_COUNTRY,
$attachmentsPayload
);
}
DB::commit();
} catch (\Throwable $th) {
DB::rollBack();
Log::error('Failed to update Form Up Country', [
'message' => $th->getMessage(),
'trace' => $th->getTraceAsString(),
]);
session()->flash('error', 'Terjadi kesalahan saat memperbarui data, silakan coba lagi.');
return redirect()->back()->withInput();
}
AuditTrailHelper::AddAuditTrail('Update', 'Update Record at Form Up Country (' . $form->expense_number . ')'); AuditTrailHelper::AddAuditTrail('Update', 'Update Record at Form Up Country (' . $form->expense_number . ')');
session()->flash('success', 'Form has been updated.'); session()->flash('success', 'Form has been updated.');
return redirect()->back(); return redirect()->back();
}
public function deleteAttachment(Request $request, $formId, $attachmentId)
{
$this->checkAuthorization(auth()->user(), ['forms.country.edit']);
$form = FormUpCountry::findOrFail($formId);
$role = auth()->user()->getRoleNames()[0];
if (($form->status !== 'On Progress' && $form->status !== 'Rejected') && $role === 'Medical Representatif') {
return response()->json(['message' => 'You cannot modify attachments once the form is approved or closed.'], 403);
}
$attachment = AttachmentForm::where('id', $attachmentId)
->where('form_id', $form->id)
->where('table_name', AttachmentTableName::FORM_UP_COUNTRY)
->first();
if (!$attachment) {
return response()->json(['message' => 'Attachment not found.'], 404);
}
$this->attachmentService->deleteAttachment($attachment->id);
AuditTrailHelper::AddAuditTrail('Delete Attachment', 'Delete Attachment at Form Up Country (' . $form->expense_number . ')');
return response()->json(['message' => 'Attachment deleted successfully.']);
}
protected function buildAttachmentPayload(Request $request, string $folderPath): array
{
$payload = [];
$attachmentInputs = $request->input('attachments', []);
$attachmentFiles = $request->file('attachments', []);
foreach ($attachmentFiles as $index => $fileGroup) {
$uploadedFile = $fileGroup['file_path'] ?? null;
if (!$uploadedFile) {
continue;
}
$category = $attachmentInputs[$index]['file_category'] ?? null;
if (!$category) {
continue;
}
$filename = Str::uuid() . '.' . $uploadedFile->extension();
$filePath = $folderPath . '/' . $filename;
NextCloudHelper::uploadFile($folderPath, $uploadedFile->getContent(), $filename);
$payload[] = [
'file_category' => $category,
'file_path' => $filePath,
];
}
return $payload;
} }
public function approve(Request $request, $id) public function approve(Request $request, $id)
@@ -591,11 +743,11 @@ class FormUpCountryController extends Controller
foreach ($roles as $role) { foreach ($roles as $role) {
$users = Admin::getUserByRoleName($role)->filter(function ($user) use ($role, $cabang_id, $region_id) { $users = Admin::getUserByRoleName($role)->filter(function ($user) use ($role, $cabang_id, $region_id) {
if ($role === 'Marketing Information System') { if ($role === 'Marketing Information System') {
return true; // Tidak ada filter cabang untuk peran ini return true;
} }
if (!$user->cabang || !$user->cabang->cabang) { if (!$user->cabang || !$user->cabang->cabang) {
return false; // Pastikan user memiliki cabang return false;
} }
if ($role === 'Admin Region' || $role === 'Marketing Operational Manager Region') { if ($role === 'Admin Region' || $role === 'Marketing Operational Manager Region') {
@@ -682,11 +834,11 @@ class FormUpCountryController extends Controller
foreach ($roles as $role) { foreach ($roles as $role) {
$users = Admin::getUserByRoleName($role)->filter(function ($user) use ($role, $cabang_id, $region_id) { $users = Admin::getUserByRoleName($role)->filter(function ($user) use ($role, $cabang_id, $region_id) {
if ($role === 'Marketing Information System' || $role === 'Head of Sales Marketing') { if ($role === 'Marketing Information System' || $role === 'Head of Sales Marketing') {
return true; // Tidak ada filter cabang untuk peran ini return true;
} }
if (!$user->cabang || !$user->cabang->cabang) { if (!$user->cabang || !$user->cabang->cabang) {
return false; // Pastikan user memiliki cabang return false;
} }
if ($role === 'Admin Region' || $role === 'Marketing Operational Manager Region') { if ($role === 'Admin Region' || $role === 'Marketing Operational Manager Region') {
@@ -741,7 +893,7 @@ class FormUpCountryController extends Controller
return redirect()->back(); return redirect()->back();
} }
public function finalApprove($id) public function finalApprove(Request $request, $id) // <--- MENAMBAHKAN OBJECT REQUEST
{ {
$this->checkAuthorization(auth()->user(), ['final_approval.approve']); $this->checkAuthorization(auth()->user(), ['final_approval.approve']);
$form = FormUpCountry::findOrFail($id); $form = FormUpCountry::findOrFail($id);
@@ -751,33 +903,52 @@ class FormUpCountryController extends Controller
return redirect()->back(); return redirect()->back();
} }
// Ambil cabang_id dari user yang mengisi form // ATURAN BISNIS: Batas Akhir Approval adalah tanggal CLOSING_DATE (Bulan Depan tgl 13)
$closingDay = (int) env('CLOSING_DATE', 13);
$startDay = (int) env('STARTING_DATE', 11);
$now = Carbon::now();
$tanggalForm = Carbon::parse($form->tanggal);
// Hitung batas maksimal closing sesuai siklus transaksi form tersebut
if ($tanggalForm->day >= $startDay) {
$maxClosingDate = Carbon::create($tanggalForm->year, $tanggalForm->month, $closingDay)->addMonth()->endOfDay();
} else {
$maxClosingDate = Carbon::create($tanggalForm->year, $tanggalForm->month, $closingDay)->endOfDay();
}
if ($now->gt($maxClosingDate)) {
session()->flash('error', "Gagal, Batas waktu maksimal approval (Closing Date) untuk pengajuan ini telah berakhir pada {$maxClosingDate->format('d M Y H:i')} WIB.");
return redirect()->back();
}
$cabang_id = UserHasCabang::where('user_id', $form->user_id)->first()?->cabang_id; $cabang_id = UserHasCabang::where('user_id', $form->user_id)->first()?->cabang_id;
if (!$cabang_id) { if (!$cabang_id) {
session()->flash('error', 'Data cabang tidak ditemukan.'); session()->flash('error', 'Data cabang tidak ditemukan.');
return redirect()->back(); return redirect()->back();
} }
// Hitung periode berdasarkan tanggal form if ($tanggalForm->day >= $startDay) {
$tanggal = Carbon::parse($form->tanggal); $periodeDate = Carbon::create($tanggalForm->year, $tanggalForm->month, $startDay);
$startDay = (int) env('STARTING_DATE', 11);
if ($tanggal->day >= $startDay) {
$periodeDate = Carbon::create($tanggal->year, $tanggal->month, $startDay);
} else { } else {
$periodeDate = Carbon::create($tanggal->copy()->subMonth()->year, $tanggal->copy()->subMonth()->month, $startDay); $periodeDate = Carbon::create($tanggalForm->copy()->subMonth()->year, $tanggalForm->copy()->subMonth()->month, $startDay);
} }
$periodeMonth = strtolower($periodeDate->format('F')); $periodeMonth = strtolower($periodeDate->format('F'));
$periodeYear = (int) $periodeDate->format('Y'); $periodeYear = (int) $periodeDate->format('Y');
// MENGAMBIL NILAI AKUMULASI CHECKLIST DARI JAVASCRIPT JS (Fallback ke total awal jika kosong)
$approvedTotal = $request->input('approved_total', $form->total);
// Ambil sisa budget sesuai periode // Ambil sisa budget sesuai periode
$availableBudget = BudgetHelper::getAvailableBudget($cabang_id, $periodeMonth, $periodeYear); $availableBudget = BudgetHelper::getAvailableBudget($cabang_id, $periodeMonth, $periodeYear);
if($form->approved_total > $availableBudget) { if($approvedTotal > $availableBudget) { // <--- DISINKRONKAN DENGAN NILAI BARU HASIL CHECKLIST
session()->flash('error', 'Budget cabang untuk periode ini tidak mencukupi, silahkan ajukan pada periode expense berikutnya'); session()->flash('error', 'Budget cabang untuk periode ini tidak mencukupi, silahkan ajukan pada periode expense berikutnya');
return redirect()->back(); return redirect()->back();
} }
// UPDATE DATABASE SECARA AKURAT BERDASARKAN HASIL PILIHAN CHECKLIST FINANCE
$form->update([ $form->update([
'approved_total' => $approvedTotal, // <--- SUNTIK NOMINAL BARU HASIL CHECKLIST KE DATABASE
'final_approved_at' => now(), 'final_approved_at' => now(),
'final_approved_by' => auth()->user()->id, 'final_approved_by' => auth()->user()->id,
'status' => 'Closed', 'status' => 'Closed',
@@ -786,7 +957,7 @@ class FormUpCountryController extends Controller
$name = $form->user->name; $name = $form->user->name;
$expense_number = $form->expense_number; $expense_number = $form->expense_number;
$tanggal = $form->tanggal; $tanggal = $form->tanggal;
$total = $form->approved_total; $total = $form->approved_total; // <--- OTOMATIS MEMAKAI DATA UPDATE TERBARU
$roles = ['Head of Sales Marketing', 'Marketing Operational Manager Region']; $roles = ['Head of Sales Marketing', 'Marketing Operational Manager Region'];
$recipients = [$form->user->email, auth()->user()->email]; $recipients = [$form->user->email, auth()->user()->email];
@@ -801,11 +972,11 @@ class FormUpCountryController extends Controller
foreach ($roles as $role) { foreach ($roles as $role) {
$users = Admin::getUserByRoleName($role)->filter(function ($user) use ($role, $cabang_id, $region_id) { $users = Admin::getUserByRoleName($role)->filter(function ($user) use ($role, $cabang_id, $region_id) {
if ($role === 'Marketing Information System' || $role === 'Head of Sales Marketing') { if ($role === 'Marketing Information System' || $role === 'Head of Sales Marketing') {
return true; // Tidak ada filter cabang untuk peran ini return true;
} }
if (!$user->cabang || !$user->cabang->cabang) { if (!$user->cabang || !$user->cabang->cabang) {
return false; // Pastikan user memiliki cabang return false;
} }
if ($role === 'Admin Region' || $role === 'Marketing Operational Manager Region') { if ($role === 'Admin Region' || $role === 'Marketing Operational Manager Region') {
@@ -859,16 +1030,21 @@ class FormUpCountryController extends Controller
public function reject(Request $request, $id) public function reject(Request $request, $id)
{ {
$this->checkAuthorization(auth()->user(), ['approval.reject']); if (!auth()->user()->hasAnyPermission(['approval.reject', 'approval2.reject', 'final_approval.approve'])) {
abort(403, 'Akses Ditolak: Anda tidak memiliki izin untuk melakukan Reject.');
}
$request->validate([ $request->validate([
'remarks' => 'required|string|max:500', 'remarks' => 'required|string|max:500',
]); ]);
$form = FormUpCountry::findOrfail($id); $form = FormUpCountry::findOrfail($id);
$form->update([ $form->update([
'status' => 'Rejected', 'status' => 'Rejected',
'remarks' => $request->remarks, 'remarks' => $request->remarks,
'rejected_by' => auth()->id(), // SUNTIK DATA AUDIT TRAIL
'rejected_at' => now(), // SUNTIK DATA AUDIT TRAIL
]); ]);
$heads = $form->user->getCabangAndRegionHead(); $heads = $form->user->getCabangAndRegionHead();
@@ -877,7 +1053,6 @@ class FormUpCountryController extends Controller
$tanggal = $form->tanggal; $tanggal = $form->tanggal;
$total = $form->total; $total = $form->total;
// Collect all recipients
$recipients = [$form->user->email, auth()->user()->email]; $recipients = [$form->user->email, auth()->user()->email];
$phoneNumbers = [$form->user->phone, auth()->user()->phone]; $phoneNumbers = [$form->user->phone, auth()->user()->phone];
@@ -891,38 +1066,24 @@ class FormUpCountryController extends Controller
$phoneNumbers[] = $heads['region_head']['phone']; $phoneNumbers[] = $heads['region_head']['phone'];
} }
// Remove duplicate email addresses, if any
$recipients = array_unique($recipients); $recipients = array_unique($recipients);
$phoneNumbers = array_unique($phoneNumbers); $phoneNumbers = array_unique($phoneNumbers);
// send whatsapp message
$url = route('forms.up-country.view', $form->id); $url = route('forms.up-country.view', $form->id);
WhatsappHelper::rejectExpense($phoneNumbers, $expense_number, $url, $tanggal, $total, $name); WhatsappHelper::rejectExpense($phoneNumbers, $expense_number, $url, $tanggal, $total, $name, $request->remarks);
// Send bulk email
$brevoService = app(BrevoService::class); $brevoService = app(BrevoService::class);
foreach ($recipients as $recipient) { foreach ($recipients as $recipient) {
try { try {
$mail = new ExpenseRejected( $mail = new ExpenseRejected($name, $expense_number, $tanggal, $total, $url, $request->remarks);
$name, $brevoService->expenseRejected($recipient, $name, $mail);
$expense_number,
$tanggal,
$total,
$url
);
$response = $brevoService->expenseRejected($recipient, $name, $mail);
if (isset($response['success']) && $response['success'] === false) {
Log::warning("Email to {$recipient} failed: " . ($response['message'] ?? 'Unknown error'));
}
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error("Failed to send email to {$recipient}: " . $e->getMessage()); Log::error("Failed to send email to {$recipient}: " . $e->getMessage());
continue; continue;
} }
} }
AuditTrailHelper::AddAuditTrail('Reject', 'Reject Expense at Form Up Country (' . $form->expense_number . ')'); AuditTrailHelper::AddAuditTrail('Reject', 'Reject Expense at Form Up Country (' . $form->expense_number . ') oleh ' . auth()->user()->name);
session()->flash('success', 'Form has been rejected.'); session()->flash('success', 'Form has been rejected.');
return redirect()->back(); return redirect()->back();
} }
@@ -30,16 +30,45 @@ use App\Helpers\OutstandingHelper;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use App\Services\BrevoService; use App\Services\BrevoService;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Validation\ValidationException;
use App\Services\AttachmentService;
use App\Enums\AttachmentTableName;
use App\Models\AttachmentForm;
use Illuminate\Support\Facades\Validator;
class FormVehicleController extends Controller class FormVehicleController extends Controller
{ {
protected AttachmentService $attachmentService;
/**
* Standardized attachment categories for Vehicle Running Cost.
*/
protected array $attachmentCategories = [
'bukti_total',
'bukti_invoice',
'bukti_bbm',
'bukti_service',
'bukti_lainnya',
];
/**
* Blocked file extensions (case insensitive) to guard against dangerous uploads.
*/
protected array $blockedExtensions = ['exe', 'bat', 'sh', 'cmd', 'dll', 'msi'];
public function __construct(AttachmentService $attachmentService)
{
$this->attachmentService = $attachmentService;
view()->share('vehicleAttachmentCategories', $this->attachmentCategories);
}
public function index() public function index()
{ {
$this->checkAuthorization(auth()->user(), ['forms.vehicle.view']); $this->checkAuthorization(auth()->user(), ['forms.vehicle.view']);
$role = auth()->user()->getRoleNames()[0]; $role = auth()->user()->getRoleNames()[0];
$startDay = (int) env('STARTING_DATE', 11); $startDay = (int) env('STARTING_DATE', 11);
$closingDay = (int) env('CLOSING_DATE', 10); $closingDay = (int) env('CLOSING_DATE', 13);
// Referensi waktu sekarang // Referensi waktu sekarang
$now = Carbon::now(); $now = Carbon::now();
@@ -120,21 +149,27 @@ class FormVehicleController extends Controller
{ {
$this->checkAuthorization(auth()->user(), ['forms.vehicle.view']); $this->checkAuthorization(auth()->user(), ['forms.vehicle.view']);
$form = FormVehicleRunningCost::with('user')->findOrFail($id); $form = FormVehicleRunningCost::with(['user', 'attachments'])->findOrFail($id);
$attachments = $this->formatAttachmentCollection($form);
$form->bukti_total = $form->bukti_total ? NextCloudHelper::getFileUrl($form->bukti_total) : null; $form->bukti_total = $form->bukti_total ? NextCloudHelper::getFileUrl($form->bukti_total) : null;
return response()->json($form); return response()->json(array_merge($form->toArray(), [
'attachments' => $attachments,
]));
} }
public function view($id) public function view($id)
{ {
$this->checkAuthorization(auth()->user(), ['forms.vehicle.view']); $this->checkAuthorization(auth()->user(), ['forms.vehicle.view']);
$form = FormVehicleRunningCost::with('user')->findOrFail($id); $form = FormVehicleRunningCost::with(['user', 'attachments'])->findOrFail($id);
$attachments = $this->formatAttachmentCollection($form);
$form->bukti_total = $form->bukti_total ? NextCloudHelper::getFileUrl($form->bukti_total) : null; $form->bukti_total = $form->bukti_total ? NextCloudHelper::getFileUrl($form->bukti_total) : null;
return view('backend.pages.forms.vehicle.view', [ return view('backend.pages.forms.vehicle.view', [
'form' => $form, 'form' => $form,
'attachments' => $attachments,
]); ]);
} }
@@ -142,13 +177,15 @@ class FormVehicleController extends Controller
{ {
$this->checkAuthorization(auth()->user(), ['forms.vehicle.create']); $this->checkAuthorization(auth()->user(), ['forms.vehicle.create']);
return view('backend.pages.forms.vehicle.create'); return view('backend.pages.forms.vehicle.create', [
'attachmentCategories' => $this->attachmentCategories,
]);
} }
public function store(Request $request) public function store(Request $request)
{ {
$this->checkAuthorization(auth()->user(), ['forms.vehicle.create']); $this->checkAuthorization(auth()->user(), ['forms.vehicle.create']);
$request->validate([ $validator = Validator::make($request->all(), [
'tanggal' => 'required', 'tanggal' => 'required',
'type' => 'required', 'type' => 'required',
'liter' => 'nullable|numeric|min:0', 'liter' => 'nullable|numeric|min:0',
@@ -157,27 +194,59 @@ class FormVehicleController extends Controller
'tipe_bensin' => 'nullable|in:pertalite,pertamax', 'tipe_bensin' => 'nullable|in:pertalite,pertamax',
'nopol' => 'nullable', 'nopol' => 'nullable',
'keterangan' => 'required', 'keterangan' => 'required',
'bukti_total' => ['nullable', 'file', 'max:51200'], 'attachments' => 'nullable|array',
'attachments.*.file_category' => 'required|string|in:' . implode(',', $this->attachmentCategories),
'attachments.*.file_path' => [
'nullable',
'file',
'max:10240', // 10 MB
'mimetypes:image/jpeg,image/png,image/webp,application/pdf,text/plain,text/csv,application/zip,application/x-zip-compressed,application/x-7z-compressed,application/vnd.rar,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation,application/octet-stream'
],
]); ]);
$tanggal = Carbon::parse($request->tanggal); $validator->after(function ($validator) use ($request) {
$startDay = (int) env('STARTING_DATE', 11); $inputAttachments = $request->input('attachments', []);
$closingDay = (int) env('CLOSING_DATE', 10); $fileAttachments = $request->file('attachments', []);
$now = Carbon::now();
// Tentukan periode input berdasarkan waktu saat ini foreach ($inputAttachments as $index => $input) {
if ($now->day >= $startDay) { $category = $input['file_category'] ?? null;
$startDate = Carbon::create($now->year, $now->month, $startDay); $file = $fileAttachments[$index]['file_path'] ?? null;
$closingDate = Carbon::create($now->copy()->addMonth()->year, $now->copy()->addMonth()->month, $closingDay);
} else { if ($category && !$file) {
$startDate = Carbon::create($now->copy()->subMonth()->year, $now->copy()->subMonth()->month, $startDay); $validator->errors()->add("attachments.{$index}.file_path", 'Attachment file is required.');
$closingDate = Carbon::create($now->year, $now->month, $closingDay); continue;
} }
// Validasi apakah tanggal form berada dalam periode input if ($file && $this->isBlockedExtension($file->getClientOriginalExtension())) {
if ($tanggal->lt($startDate) || $tanggal->gt($closingDate)) { $validator->errors()->add("attachments.{$index}.file_path", 'File type is not allowed.');
session()->flash('error', "Gagal, tanggal tidak berada dalam periode input: {$startDate->format('d M')} - {$closingDate->format('d M')}"); }
return redirect()->back(); }
});
$validator->validate();
$tanggal = Carbon::parse($request->tanggal);
// PEMBATASAN INPUT MENGGUNAKAN STARTING_DATE & INPUT_MAX_DATE
$startDay = (int) env('STARTING_DATE', 11);
$inputMaxDay = (int) env('INPUT_MAX_DATE', 10);
$now = Carbon::now();
// Tentukan periode aktif berdasarkan tanggal saat ini
if ($now->day >= $startDay) {
// Jendela Aktif: Tgl 11 Bulan Ini s/d Tgl 10 Bulan Depan
$startDate = Carbon::create($now->year, $now->month, $startDay)->startOfDay();
$maxInputDate = Carbon::create($now->copy()->addMonth()->year, $now->copy()->addMonth()->month, $inputMaxDay)->endOfDay();
} else {
// Jendela Aktif: Tgl 11 Bulan Lalu s/d Tgl 10 Bulan Ini
$startDate = Carbon::create($now->copy()->subMonth()->year, $now->copy()->subMonth()->month, $startDay)->startOfDay();
$maxInputDate = Carbon::create($now->year, $now->month, $inputMaxDay)->endOfDay();
}
// Validasi apakah tanggal pengajuan masuk ke periode input saat ini
if ($tanggal->lt($startDate) || $tanggal->gt($maxInputDate)) {
session()->flash('error', "Gagal, tanggal expense tidak berada dalam batas waktu input yang diizinkan: {$startDate->format('d M Y')} - {$maxInputDate->format('d M Y')}");
return redirect()->back()->withInput();
} }
// Ambil cabang user // Ambil cabang user
@@ -187,7 +256,7 @@ class FormVehicleController extends Controller
return redirect()->back(); return redirect()->back();
} }
// Tentukan periode budget berdasarkan tanggal input (bukan now) // Tentukan periode budget dari tanggal input
if ($tanggal->day >= $startDay) { if ($tanggal->day >= $startDay) {
$periodeDate = Carbon::create($tanggal->year, $tanggal->month, $startDay); $periodeDate = Carbon::create($tanggal->year, $tanggal->month, $startDay);
} else { } else {
@@ -201,37 +270,29 @@ class FormVehicleController extends Controller
$availableBudget = BudgetHelper::getAvailableBudget($cabang->id, $periodeMonth, $periodeYear); $availableBudget = BudgetHelper::getAvailableBudget($cabang->id, $periodeMonth, $periodeYear);
if($request->total > $availableBudget) { if($request->total > $availableBudget) {
session()->flash('error', 'Alokasi budget bulanan cabang tidak mencukupi,silahkan ajukan pada periode expense berikutnya'); session()->flash('error', 'Alokasi budget bulanan cabang tidak mencukupi,silahkan ajukan pada periode expense berikutnya');
return redirect()->back(); return redirect()->back()->withInput();
} }
$region = Region::where('id', $cabang->region_id)->first(); $region = Region::where('id', $cabang->region_id)->first();
$kategori = Kategori::where('name', 'Vehicle Running Cost')->first(); // REPARASI SUNTIK KATEGORI: Penggunaan LIKE untuk toleransi kecocokan spasi
$kategori = Kategori::where('name', 'LIKE', '%Vehicle Running Cost%')->first();
$filePaths = [];
$folderPath = '/expense/' . $region->code . '/' . $cabang->code . '/vehicle'; $folderPath = '/expense/' . $region->code . '/' . $cabang->code . '/vehicle';
if ($request->hasFile('bukti_total')) { $attachmentsPayload = $this->buildAttachmentPayload($request, $folderPath);
$bukti_total = $request->file('bukti_total');
$filename = Str::uuid() . '.' . $bukti_total->extension();
$filePaths['bukti_total'] = $folderPath . '/' . $filename;
NextCloudHelper::uploadFile(
$folderPath,
$bukti_total->getContent(),
$filename
);
}
// Generate sequence number and expense number // Generate sequence number and expense number
$sequence_number = FormVehicleRunningCost::withTrashed()->where('expense_number', 'like', $region->code . '-' . $cabang->code . '-VHC-' . date('ym') . '%')->count() + 1; $sequence_number = FormVehicleRunningCost::withTrashed()->where('expense_number', 'like', $region->code . '-' . $cabang->code . '-VHC-' . date('ym') . '%')->count() + 1;
$formatted_sequence_number = str_pad($sequence_number, 4, '0', STR_PAD_LEFT); $formatted_sequence_number = str_pad($sequence_number, 4, '0', STR_PAD_LEFT);
$expense_number = $region->code . '-' . $cabang->code . '-VHC-' . date('ym') . $formatted_sequence_number; $expense_number = $region->code . '-' . $cabang->code . '-VHC-' . date('ym') . $formatted_sequence_number;
// Save the form data DB::beginTransaction();
try {
$form = FormVehicleRunningCost::create([ $form = FormVehicleRunningCost::create([
'expense_number' => $expense_number, 'expense_number' => $expense_number,
'user_id' => auth()->user()->id, 'user_id' => auth()->user()->id,
'cabang_id' => $cabang->id, // <--- REPARASI SUNTIK CABANG ID
'kategori_id' => $kategori->id ?? null, // <--- REPARASI SUNTIK KATEGORI ID
'tanggal' => $request->tanggal, 'tanggal' => $request->tanggal,
'type' => $request->type, 'type' => $request->type,
'liter' => $request->liter ?? 0, 'liter' => $request->liter ?? 0,
@@ -240,11 +301,33 @@ class FormVehicleController extends Controller
'tipe_bensin' => $request->tipe_bensin ?? '-', 'tipe_bensin' => $request->tipe_bensin ?? '-',
'nopol' => $request->nopol ?? '-', 'nopol' => $request->nopol ?? '-',
'keterangan' => $request->keterangan, 'keterangan' => $request->keterangan,
'bukti_total' => $filePaths['bukti_total'] ?? null, 'bukti_total' => null,
'account_number' => $kategori->account_number, 'account_number' => $kategori->account_number ?? null,
'status' => 'On Progress', 'status' => 'On Progress',
]); ]);
if (!empty($attachmentsPayload)) {
$this->attachmentService->addMultipleAttachments(
$form->id,
AttachmentTableName::FORM_VEHICLE_RUNNING_COST,
$attachmentsPayload
);
}
DB::commit();
} catch (\Throwable $th) {
DB::rollBack();
Log::error('Failed to store Vehicle Running Cost form', [
'message' => $th->getMessage(),
'trace' => $th->getTraceAsString(),
]);
session()->flash('error', 'Terjadi kesalahan saat menyimpan data, silakan coba lagi.');
return redirect()->back()->withInput();
}
$form->load('user');
$name = auth()->user()->name; $name = auth()->user()->name;
$tanggal = $form->tanggal; $tanggal = $form->tanggal;
$total = $form->total; $total = $form->total;
@@ -330,14 +413,16 @@ class FormVehicleController extends Controller
$this->checkAuthorization(auth()->user(), ['forms.vehicle.edit']); $this->checkAuthorization(auth()->user(), ['forms.vehicle.edit']);
$role = auth()->user()->getRoleNames()[0]; $role = auth()->user()->getRoleNames()[0];
$form = FormVehicleRunningCost::findOrfail($id); $form = FormVehicleRunningCost::with('attachments')->findOrfail($id);
if($form->status != 'On Progress' && $role == 'Medical Representatif') { if($form->status != 'On Progress' && $role == 'Medical Representatif') {
session()->flash('error', 'You cannot edit this form because it has been approved or rejected.'); session()->flash('error', 'You cannot edit this form because it has been approved or rejected.');
return redirect()->back(); return redirect()->back();
} }
return view('backend.pages.forms.vehicle.edit', [ return view('backend.pages.forms.vehicle.edit', [
'form' => $form 'form' => $form,
'attachments' => $this->formatAttachmentCollection($form),
'attachmentCategories' => $this->attachmentCategories,
]); ]);
} }
@@ -345,19 +430,37 @@ class FormVehicleController extends Controller
{ {
$this->checkAuthorization(auth()->user(), ['forms.vehicle.edit']); $this->checkAuthorization(auth()->user(), ['forms.vehicle.edit']);
$role = auth()->user()->getRoleNames()[0]; $role = auth()->user()->getRoleNames()[0];
$form = FormVehicleRunningCost::findOrFail($id); $form = FormVehicleRunningCost::with('attachments')->findOrFail($id);
// Ambil cabang ID dari user yang mengisi form // Ambil cabang ID dari user yang mengisi form
$cabang_id = UserHasCabang::where('user_id', $form->user_id)->first()?->cabang_id; $cabang = UserHasCabang::with('cabang')->where('user_id', $form->user_id)->first()?->cabang;
if (!$cabang_id) { if (!$cabang) {
session()->flash('error', 'Data cabang tidak ditemukan.'); session()->flash('error', 'Data cabang tidak ditemukan.');
return redirect()->back(); return redirect()->back();
} }
// Hitung periode berdasarkan tanggal input
$tanggal = Carbon::parse($request->tanggal); $tanggal = Carbon::parse($request->tanggal);
$startDay = (int) env('STARTING_DATE', 11);
// PEMBATASAN INPUT MENGGUNAKAN STARTING_DATE & INPUT_MAX_DATE
$startDay = (int) env('STARTING_DATE', 11);
$inputMaxDay = (int) env('INPUT_MAX_DATE', 10);
$now = Carbon::now();
if ($now->day >= $startDay) {
$startDate = Carbon::create($now->year, $now->month, $startDay)->startOfDay();
$maxInputDate = Carbon::create($now->copy()->addMonth()->year, $now->copy()->addMonth()->month, $inputMaxDay)->endOfDay();
} else {
$startDate = Carbon::create($now->copy()->subMonth()->year, $now->copy()->subMonth()->month, $startDay)->startOfDay();
$maxInputDate = Carbon::create($now->year, $now->month, $inputMaxDay)->endOfDay();
}
// Validasi apakah tanggal pengajuan masuk ke periode input saat ini
if ($tanggal->lt($startDate) || $tanggal->gt($maxInputDate)) {
session()->flash('error', "Gagal, tanggal expense tidak berada dalam batas waktu input yang diizinkan: {$startDate->format('d M Y')} - {$maxInputDate->format('d M Y')}");
return redirect()->back()->withInput();
}
// Hitung periode dari tanggal form yang diinput
if ($tanggal->day >= $startDay) { if ($tanggal->day >= $startDay) {
$periodeDate = Carbon::create($tanggal->year, $tanggal->month, $startDay); $periodeDate = Carbon::create($tanggal->year, $tanggal->month, $startDay);
} else { } else {
@@ -368,10 +471,10 @@ class FormVehicleController extends Controller
$periodeYear = (int) $periodeDate->format('Y'); $periodeYear = (int) $periodeDate->format('Y');
// Hitung sisa budget // Hitung sisa budget
$availableBudget = BudgetHelper::getAvailableBudget($cabang_id, $periodeMonth, $periodeYear); $availableBudget = BudgetHelper::getAvailableBudget($cabang->id, $periodeMonth, $periodeYear);
// Validasi input // Validasi input
$request->validate([ $validator = Validator::make($request->all(), [
'tanggal' => 'required', 'tanggal' => 'required',
'type' => 'required', 'type' => 'required',
'liter' => 'nullable|numeric|min:0', 'liter' => 'nullable|numeric|min:0',
@@ -380,36 +483,55 @@ class FormVehicleController extends Controller
'tipe_bensin' => 'nullable|in:pertalite,pertamax', 'tipe_bensin' => 'nullable|in:pertalite,pertamax',
'nopol' => 'nullable', 'nopol' => 'nullable',
'keterangan' => 'required', 'keterangan' => 'required',
'bukti_total' => ['nullable', 'file', 'max:51200'], 'attachments' => 'nullable|array',
'attachments.*.file_category' => 'required|string|in:' . implode(',', $this->attachmentCategories),
'attachments.*.file_path' => [
'nullable',
'file',
'max:10240',
'mimetypes:image/jpeg,image/png,image/webp,application/pdf,text/plain,text/csv,application/zip,application/x-zip-compressed,application/x-7z-compressed,application/vnd.rar,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation,application/octet-stream'
],
]); ]);
$validator->after(function ($validator) use ($request) {
$inputAttachments = $request->input('attachments', []);
$fileAttachments = $request->file('attachments', []);
foreach ($inputAttachments as $index => $input) {
$category = $input['file_category'] ?? null;
$file = $fileAttachments[$index]['file_path'] ?? null;
if ($category && !$file) {
$validator->errors()->add("attachments.{$index}.file_path", 'Attachment file is required.');
continue;
}
if ($file && $this->isBlockedExtension($file->getClientOriginalExtension())) {
$validator->errors()->add("attachments.{$index}.file_path", 'File type is not allowed.');
}
}
});
$validator->validate();
if($form->status != 'On Progress' && $role == 'Medical Representatif') { if($form->status != 'On Progress' && $role == 'Medical Representatif') {
session()->flash('error', 'You cannot edit this form because it has been approved or rejected.'); session()->flash('error', 'You cannot edit this form because it has been approved or rejected.');
return redirect()->back(); return redirect()->back();
} }
$cabang = UserHasCabang::with('cabang')->where('user_id', $form->user_id)->first()->cabang;
$region = Region::where('id', $cabang->region_id)->first(); $region = Region::where('id', $cabang->region_id)->first();
$kategori = Kategori::where('name', 'Vehicle Running Cost')->first(); // REPARASI SUNTIK KATEGORI_ID SAAT UPDATE
$kategori = Kategori::where('name', 'LIKE', '%Vehicle Running Cost%')->first();
$filePaths = [];
$folderPath = '/expense/' . $region->code . '/' . $cabang->code . '/vehicle'; $folderPath = '/expense/' . $region->code . '/' . $cabang->code . '/vehicle';
if ($request->hasFile('bukti_total')) { $attachmentsPayload = $this->buildAttachmentPayload($request, $folderPath);
$bukti_total = $request->file('bukti_total');
$filename = Str::uuid() . '.' . $bukti_total->extension();
$filePaths['bukti_total'] = $folderPath . '/' . $filename; DB::beginTransaction();
try {
NextCloudHelper::uploadFile(
$folderPath,
$bukti_total->getContent(),
$filename
);
}
// Save the form data
$form->update([ $form->update([
'cabang_id' => $cabang->id, // <--- REPARASI SUNTIK CABANG_ID
'kategori_id' => $kategori->id ?? null, // <--- REPARASI SUNTIK KATEGORI_ID
'tanggal' => $request->tanggal, 'tanggal' => $request->tanggal,
'type' => $request->type, 'type' => $request->type,
'liter' => $request->liter ?? 0, 'liter' => $request->liter ?? 0,
@@ -418,16 +540,64 @@ class FormVehicleController extends Controller
'tipe_bensin' => $request->tipe_bensin ?? '-', 'tipe_bensin' => $request->tipe_bensin ?? '-',
'nopol' => $request->nopol ?? '-', 'nopol' => $request->nopol ?? '-',
'keterangan' => $request->keterangan, 'keterangan' => $request->keterangan,
'bukti_total' => $filePaths['bukti_total'] ?? $form->bukti_total, 'bukti_total' => $form->bukti_total,
'account_number' => $kategori->account_number, 'account_number' => $kategori->account_number ?? $form->account_number,
'status' => 'On Progress', 'status' => 'On Progress',
]); ]);
if (!empty($attachmentsPayload)) {
$this->attachmentService->addMultipleAttachments(
$form->id,
AttachmentTableName::FORM_VEHICLE_RUNNING_COST,
$attachmentsPayload
);
}
DB::commit();
} catch (\Throwable $th) {
DB::rollBack();
Log::error('Failed to update Vehicle Running Cost form', [
'form_id' => $form->id,
'message' => $th->getMessage(),
'trace' => $th->getTraceAsString(),
]);
session()->flash('error', 'Terjadi kesalahan saat memperbarui data, silakan coba lagi.');
return redirect()->back()->withInput();
}
AuditTrailHelper::AddAuditTrail('Update', 'Update Record at Form Vehicle Running Cost (' . $form->expense_number . ')'); AuditTrailHelper::AddAuditTrail('Update', 'Update Record at Form Vehicle Running Cost (' . $form->expense_number . ')');
session()->flash('success', 'Form has been updated.'); session()->flash('success', 'Form has been updated.');
return redirect()->back(); return redirect()->back();
} }
public function deleteAttachment(Request $request, $formId, $attachmentId)
{
$this->checkAuthorization(auth()->user(), ['forms.vehicle.edit']);
$form = FormVehicleRunningCost::findOrFail($formId);
$role = auth()->user()->getRoleNames()[0];
if (($form->status !== 'On Progress' && $form->status !== 'Rejected') && $role === 'Medical Representatif') {
return response()->json(['message' => 'You cannot modify attachments once the form is approved or closed.'], 403);
}
$attachment = AttachmentForm::where('id', $attachmentId)
->where('form_id', $form->id)
->where('table_name', AttachmentTableName::FORM_VEHICLE_RUNNING_COST)
->first();
if (!$attachment) {
return response()->json(['message' => 'Attachment not found.'], 404);
}
$this->attachmentService->deleteAttachment($attachment->id);
AuditTrailHelper::AddAuditTrail('Delete Attachment', 'Delete Attachment at Vehicle Running Cost (' . $form->expense_number . ')');
return response()->json(['message' => 'Attachment deleted successfully.']);
}
public function approve($id) public function approve($id)
{ {
$this->checkAuthorization(auth()->user(), ['approval.approve']); $this->checkAuthorization(auth()->user(), ['approval.approve']);
@@ -612,7 +782,8 @@ class FormVehicleController extends Controller
return redirect()->back(); return redirect()->back();
} }
public function finalApprove($id) // <--- REPARASI SUNTIKAN REQUEST DARI JAVASCRIPT MODAL AGAR DATA BISA DIAMBIL
public function finalApprove(Request $request, $id)
{ {
$this->checkAuthorization(auth()->user(), ['final_approval.approve']); $this->checkAuthorization(auth()->user(), ['final_approval.approve']);
$form = FormVehicleRunningCost::findOrFail($id); $form = FormVehicleRunningCost::findOrFail($id);
@@ -642,14 +813,18 @@ class FormVehicleController extends Controller
$periodeMonth = strtolower($periodeDate->format('F')); $periodeMonth = strtolower($periodeDate->format('F'));
$periodeYear = (int) $periodeDate->format('Y'); $periodeYear = (int) $periodeDate->format('Y');
// Hitung sisa budget cabang di periode tersebut // <--- REPARASI: Penangkapan nilai payload dari checkbox JavaScript
$approvedTotal = $request->input('approved_total', $form->total);
// Hitung sisa budget cabang di periode tersebut disinkronisasi dengan variabel kalkulasi terbaru
$availableBudget = BudgetHelper::getAvailableBudget($cabang_id, $periodeMonth, $periodeYear); $availableBudget = BudgetHelper::getAvailableBudget($cabang_id, $periodeMonth, $periodeYear);
if($form->approved_total > $availableBudget) { if($approvedTotal > $availableBudget) {
session()->flash('error', 'Budget cabang untuk periode ini tidak mencukupi, silahkan ajukan pada periode expense berikutnya'); session()->flash('error', 'Budget cabang untuk periode ini tidak mencukupi, silahkan ajukan pada periode expense berikutnya');
return redirect()->back(); return redirect()->back();
} }
$form->update([ $form->update([
'approved_total' => $approvedTotal, // <--- SUNTIK DATA UPDATE HASIL CHECKLIST
'final_approved_at' => now(), 'final_approved_at' => now(),
'final_approved_by' => auth()->user()->id, 'final_approved_by' => auth()->user()->id,
'status' => 'Closed', 'status' => 'Closed',
@@ -658,7 +833,7 @@ class FormVehicleController extends Controller
$name = $form->user->name; $name = $form->user->name;
$expense_number = $form->expense_number; $expense_number = $form->expense_number;
$tanggal = $form->created_at; $tanggal = $form->created_at;
$total = $form->approved_total; $total = $form->approved_total; // <--- TOTAL BARU YANG DIKIRIMKAN KE EMAIL / WHATSAPP
$roles = ['Head of Sales Marketing', 'Marketing Operational Manager Region']; $roles = ['Head of Sales Marketing', 'Marketing Operational Manager Region'];
$recipients = [$form->user->email, auth()->user()->email]; $recipients = [$form->user->email, auth()->user()->email];
@@ -731,26 +906,30 @@ class FormVehicleController extends Controller
} }
public function reject(Request $request, $id) public function reject(Request $request, $id)
{ {
$this->checkAuthorization(auth()->user(), ['approval.reject']); if (!auth()->user()->hasAnyPermission(['approval.reject', 'approval2.reject', 'final_approval.approve'])) {
abort(403, 'Akses Ditolak: Anda tidak memiliki izin untuk melakukan Reject.');
}
$request->validate([ $request->validate([
'remarks' => 'required|string|max:500', 'remarks' => 'required|string|max:500',
]); ]);
$form = FormVehicleRunningCost::findOrfail($id); $form = FormVehicleRunningCost::findOrfail($id);
$form->update([ $form->update([
'status' => 'Rejected', 'status' => 'Rejected',
'remarks' => $request->remarks, 'remarks' => $request->remarks,
]); 'rejected_by' => auth()->id(), // Kolom baru
'rejected_at' => now(), // Kolom baru
]);
$heads = $form->user->getCabangAndRegionHead(); $heads = $form->user->getCabangAndRegionHead();
$name = $form->user->name; $name = $form->user->name;
$expense_number = $form->expense_number; $expense_number = $form->expense_number;
$tanggal = $form->created_at; $tanggal = $form->tanggal;
$total = $form->total; $total = $form->total;
// Collect all recipients
$recipients = [$form->user->email, auth()->user()->email]; $recipients = [$form->user->email, auth()->user()->email];
$phoneNumbers = [$form->user->phone, auth()->user()->phone]; $phoneNumbers = [$form->user->phone, auth()->user()->phone];
@@ -764,41 +943,27 @@ class FormVehicleController extends Controller
$phoneNumbers[] = $heads['region_head']['phone']; $phoneNumbers[] = $heads['region_head']['phone'];
} }
// Remove duplicate email addresses, if any
$recipients = array_unique($recipients); $recipients = array_unique($recipients);
$phoneNumbers = array_unique($phoneNumbers); $phoneNumbers = array_unique($phoneNumbers);
// send whatsapp message
$url = route('forms.vehicle.view', $form->id); $url = route('forms.vehicle.view', $form->id);
WhatsappHelper::rejectExpense($phoneNumbers, $expense_number, $url, $tanggal, $total, $name); WhatsappHelper::rejectExpense($phoneNumbers, $expense_number, $url, $tanggal, $total, $name, $request->remarks);
// Send bulk email
$brevoService = app(BrevoService::class); $brevoService = app(BrevoService::class);
foreach ($recipients as $recipient) { foreach ($recipients as $recipient) {
try { try {
$mail = new ExpenseRejected( $mail = new ExpenseRejected($name, $expense_number, $tanggal, $total, $url, $request->remarks);
$name, $brevoService->expenseRejected($recipient, $name, $mail);
$expense_number,
$tanggal,
$total,
$url
);
$response = $brevoService->expenseRejected($recipient, $name, $mail);
if (isset($response['success']) && $response['success'] === false) {
Log::warning("Email to {$recipient} failed: " . ($response['message'] ?? 'Unknown error'));
}
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error("Failed to send email to {$recipient}: " . $e->getMessage()); Log::error("Failed to send email to {$recipient}: " . $e->getMessage());
continue; continue;
} }
} }
AuditTrailHelper::AddAuditTrail('Reject', 'Reject Expense at Form Vehicle Running Cost (' . $form->expense_number . ')'); AuditTrailHelper::AddAuditTrail('Reject', 'Reject Expense at Form Vehicle Running Cost (' . $form->expense_number . ') oleh ' . auth()->user()->name);
session()->flash('success', 'Form has been rejected.'); session()->flash('success', 'Form has been rejected.');
return redirect()->back(); return redirect()->back();
} }
public function open($id) public function open($id)
{ {
@@ -834,4 +999,106 @@ class FormVehicleController extends Controller
session()->flash('success', 'Form has been deleted.'); session()->flash('success', 'Form has been deleted.');
return redirect()->back(); return redirect()->back();
} }
protected function formatAttachmentCollection(FormVehicleRunningCost $form): array
{
$attachments = $form->attachments->map(function ($attachment) {
return $this->formatAttachmentData(
$attachment->id,
$attachment->file_category,
$attachment->file_path
);
});
if ($attachments->isEmpty() && $form->bukti_total) {
$attachments->push($this->formatAttachmentData(null, 'bukti_total', $form->bukti_total));
}
return $attachments->values()->all();
}
protected function formatAttachmentData(?int $id, ?string $category, string $filePath): array
{
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
$previewType = $this->determinePreviewType($extension);
return [
'id' => $id,
'file_category' => $category,
'file_path' => $filePath,
'download_url' => NextCloudHelper::getFileUrl($filePath),
'preview_url' => in_array($previewType, ['image', 'pdf'], true) ? NextCloudHelper::getPreviewUrl($filePath) : null,
'preview_type' => $previewType,
'filename' => basename($filePath),
'extension' => $extension,
'can_delete' => !is_null($id),
];
}
protected function determinePreviewType(?string $extension): string
{
$extension = strtolower((string) $extension);
$imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'];
if (in_array($extension, $imageExtensions, true)) {
return 'image';
}
if ($extension === 'pdf') {
return 'pdf';
}
return 'other';
}
protected function isBlockedExtension(?string $extension): bool
{
return in_array(strtolower((string) $extension), $this->blockedExtensions, true);
}
protected function buildAttachmentPayload(Request $request, string $folderPath): array
{
$payload = [];
$inputs = $request->input('attachments', []);
$files = $request->file('attachments', []);
foreach ($inputs as $index => $input) {
$category = $input['file_category'] ?? null;
$file = $files[$index]['file_path'] ?? null;
if (!$category || !$file) {
continue;
}
$extension = strtolower($file->getClientOriginalExtension());
if ($this->isBlockedExtension($extension)) {
throw ValidationException::withMessages([
"attachments.{$index}.file_path" => 'File type is not allowed.',
]);
}
$filename = Str::uuid() . '.' . $extension;
$filePath = $folderPath . '/' . $filename;
$uploadResponse = NextCloudHelper::uploadFile(
$folderPath,
$file->getContent(),
$filename
);
if (is_array($uploadResponse) && isset($uploadResponse['success']) && $uploadResponse['success'] === false) {
throw ValidationException::withMessages([
"attachments.{$index}.file_path" => $uploadResponse['message'] ?? 'Failed to upload attachment.',
]);
}
$payload[] = [
'file_category' => $category,
'file_path' => $filePath,
];
}
return $payload;
}
} }
+5 -1
View File
@@ -18,17 +18,19 @@ class ExpenseRejected extends Mailable
protected string $tanggal; protected string $tanggal;
protected int $total; protected int $total;
protected string $url; protected string $url;
protected ?string $remarks;
/** /**
* Create a new message instance. * Create a new message instance.
*/ */
public function __construct(string $name, string $expense_number, string $tanggal, int $total, string $url) public function __construct(string $name, string $expense_number, string $tanggal, int $total, string $url, ?string $remarks = null)
{ {
$this->name = $name; $this->name = $name;
$this->expense_number = $expense_number; $this->expense_number = $expense_number;
$this->tanggal = $tanggal; $this->tanggal = $tanggal;
$this->total = $total; $this->total = $total;
$this->url = $url; $this->url = $url;
$this->remarks = $remarks;
} }
/** /**
@@ -54,6 +56,7 @@ class ExpenseRejected extends Mailable
'tanggal' => $this->tanggal, 'tanggal' => $this->tanggal,
'total' => $this->total, 'total' => $this->total,
'url' => $this->url, 'url' => $this->url,
'remarks' => $this->remarks,
], ],
); );
} }
@@ -79,6 +82,7 @@ class ExpenseRejected extends Mailable
'tanggal' => $this->tanggal, 'tanggal' => $this->tanggal,
'total' => $this->total, 'total' => $this->total,
'url' => $this->url, 'url' => $this->url,
'remarks' => $this->remarks,
])->render(); ])->render();
} }
} }
+2
View File
@@ -21,6 +21,8 @@ class BudgetControl extends Model
'transferred_at', 'transferred_at',
'closing_at', 'closing_at',
'status', 'status',
'transfer_proof',
'transfer_status',
]; ];
public function user() public function user()
+29 -6
View File
@@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class FormEntertaimentPresentation extends Model class FormEntertaimentPresentation extends Model
{ {
use SoftDeletes; use SoftDeletes, \App\Traits\HasAttachments;
protected $table = 'form_entertainment_presentation'; protected $table = 'form_entertainment_presentation';
protected $fillable = [ protected $fillable = [
@@ -31,17 +31,40 @@ class FormEntertaimentPresentation extends Model
'approved2_by', 'approved2_by',
'final_approved_at', 'final_approved_at',
'final_approved_by', 'final_approved_by',
// Kolom baru
'jabatan',
'nama_perusahaan',
'jenis_usaha',
]; ];
protected $casts = [
'created_at' => 'datetime',
'approved_at' => 'datetime',
'approved2_at' => 'datetime',
'final_approved_at' => 'datetime',
];
public function user() public function user()
{ {
return $this->belongsTo('App\Models\Admin'); return $this->belongsTo('App\Models\Admin');
} }
public function attachments() // Relasi ke kategori untuk mengambil account_number
{ public function kategori()
return $this->hasMany(\App\Models\AttachmentForm::class, 'form_id') {
->where('table_name', \App\Enums\AttachmentTableName::FORM_ENTERTAINMENT_PRESENTATION); return $this->belongsTo(Kategori::class, 'kategori_id');
} }
public function rejector() {
return $this->belongsTo(User::class, 'rejected_by');
}
// public function attachments()
// {
// // form_id adalah kolom di tabel attachment_forms
// // table_name digunakan jika satu tabel attachment dipakai banyak form
// return $this->hasMany(\App\Models\AttachmentForm::class, 'form_id')
// ->where('table_name', 'form_entertainment_presentation');
// }
} }
+32 -25
View File
@@ -4,46 +4,53 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class FormMeetingSeminar extends Model class FormMeetingSeminar extends Model
{ {
use SoftDeletes; use SoftDeletes;
protected $table = 'form_meeting_seminar'; protected $table = 'form_meeting_seminar';
protected $fillable = [ protected $fillable = [
'expense_number', 'expense_number', 'user_id', 'tanggal', 'allowance', 'transport_ankot',
'user_id', 'hotel', 'total', 'approved_allowance', 'approved_transport_ankot',
'tanggal', 'approved_hotel', 'approved_total', 'bukti_allowance', 'bukti_transport_ankot',
'allowance', 'bukti_hotel', 'account_number', 'status', 'remarks', 'approved_at',
'transport_ankot', 'approved2_at', 'approved_by', 'approved2_by', 'final_approved_at',
'hotel',
'total',
'approved_allowance',
'approved_transport_ankot',
'approved_hotel',
'approved_total',
'bukti_allowance',
'bukti_transport_ankot',
'bukti_hotel',
'account_number',
'status',
'remarks',
'approved_at',
'approved2_at',
'approved_by',
'approved2_by',
'final_approved_at',
'final_approved_by', 'final_approved_by',
]; ];
public function user() /**
* Casts untuk otomatisasi format Carbon pada tanggal agar konsisten di Blade.
*/
protected $casts = [
'tanggal' => 'date',
'final_approved_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/**
* Relasi ke Admin (Pengaju).
*/
public function user(): BelongsTo
{ {
return $this->belongsTo(Admin::class, 'user_id'); return $this->belongsTo(Admin::class, 'user_id');
} }
public function attachments() /**
* Relasi ke Attachments (Dokumen Digital) sesuai pola Entertainment.
*/
public function attachments(): HasMany
{ {
return $this->hasMany(\App\Models\AttachmentForm::class, 'form_id') return $this->hasMany(AttachmentForm::class, 'form_id')
->where('table_name', \App\Enums\AttachmentTableName::FORM_MEETING_SEMINAR); ->where('table_name', \App\Enums\AttachmentTableName::FORM_MEETING_SEMINAR);
} }
public function rejector() {
return $this->belongsTo(User::class, 'rejected_by');
}
} }
+35 -22
View File
@@ -4,6 +4,8 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class FormOthers extends Model class FormOthers extends Model
{ {
@@ -11,38 +13,49 @@ class FormOthers extends Model
protected $table = 'form_others'; protected $table = 'form_others';
protected $fillable = [ protected $fillable = [
'expense_number', 'expense_number', 'user_id', 'kategori_id', 'tanggal', 'keterangan',
'user_id', 'total', 'approved_total', 'bukti_total', 'account_number', 'status',
'kategori_id', 'remarks', 'approved_at', 'approved2_at', 'approved_by', 'approved2_by',
'tanggal', 'final_approved_at', 'final_approved_by',
'keterangan',
'total',
'approved_total',
'bukti_total',
'account_number',
'status',
'remarks',
'approved_at',
'approved2_at',
'approved_by',
'approved2_by',
'final_approved_at',
'final_approved_by',
]; ];
public function user() /**
* Casts untuk otomatisasi format Carbon pada tanggal agar konsisten di Blade.
*/
protected $casts = [
'tanggal' => 'date',
'final_approved_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/**
* Relasi ke Admin (Pengaju).
*/
public function user(): BelongsTo
{ {
return $this->belongsTo(Admin::class); return $this->belongsTo(Admin::class, 'user_id');
} }
public function kategori() /**
* Relasi ke Kategori.
*/
public function kategori(): BelongsTo
{ {
return $this->belongsTo(Kategori::class); return $this->belongsTo(Kategori::class, 'kategori_id');
} }
public function attachments() /**
* Relasi ke Attachments (Dokumen Digital) sesuai pola Entertainment.
*/
public function attachments(): HasMany
{ {
return $this->hasMany(\App\Models\AttachmentForm::class, 'form_id') return $this->hasMany(\App\Models\AttachmentForm::class, 'form_id')
->where('table_name', \App\Enums\AttachmentTableName::FORM_OTHERS); ->where('table_name', \App\Enums\AttachmentTableName::FORM_OTHERS);
} }
public function rejector() {
return $this->belongsTo(User::class, 'rejected_by');
}
} }
+39 -36
View File
@@ -4,58 +4,61 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class FormUpCountry extends Model class FormUpCountry extends Model
{ {
use SoftDeletes; use SoftDeletes;
protected $table = 'form_up_country'; protected $table = 'form_up_country';
protected $fillable = [ protected $fillable = [
'expense_number', 'expense_number', 'user_id', 'rayon_id', 'tanggal', 'tujuan', 'jarak',
'user_id', 'allowance', 'transport_dalkot', 'transport_ankot', 'hotel', 'total',
'rayon_id', 'approved_allowance', 'approved_transport_dalkot', 'approved_transport_ankot',
'tanggal', 'approved_hotel', 'approved_total', 'bukti_allowance', 'bukti_transport_dalkot',
'tujuan', 'bukti_transport_ankot', 'bukti_hotel', 'uc_plan', 'account_number', 'status',
'jarak', 'remarks', 'approved_at', 'approved2_at', 'approved_by', 'approved2_by',
'allowance', 'final_approved_at', 'final_approved_by','cabang_id','kategori_id',
'transport_dalkot',
'transport_ankot',
'hotel',
'total',
'approved_allowance',
'approved_transport_dalkot',
'approved_transport_ankot',
'approved_hotel',
'approved_total',
'bukti_allowance',
'bukti_transport_dalkot',
'bukti_transport_ankot',
'bukti_hotel',
'uc_plan',
'account_number',
'status',
'remarks',
'approved_at',
'approved2_at',
'approved_by',
'approved2_by',
'final_approved_at',
'final_approved_by',
]; ];
public function user() /**
* Casts untuk otomatisasi format Carbon pada tanggal.
*/
protected $casts = [
'tanggal' => 'date',
'final_approved_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/**
* Relasi ke Admin (Pengaju).
*/
public function user(): BelongsTo
{ {
return $this->belongsTo('App\Models\Admin'); return $this->belongsTo(Admin::class, 'user_id');
} }
public function rayon() /**
* Relasi ke Rayon.
*/
public function rayon(): BelongsTo
{ {
return $this->belongsTo('App\Models\Rayon'); return $this->belongsTo(Rayon::class, 'rayon_id');
} }
public function attachments() /**
* Relasi ke Attachments (Dokumen Digital) sesuai pola Entertainment.
*/
public function attachments(): HasMany
{ {
return $this->hasMany(\App\Models\AttachmentForm::class, 'form_id') return $this->hasMany(AttachmentForm::class, 'form_id')
->where('table_name', \App\Enums\AttachmentTableName::FORM_UP_COUNTRY); ->where('table_name', \App\Enums\AttachmentTableName::FORM_UP_COUNTRY);
} }
public function rejector() {
return $this->belongsTo(User::class, 'rejected_by');
}
} }
+32 -25
View File
@@ -4,44 +4,51 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class FormVehicleRunningCost extends Model class FormVehicleRunningCost extends Model
{ {
use SoftDeletes; use SoftDeletes;
protected $table = 'form_vehicle_running_cost'; protected $table = 'form_vehicle_running_cost';
protected $fillable = [ protected $fillable = [
'expense_number', 'expense_number', 'user_id', 'tanggal', 'liter', 'total', 'approved_total',
'user_id', 'jarak', 'tipe_bensin', 'nopol', 'keterangan', 'bukti_total', 'account_number',
'tanggal', 'type', 'status', 'remarks', 'approved_at', 'approved2_at', 'approved_by',
'liter', 'approved2_by', 'final_approved_at', 'final_approved_by',
'total',
'approved_total',
'jarak',
'tipe_bensin',
'nopol',
'keterangan',
'bukti_total',
'account_number',
'type',
'status',
'remarks',
'approved_at',
'approved2_at',
'approved_by',
'approved2_by',
'final_approved_at',
'final_approved_by',
]; ];
public function user() /**
* Casts untuk otomatisasi format Carbon pada tanggal.
*/
protected $casts = [
'tanggal' => 'date',
'final_approved_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/**
* Relasi ke Admin (Pengaju)
*/
public function user(): BelongsTo
{ {
return $this->belongsTo('App\Models\Admin', 'user_id'); return $this->belongsTo(Admin::class, 'user_id');
} }
public function attachments() /**
* Relasi ke Attachments (Dokumen Digital)
*/
public function attachments(): HasMany
{ {
return $this->hasMany(\App\Models\AttachmentForm::class, 'form_id') return $this->hasMany(AttachmentForm::class, 'form_id')
->where('table_name', \App\Enums\AttachmentTableName::FORM_VEHICLE_RUNNING_COST); ->where('table_name', \App\Enums\AttachmentTableName::FORM_VEHICLE_RUNNING_COST);
} }
public function rejector() {
return $this->belongsTo(User::class, 'rejected_by');
}
} }
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Transaction extends Model
{
use SoftDeletes;
protected $fillable = [
'cabang_id', 'budget_control_id', 'transaction_date', 'reference_number',
'description', 'amount', 'type', 'category', 'periode_month',
'periode_year', 'created_by'
];
protected $casts = [
'transaction_date' => 'date',
'amount' => 'decimal:2',
];
public function cabang() {
return $this->belongsTo(Cabang::class);
}
}
+40
View File
@@ -25,6 +25,8 @@ class User extends Authenticatable
'username', 'username',
'password', 'password',
'phone', 'phone',
'region_id',
'cabang_id'
]; ];
/** /**
@@ -49,4 +51,42 @@ class User extends Authenticatable
'password' => 'hashed', 'password' => 'hashed',
]; ];
} }
/**
* Helper untuk mendapatkan ID Cabang & Region milik user
*/
public function getMyCabangAndRegionId(): array
{
// Jika User punya cabang_id (Level AM / MR)
if ($this->cabang_id) {
$cabang = \DB::table('cabang')->where('id', $this->cabang_id)->first();
return [
'cabang' => (int) $this->cabang_id,
'region' => $cabang ? (int) $cabang->region_id : null
];
}
// Jika User level Region (Admin Region / MOM Region)
// Pastikan kolom 'region_id' ada di tabel users Anda
return [
'cabang' => null,
'region' => $this->region_id ? (int) $this->region_id : null
];
}
public function cabangs()
{
// Relasi Many-to-Many melalui tabel pivot user_has_cabang
return $this->belongsToMany(Cabang::class, 'user_has_cabang', 'user_id', 'cabang_id');
}
/**
* Mendapatkan ID Region berdasarkan penugasan cabang pertama
*/
public function getAssignedRegionId()
{
$firstAssignment = $this->cabangs()->first();
return $firstAssignment ? $firstAssignment->region_id : null;
}
} }
+3
View File
@@ -4,6 +4,7 @@ namespace App\Providers;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Pagination\Paginator;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
@@ -29,6 +30,8 @@ class AppServiceProvider extends ServiceProvider
URL::forceScheme('https'); URL::forceScheme('https');
} }
Paginator::useBootstrap();
} }
} }
+284
View File
@@ -0,0 +1,284 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class DashboardService
{
protected $startDate;
protected $endDate;
protected $allowedCabangIds = [];
protected $user;
public function __construct($user)
{
$this->user = $user;
$this->setPeriode();
$this->setUserVisibility();
}
/**
* Set Periode Cut-off (11 - 10)
*/
protected function setPeriode(): void
{
$startDay = (int) env('STARTING_DATE', 11);
$closingDay = (int) env('CLOSING_DATE', 10);
$now = Carbon::now();
if ($now->day >= $startDay) {
$this->startDate = $now->copy()->day($startDay)->startOfDay();
$this->endDate = $now->copy()->addMonth()->day($closingDay)->endOfDay();
} else {
$this->startDate = $now->copy()->subMonth()->day($startDay)->startOfDay();
$this->endDate = $now->copy()->day($closingDay)->endOfDay();
}
}
/**
* Atur Visibilitas Berdasarkan Hierarki Role
*/
protected function setUserVisibility(): void
{
// 1. MIS / HEAD / SUPERADMIN: Akses Nasional
if ($this->user->hasRole(['Marketing Information System', 'Head of Sales Management', 'Super Admin'])) {
$this->allowedCabangIds = [];
return;
}
$myCabangs = DB::table('user_has_cabang')
->where('user_id', $this->user->id)
->whereNull('deleted_at')
->pluck('cabang_id')
->toArray();
// 2. MOM / ADMIN REGION: Akses Regional (Semua cabang di dalam regionnya)
if ($this->user->hasRole(['Marketing Operational Manager Region', 'Admin Region'])) {
$regionIds = DB::table('cabang')
->whereIn('id', $myCabangs)
->pluck('region_id')
->toArray();
$this->allowedCabangIds = DB::table('cabang')
->whereIn('region_id', $regionIds)
->pluck('id')
->toArray();
}
// 3. AM / MR: Akses Cabang Spesifik
else {
$this->allowedCabangIds = $myCabangs;
}
}
/**
* Helper Filter: Digunakan di semua query analitik
*/
protected function applyFilters($query)
{
if (!empty($this->allowedCabangIds)) {
$query->whereIn('combined_forms.cabang_id', $this->allowedCabangIds);
}
return $query;
}
/**
* Query Utama: Menggabungkan 5 tabel kategori expense
*/
protected function getCombinedFormsQuery()
{
$tables = [
'form_up_country',
'form_vehicle_running_cost',
'form_entertainment_presentation',
'form_meeting_seminar',
'form_others'
];
$query = null;
foreach ($tables as $table) {
$sub = DB::table($table)
->select(
'id',
'cabang_id',
'total',
'status',
'created_at',
'final_approved_at',
DB::raw("'$table' as source_table")
)
->whereNull('deleted_at');
$query = ($query === null) ? $sub : $query->unionAll($sub);
}
return DB::table(DB::raw("({$query->toSql()}) as combined_forms"))
->mergeBindings($query)
->join('cabang', 'combined_forms.cabang_id', '=', 'cabang.id')
->join('region', 'cabang.region_id', '=', 'region.id');
}
/**
* Data untuk 4 Kartu Statistik Utama
*/
public function getApprovalStats(): array
{
// Ambil semua data tanpa filter tanggal dulu untuk kartu "Waiting"
$allData = $this->getCombinedFormsQuery()
->select('combined_forms.status', 'combined_forms.total', 'combined_forms.created_at')
->get();
$map = [
// 1. TOTAL PENDING: Khusus periode aktif (11 Maret - 10 April) & Status On Progress
'total_pending' => [
'label' => 'Total Pending',
'keys' => ['on progress'],
'color' => 'warning',
'icon' => 'fas fa-hourglass-half',
'use_period' => true
],
// 2. WAITING APPR 1: Semua yang statusnya On Progress
'appr1' => [
'label' => 'Waiting Appr 1',
'keys' => ['on progress'],
'color' => 'info',
'icon' => 'fas fa-user-check',
'use_period' => false
],
// 3. WAITING APPR 2: Semua yang statusnya Approved 1
'appr2' => [
'label' => 'Waiting Appr 2',
'keys' => ['approved 1'],
'color' => 'primary',
'icon' => 'fas fa-file-invoice-dollar',
'use_period' => false
],
// 4. WAITING FINAL APPROVED: Semua yang statusnya Approved 2
'waiting_final' => [
'label' => 'Waiting Final Approved',
'keys' => ['approved 2'],
'color' => 'secondary',
'icon' => 'fas fa-tasks',
'use_period' => false
],
// 5. FINAL APPROVED (CLOSED): Semua yang sudah Closed
'closed' => [
'label' => 'Final Approved',
'keys' => ['closed', 'approved'],
'color' => 'success',
'icon' => 'fas fa-check-double',
'use_period' => false
],
];
$results = [];
foreach ($map as $config) {
$filtered = $allData->filter(function ($item) use ($config) {
$statusMatch = in_array(strtolower(trim((string)$item->status)), $config['keys']);
// Jika kartu membutuhkan filter periode (Maret 11 - April 10)
if ($config['use_period']) {
$dateMatch = Carbon::parse($item->created_at)->between($this->startDate, $this->endDate);
return $statusMatch && $dateMatch;
}
return $statusMatch;
});
$results[] = [
'label' => $config['label'],
'count' => $filtered->count(),
'amount' => (float) $filtered->sum('total'),
'color' => $config['color'],
'icon' => $config['icon']
];
}
return $results;
}
/**
* Mengambil daftar pengajuan dengan status 'Approved 2'
* Untuk antrian persetujuan akhir (HOSM/MIS)
*/
public function getPendingFinalApproval()
{
$query = $this->getCombinedFormsQuery()
->select(
'combined_forms.*',
'cabang.name as cabang_name',
'region.name as region_name'
)
->where('combined_forms.status', 'Approved 2');
$this->applyFilters($query);
return $query->orderBy('combined_forms.created_at', 'desc')->paginate(10);
}
/**
* Summary per Kategori untuk Chart
*/
public function getTotals(): array
{
$query = $this->getCombinedFormsQuery()
->whereBetween('combined_forms.created_at', [$this->startDate, $this->endDate])
->whereIn('combined_forms.status', ['Approved', 'Closed']);
$this->applyFilters($query);
$results = $query->select('combined_forms.source_table', DB::raw('SUM(combined_forms.total) as total_per_table'))
->groupBy('combined_forms.source_table')
->get();
return [
'forms' => [
'upcountry' => $results->where('source_table', 'form_up_country')->first()->total_per_table ?? 0,
'vehicle' => $results->where('source_table', 'form_vehicle_running_cost')->first()->total_per_table ?? 0,
'entertainment' => $results->where('source_table', 'form_entertainment_presentation')->first()->total_per_table ?? 0,
'meeting' => $results->where('source_table', 'form_meeting_seminar')->first()->total_per_table ?? 0,
'others' => $results->where('source_table', 'form_others')->first()->total_per_table ?? 0,
],
'grand_total' => $results->sum('total_per_table')
];
}
/**
* Top 5 Pengeluaran Terbanyak per Cabang
*/
/**
* Top 10 Pengeluaran Terbanyak (Volume Giants)
*/
public function getTopExpenses()
{
$query = $this->getCombinedFormsQuery()
->whereBetween('combined_forms.created_at', [$this->startDate, $this->endDate]);
$this->applyFilters($query);
return $query->select('cabang.name', 'region.name as region_name', DB::raw('SUM(combined_forms.total) as total_amount'))
->groupBy('cabang.id', 'cabang.name', 'region.name')
->orderBy('total_amount', 'desc')
->take(10) // Update ke 10
->get();
}
/**
* Top 10 Efisiensi Approval (Speed Kings)
*/
public function getFastestClosing()
{
$query = $this->getCombinedFormsQuery()
->select('cabang.name', DB::raw('AVG(TIMESTAMPDIFF(HOUR, combined_forms.created_at, combined_forms.final_approved_at)) as avg_hours'))
->whereIn('combined_forms.status', ['Approved', 'Closed'])
->whereNotNull('combined_forms.final_approved_at')
->whereBetween('combined_forms.final_approved_at', [$this->startDate, $this->endDate]);
$this->applyFilters($query);
return $query->groupBy('cabang.id', 'cabang.name')
->orderBy('avg_hours', 'asc')
->take(10) // Update ke 10
->get();
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace App\Traits;
use App\Models\AttachmentForm;
trait HasAttachments
{
public function attachments()
{
// Relasi ini akan otomatis menyesuaikan table_name berdasarkan property $table di model
return $this->hasMany(AttachmentForm::class, 'form_id')
->where('table_name', $this->table);
}
}
+484
View File
@@ -0,0 +1,484 @@
SSUUMMMMAARRYY OOFF LLEESSSS CCOOMMMMAANNDDSS
Commands marked with * may be preceded by a number, _N.
Notes in parentheses indicate the behavior if _N is given.
A key preceded by a caret indicates the Ctrl key; thus ^K is ctrl-K.
h H Display this help.
q :q Q :Q ZZ Exit.
---------------------------------------------------------------------------
MMOOVVIINNGG
e ^E j ^N CR * Forward one line (or _N lines).
y ^Y k ^K ^P * Backward one line (or _N lines).
f ^F ^V SPACE * Forward one window (or _N lines).
b ^B ESC-v * Backward one window (or _N lines).
z * Forward one window (and set window to _N).
w * Backward one window (and set window to _N).
ESC-SPACE * Forward one window, but don't stop at end-of-file.
d ^D * Forward one half-window (and set half-window to _N).
u ^U * Backward one half-window (and set half-window to _N).
ESC-) RightArrow * Right one half screen width (or _N positions).
ESC-( LeftArrow * Left one half screen width (or _N positions).
ESC-} ^RightArrow Right to last column displayed.
ESC-{ ^LeftArrow Left to first column.
F Forward forever; like "tail -f".
ESC-F Like F but stop when search pattern is found.
r ^R ^L Repaint screen.
R Repaint screen, discarding buffered input.
---------------------------------------------------
Default "window" is the screen height.
Default "half-window" is half of the screen height.
---------------------------------------------------------------------------
SSEEAARRCCHHIINNGG
/_p_a_t_t_e_r_n * Search forward for (_N-th) matching line.
?_p_a_t_t_e_r_n * Search backward for (_N-th) matching line.
n * Repeat previous search (for _N-th occurrence).
N * Repeat previous search in reverse direction.
ESC-n * Repeat previous search, spanning files.
ESC-N * Repeat previous search, reverse dir. & spanning files.
ESC-u Undo (toggle) search highlighting.
&_p_a_t_t_e_r_n * Display only matching lines
---------------------------------------------------
A search pattern may be preceded by one or more of:
^N or ! Search for NON-matching lines.
^E or * Search multiple files (pass thru END OF FILE).
^F or @ Start search at FIRST file (for /) or last file (for ?).
^K Highlight matches, but don't move (KEEP position).
^R Don't use REGULAR EXPRESSIONS.
---------------------------------------------------------------------------
JJUUMMPPIINNGG
g < ESC-< * Go to first line in file (or line _N).
G > ESC-> * Go to last line in file (or line _N).
p % * Go to beginning of file (or _N percent into file).
t * Go to the (_N-th) next tag.
T * Go to the (_N-th) previous tag.
{ ( [ * Find close bracket } ) ].
} ) ] * Find open bracket { ( [.
ESC-^F _<_c_1_> _<_c_2_> * Find close bracket _<_c_2_>.
ESC-^B _<_c_1_> _<_c_2_> * Find open bracket _<_c_1_>
---------------------------------------------------
Each "find close bracket" command goes forward to the close bracket
matching the (_N-th) open bracket in the top line.
Each "find open bracket" command goes backward to the open bracket
matching the (_N-th) close bracket in the bottom line.
m_<_l_e_t_t_e_r_> Mark the current top line with <letter>.
M_<_l_e_t_t_e_r_> Mark the current bottom line with <letter>.
'_<_l_e_t_t_e_r_> Go to a previously marked position.
'' Go to the previous position.
^X^X Same as '.
ESC-M_<_l_e_t_t_e_r_> Clear a mark.
---------------------------------------------------
A mark is any upper-case or lower-case letter.
Certain marks are predefined:
^ means beginning of the file
$ means end of the file
---------------------------------------------------------------------------
CCHHAANNGGIINNGG FFIILLEESS
:e [_f_i_l_e] Examine a new file.
^X^V Same as :e.
:n * Examine the (_N-th) next file from the command line.
:p * Examine the (_N-th) previous file from the command line.
:x * Examine the first (or _N-th) file from the command line.
:d Delete the current file from the command line list.
= ^G :f Print current file name.
---------------------------------------------------------------------------
MMIISSCCEELLLLAANNEEOOUUSS CCOOMMMMAANNDDSS
-_<_f_l_a_g_> Toggle a command line option [see OPTIONS below].
--_<_n_a_m_e_> Toggle a command line option, by name.
__<_f_l_a_g_> Display the setting of a command line option.
___<_n_a_m_e_> Display the setting of an option, by name.
+_c_m_d Execute the less cmd each time a new file is examined.
!_c_o_m_m_a_n_d Execute the shell command with $SHELL.
|XX_c_o_m_m_a_n_d Pipe file between current pos & mark XX to shell command.
s _f_i_l_e Save input to a file.
v Edit the current file with $VISUAL or $EDITOR.
V Print version number of "less".
---------------------------------------------------------------------------
OOPPTTIIOONNSS
Most options may be changed either on the command line,
or from within less by using the - or -- command.
Options may be given in one of two forms: either a single
character preceded by a -, or a name preceded by --.
-? ........ --help
Display help (from command line).
-a ........ --search-skip-screen
Search skips current screen.
-A ........ --SEARCH-SKIP-SCREEN
Search starts just after target line.
-b [_N] .... --buffers=[_N]
Number of buffers.
-B ........ --auto-buffers
Don't automatically allocate buffers for pipes.
-c -C .... --clear-screen --CLEAR-SCREEN
Repaint by clearing rather than scrolling.
-d ........ --dumb
Dumb terminal.
-D [_x_n_._n] . --color=_x_n_._n
Set screen colors. (MS-DOS only)
-e -E .... --quit-at-eof --QUIT-AT-EOF
Quit at end of file.
-f ........ --force
Force open non-regular files.
-F ........ --quit-if-one-screen
Quit if entire file fits on first screen.
-g ........ --hilite-search
Highlight only last match for searches.
-G ........ --HILITE-SEARCH
Don't highlight any matches for searches.
--old-bot
Revert to the old bottom of screen behavior.
-h [_N] .... --max-back-scroll=[_N]
Backward scroll limit.
-i ........ --ignore-case
Ignore case in searches that do not contain uppercase.
-I ........ --IGNORE-CASE
Ignore case in all searches.
-j [_N] .... --jump-target=[_N]
Screen position of target lines.
-J ........ --status-column
Display a status column at left edge of screen.
-k [_f_i_l_e] . --lesskey-file=[_f_i_l_e]
Use a lesskey file.
-K --quit-on-intr
Exit less in response to ctrl-C.
-L ........ --no-lessopen
Ignore the LESSOPEN environment variable.
-m -M .... --long-prompt --LONG-PROMPT
Set prompt style.
-n ........ --line-numbers
Don't use line numbers.
-N ........ --LINE-NUMBERS
Use line numbers.
-o [_f_i_l_e] . --log-file=[_f_i_l_e]
Copy to log file (standard input only).
-O [_f_i_l_e] . --LOG-FILE=[_f_i_l_e]
Copy to log file (unconditionally overwrite).
-p [_p_a_t_t_e_r_n] --pattern=[_p_a_t_t_e_r_n]
Start at pattern (from command line).
-P [_p_r_o_m_p_t] --prompt=[_p_r_o_m_p_t]
Define new prompt.
-q -Q .... --quiet --QUIET --silent --SILENT
Quiet the terminal bell.
-r -R .... --raw-control-chars --RAW-CONTROL-CHARS
Output "raw" control characters.
-s ........ --squeeze-blank-lines
Squeeze multiple blank lines.
-S ........ --chop-long-lines
Chop (truncate) long lines rather than wrapping.
-t [_t_a_g] .. --tag=[_t_a_g]
Find a tag.
-T [_t_a_g_s_f_i_l_e] --tag-file=[_t_a_g_s_f_i_l_e]
Use an alternate tags file.
-u -U .... --underline-special --UNDERLINE-SPECIAL
Change handling of backspaces.
-V ........ --version
Display the version number of "less".
-w ........ --hilite-unread
Highlight first new line after forward-screen.
-W ........ --HILITE-UNREAD
Highlight first new line after any forward movement.
-x [_N[,...]] --tabs=[_N[,...]]
Set tab stops.
-X ........ --no-init
Don't use termcap init/deinit strings.
-y [_N] .... --max-forw-scroll=[_N]
Forward scroll limit.
-z [_N] .... --window=[_N]
Set size of window.
-" [_c[_c]] . --quotes=[_c[_c]]
Set shell quote characters.
-~ ........ --tilde
Don't display tildes after end of file.
-# [_N] .... --shift=[_N]
Horizontal scroll amount (0 = one half screen width)
........ --no-keypad
Don't send termcap keypad init/deinit strings.
........ --follow-name
The F command changes files if the input file is renamed.
........ --use-backslash
Subsequent options use backslash as escape char.
---------------------------------------------------------------------------
LLIINNEE EEDDIITTIINNGG
These keys can be used to edit text being entered
on the "command line" at the bottom of the screen.
RightArrow ..................... ESC-l ... Move cursor right one character.
LeftArrow ...................... ESC-h ... Move cursor left one character.
ctrl-RightArrow ESC-RightArrow ESC-w ... Move cursor right one word.
ctrl-LeftArrow ESC-LeftArrow ESC-b ... Move cursor left one word.
HOME ........................... ESC-0 ... Move cursor to start of line.
END ............................ ESC-$ ... Move cursor to end of line.
BACKSPACE ................................ Delete char to left of cursor.
DELETE ......................... ESC-x ... Delete char under cursor.
ctrl-BACKSPACE ESC-BACKSPACE ........... Delete word to left of cursor.
ctrl-DELETE .... ESC-DELETE .... ESC-X ... Delete word under cursor.
ctrl-U ......... ESC (MS-DOS only) ....... Delete entire line.
UpArrow ........................ ESC-k ... Retrieve previous command line.
DownArrow ...................... ESC-j ... Retrieve next command line.
TAB ...................................... Complete filename & cycle.
SHIFT-TAB ...................... ESC-TAB Complete filename & reverse cycle.
ctrl-L ................................... Complete filename, list all.
SSUUMMMMAARRYY OOFF LLEESSSS CCOOMMMMAANNDDSS
Commands marked with * may be preceded by a number, _N.
Notes in parentheses indicate the behavior if _N is given.
A key preceded by a caret indicates the Ctrl key; thus ^K is ctrl-K.
h H Display this help.
q :q Q :Q ZZ Exit.
---------------------------------------------------------------------------
MMOOVVIINNGG
e ^E j ^N CR * Forward one line (or _N lines).
y ^Y k ^K ^P * Backward one line (or _N lines).
f ^F ^V SPACE * Forward one window (or _N lines).
b ^B ESC-v * Backward one window (or _N lines).
z * Forward one window (and set window to _N).
w * Backward one window (and set window to _N).
ESC-SPACE * Forward one window, but don't stop at end-of-file.
d ^D * Forward one half-window (and set half-window to _N).
u ^U * Backward one half-window (and set half-window to _N).
ESC-) RightArrow * Right one half screen width (or _N positions).
ESC-( LeftArrow * Left one half screen width (or _N positions).
ESC-} ^RightArrow Right to last column displayed.
ESC-{ ^LeftArrow Left to first column.
F Forward forever; like "tail -f".
ESC-F Like F but stop when search pattern is found.
r ^R ^L Repaint screen.
R Repaint screen, discarding buffered input.
---------------------------------------------------
Default "window" is the screen height.
Default "half-window" is half of the screen height.
---------------------------------------------------------------------------
SSEEAARRCCHHIINNGG
/_p_a_t_t_e_r_n * Search forward for (_N-th) matching line.
?_p_a_t_t_e_r_n * Search backward for (_N-th) matching line.
n * Repeat previous search (for _N-th occurrence).
N * Repeat previous search in reverse direction.
ESC-n * Repeat previous search, spanning files.
ESC-N * Repeat previous search, reverse dir. & spanning files.
ESC-u Undo (toggle) search highlighting.
&_p_a_t_t_e_r_n * Display only matching lines
---------------------------------------------------
A search pattern may be preceded by one or more of:
^N or ! Search for NON-matching lines.
^E or * Search multiple files (pass thru END OF FILE).
^F or @ Start search at FIRST file (for /) or last file (for ?).
^K Highlight matches, but don't move (KEEP position).
^R Don't use REGULAR EXPRESSIONS.
---------------------------------------------------------------------------
JJUUMMPPIINNGG
g < ESC-< * Go to first line in file (or line _N).
G > ESC-> * Go to last line in file (or line _N).
p % * Go to beginning of file (or _N percent into file).
t * Go to the (_N-th) next tag.
T * Go to the (_N-th) previous tag.
{ ( [ * Find close bracket } ) ].
} ) ] * Find open bracket { ( [.
ESC-^F _<_c_1_> _<_c_2_> * Find close bracket _<_c_2_>.
ESC-^B _<_c_1_> _<_c_2_> * Find open bracket _<_c_1_>
---------------------------------------------------
Each "find close bracket" command goes forward to the close bracket
matching the (_N-th) open bracket in the top line.
Each "find open bracket" command goes backward to the open bracket
matching the (_N-th) close bracket in the bottom line.
m_<_l_e_t_t_e_r_> Mark the current top line with <letter>.
M_<_l_e_t_t_e_r_> Mark the current bottom line with <letter>.
'_<_l_e_t_t_e_r_> Go to a previously marked position.
'' Go to the previous position.
^X^X Same as '.
ESC-M_<_l_e_t_t_e_r_> Clear a mark.
---------------------------------------------------
A mark is any upper-case or lower-case letter.
Certain marks are predefined:
^ means beginning of the file
$ means end of the file
---------------------------------------------------------------------------
CCHHAANNGGIINNGG FFIILLEESS
:e [_f_i_l_e] Examine a new file.
^X^V Same as :e.
:n * Examine the (_N-th) next file from the command line.
:p * Examine the (_N-th) previous file from the command line.
:x * Examine the first (or _N-th) file from the command line.
:d Delete the current file from the command line list.
= ^G :f Print current file name.
---------------------------------------------------------------------------
MMIISSCCEELLLLAANNEEOOUUSS CCOOMMMMAANNDDSS
-_<_f_l_a_g_> Toggle a command line option [see OPTIONS below].
--_<_n_a_m_e_> Toggle a command line option, by name.
__<_f_l_a_g_> Display the setting of a command line option.
___<_n_a_m_e_> Display the setting of an option, by name.
+_c_m_d Execute the less cmd each time a new file is examined.
!_c_o_m_m_a_n_d Execute the shell command with $SHELL.
|XX_c_o_m_m_a_n_d Pipe file between current pos & mark XX to shell command.
s _f_i_l_e Save input to a file.
v Edit the current file with $VISUAL or $EDITOR.
V Print version number of "less".
---------------------------------------------------------------------------
OOPPTTIIOONNSS
Most options may be changed either on the command line,
or from within less by using the - or -- command.
Options may be given in one of two forms: either a single
character preceded by a -, or a name preceded by --.
-? ........ --help
Display help (from command line).
-a ........ --search-skip-screen
Search skips current screen.
-A ........ --SEARCH-SKIP-SCREEN
Search starts just after target line.
-b [_N] .... --buffers=[_N]
Number of buffers.
-B ........ --auto-buffers
Don't automatically allocate buffers for pipes.
-c -C .... --clear-screen --CLEAR-SCREEN
Repaint by clearing rather than scrolling.
-d ........ --dumb
Dumb terminal.
-D [_x_n_._n] . --color=_x_n_._n
Set screen colors. (MS-DOS only)
-e -E .... --quit-at-eof --QUIT-AT-EOF
Quit at end of file.
-f ........ --force
Force open non-regular files.
-F ........ --quit-if-one-screen
Quit if entire file fits on first screen.
-g ........ --hilite-search
Highlight only last match for searches.
-G ........ --HILITE-SEARCH
Don't highlight any matches for searches.
--old-bot
Revert to the old bottom of screen behavior.
-h [_N] .... --max-back-scroll=[_N]
Backward scroll limit.
-i ........ --ignore-case
Ignore case in searches that do not contain uppercase.
-I ........ --IGNORE-CASE
Ignore case in all searches.
-j [_N] .... --jump-target=[_N]
Screen position of target lines.
-J ........ --status-column
Display a status column at left edge of screen.
-k [_f_i_l_e] . --lesskey-file=[_f_i_l_e]
Use a lesskey file.
-K --quit-on-intr
Exit less in response to ctrl-C.
-L ........ --no-lessopen
Ignore the LESSOPEN environment variable.
-m -M .... --long-prompt --LONG-PROMPT
Set prompt style.
-n ........ --line-numbers
Don't use line numbers.
-N ........ --LINE-NUMBERS
Use line numbers.
-o [_f_i_l_e] . --log-file=[_f_i_l_e]
Copy to log file (standard input only).
-O [_f_i_l_e] . --LOG-FILE=[_f_i_l_e]
Copy to log file (unconditionally overwrite).
-p [_p_a_t_t_e_r_n] --pattern=[_p_a_t_t_e_r_n]
Start at pattern (from command line).
-P [_p_r_o_m_p_t] --prompt=[_p_r_o_m_p_t]
Define new prompt.
-q -Q .... --quiet --QUIET --silent --SILENT
Quiet the terminal bell.
-r -R .... --raw-control-chars --RAW-CONTROL-CHARS
Output "raw" control characters.
-s ........ --squeeze-blank-lines
Squeeze multiple blank lines.
-S ........ --chop-long-lines
Chop (truncate) long lines rather than wrapping.
-t [_t_a_g] .. --tag=[_t_a_g]
Find a tag.
-T [_t_a_g_s_f_i_l_e] --tag-file=[_t_a_g_s_f_i_l_e]
Use an alternate tags file.
-u -U .... --underline-special --UNDERLINE-SPECIAL
Change handling of backspaces.
-V ........ --version
Display the version number of "less".
-w ........ --hilite-unread
Highlight first new line after forward-screen.
-W ........ --HILITE-UNREAD
Highlight first new line after any forward movement.
-x [_N[,...]] --tabs=[_N[,...]]
Set tab stops.
-X ........ --no-init
Don't use termcap init/deinit strings.
-y [_N] .... --max-forw-scroll=[_N]
Forward scroll limit.
-z [_N] .... --window=[_N]
Set size of window.
-" [_c[_c]] . --quotes=[_c[_c]]
Set shell quote characters.
-~ ........ --tilde
Don't display tildes after end of file.
-# [_N] .... --shift=[_N]
Horizontal scroll amount (0 = one half screen width)
........ --no-keypad
Don't send termcap keypad init/deinit strings.
........ --follow-name
The F command changes files if the input file is renamed.
........ --use-backslash
Subsequent options use backslash as escape char.
---------------------------------------------------------------------------
LLIINNEE EEDDIITTIINNGG
These keys can be used to edit text being entered
on the "command line" at the bottom of the screen.
RightArrow ..................... ESC-l ... Move cursor right one character.
LeftArrow ...................... ESC-h ... Move cursor left one character.
ctrl-RightArrow ESC-RightArrow ESC-w ... Move cursor right one word.
ctrl-LeftArrow ESC-LeftArrow ESC-b ... Move cursor left one word.
HOME ........................... ESC-0 ... Move cursor to start of line.
END ............................ ESC-$ ... Move cursor to end of line.
BACKSPACE ................................ Delete char to left of cursor.
DELETE ......................... ESC-x ... Delete char under cursor.
ctrl-BACKSPACE ESC-BACKSPACE ........... Delete word to left of cursor.
ctrl-DELETE .... ESC-DELETE .... ESC-X ... Delete word under cursor.
ctrl-U ......... ESC (MS-DOS only) ....... Delete entire line.
UpArrow ........................ ESC-k ... Retrieve previous command line.
DownArrow ...................... ESC-j ... Retrieve next command line.
TAB ...................................... Complete filename & cycle.
SHIFT-TAB ...................... ESC-TAB Complete filename & reverse cycle.
ctrl-L ................................... Complete filename, list all.
Regular → Executable
View File
+5 -2
View File
@@ -3,7 +3,10 @@
"name": "laravel/laravel", "name": "laravel/laravel",
"type": "project", "type": "project",
"description": "The skeleton application for the Laravel framework.", "description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"], "keywords": [
"laravel",
"framework"
],
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.2", "php": "^8.2",
@@ -16,8 +19,8 @@
"laravel/ui": "^4.6", "laravel/ui": "^4.6",
"laravolt/avatar": "^6.0", "laravolt/avatar": "^6.0",
"league/flysystem-webdav": "^3.29", "league/flysystem-webdav": "^3.29",
"maatwebsite/excel": "^3.1",
"nino/laravel-nextcloud-fs": "^2.0", "nino/laravel-nextcloud-fs": "^2.0",
"phpoffice/phpspreadsheet": "^3.5",
"singlequote/laravel-webdav": "^1.1", "singlequote/laravel-webdav": "^1.1",
"spatie/image": "^3.7", "spatie/image": "^3.7",
"spatie/laravel-html": "^3.11", "spatie/laravel-html": "^3.11",
Generated
+1862 -1169
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -47,7 +47,7 @@ return [
| |
*/ */
'timezone' => env('APP_TIMEZONE', 'UTC'), 'timezone' => env('APP_TIMEZONE', 'Asia/Jakarta'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
+16 -2
View File
@@ -62,6 +62,20 @@ return [
]) : [], ]) : [],
], ],
'mysql_lama' => [
'driver' => 'mysql',
'host' => env('DB_OLD_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_OLD_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'strict' => true,
'engine' => null,
],
'mariadb' => [ 'mariadb' => [
'driver' => 'mariadb', 'driver' => 'mariadb',
'url' => env('DB_URL'), 'url' => env('DB_URL'),
@@ -78,8 +92,8 @@ return [
'strict' => true, 'strict' => true,
'engine' => null, 'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([ 'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), \Pdo\Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [], ]) : [],
], ],
'pgsql' => [ 'pgsql' => [
@@ -6,8 +6,13 @@ use Illuminate\Support\Facades\Schema;
return new class extends Migration return new class extends Migration
{ {
/**
* Run the migrations.
*/
public function up(): void public function up(): void
{ {
// Pengecekan agar tidak error jika tabel sudah ada di database
if (!Schema::hasTable('attachment_form')) {
Schema::create('attachment_form', function (Blueprint $table) { Schema::create('attachment_form', function (Blueprint $table) {
$table->bigIncrements('id'); $table->bigIncrements('id');
$table->unsignedBigInteger('form_id'); $table->unsignedBigInteger('form_id');
@@ -17,10 +22,15 @@ return new class extends Migration
$table->timestamps(); $table->timestamps();
$table->softDeletes(); $table->softDeletes();
// Menambahkan index untuk performa query
$table->index(['form_id', 'table_name'], 'idx_form_table'); $table->index(['form_id', 'table_name'], 'idx_form_table');
}); });
} }
}
/**
* Reverse the migrations.
*/
public function down(): void public function down(): void
{ {
Schema::dropIfExists('attachment_form'); Schema::dropIfExists('attachment_form');
@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('budget_control', function (Blueprint $table) {
// CEK: Hanya tambah kolom jika belum ada di database
if (!Schema::hasColumn('budget_control', 'region_id')) {
$table->unsignedBigInteger('region_id')->nullable()->after('cabang_id');
}
});
}
public function down(): void
{
Schema::table('budget_control', function (Blueprint $table) {
if (Schema::hasColumn('budget_control', 'region_id')) {
$table->dropColumn('region_id');
}
});
}
};
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('budget_control', function (Blueprint $table) {
// Menambahkan kolom bukti transfer (berisi path file)
$table->string('transfer_proof')->after('remarks')->nullable();
// Menambahkan status transfer untuk approval Accounting
$table->enum('transfer_status', ['Pending', 'Approved', 'Rejected'])
->default('Pending')
->after('transfer_proof');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('budget_control', function (Blueprint $table) {
// Menghapus kolom jika migration di-rollback
$table->dropColumn(['transfer_proof', 'transfer_status']);
});
}
};
@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
// TAHAP 1: Ubah kolom menjadi string (VARCHAR) agar bisa menerima teks apapun
Schema::table('budget_control', function (Blueprint $table) {
$table->string('transfer_status')->change();
});
// TAHAP 2: Update data lama ke status baru
// Kita tangani semua kemungkinan data agar tidak ada yang 'nyangkut'
DB::table('budget_control')
->where('transfer_status', 'Approved')
->update(['transfer_status' => 'Validated']);
// Opsional: Jika ada data kosong/null, amankan ke 'Pending'
DB::table('budget_control')
->whereNull('transfer_status')
->orWhere('transfer_status', '')
->update(['transfer_status' => 'Pending']);
// TAHAP 3: Kembalikan kolom ke ENUM dengan definisi baru
Schema::table('budget_control', function (Blueprint $table) {
$table->enum('transfer_status', ['Pending', 'Waiting Approval', 'Validated', 'Rejected'])
->default('Pending')
->change();
});
}
public function down(): void
{
// Rollback: Kembalikan ke definisi awal jika diperlukan
Schema::table('budget_control', function (Blueprint $table) {
$table->enum('transfer_status', ['Pending', 'Approved', 'Rejected'])
->default('Pending')
->change();
});
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
// FK ke tabel region dan cabang
$table->foreignId('region_id')->after('password')->nullable()->constrained('region');
$table->foreignId('cabang_id')->after('region_id')->nullable()->constrained('cabang');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
//
});
}
};
@@ -0,0 +1,93 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// 1. Jika tabel belum ada sama sekali, buat baru dengan struktur lengkap
if (!Schema::hasTable('transactions')) {
Schema::create('transactions', function (Blueprint $table) {
$table->id();
$table->foreignId('cabang_id')->constrained('cabang')->onDelete('cascade');
$table->foreignId('budget_control_id')->nullable()->constrained('budget_control')->onDelete('set null');
$table->date('transaction_date');
$table->string('reference_number')->unique();
$table->string('description');
$table->decimal('amount', 15, 2);
$table->enum('type', ['DEBIT', 'CREDIT']);
$table->string('category')->default('General');
$table->string('periode_month');
$table->year('periode_year');
$table->foreignId('created_by')->constrained('admins');
$table->timestamps();
$table->softDeletes();
});
return;
}
// 2. JIKA TABEL SUDAH EXIST: Tambahkan kolom baru tanpa merusak / menimpa kolom cabang_id yang sudah ada
Schema::table('transactions', function (Blueprint $table) {
// Pastikan cabang_id tidak dibuat ulang, kita hanya memastikan tipenya aman jika belum ada
if (!Schema::hasColumn('transactions', 'cabang_id')) {
$table->foreignId('cabang_id')->constrained('cabang')->onDelete('cascade');
}
// Tambahkan kolom pendukung lainnya secara aman jika belum eksis di database
if (!Schema::hasColumn('transactions', 'budget_control_id')) {
$table->foreignId('budget_control_id')->nullable()->after('cabang_id')->constrained('budget_control')->onDelete('set null');
}
if (!Schema::hasColumn('transactions', 'transaction_date')) {
$table->date('transaction_date')->nullable()->after('budget_control_id');
}
if (!Schema::hasColumn('transactions', 'reference_number')) {
$table->string('reference_number')->nullable()->after('transaction_date');
}
if (!Schema::hasColumn('transactions', 'description')) {
$table->string('description')->nullable()->after('reference_number');
}
if (!Schema::hasColumn('transactions', 'amount')) {
$table->decimal('amount', 15, 2)->default(0)->after('description');
}
if (!Schema::hasColumn('transactions', 'type')) {
$table->enum('type', ['DEBIT', 'CREDIT'])->default('CREDIT')->after('amount');
}
if (!Schema::hasColumn('transactions', 'category')) {
$table->string('category')->default('General')->after('type');
}
if (!Schema::hasColumn('transactions', 'periode_month')) {
$table->string('periode_month')->nullable()->after('category');
}
if (!Schema::hasColumn('transactions', 'periode_year')) {
$table->year('periode_year')->nullable()->after('periode_month');
}
if (!Schema::hasColumn('transactions', 'created_by')) {
// Buat nullable dulu agar baris data lama yang sudah exist tidak error saat migrasi berjalan
$table->foreignId('created_by')->nullable()->after('periode_year')->constrained('admins');
}
if (!Schema::hasColumn('transactions', 'deleted_at')) {
$table->softDeletes();
}
});
}
public function down(): void
{
// Opsional: Hanya drop jika Anda benar-benar sedang di fase development murni
// Schema::dropIfExists('transactions');
}
};
@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('form_entertainment_presentation', function (Blueprint $table) {
$table->string('jabatan')->nullable()->after('status');
$table->string('nama_perusahaan')->nullable()->after('jabatan');
$table->string('jenis_usaha')->nullable()->after('nama_perusahaan');
});
}
public function down(): void
{
Schema::table('form_entertainment_presentation', function (Blueprint $table) {
$table->dropColumn(['jabatan', 'nama_perusahaan', 'jenis_usaha']);
});
}
};
@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
protected $tables = [
'form_up_country',
'form_vehicle_running_cost',
'form_entertainment_presentation',
'form_meeting_seminar',
'form_others'
];
public function up(): void
{
foreach ($this->tables as $table) {
if (Schema::hasTable($table) && !Schema::hasColumn($table, 'rejected_by')) {
Schema::table($table, function (Blueprint $table) {
// Menambahkan kolom rejected_by sebagai foreign key ke tabel admins
$table->unsignedBigInteger('rejected_by')->nullable()->after('status');
$table->timestamp('rejected_at')->nullable()->after('rejected_by');
$table->foreign('rejected_by')->references('id')->on('admins')->onDelete('set null');
});
}
}
}
public function down(): void
{
foreach ($this->tables as $table) {
if (Schema::hasColumn($table, 'rejected_by')) {
Schema::table($table, function (Blueprint $table) {
$table->dropForeign([$table . '_rejected_by_foreign']);
$table->dropColumn(['rejected_by', 'rejected_at']);
});
}
}
}
};
@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
protected $tables = [
'form_up_country',
'form_vehicle_running_cost',
'form_entertainment_presentation',
'form_meeting_seminar'
];
public function up(): void
{
foreach ($this->tables as $table) {
if (Schema::hasTable($table) && !Schema::hasColumn($table, 'kategori_id')) {
Schema::table($table, function (Blueprint $bp) {
// Kita panggil nama tabel asli Anda 'kategori' di dalam fungsi constrained()
$bp->foreignId('kategori_id')
->nullable()
->after('user_id')
->constrained('kategori') // <-- PERBAIKAN: Menembak tabel 'kategori'
->onDelete('set null');
});
}
}
}
public function down(): void
{
foreach ($this->tables as $table) {
if (Schema::hasColumn($table, 'kategori_id')) {
Schema::table($table, function (Blueprint $bp) use ($table) {
$bp->dropForeign($table . '_kategori_id_foreign');
$bp->dropColumn('kategori_id');
});
}
}
}
};
+258
View File
@@ -0,0 +1,258 @@
SSUUMMMMAARRYY OOFF LLEESSSS CCOOMMMMAANNDDSS
Commands marked with * may be preceded by a number, _N.
Notes in parentheses indicate the behavior if _N is given.
A key preceded by a caret indicates the Ctrl key; thus ^K is ctrl-K.
h H Display this help.
q :q Q :Q ZZ Exit.
---------------------------------------------------------------------------
MMOOVVIINNGG
e ^E j ^N CR * Forward one line (or _N lines).
y ^Y k ^K ^P * Backward one line (or _N lines).
f ^F ^V SPACE * Forward one window (or _N lines).
b ^B ESC-v * Backward one window (or _N lines).
z * Forward one window (and set window to _N).
w * Backward one window (and set window to _N).
ESC-SPACE * Forward one window, but don't stop at end-of-file.
d ^D * Forward one half-window (and set half-window to _N).
u ^U * Backward one half-window (and set half-window to _N).
ESC-) RightArrow * Right one half screen width (or _N positions).
ESC-( LeftArrow * Left one half screen width (or _N positions).
ESC-} ^RightArrow Right to last column displayed.
ESC-{ ^LeftArrow Left to first column.
F Forward forever; like "tail -f".
ESC-F Like F but stop when search pattern is found.
r ^R ^L Repaint screen.
R Repaint screen, discarding buffered input.
---------------------------------------------------
Default "window" is the screen height.
Default "half-window" is half of the screen height.
---------------------------------------------------------------------------
SSEEAARRCCHHIINNGG
/_p_a_t_t_e_r_n * Search forward for (_N-th) matching line.
?_p_a_t_t_e_r_n * Search backward for (_N-th) matching line.
n * Repeat previous search (for _N-th occurrence).
N * Repeat previous search in reverse direction.
ESC-n * Repeat previous search, spanning files.
ESC-N * Repeat previous search, reverse dir. & spanning files.
ESC-u Undo (toggle) search highlighting.
ESC-U Clear search highlighting.
&_p_a_t_t_e_r_n * Display only matching lines.
---------------------------------------------------
A search pattern may begin with one or more of:
^N or ! Search for NON-matching lines.
^E or * Search multiple files (pass thru END OF FILE).
^F or @ Start search at FIRST file (for /) or last file (for ?).
^K Highlight matches, but don't move (KEEP position).
^R Don't use REGULAR EXPRESSIONS.
^W WRAP search if no match found.
---------------------------------------------------------------------------
JJUUMMPPIINNGG
g < ESC-< * Go to first line in file (or line _N).
G > ESC-> * Go to last line in file (or line _N).
p % * Go to beginning of file (or _N percent into file).
t * Go to the (_N-th) next tag.
T * Go to the (_N-th) previous tag.
{ ( [ * Find close bracket } ) ].
} ) ] * Find open bracket { ( [.
ESC-^F _<_c_1_> _<_c_2_> * Find close bracket _<_c_2_>.
ESC-^B _<_c_1_> _<_c_2_> * Find open bracket _<_c_1_>.
---------------------------------------------------
Each "find close bracket" command goes forward to the close bracket
matching the (_N-th) open bracket in the top line.
Each "find open bracket" command goes backward to the open bracket
matching the (_N-th) close bracket in the bottom line.
m_<_l_e_t_t_e_r_> Mark the current top line with <letter>.
M_<_l_e_t_t_e_r_> Mark the current bottom line with <letter>.
'_<_l_e_t_t_e_r_> Go to a previously marked position.
'' Go to the previous position.
^X^X Same as '.
ESC-M_<_l_e_t_t_e_r_> Clear a mark.
---------------------------------------------------
A mark is any upper-case or lower-case letter.
Certain marks are predefined:
^ means beginning of the file
$ means end of the file
---------------------------------------------------------------------------
CCHHAANNGGIINNGG FFIILLEESS
:e [_f_i_l_e] Examine a new file.
^X^V Same as :e.
:n * Examine the (_N-th) next file from the command line.
:p * Examine the (_N-th) previous file from the command line.
:x * Examine the first (or _N-th) file from the command line.
:d Delete the current file from the command line list.
= ^G :f Print current file name.
---------------------------------------------------------------------------
MMIISSCCEELLLLAANNEEOOUUSS CCOOMMMMAANNDDSS
-_<_f_l_a_g_> Toggle a command line option [see OPTIONS below].
--_<_n_a_m_e_> Toggle a command line option, by name.
__<_f_l_a_g_> Display the setting of a command line option.
___<_n_a_m_e_> Display the setting of an option, by name.
+_c_m_d Execute the less cmd each time a new file is examined.
!_c_o_m_m_a_n_d Execute the shell command with $SHELL.
|XX_c_o_m_m_a_n_d Pipe file between current pos & mark XX to shell command.
s _f_i_l_e Save input to a file.
v Edit the current file with $VISUAL or $EDITOR.
V Print version number of "less".
---------------------------------------------------------------------------
OOPPTTIIOONNSS
Most options may be changed either on the command line,
or from within less by using the - or -- command.
Options may be given in one of two forms: either a single
character preceded by a -, or a name preceded by --.
-? ........ --help
Display help (from command line).
-a ........ --search-skip-screen
Search skips current screen.
-A ........ --SEARCH-SKIP-SCREEN
Search starts just after target line.
-b [_N] .... --buffers=[_N]
Number of buffers.
-B ........ --auto-buffers
Don't automatically allocate buffers for pipes.
-c ........ --clear-screen
Repaint by clearing rather than scrolling.
-d ........ --dumb
Dumb terminal.
-D xx_c_o_l_o_r . --color=xx_c_o_l_o_r
Set screen colors.
-e -E .... --quit-at-eof --QUIT-AT-EOF
Quit at end of file.
-f ........ --force
Force open non-regular files.
-F ........ --quit-if-one-screen
Quit if entire file fits on first screen.
-g ........ --hilite-search
Highlight only last match for searches.
-G ........ --HILITE-SEARCH
Don't highlight any matches for searches.
-h [_N] .... --max-back-scroll=[_N]
Backward scroll limit.
-i ........ --ignore-case
Ignore case in searches that do not contain uppercase.
-I ........ --IGNORE-CASE
Ignore case in all searches.
-j [_N] .... --jump-target=[_N]
Screen position of target lines.
-J ........ --status-column
Display a status column at left edge of screen.
-k [_f_i_l_e] . --lesskey-file=[_f_i_l_e]
Use a lesskey file.
-K ........ --quit-on-intr
Exit less in response to ctrl-C.
-L ........ --no-lessopen
Ignore the LESSOPEN environment variable.
-m -M .... --long-prompt --LONG-PROMPT
Set prompt style.
-n -N .... --line-numbers --LINE-NUMBERS
Don't use line numbers.
-o [_f_i_l_e] . --log-file=[_f_i_l_e]
Copy to log file (standard input only).
-O [_f_i_l_e] . --LOG-FILE=[_f_i_l_e]
Copy to log file (unconditionally overwrite).
-p [_p_a_t_t_e_r_n] --pattern=[_p_a_t_t_e_r_n]
Start at pattern (from command line).
-P [_p_r_o_m_p_t] --prompt=[_p_r_o_m_p_t]
Define new prompt.
-q -Q .... --quiet --QUIET --silent --SILENT
Quiet the terminal bell.
-r -R .... --raw-control-chars --RAW-CONTROL-CHARS
Output "raw" control characters.
-s ........ --squeeze-blank-lines
Squeeze multiple blank lines.
-S ........ --chop-long-lines
Chop (truncate) long lines rather than wrapping.
-t [_t_a_g] .. --tag=[_t_a_g]
Find a tag.
-T [_t_a_g_s_f_i_l_e] --tag-file=[_t_a_g_s_f_i_l_e]
Use an alternate tags file.
-u -U .... --underline-special --UNDERLINE-SPECIAL
Change handling of backspaces.
-V ........ --version
Display the version number of "less".
-w ........ --hilite-unread
Highlight first new line after forward-screen.
-W ........ --HILITE-UNREAD
Highlight first new line after any forward movement.
-x [_N[,...]] --tabs=[_N[,...]]
Set tab stops.
-X ........ --no-init
Don't use termcap init/deinit strings.
-y [_N] .... --max-forw-scroll=[_N]
Forward scroll limit.
-z [_N] .... --window=[_N]
Set size of window.
-" [_c[_c]] . --quotes=[_c[_c]]
Set shell quote characters.
-~ ........ --tilde
Don't display tildes after end of file.
-# [_N] .... --shift=[_N]
Set horizontal scroll amount (0 = one half screen width).
--file-size
Automatically determine the size of the input file.
--follow-name
The F command changes files if the input file is renamed.
--incsearch
Search file as each pattern character is typed in.
--line-num-width=N
Set the width of the -N line number field to N characters.
--mouse
Enable mouse input.
--no-keypad
Don't send termcap keypad init/deinit strings.
--no-histdups
Remove duplicates from command history.
--rscroll=C
Set the character used to mark truncated lines.
--save-marks
Retain marks across invocations of less.
--status-col-width=N
Set the width of the -J status column to N characters.
--use-backslash
Subsequent options use backslash as escape char.
--use-color
Enables colored text.
--wheel-lines=N
Each click of the mouse wheel moves N lines.
---------------------------------------------------------------------------
LLIINNEE EEDDIITTIINNGG
These keys can be used to edit text being entered
on the "command line" at the bottom of the screen.
RightArrow ..................... ESC-l ... Move cursor right one character.
LeftArrow ...................... ESC-h ... Move cursor left one character.
ctrl-RightArrow ESC-RightArrow ESC-w ... Move cursor right one word.
ctrl-LeftArrow ESC-LeftArrow ESC-b ... Move cursor left one word.
HOME ........................... ESC-0 ... Move cursor to start of line.
END ............................ ESC-$ ... Move cursor to end of line.
BACKSPACE ................................ Delete char to left of cursor.
DELETE ......................... ESC-x ... Delete char under cursor.
ctrl-BACKSPACE ESC-BACKSPACE ........... Delete word to left of cursor.
ctrl-DELETE .... ESC-DELETE .... ESC-X ... Delete word under cursor.
ctrl-U ......... ESC (MS-DOS only) ....... Delete entire line.
UpArrow ........................ ESC-k ... Retrieve previous command line.
DownArrow ...................... ESC-j ... Retrieve next command line.
TAB ...................................... Complete filename & cycle.
SHIFT-TAB ...................... ESC-TAB Complete filename & reverse cycle.
ctrl-L ................................... Complete filename, list all.
@@ -0,0 +1,258 @@
SSUUMMMMAARRYY OOFF LLEESSSS CCOOMMMMAANNDDSS
Commands marked with * may be preceded by a number, _N.
Notes in parentheses indicate the behavior if _N is given.
A key preceded by a caret indicates the Ctrl key; thus ^K is ctrl-K.
h H Display this help.
q :q Q :Q ZZ Exit.
---------------------------------------------------------------------------
MMOOVVIINNGG
e ^E j ^N CR * Forward one line (or _N lines).
y ^Y k ^K ^P * Backward one line (or _N lines).
f ^F ^V SPACE * Forward one window (or _N lines).
b ^B ESC-v * Backward one window (or _N lines).
z * Forward one window (and set window to _N).
w * Backward one window (and set window to _N).
ESC-SPACE * Forward one window, but don't stop at end-of-file.
d ^D * Forward one half-window (and set half-window to _N).
u ^U * Backward one half-window (and set half-window to _N).
ESC-) RightArrow * Right one half screen width (or _N positions).
ESC-( LeftArrow * Left one half screen width (or _N positions).
ESC-} ^RightArrow Right to last column displayed.
ESC-{ ^LeftArrow Left to first column.
F Forward forever; like "tail -f".
ESC-F Like F but stop when search pattern is found.
r ^R ^L Repaint screen.
R Repaint screen, discarding buffered input.
---------------------------------------------------
Default "window" is the screen height.
Default "half-window" is half of the screen height.
---------------------------------------------------------------------------
SSEEAARRCCHHIINNGG
/_p_a_t_t_e_r_n * Search forward for (_N-th) matching line.
?_p_a_t_t_e_r_n * Search backward for (_N-th) matching line.
n * Repeat previous search (for _N-th occurrence).
N * Repeat previous search in reverse direction.
ESC-n * Repeat previous search, spanning files.
ESC-N * Repeat previous search, reverse dir. & spanning files.
ESC-u Undo (toggle) search highlighting.
ESC-U Clear search highlighting.
&_p_a_t_t_e_r_n * Display only matching lines.
---------------------------------------------------
A search pattern may begin with one or more of:
^N or ! Search for NON-matching lines.
^E or * Search multiple files (pass thru END OF FILE).
^F or @ Start search at FIRST file (for /) or last file (for ?).
^K Highlight matches, but don't move (KEEP position).
^R Don't use REGULAR EXPRESSIONS.
^W WRAP search if no match found.
---------------------------------------------------------------------------
JJUUMMPPIINNGG
g < ESC-< * Go to first line in file (or line _N).
G > ESC-> * Go to last line in file (or line _N).
p % * Go to beginning of file (or _N percent into file).
t * Go to the (_N-th) next tag.
T * Go to the (_N-th) previous tag.
{ ( [ * Find close bracket } ) ].
} ) ] * Find open bracket { ( [.
ESC-^F _<_c_1_> _<_c_2_> * Find close bracket _<_c_2_>.
ESC-^B _<_c_1_> _<_c_2_> * Find open bracket _<_c_1_>.
---------------------------------------------------
Each "find close bracket" command goes forward to the close bracket
matching the (_N-th) open bracket in the top line.
Each "find open bracket" command goes backward to the open bracket
matching the (_N-th) close bracket in the bottom line.
m_<_l_e_t_t_e_r_> Mark the current top line with <letter>.
M_<_l_e_t_t_e_r_> Mark the current bottom line with <letter>.
'_<_l_e_t_t_e_r_> Go to a previously marked position.
'' Go to the previous position.
^X^X Same as '.
ESC-M_<_l_e_t_t_e_r_> Clear a mark.
---------------------------------------------------
A mark is any upper-case or lower-case letter.
Certain marks are predefined:
^ means beginning of the file
$ means end of the file
---------------------------------------------------------------------------
CCHHAANNGGIINNGG FFIILLEESS
:e [_f_i_l_e] Examine a new file.
^X^V Same as :e.
:n * Examine the (_N-th) next file from the command line.
:p * Examine the (_N-th) previous file from the command line.
:x * Examine the first (or _N-th) file from the command line.
:d Delete the current file from the command line list.
= ^G :f Print current file name.
---------------------------------------------------------------------------
MMIISSCCEELLLLAANNEEOOUUSS CCOOMMMMAANNDDSS
-_<_f_l_a_g_> Toggle a command line option [see OPTIONS below].
--_<_n_a_m_e_> Toggle a command line option, by name.
__<_f_l_a_g_> Display the setting of a command line option.
___<_n_a_m_e_> Display the setting of an option, by name.
+_c_m_d Execute the less cmd each time a new file is examined.
!_c_o_m_m_a_n_d Execute the shell command with $SHELL.
|XX_c_o_m_m_a_n_d Pipe file between current pos & mark XX to shell command.
s _f_i_l_e Save input to a file.
v Edit the current file with $VISUAL or $EDITOR.
V Print version number of "less".
---------------------------------------------------------------------------
OOPPTTIIOONNSS
Most options may be changed either on the command line,
or from within less by using the - or -- command.
Options may be given in one of two forms: either a single
character preceded by a -, or a name preceded by --.
-? ........ --help
Display help (from command line).
-a ........ --search-skip-screen
Search skips current screen.
-A ........ --SEARCH-SKIP-SCREEN
Search starts just after target line.
-b [_N] .... --buffers=[_N]
Number of buffers.
-B ........ --auto-buffers
Don't automatically allocate buffers for pipes.
-c ........ --clear-screen
Repaint by clearing rather than scrolling.
-d ........ --dumb
Dumb terminal.
-D xx_c_o_l_o_r . --color=xx_c_o_l_o_r
Set screen colors.
-e -E .... --quit-at-eof --QUIT-AT-EOF
Quit at end of file.
-f ........ --force
Force open non-regular files.
-F ........ --quit-if-one-screen
Quit if entire file fits on first screen.
-g ........ --hilite-search
Highlight only last match for searches.
-G ........ --HILITE-SEARCH
Don't highlight any matches for searches.
-h [_N] .... --max-back-scroll=[_N]
Backward scroll limit.
-i ........ --ignore-case
Ignore case in searches that do not contain uppercase.
-I ........ --IGNORE-CASE
Ignore case in all searches.
-j [_N] .... --jump-target=[_N]
Screen position of target lines.
-J ........ --status-column
Display a status column at left edge of screen.
-k [_f_i_l_e] . --lesskey-file=[_f_i_l_e]
Use a lesskey file.
-K ........ --quit-on-intr
Exit less in response to ctrl-C.
-L ........ --no-lessopen
Ignore the LESSOPEN environment variable.
-m -M .... --long-prompt --LONG-PROMPT
Set prompt style.
-n -N .... --line-numbers --LINE-NUMBERS
Don't use line numbers.
-o [_f_i_l_e] . --log-file=[_f_i_l_e]
Copy to log file (standard input only).
-O [_f_i_l_e] . --LOG-FILE=[_f_i_l_e]
Copy to log file (unconditionally overwrite).
-p [_p_a_t_t_e_r_n] --pattern=[_p_a_t_t_e_r_n]
Start at pattern (from command line).
-P [_p_r_o_m_p_t] --prompt=[_p_r_o_m_p_t]
Define new prompt.
-q -Q .... --quiet --QUIET --silent --SILENT
Quiet the terminal bell.
-r -R .... --raw-control-chars --RAW-CONTROL-CHARS
Output "raw" control characters.
-s ........ --squeeze-blank-lines
Squeeze multiple blank lines.
-S ........ --chop-long-lines
Chop (truncate) long lines rather than wrapping.
-t [_t_a_g] .. --tag=[_t_a_g]
Find a tag.
-T [_t_a_g_s_f_i_l_e] --tag-file=[_t_a_g_s_f_i_l_e]
Use an alternate tags file.
-u -U .... --underline-special --UNDERLINE-SPECIAL
Change handling of backspaces.
-V ........ --version
Display the version number of "less".
-w ........ --hilite-unread
Highlight first new line after forward-screen.
-W ........ --HILITE-UNREAD
Highlight first new line after any forward movement.
-x [_N[,...]] --tabs=[_N[,...]]
Set tab stops.
-X ........ --no-init
Don't use termcap init/deinit strings.
-y [_N] .... --max-forw-scroll=[_N]
Forward scroll limit.
-z [_N] .... --window=[_N]
Set size of window.
-" [_c[_c]] . --quotes=[_c[_c]]
Set shell quote characters.
-~ ........ --tilde
Don't display tildes after end of file.
-# [_N] .... --shift=[_N]
Set horizontal scroll amount (0 = one half screen width).
--file-size
Automatically determine the size of the input file.
--follow-name
The F command changes files if the input file is renamed.
--incsearch
Search file as each pattern character is typed in.
--line-num-width=N
Set the width of the -N line number field to N characters.
--mouse
Enable mouse input.
--no-keypad
Don't send termcap keypad init/deinit strings.
--no-histdups
Remove duplicates from command history.
--rscroll=C
Set the character used to mark truncated lines.
--save-marks
Retain marks across invocations of less.
--status-col-width=N
Set the width of the -J status column to N characters.
--use-backslash
Subsequent options use backslash as escape char.
--use-color
Enables colored text.
--wheel-lines=N
Each click of the mouse wheel moves N lines.
---------------------------------------------------------------------------
LLIINNEE EEDDIITTIINNGG
These keys can be used to edit text being entered
on the "command line" at the bottom of the screen.
RightArrow ..................... ESC-l ... Move cursor right one character.
LeftArrow ...................... ESC-h ... Move cursor left one character.
ctrl-RightArrow ESC-RightArrow ESC-w ... Move cursor right one word.
ctrl-LeftArrow ESC-LeftArrow ESC-b ... Move cursor left one word.
HOME ........................... ESC-0 ... Move cursor to start of line.
END ............................ ESC-$ ... Move cursor to end of line.
BACKSPACE ................................ Delete char to left of cursor.
DELETE ......................... ESC-x ... Delete char under cursor.
ctrl-BACKSPACE ESC-BACKSPACE ........... Delete word to left of cursor.
ctrl-DELETE .... ESC-DELETE .... ESC-X ... Delete word under cursor.
ctrl-U ......... ESC (MS-DOS only) ....... Delete entire line.
UpArrow ........................ ESC-k ... Retrieve previous command line.
DownArrow ...................... ESC-j ... Retrieve next command line.
TAB ...................................... Complete filename & cycle.
SHIFT-TAB ...................... ESC-TAB Complete filename & reverse cycle.
ctrl-L ................................... Complete filename, list all.
@@ -0,0 +1,79 @@
= Illuminate\Database\Query\Builder {#8371
+connection: Illuminate\Database\MySqlConnection {#8375},
+grammar: Illuminate\Database\Query\Grammars\MySqlGrammar {#8374},
+processor: Illuminate\Database\Query\Processors\MySqlProcessor {#8373},
+bindings: [
"select" => [],
"from" => [],
"join" => [],
"where" => [],
"groupBy" => [],
"having" => [],
"order" => [],
"union" => [],
"unionOrder" => [],
],
+aggregate: null,
+columns: null,
+distinct: false,
+from: "form_up_country",
+indexHint: null,
+joins: null,
+wheres: [],
+groups: null,
+havings: null,
+orders: null,
+limit: null,
+groupLimit: null,
+offset: null,
+unions: null,
+unionLimit: null,
+unionOffset: null,
+unionOrders: null,
+lock: null,
+beforeQueryCallbacks: [],
+operators: [
"=",
"<",
">",
"<=",
">=",
"<>",
"!=",
"<=>",
"like",
"like binary",
"not like",
"ilike",
"&",
"|",
"^",
"<<",
">>",
"&~",
"is",
"is not",
"rlike",
"not rlike",
"regexp",
"not regexp",
"~",
"~*",
"!~",
"!~*",
"similar to",
"not similar to",
"not ilike",
"~~*",
"!~~*",
],
+bitwiseOperators: [
"&",
"|",
"^",
"<<",
">>",
"&~",
],
+useWritePdo: false,
}
@@ -0,0 +1,258 @@
SSUUMMMMAARRYY OOFF LLEESSSS CCOOMMMMAANNDDSS
Commands marked with * may be preceded by a number, _N.
Notes in parentheses indicate the behavior if _N is given.
A key preceded by a caret indicates the Ctrl key; thus ^K is ctrl-K.
h H Display this help.
q :q Q :Q ZZ Exit.
---------------------------------------------------------------------------
MMOOVVIINNGG
e ^E j ^N CR * Forward one line (or _N lines).
y ^Y k ^K ^P * Backward one line (or _N lines).
f ^F ^V SPACE * Forward one window (or _N lines).
b ^B ESC-v * Backward one window (or _N lines).
z * Forward one window (and set window to _N).
w * Backward one window (and set window to _N).
ESC-SPACE * Forward one window, but don't stop at end-of-file.
d ^D * Forward one half-window (and set half-window to _N).
u ^U * Backward one half-window (and set half-window to _N).
ESC-) RightArrow * Right one half screen width (or _N positions).
ESC-( LeftArrow * Left one half screen width (or _N positions).
ESC-} ^RightArrow Right to last column displayed.
ESC-{ ^LeftArrow Left to first column.
F Forward forever; like "tail -f".
ESC-F Like F but stop when search pattern is found.
r ^R ^L Repaint screen.
R Repaint screen, discarding buffered input.
---------------------------------------------------
Default "window" is the screen height.
Default "half-window" is half of the screen height.
---------------------------------------------------------------------------
SSEEAARRCCHHIINNGG
/_p_a_t_t_e_r_n * Search forward for (_N-th) matching line.
?_p_a_t_t_e_r_n * Search backward for (_N-th) matching line.
n * Repeat previous search (for _N-th occurrence).
N * Repeat previous search in reverse direction.
ESC-n * Repeat previous search, spanning files.
ESC-N * Repeat previous search, reverse dir. & spanning files.
ESC-u Undo (toggle) search highlighting.
ESC-U Clear search highlighting.
&_p_a_t_t_e_r_n * Display only matching lines.
---------------------------------------------------
A search pattern may begin with one or more of:
^N or ! Search for NON-matching lines.
^E or * Search multiple files (pass thru END OF FILE).
^F or @ Start search at FIRST file (for /) or last file (for ?).
^K Highlight matches, but don't move (KEEP position).
^R Don't use REGULAR EXPRESSIONS.
^W WRAP search if no match found.
---------------------------------------------------------------------------
JJUUMMPPIINNGG
g < ESC-< * Go to first line in file (or line _N).
G > ESC-> * Go to last line in file (or line _N).
p % * Go to beginning of file (or _N percent into file).
t * Go to the (_N-th) next tag.
T * Go to the (_N-th) previous tag.
{ ( [ * Find close bracket } ) ].
} ) ] * Find open bracket { ( [.
ESC-^F _<_c_1_> _<_c_2_> * Find close bracket _<_c_2_>.
ESC-^B _<_c_1_> _<_c_2_> * Find open bracket _<_c_1_>.
---------------------------------------------------
Each "find close bracket" command goes forward to the close bracket
matching the (_N-th) open bracket in the top line.
Each "find open bracket" command goes backward to the open bracket
matching the (_N-th) close bracket in the bottom line.
m_<_l_e_t_t_e_r_> Mark the current top line with <letter>.
M_<_l_e_t_t_e_r_> Mark the current bottom line with <letter>.
'_<_l_e_t_t_e_r_> Go to a previously marked position.
'' Go to the previous position.
^X^X Same as '.
ESC-M_<_l_e_t_t_e_r_> Clear a mark.
---------------------------------------------------
A mark is any upper-case or lower-case letter.
Certain marks are predefined:
^ means beginning of the file
$ means end of the file
---------------------------------------------------------------------------
CCHHAANNGGIINNGG FFIILLEESS
:e [_f_i_l_e] Examine a new file.
^X^V Same as :e.
:n * Examine the (_N-th) next file from the command line.
:p * Examine the (_N-th) previous file from the command line.
:x * Examine the first (or _N-th) file from the command line.
:d Delete the current file from the command line list.
= ^G :f Print current file name.
---------------------------------------------------------------------------
MMIISSCCEELLLLAANNEEOOUUSS CCOOMMMMAANNDDSS
-_<_f_l_a_g_> Toggle a command line option [see OPTIONS below].
--_<_n_a_m_e_> Toggle a command line option, by name.
__<_f_l_a_g_> Display the setting of a command line option.
___<_n_a_m_e_> Display the setting of an option, by name.
+_c_m_d Execute the less cmd each time a new file is examined.
!_c_o_m_m_a_n_d Execute the shell command with $SHELL.
|XX_c_o_m_m_a_n_d Pipe file between current pos & mark XX to shell command.
s _f_i_l_e Save input to a file.
v Edit the current file with $VISUAL or $EDITOR.
V Print version number of "less".
---------------------------------------------------------------------------
OOPPTTIIOONNSS
Most options may be changed either on the command line,
or from within less by using the - or -- command.
Options may be given in one of two forms: either a single
character preceded by a -, or a name preceded by --.
-? ........ --help
Display help (from command line).
-a ........ --search-skip-screen
Search skips current screen.
-A ........ --SEARCH-SKIP-SCREEN
Search starts just after target line.
-b [_N] .... --buffers=[_N]
Number of buffers.
-B ........ --auto-buffers
Don't automatically allocate buffers for pipes.
-c ........ --clear-screen
Repaint by clearing rather than scrolling.
-d ........ --dumb
Dumb terminal.
-D xx_c_o_l_o_r . --color=xx_c_o_l_o_r
Set screen colors.
-e -E .... --quit-at-eof --QUIT-AT-EOF
Quit at end of file.
-f ........ --force
Force open non-regular files.
-F ........ --quit-if-one-screen
Quit if entire file fits on first screen.
-g ........ --hilite-search
Highlight only last match for searches.
-G ........ --HILITE-SEARCH
Don't highlight any matches for searches.
-h [_N] .... --max-back-scroll=[_N]
Backward scroll limit.
-i ........ --ignore-case
Ignore case in searches that do not contain uppercase.
-I ........ --IGNORE-CASE
Ignore case in all searches.
-j [_N] .... --jump-target=[_N]
Screen position of target lines.
-J ........ --status-column
Display a status column at left edge of screen.
-k [_f_i_l_e] . --lesskey-file=[_f_i_l_e]
Use a lesskey file.
-K ........ --quit-on-intr
Exit less in response to ctrl-C.
-L ........ --no-lessopen
Ignore the LESSOPEN environment variable.
-m -M .... --long-prompt --LONG-PROMPT
Set prompt style.
-n -N .... --line-numbers --LINE-NUMBERS
Don't use line numbers.
-o [_f_i_l_e] . --log-file=[_f_i_l_e]
Copy to log file (standard input only).
-O [_f_i_l_e] . --LOG-FILE=[_f_i_l_e]
Copy to log file (unconditionally overwrite).
-p [_p_a_t_t_e_r_n] --pattern=[_p_a_t_t_e_r_n]
Start at pattern (from command line).
-P [_p_r_o_m_p_t] --prompt=[_p_r_o_m_p_t]
Define new prompt.
-q -Q .... --quiet --QUIET --silent --SILENT
Quiet the terminal bell.
-r -R .... --raw-control-chars --RAW-CONTROL-CHARS
Output "raw" control characters.
-s ........ --squeeze-blank-lines
Squeeze multiple blank lines.
-S ........ --chop-long-lines
Chop (truncate) long lines rather than wrapping.
-t [_t_a_g] .. --tag=[_t_a_g]
Find a tag.
-T [_t_a_g_s_f_i_l_e] --tag-file=[_t_a_g_s_f_i_l_e]
Use an alternate tags file.
-u -U .... --underline-special --UNDERLINE-SPECIAL
Change handling of backspaces.
-V ........ --version
Display the version number of "less".
-w ........ --hilite-unread
Highlight first new line after forward-screen.
-W ........ --HILITE-UNREAD
Highlight first new line after any forward movement.
-x [_N[,...]] --tabs=[_N[,...]]
Set tab stops.
-X ........ --no-init
Don't use termcap init/deinit strings.
-y [_N] .... --max-forw-scroll=[_N]
Forward scroll limit.
-z [_N] .... --window=[_N]
Set size of window.
-" [_c[_c]] . --quotes=[_c[_c]]
Set shell quote characters.
-~ ........ --tilde
Don't display tildes after end of file.
-# [_N] .... --shift=[_N]
Set horizontal scroll amount (0 = one half screen width).
--file-size
Automatically determine the size of the input file.
--follow-name
The F command changes files if the input file is renamed.
--incsearch
Search file as each pattern character is typed in.
--line-num-width=N
Set the width of the -N line number field to N characters.
--mouse
Enable mouse input.
--no-keypad
Don't send termcap keypad init/deinit strings.
--no-histdups
Remove duplicates from command history.
--rscroll=C
Set the character used to mark truncated lines.
--save-marks
Retain marks across invocations of less.
--status-col-width=N
Set the width of the -J status column to N characters.
--use-backslash
Subsequent options use backslash as escape char.
--use-color
Enables colored text.
--wheel-lines=N
Each click of the mouse wheel moves N lines.
---------------------------------------------------------------------------
LLIINNEE EEDDIITTIINNGG
These keys can be used to edit text being entered
on the "command line" at the bottom of the screen.
RightArrow ..................... ESC-l ... Move cursor right one character.
LeftArrow ...................... ESC-h ... Move cursor left one character.
ctrl-RightArrow ESC-RightArrow ESC-w ... Move cursor right one word.
ctrl-LeftArrow ESC-LeftArrow ESC-b ... Move cursor left one word.
HOME ........................... ESC-0 ... Move cursor to start of line.
END ............................ ESC-$ ... Move cursor to end of line.
BACKSPACE ................................ Delete char to left of cursor.
DELETE ......................... ESC-x ... Delete char under cursor.
ctrl-BACKSPACE ESC-BACKSPACE ........... Delete word to left of cursor.
ctrl-DELETE .... ESC-DELETE .... ESC-X ... Delete word under cursor.
ctrl-U ......... ESC (MS-DOS only) ....... Delete entire line.
UpArrow ........................ ESC-k ... Retrieve previous command line.
DownArrow ...................... ESC-j ... Retrieve next command line.
TAB ...................................... Complete filename & cycle.
SHIFT-TAB ...................... ESC-TAB Complete filename & reverse cycle.
ctrl-L ................................... Complete filename, list all.
+2700
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+13
View File
@@ -0,0 +1,13 @@
{
"resources/css/app.css": {
"file": "assets/app-DamXIAY7.css",
"src": "resources/css/app.css",
"isEntry": true
},
"resources/js/app.js": {
"file": "assets/app-BuG9aa18.js",
"name": "app",
"src": "resources/js/app.js",
"isEntry": true
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

@@ -0,0 +1,20 @@
@props([
'modalId',
'title' => 'Attachment Preview',
])
<div class="modal fade" id="{{ $modalId }}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ $title }} - <span class="attachment-preview-modal-title"></span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<img class="img-fluid d-none attachment-preview-image" alt="Attachment preview">
<object class="w-100 d-none attachment-preview-object" style="min-height: 480px;" type="application/pdf"></object>
<div class="text-center text-muted attachment-preview-placeholder">Tidak ada file untuk ditampilkan.</div>
</div>
</div>
</div>
</div>
@@ -1,23 +1,31 @@
@extends('layouts.app') @extends('layouts.app')
@section('title') @section('title', $pageInfo['title'])
{{ $pageInfo['title'] }}
@endsection
@section('admin-content') @section('admin-content')
<section class="content-header"> <section class="content-header">
<div class="container-fluid"> <div class="container-fluid">
<div class="row mb-2"> <div class="row mb-3">
<div class="col-sm-6"> <div class="col-sm-6">
<h1>{{ $pageInfo['title'] }}</h1> <h1 class="fw-bold text-dark">{{ $pageInfo['title'] }}</h1>
</div> </div>
<div class="col-sm-6"> <div class="col-sm-6">
<ol class="breadcrumb float-sm-right"> <!-- Tombol Aksi Global (Kanan Atas) -->
<li class="breadcrumb-item"><a href="{{ route('admin.dashboard') }}">{{ __('Dashboard') }}</a></li> <div class="float-sm-right d-flex align-items-center" style="gap: 10px;">
<li class="breadcrumb-item active"><a href="{{ $pageInfo['path'] }}">{{ $pageInfo['title'] }}</a> <a href="{{ route('admin.audit-trail.clear-cache') }}"
</li> class="btn btn-outline-info btn-sm shadow-sm"
</ol> onclick="return confirm('Apakah Anda yakin ingin mereset cache sistem?')">
<i class="fa fa-sync"></i> Reset Cache
</a>
<form action="{{ route('admin.audit-trail.clear') }}" method="POST" onsubmit="return confirm('PERINGATAN! Semua log audit akan dihapus permanen. Lanjutkan?')">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-outline-danger btn-sm shadow-sm">
<i class="fa fa-trash"></i> Clear All Logs
</button>
</form>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -25,82 +33,137 @@
<section class="content"> <section class="content">
<div class="container-fluid"> <div class="container-fluid">
<!-- CARD FILTER -->
<div class="card shadow-sm mb-4" style="border-radius: 12px; border: none; border-top: 3px solid #007bff;">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-primary"><i class="fa fa-filter mr-1"></i> Panel Pencarian & Filter</h6>
</div>
<div class="card-body">
<form action="{{ route('admin.audit-trail.index') }}" method="GET">
<div class="row">
<!-- Filter User -->
<div class="col-md-3 mb-3">
<label class="small font-weight-bold text-muted text-uppercase">Berdasarkan User</label>
<select name="admin_id" class="form-control select2 shadow-none">
<option value="">-- Semua User --</option>
@foreach($admins as $admin)
<option value="{{ $admin->id }}" {{ request('admin_id') == $admin->id ? 'selected' : '' }}>
{{ $admin->name }}
</option>
@endforeach
</select>
</div>
<!-- Filter Region -->
<div class="col-md-3 mb-3">
<label class="small font-weight-bold text-muted text-uppercase">Berdasarkan Region</label>
<select name="region" class="form-control shadow-none">
<option value="">-- Semua Region --</option>
@foreach($regions as $reg)
<option value="{{ $reg->code }}" {{ request('region') == $reg->code ? 'selected' : '' }}>
{{ $reg->name }}
</option>
@endforeach
</select>
</div>
<!-- Filter Rentang Waktu -->
<div class="col-md-4 mb-3">
<label class="small font-weight-bold text-muted text-uppercase">Rentang Waktu</label>
<div class="input-group">
<input type="date" name="start_date" class="form-control shadow-none" value="{{ request('start_date') }}">
<div class="input-group-append">
<span class="input-group-text bg-light border-left-0 border-right-0">s/d</span>
</div>
<input type="date" name="end_date" class="form-control shadow-none" value="{{ request('end_date') }}">
</div>
</div>
<!-- Tombol Eksekusi Filter -->
<div class="col-md-2 mb-3 text-right d-flex align-items-end justify-content-end" style="gap: 5px;">
<button type="submit" class="btn btn-primary btn-block mb-0 shadow-sm">
<i class="fa fa-search"></i> Cari
</button>
<a href="{{ route('admin.audit-trail.index') }}" class="btn btn-light border shadow-sm px-3" title="Reset Filter">
<i class="fa fa-undo"></i>
</a>
</div>
</div>
<hr class="my-3">
<!-- Tombol Export Excel -->
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<div class="card"> <a href="{{ route('admin.audit-trail.export', request()->all()) }}" class="btn btn-success shadow-sm">
<div class="card-body"> <i class="fa fa-file-excel"></i> Export Hasil Pencarian ke Excel
<h4 class="header-title float-left">{{ $pageInfo['title'] }}</h4> </a>
<div class="clearfix"></div> <span class="text-muted small ml-2 mt-2 d-inline-block">
<div class="data-tables"> * Menampilkan <strong>{{ $data->firstItem() }}</strong> sampai <strong>{{ $data->lastItem() }}</strong> dari <strong>{{ $data->total() }}</strong> log.
</span>
</div>
</div>
</form>
</div>
</div>
<!-- TABEL DATA -->
<div class="card shadow-sm" style="border-radius: 12px; border: none;">
<div class="card-body p-0">
@include('backend.layouts.partials.messages') @include('backend.layouts.partials.messages')
<div class="table-responsive"> <div class="table-responsive">
<table id="dataTable" class="text-center"> <table id="dataTable" class="table table-hover mb-0">
<thead class="bg-light text-capitalize"> <thead class="bg-light">
<tr> <tr>
<th width="5%">{{ __('User') }}</th> <th class="border-top-0" width="10%">User</th>
<th width="5%">{{ __('Ip Address') }}</th> <th class="border-top-0" width="10%">IP Address</th>
<th width="5%">{{ __('Action') }}</th> <th class="border-top-0" width="10%">Aksi</th>
<th width="15%">{{ __('Activity') }}</th> <th class="border-top-0" width="35%">Aktivitas</th>
<th width="5%">Browser</th> <th class="border-top-0" width="10%">Browser</th>
<th width="15%">User Agent</th> <th class="border-top-0" width="15%">Waktu</th>
<th width="5%">Tanggal</th>
<th width="5%">Waktu</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@foreach ($data as $item) @foreach ($data as $item)
<tr> <tr>
<td> <td class="align-middle">
{{ $item->user ? $item->user->username : preg_replace('/\).*$/', '', explode('(', $item->activity)[1]) }} <span class="text-primary font-weight-bold">{{ $item->user->username ?? 'System' }}</span>
</td> </td>
<td>{{ $item->ip_address }}</td> <td class="align-middle text-muted small">{{ $item->ip_address }}</td>
<td> <td class="align-middle text-center">
@php @php
$color = 'info'; $badge = match($item->action) {
if ($item->action == 'Create') { 'Insert', 'Create' => 'success',
$color = 'success'; 'Update' => 'warning',
} elseif ($item->action == 'Update') { 'Delete', 'Reject', 'Error' => 'danger',
$color = 'warning'; 'Approve' => 'primary',
} elseif ($item->action == 'Delete' || $item->action == 'Error') { default => 'info'
$color = 'danger'; };
}
@endphp @endphp
<div class="badge badge-{{ $color }} text-white"> <span class="badge badge-{{ $badge }} px-2 py-1 text-white shadow-xs">{{ $item->action }}</span>
{{ $item->action }} </td>
<td class="align-middle text-left"><small class="text-dark">{{ $item->activity }}</small></td>
<td class="align-middle"><small class="text-muted">{{ $item->browser }}</small></td>
<td class="align-middle">
<div style="line-height: 1.1;">
<small class="d-block font-weight-bold">{{ $item->created_at->format('d M Y') }}</small>
<small class="text-muted" style="font-size: 10px;">{{ $item->created_at->format('H:i') }} ({{ $item->created_at->diffForHumans() }})</small>
</div> </div>
</td> </td>
<td>{{ $item->activity }}</td>
<td>{{ $item->browser }}</td>
<td>{{ $item->user_agent }}</td>
<td>{{ $item->created_at }}</td>
<td>{{ $item->created_at->diffForHumans() }}</td>
</tr> </tr>
@endforeach @endforeach
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
</div> <!-- FOOTER CARD UNTUK PAGINATION -->
<div class="card-footer bg-white py-3 border-top-0">
<div class="d-flex justify-content-center">
{{ $data->links() }}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
@endsection @endsection
@section('scripts')
<script>
$(document).ready(function () {
if ($('#dataTable').length) {
$('#dataTable').DataTable({
responsive: false,
dom: 'Bfrtip',
buttons: [
'excel', 'pdf', 'print'
],
order: [[6, 'desc']]
});
}
});
</script>
@endsection
@@ -1,138 +1,273 @@
@extends('layouts.app') @extends('layouts.app')
@section('title') @section('title', 'Active Budgets Control')
Dashboard
@endsection
@section('admin-content') @section('admin-content')
<section class="content-header"> <section class="content-header">
<div class="container-fluid"> <div class="container-fluid">
<div class="row mb-2"> <div class="row mb-2">
<div class="col-sm-6"> <div class="col-sm-6">
<h1>Active Budgets</h1> <h1 class="fw-bold text-dark">Active Budgets Control</h1>
</div> </div>
<div class="col-sm-6"> <div class="col-sm-6">
<ol class="breadcrumb float-sm-right"> <ol class="breadcrumb float-sm-right">
<li class="breadcrumb-item"><a href="{{ route('dashboard.index') }}">Dashboard</a></li> <li class="breadcrumb-item"><a href="{{ route('dashboard.index') }}">Dashboard</a></li>
</li> <li class="breadcrumb-item active">Budget Control</li>
</ol> </ol>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<section class="content"> <section class="content">
<div class="container-fluid"> <div class="container-fluid">
<div class="card card-primary card-outline">
<div class="card-body"> {{-- --- BAGIAN 1: PANEL FILTER DINAMIS --- --}}
<h4 class="header-title float-left">Current Active Budget</h4> <div class="card shadow-sm mb-4" style="border-radius: 15px; border-top: 3px solid #007bff;">
<p class="float-right mb-2"> <div class="card-body py-3">
@if (auth()->user()->can('budget_control.create')) <form action="{{ route('budget_control') }}" method="GET" id="filter-form">
<a class="btn btn-primary text-white" href="{{ route('budget_control.create') }}"> <div class="row align-items-end">
Add New Budget <div class="col-md-3 mb-2 mb-md-0">
</a> <label class="small font-weight-bold text-muted text-uppercase">Region</label>
<select name="region_id" id="filter_region" class="form-control form-control-sm select2-filter">
{{-- Munculkan 'Semua Region' HANYA untuk role pusat --}}
@if(auth()->user()->hasAnyRole(['Superadmin', 'Super Admin', 'Accounting', 'accounting']))
<option value="">Semua Region</option>
@else
{{-- Untuk Admin Region, langsung arahkan ke pilihannya --}}
<option value="" disabled>Pilih Region Anda</option>
@endif @endif
</p>
<div class="clearfix"></div> @foreach($regions as $reg)
<option value="{{ $reg->id }}" {{ request('region_id', (count($regions) == 1 ? $regions->first()->id : '')) == $reg->id ? 'selected' : '' }}>
{{ $reg->name }}
</option>
@endforeach
</select>
</div>
<div class="col-md-3 mb-2 mb-md-0">
<label class="small font-weight-bold text-muted text-uppercase">Cabang</label>
<select name="cabang_id" id="filter_cabang" class="form-control form-control-sm select2-filter">
<option value="">Semua Cabang</option>
@foreach($cabangOptions as $cab)
<option value="{{ $cab->id }}" {{ request('cabang_id') == $cab->id ? 'selected' : '' }}>{{ $cab->name }}</option>
@endforeach
</select>
</div>
<div class="col-md-2 mb-2 mb-md-0">
<label class="small font-weight-bold text-muted text-uppercase">Bulan</label>
<select name="month" class="form-control form-control-sm select2-filter">
<option value="">Semua Bulan</option>
@foreach(['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] as $m)
<option value="{{ $m }}" {{ request('month') == $m ? 'selected' : '' }}>{{ $m }}</option>
@endforeach
</select>
</div>
<div class="col-md-2 mb-2 mb-md-0">
<label class="small font-weight-bold text-muted text-uppercase">Tahun</label>
<select name="year" class="form-control form-control-sm select2-filter">
@for($y = date('Y'); $y >= 2024; $y--)
<option value="{{ $y }}" {{ request('year', date('Y')) == $y ? 'selected' : '' }}>{{ $y }}</option>
@endfor
</select>
</div>
<div class="col-md-2">
<div class="btn-group w-100">
<button type="submit" class="btn btn-sm btn-primary shadow-sm"><i class="fas fa-filter mr-1"></i></button>
<a href="{{ route('budget_control') }}" class="btn btn-sm btn-secondary shadow-sm"><i class="fas fa-sync-alt"></i></a>
</div>
</div>
</div>
</form>
</div>
</div>
<div class="card card-primary card-outline shadow-sm" style="border-radius: 15px;">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="header-title m-0">Current Active Budget</h4>
@if (auth()->user()->can('budget_control.create'))
<div>
@if(auth()->user()->hasAnyRole(['Accounting', 'accounting', 'Admin Region', 'Superadmin', 'Super Admin']))
<button type="button" class="btn btn-success btn-sm rounded-pill px-3 shadow-sm mr-2" data-toggle="modal" data-target="#modalBulkUpload">
<i class="fas fa-file-excel mr-1"></i> Bulk Upload
</button>
@endif
<a class="btn btn-primary btn-sm rounded-pill px-3 shadow-sm" href="{{ route('budget_control.create') }}">
<i class="fas fa-plus-circle mr-1"></i> Add New Budget
</a>
</div>
@endif
</div>
<div class="dataTables_wrapper dt-bootstrap4 mt-2"> <div class="dataTables_wrapper dt-bootstrap4 mt-2">
@include('backend.layouts.partials.messages') @include('backend.layouts.partials.messages')
<div class="table-responsive"> <div class="table-responsive">
<table id="dataTable" <table id="dataTable" class="table table-bordered table-striped table-hover w-100">
class="table table-bordered table-striped table-head-fixed dataTable dtr-inline"
style="width:100%">
<thead class="bg-light text-capitalize"> <thead class="bg-light text-capitalize">
<tr> <tr>
<th>#</th> <th width="3%">#</th>
<th>Penerima</th> <th>Penerima / Cabang</th>
<th>Cabang</th>
<th>Pengirim</th> <th>Pengirim</th>
<th>Total</th> <th>Assigned Budget</th>
<th>Sisa Budget Tersedia</th> <th>Real-time Sisa Budget</th>
<th>Keterangan</th>
<th>Periode</th> <th>Periode</th>
<th>Status</th> <th>Status Budget</th>
<th>Aksi</th> <th>Status Transfer</th>
<th width="5%" class="text-center">Bukti</th>
<th width="10%">Aksi</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@php
use App\Helpers\BudgetHelper;
@endphp
@foreach ($data as $item) @foreach ($data as $item)
@php
$realtimeBudget = $item->availableBudget ?? 0;
@endphp
<tr> <tr>
<td>{{ $loop->iteration }}</td> <td>{{ ($data->currentPage() - 1) * $data->perPage() + $loop->iteration }}</td>
<td>{{ $item->receiver->name }}</td>
<td>{{ $item->cabang->name }}</td>
<td>{{ $item->sender->name }}</td>
<td>{{ number_format($item->amount, 0, ',', '.') }}</td>
<td>{{ number_format($item->availableBudget, 0, ',', '.') }}</td>
<td>{{ $item->remarks }}</td>
<td>{{ strtoupper($item->periode_month) }} {{ $item->periode_year }}</td>
<td> <td>
@if ($item->status == 'Unassigned') <div class="fw-bold text-dark">{{ $item->receiver->name ?? '-' }}</div>
<span class="badge badge-warning text-white">{{ $item->status }}</span> <small class="text-muted"><i class="fas fa-building mr-1"></i>{{ $item->cabang->name ?? '-' }}</small>
@elseif ($item->status == 'Assigned') </td>
<span class="badge badge-success text-white">{{ $item->status }}</span> <td>{{ $item->sender->name ?? '-' }}</td>
<td class="align-middle">
<div class="fw-bold text-primary">Rp {{ number_format($item->amount, 0, ',', '.') }}</div>
</td>
<td class="align-middle">
<div class="fw-bold {{ $realtimeBudget >= 0 ? 'text-success' : 'text-danger' }}">
Rp {{ number_format($realtimeBudget, 0, ',', '.') }}
</div>
</td>
<td class="align-middle">
<span class="badge badge-light border shadow-sm px-2 py-1">
{{ strtoupper($item->periode_month) }} {{ $item->periode_year }}
</span>
</td>
<td class="align-middle text-center">
<span class="badge {{ $item->status == 'Assigned' ? 'badge-success' : 'badge-warning' }} px-2 py-1">
{{ $item->status }}
</span>
<div class="mt-1" style="line-height: 1.1;">
<small class="text-muted d-block" style="font-size: 10px;">{{ $item->created_at->format('d M Y H:i') }} WIB</small>
</div>
</td>
<td class="align-middle text-center">
@php
$tStatus = $item->transfer_status ?? 'Pending';
$badgeClass = match(strtolower($tStatus)) {
'validated' => 'badge-success',
'waiting approval' => 'badge-info',
'rejected' => 'badge-danger',
default => 'badge-secondary'
};
@endphp
<span class="badge {{ $badgeClass }} px-2 py-1">{{ $tStatus }}</span>
</td>
<td class="text-center align-middle">
@if($item->transfer_proof)
<a href="{{ asset(str_starts_with($item->transfer_proof, 'uploads/') ? $item->transfer_proof : 'uploads/' . $item->transfer_proof) }}"
target="_blank" class="btn btn-xs btn-info shadow-sm">
<i class="fas fa-eye"></i> View
</a>
@else @else
<span class="badge badge-danger text-white">{{ $item->status }}</span> <span class="text-muted small">-</span>
@endif @endif
</td> </td>
<td> <td class="align-middle">
@if (auth()->user()->can('budget_control.delete')) <div class="btn-group shadow-sm">
<form action="{{ route('budget_control.destroy', $item->id) }}" method="POST" class="d-inline" onsubmit="return confirm('Are you sure you want to delete this item?');"> @if(strtolower($item->transfer_status) != 'validated')
@csrf <button type="button" class="btn btn-sm btn-outline-primary btn-upload" data-id="{{ $item->id }}" data-toggle="modal" data-target="#modalUploadProof">
@method('DELETE') <i class="fas fa-upload"></i>
<button type="submit" class="btn btn-danger btn-sm">
<i class="fas fa-trash"></i>
</button> </button>
@endif
@if(Auth::user()->hasAnyRole(['accounting', 'Accounting', 'admin', 'Admin', 'Superadmin']) && strtolower($item->transfer_status) == 'waiting approval')
<form action="{{ route('budget_control.approve_transfer', $item->id) }}" method="POST" class="d-inline ml-1">
@csrf
<button type="submit" class="btn btn-sm btn-success" onclick="return confirm('Sahkan transfer ini?')"><i class="fas fa-check"></i></button>
</form>
<form action="{{ route('budget_control.reject_transfer', $item->id) }}" method="POST" class="d-inline ml-1">
@csrf
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Tolak transfer ini?')"><i class="fas fa-times"></i></button>
</form> </form>
@endif @endif
@if (auth()->user()->can('budget_control.delete'))
<form action="{{ route('budget_control.destroy', $item->id) }}" method="POST" class="d-inline ml-1" onsubmit="return confirm('Hapus data?');">
@csrf @method('DELETE')
<button type="submit" class="btn btn-danger btn-sm"><i class="fas fa-trash"></i></button>
</form>
@endif
</div>
</td> </td>
</tr> </tr>
@endforeach @endforeach
</tbody> </tbody>
</table> </table>
</div> </div>
{{-- --- BAGIAN 2: NAVIGASI HALAMAN (SERVER-SIDE) --- --}}
<div class="mt-4 d-flex justify-content-between align-items-center">
<div class="small text-muted">
Menampilkan {{ $data->firstItem() }} sampai {{ $data->lastItem() }} dari {{ $data->total() }} data
</div>
<div>
{{ $data->appends(request()->query())->links('pagination::bootstrap-4') }}
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
@include('backend.pages.budget_control.partials.modals')
@endsection @endsection
@section('scripts') @section('scripts')
<script> <script>
function toggleUploadType() {
const isRegion = document.getElementById('type_region').checked;
document.getElementById('group_region').classList.toggle('d-none', !isRegion);
document.getElementById('group_cabang').classList.toggle('d-none', isRegion);
}
$(document).ready(function () { $(document).ready(function () {
// Inisialisasi Select2 untuk Filter
$('.select2-filter').select2({
theme: 'bootstrap4',
width: '100%'
}).on('change', function() {
$('#filter-form').submit();
});
// Inisialisasi DataTables
if ($('#dataTable').length) { if ($('#dataTable').length) {
$('#dataTable').DataTable({ $('#dataTable').DataTable({
paging: false,
info: false,
responsive: false, responsive: false,
dom: 'Blfrtip', dom: 'Blfrtip',
lengthMenu: [ [10, 50, 100, -1], [10, 50, 100, "All"] ], // Control the entries dropdown
buttons: [ buttons: [
{ { extend: 'excel', className: 'btn-sm btn-success', text: '<i class="fas fa-file-excel mr-1"></i> Excel' },
extend: 'excel', { extend: 'pdf', className: 'btn-sm btn-danger', text: '<i class="fas fa-file-pdf mr-1"></i> PDF' }
text: 'Excel'
},
{
extend: 'pdf',
text: 'PDF',
orientation: 'landscape',
pageSize: 'A4',
exportOptions: {
columns: ':visible'
}
},
{
extend: 'print',
text: 'Print',
orientation: 'landscape',
exportOptions: {
columns: ':visible'
}
},
'colvis'
] ]
}); });
} }
$(document).on('click', '.btn-upload', function() {
$('#budget_id').val($(this).data('id'));
});
// Select2 di dalam modal
$('#modalBulkUpload').on('shown.bs.modal', function () {
$('.select2_modal').select2({
theme: 'bootstrap4',
width: '100%',
dropdownParent: $('#modalBulkUpload')
});
});
}); });
</script> </script>
@endsection @endsection
@@ -1,90 +1,270 @@
@extends('layouts.app') @extends('layouts.app')
@section('title') @section('title', 'Riwayat Transfer & Realisasi')
Dashboard
@endsection
@section('admin-content') @section('admin-content')
<section class="content-header"> <section class="content-header">
<div class="container-fluid"> <div class="container-fluid">
<div class="row mb-2"> <div class="row mb-2">
<div class="col-sm-6"> <div class="col-sm-6">
<h1>Log Transfer Budgets</h1> <h1 class="fw-bold text-dark">Log Riwayat Budget & Transaksi</h1>
</div> </div>
<div class="col-sm-6"> <div class="col-sm-6">
<ol class="breadcrumb float-sm-right"> <ol class="breadcrumb float-sm-right">
<li class="breadcrumb-item"><a href="{{ route('dashboard.index') }}">Dashboard</a></li> <li class="breadcrumb-item"><a href="{{ route('dashboard.index') }}">Dashboard</a></li>
</li> <li class="breadcrumb-item"><a href="{{ route('budget_control') }}">Budget Control</a></li>
<li class="breadcrumb-item active">Riwayat</li>
</ol> </ol>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<section class="content">
<section class="content">
<div class="container-fluid"> <div class="container-fluid">
<div class="card card-primary card-outline">
{{-- --- BAGIAN BARU: PANEL FILTER --- --}}
<div class="card shadow-sm mb-4" style="border-radius: 15px; border-top: 3px solid #17a2b8;">
<div class="card-body py-3">
<form action="{{ route('budget_control.log') }}" method="GET" id="filter-form">
<div class="row align-items-end">
<div class="col-md-3 mb-2 mb-md-0">
<label class="small font-weight-bold text-muted text-uppercase">Region</label>
<select name="region_id" class="form-control form-control-sm select2" onchange="this.form.submit()">
<option value="">Semua Region</option>
@foreach($regions as $reg)
<option value="{{ $reg->id }}" {{ request('region_id') == $reg->id ? 'selected' : '' }}>{{ $reg->name }}</option>
@endforeach
</select>
</div>
<div class="col-md-3 mb-2 mb-md-0">
<label class="small font-weight-bold text-muted text-uppercase">Cabang</label>
<select name="cabang_id" class="form-control form-control-sm select2" onchange="this.form.submit()">
<option value="">Semua Cabang</option>
@foreach($cabangOptions as $cab)
<option value="{{ $cab->id }}" {{ request('cabang_id') == $cab->id ? 'selected' : '' }}>{{ $cab->name }}</option>
@endforeach
</select>
</div>
<div class="col-md-2 mb-2 mb-md-0">
<label class="small font-weight-bold text-muted text-uppercase">Bulan</label>
<select name="month" class="form-control form-control-sm" onchange="this.form.submit()">
<option value="">Semua Bulan</option>
@foreach(['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] as $m)
<option value="{{ $m }}" {{ request('month') == $m ? 'selected' : '' }}>{{ $m }}</option>
@endforeach
</select>
</div>
<div class="col-md-2 mb-2 mb-md-0">
<label class="small font-weight-bold text-muted text-uppercase">Tahun</label>
<select name="year" class="form-control form-control-sm" onchange="this.form.submit()">
@for($y = date('Y'); $y >= 2024; $y--)
<option value="{{ $y }}" {{ request('year', date('Y')) == $y ? 'selected' : '' }}>{{ $y }}</option>
@endfor
</select>
</div>
<div class="col-md-2">
<div class="btn-group w-100">
<button type="submit" class="btn btn-sm btn-primary px-3 shadow-sm">
<i class="fas fa-filter"></i>
</button>
<a href="{{ route('budget_control.log') }}" class="btn btn-sm btn-secondary shadow-sm" title="Reset Filter">
<i class="fas fa-sync-alt"></i>
</a>
</div>
</div>
</div>
</form>
</div>
</div>
{{-- --- BAGIAN GRAFIK PERBANDINGAN (TETAP ADA) --- --}}
<div class="row">
<div class="col-12">
<div class="card shadow-sm mb-4" style="border-radius: 15px; border-left: 5px solid #007bff;">
<div class="card-header bg-white border-0 pt-3">
<h3 class="card-title fw-bold text-primary">
<i class="fas fa-chart-bar mr-2"></i> Perbandingan Petty Cash vs Realisasi (Halaman Ini)
</h3>
</div>
<div class="card-body"> <div class="card-body">
<h4 class="header-title float-left">Log Transfer Budgets</h4> <div style="height: 280px;">
<div class="clearfix"></div> <canvas id="logComparisonChart"></canvas>
<div class="dataTables_wrapper dt-bootstrap4 mt-2"> </div>
@include('backend.layouts.partials.messages') </div>
</div>
</div>
</div>
{{-- --- TABEL DATA --- --}}
<div class="card card-primary card-outline shadow-sm" style="border-radius: 15px;">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="text-muted font-weight-bold m-0 text-uppercase small">Detail Transaksi Riwayat</h6>
<div id="dt-buttons-container"></div>
</div>
<div class="table-responsive"> <div class="table-responsive">
<table id="dataTable" <table id="logTable" class="table table-bordered table-striped table-hover w-100">
class="table table-bordered table-striped table-head-fixed dataTable dtr-inline" <thead class="bg-light text-center text-uppercase small font-weight-bold">
style="width:100%">
<thead class="bg-light text-capitalize">
<tr> <tr>
<th>#</th> <th width="3%">#</th>
<th>Penerima</th> <th class="text-left">Cabang / Region</th>
<th>Cabang</th>
<th>Pengirim</th>
<th>Total</th>
<th>Keterangan</th>
<th>Periode</th> <th>Periode</th>
<th>Total Budget (Petty Cash)</th>
<th>Total Debet (Masuk)</th>
<th>Total Kredit (Keluar)</th>
<th class="text-success">Realisasi (Sisa)</th>
<th>Waktu Assign</th>
<th>Status</th> <th>Status</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody class="text-center">
@foreach ($data as $item) @forelse ($data as $item)
<tr> <tr>
<td>{{ $loop->iteration }}</td> <td>{{ ($data->currentPage() - 1) * $data->perPage() + $loop->iteration }}</td>
<td>{{ $item->receiver->name }}</td> <td class="text-left align-middle">
<td>{{ $item->cabang->name }}</td> <div class="fw-bold text-dark">{{ $item->cabang->name ?? '-' }}</div>
<td>{{ $item->sender->name }}</td> <small class="text-muted">{{ $item->cabang->region->name ?? '-' }}</small>
<td>{{ number_format($item->amount, 0, ',', '.') }}</td> </td>
<td>{{ $item->remarks }}</td> <td class="align-middle">
<td>{{ strtoupper($item->periode_month) }} {{ $item->periode_year }}</td> <span class="badge badge-light border shadow-sm px-2 py-1">
<td> {{ strtoupper($item->periode_month) }} {{ $item->periode_year }}
@if ($item->status == 'Unassigned') </span>
<span class="badge badge-warning text-white">{{ $item->status }}</span> </td>
@elseif ($item->status == 'Assigned')
<span class="badge badge-success text-white">{{ $item->status }}</span> <td class="align-middle fw-bold text-dark">
@else Rp {{ number_format($item->total_budget, 0, ',', '.') }}
<span class="badge badge-danger text-white">{{ $item->status }}</span> </td>
@endif <td class="align-middle text-primary">
Rp {{ number_format($item->total_debet, 0, ',', '.') }}
</td>
<td class="align-middle text-danger">
Rp {{ number_format($item->total_credit, 0, ',', '.') }}
</td>
<td class="align-middle fw-bold text-success" style="font-size: 15px;">
Rp {{ number_format($item->realisasi, 0, ',', '.') }}
</td>
<td class="align-middle">
<small class="text-muted d-block">
<i class="fas fa-calendar-alt mr-1"></i>{{ $item->created_at->format('d M Y') }}
</small>
<small class="text-muted d-block">
<i class="fas fa-clock mr-1"></i>{{ $item->created_at->format('H:i') }} WIB
</small>
</td>
<td class="align-middle">
<span class="badge {{ $item->status == 'Assigned' ? 'badge-success' : 'badge-secondary' }} px-2 py-1 shadow-sm">
{{ $item->status }}
</span>
</td> </td>
</tr> </tr>
@endforeach @empty
<tr>
<td colspan="9" class="text-center py-5 text-muted">
<i class="fas fa-folder-open fa-2x mb-2"></i><br>
Belum ada riwayat budget yang tercatat.
</td>
</tr>
@endforelse
</tbody> </tbody>
</table> </table>
</div> </div>
{{-- --- PAGINATION LARAVEL (TETAP ADA) --- --}}
<div class="mt-4 d-flex justify-content-between align-items-center">
<div class="small text-muted">
Menampilkan {{ $data->firstItem() }} sampai {{ $data->lastItem() }} dari {{ $data->total() }} data
</div>
<div>
{{ $data->appends(request()->query())->links('pagination::bootstrap-4') }}
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </section>
</section>
@endsection @endsection
@section('scripts') @section('scripts')
<script> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
$(document).ready(function () { $(document).ready(function () {
if ($('#dataTable').length) {
$('#dataTable').DataTable({ // 1. INISIALISASI GRAFIK PERBANDINGAN
responsive: false, const ctx = document.getElementById('logComparisonChart').getContext('2d');
dom: 'Bfrtip', new Chart(ctx, {
buttons: ['excel', 'pdf', 'print'] type: 'bar',
}); data: {
labels: {!! json_encode($chartData['labels'] ?? []) !!},
datasets: [
{
label: 'Petty Cash (Budget)',
data: {!! json_encode($chartData['budget'] ?? []) !!},
backgroundColor: 'rgba(54, 162, 235, 0.7)',
borderColor: 'rgba(54, 162, 235, 1)',
borderWidth: 1,
borderRadius: 5
},
{
label: 'Sisa Realisasi',
data: {!! json_encode($chartData['realisasi'] ?? []) !!},
backgroundColor: 'rgba(40, 167, 69, 0.7)',
borderColor: 'rgba(40, 167, 69, 1)',
borderWidth: 1,
borderRadius: 5
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'top' }
},
scales: {
y: {
beginAtZero: true,
ticks: {
callback: function(value) { return 'Rp ' + value.toLocaleString('id-ID'); }
}
}
}
} }
}); });
</script>
// 2. DATATABLES OPTIMIZATION
if ($('#logTable').length) {
var table = $('#logTable').DataTable({
paging: false,
info: false,
searching: true,
responsive: false,
dom: 'Bfrtip',
buttons: [
{
extend: 'excelHtml5',
className: 'btn-sm btn-success shadow-sm',
text: '<i class="fas fa-file-excel mr-1"></i> Export Excel',
title: 'Laporan Riwayat Budget - ' + new Date().toLocaleDateString('id-ID'),
exportOptions: { columns: ':visible' }
},
{
extend: 'pdfHtml5',
className: 'btn-sm btn-danger shadow-sm',
text: '<i class="fas fa-file-pdf mr-1"></i> Export PDF',
orientation: 'landscape',
pageSize: 'A4'
}
]
});
table.buttons().container().appendTo('#dt-buttons-container');
}
});
</script>
@endsection @endsection
@@ -0,0 +1,113 @@
{{-- Modal Upload Bukti Transfer --}}
<div class="modal fade" id="modalUploadProof" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content" style="border-radius: 15px; border: none;">
<form id="formUploadProof" action="{{ route('budget_control.upload_proof') }}" method="POST" enctype="multipart/form-data">
@csrf
<input type="hidden" name="budget_id" id="budget_id">
<div class="modal-header bg-primary text-white" style="border-radius: 15px 15px 0 0;">
<h5 class="modal-title font-weight-bold"><i class="fas fa-file-invoice mr-2"></i>Upload Bukti Transfer</h5>
<button type="button" class="close text-white" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="transfer_proof">Upload Dokumen Budget (PDF/Gambar) <span class="text-danger">*</span></label>
<input type="file" name="transfer_proof" id="transfer_proof" class="form-control" accept=".pdf, .jpg, .jpeg, .png" required>
<small class="form-text text-muted">Format yang diizinkan: PDF, JPG, JPEG, PNG. Maksimal ukuran file: 5MB.</small>
</div>
</div>
<div class="modal-footer bg-light" style="border-radius: 0 0 15px 15px;">
<button type="button" class="btn btn-secondary rounded-pill px-4" data-dismiss="modal">Batal</button>
<button type="submit" class="btn btn-primary rounded-pill px-4 shadow">Simpan Bukti</button>
</div>
</form>
</div>
</div>
</div>
{{-- Modal Bulk Upload Budget --}}
<div class="modal fade" id="modalBulkUpload" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content" style="border-radius: 15px; border: none;">
<div class="modal-header bg-success text-white" style="border-radius: 15px 15px 0 0;">
<h5 class="modal-title"><i class="fas fa-upload mr-2"></i> Bulk Upload Budget (Excel)</h5>
<button type="button" class="close text-white" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<form action="{{ route('budget_control.bulk_upload') }}" method="POST" enctype="multipart/form-data">
@csrf
<div class="modal-body">
<div class="alert alert-info small shadow-sm">
<div class="d-flex justify-content-between align-items-center">
<div>
<b>Format Kolom Excel (Baris 1 wajib Header):</b><br>
Kolom A: Kode Cabang | Kolom B: Bulan | Kolom C: Nominal | Kolom D: Keterangan
</div>
<div>
<a href="{{ route('budget_control.download_template') }}" class="btn btn-sm btn-light font-weight-bold shadow-sm text-info">
<i class="fas fa-download mr-1"></i> Download Template
</a>
</div>
</div>
</div>
<div class="form-group">
<label class="font-weight-bold small text-muted">METODE UPLOAD <span class="text-danger">*</span></label>
<div class="d-flex">
<div class="custom-control custom-radio mr-4">
<input type="radio" id="type_region" name="upload_type" value="region" class="custom-control-input" checked onchange="toggleUploadType()">
<label class="custom-control-label" for="type_region">Per Region</label>
</div>
<div class="custom-control custom-radio">
<input type="radio" id="type_cabang" name="upload_type" value="cabang" class="custom-control-input" onchange="toggleUploadType()">
<label class="custom-control-label" for="type_cabang">Pilih Cabang Tertentu</label>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 form-group" id="group_region">
<label class="font-weight-bold small text-muted text-uppercase">Region <span class="text-danger">*</span></label>
<select name="region_id" class="form-control select2_modal">
<option value="">-- Pilih Region --</option>
@foreach($regions as $reg)
<option value="{{ $reg->id }}">{{ $reg->name }}</option>
@endforeach
</select>
</div>
<div class="col-md-6 form-group d-none" id="group_cabang">
<label class="font-weight-bold small text-muted text-uppercase">Cabang <span class="text-danger">*</span></label>
<select name="cabang_id[]" class="form-control select2_modal" multiple="multiple" style="width: 100%;">
@foreach($cabangOptions as $cab)
<option value="{{ $cab->id }}">{{ $cab->code }} - {{ $cab->name }}</option>
@endforeach
</select>
</div>
<div class="col-md-6 form-group">
<label class="font-weight-bold small text-muted text-uppercase">Tahun Periode <span class="text-danger">*</span></label>
<input type="number" name="periode_year" class="form-control" value="{{ date('Y') }}" required>
</div>
</div>
<hr>
<div class="form-group">
<label class="font-weight-bold small text-muted">FILE UPLOAD (.xlsx, .xls, .csv) <span class="text-danger">*</span></label>
<input type="file" name="file_excel" class="form-control-file" accept=".xlsx, .xls, .csv" required>
</div>
</div>
<div class="modal-footer bg-light" style="border-radius: 0 0 15px 15px;">
<button type="button" class="btn btn-secondary shadow-sm" data-dismiss="modal">Batal</button>
<button type="submit" class="btn btn-success shadow-sm"><i class="fas fa-check"></i> Proses Upload</button>
</div>
</form>
</div>
</div>
</div>
@@ -1,125 +1,357 @@
@extends('layouts.app') @extends('layouts.app')
@section('title') @section('title', 'Dashboard Operational Analytics')
Dashboard
@section('styles')
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.7/css/dataTables.bootstrap4.min.css">
<link rel="stylesheet" href="https://cdn.datatables.net/buttons/2.4.2/css/buttons.bootstrap4.min.css">
{{-- Tambahan CDN Select2 untuk menjamin inisialisasi select2 dropdown berjalan lancar --}}
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@ttskch/select2-bootstrap4-theme@x.x.x/dist/select2-bootstrap4.min.css">
<style>
.chart-container { position: relative; height: 300px; width: 100%; }
.select2-container--bootstrap4 .select2-selection--single { height: calc(1.8125rem + 2px) !important; }
#table-monitoring thead th a { color: inherit; text-decoration: none; display: block; width: 100%; }
#table-monitoring tbody tr:hover { background-color: rgba(0,123,255,0.05); transition: 0.2s; }
.section-title { border-left: 5px solid #007bff; padding-left: 15px; margin-bottom: 20px; font-weight: 700; color: #333; text-transform: uppercase; }
.row-region-group { background-color: #f4f6f9 !important; border-top: 2px solid #dee2e6; }
</style>
@endsection @endsection
@section('admin-content') @section('admin-content')
<section class="content-header"> <div class="container-fluid pt-5">
<div class="container-fluid">
<div class="row mb-2"> {{-- 1. HEADER & LIVE CLOCK --}}
<div class="col-sm-6"> <div class="alert bg-primary text-white shadow-sm d-flex justify-content-between align-items-center mb-4" style="border-radius: 15px; border: none;">
<h1>Home</h1> <div>
<span class="font-weight-bold"><i class="fas fa-clock mr-2"></i> Waktu Sistem: </span>
<span id="live-clock" class="badge badge-light text-primary px-3 py-2 shadow-sm" style="font-size: 0.95rem; border-radius: 10px;">
Memuat...
</span>
</div> </div>
<div class="col-sm-6"> <div class="d-flex align-items-center">
<ol class="breadcrumb float-sm-right"> <div class="bg-white text-primary px-4 py-2 shadow-sm d-none d-md-block ml-3" style="border-radius: 50px;">
<li class="breadcrumb-item"><a href="{{ route('dashboard.index') }}">Dashboard</a></li> <i class="far fa-calendar-alt mr-2"></i>
</ol> <span class="font-weight-bold">Periode Input: {{ $periodeLabel ?? '-' }}</span>
</div> </div>
</div> </div>
</div> </div>
</section>
<section class="content"> {{-- 2. KARTU STATISTIK --}}
<div class="container-fluid"> <div class="row">
<!-- Statistic Cards Grid --> <div class="col-lg-3 col-6 mb-3">
<div class="row row-cols-2 row-cols-md-2 row-cols-lg-3 g-4"> <div class="small-box bg-info shadow-sm h-100" style="border-radius: 15px;">
<!-- Statistic Card 1 --> <div class="inner p-4"><h3>{{ $totalRegions ?? 0 }}</h3><p>Total Region</p></div>
<div class="col"> <div class="icon"><i class="fas fa-map-marked-alt"></i></div>
<div class="small-box bg-info">
<div class="inner">
<h3>Rp {{ number_format($forms['vehicle'] + $forms['entertainment'] + $forms['meeting'] + $forms['upcountry'] + $forms['others'], 0, ',', '.') }}</h3>
<p>All Approved Expenses</p>
</div>
<div class="icon">
<i class="fas fa-check-circle"></i>
</div>
<a href="#" class="small-box-footer">
More info <i class="fas fa-arrow-circle-right"></i>
</a>
</div> </div>
</div> </div>
<!-- Statistic Card 2 --> <div class="col-lg-3 col-6 mb-3">
<div class="col"> <div class="small-box bg-success shadow-sm h-100" style="border-radius: 15px;">
<div class="small-box bg-success"> <div class="inner p-4"><h3>{{ $totalCabangs ?? 0 }}</h3><p>Cabang Aktif</p></div>
<div class="inner"> <div class="icon"><i class="fas fa-building"></i></div>
<h3>Rp {{ number_format($forms['upcountry'], 0, ',', '.') }}</h3>
<p>Approved Up Country Expenses</p>
</div>
<div class="icon">
<i class="fa fa-flag" aria-hidden="true"></i>
</div>
<a href="{{ route('forms.up-country') }}" class="small-box-footer">
More info <i class="fas fa-arrow-circle-right"></i>
</a>
</div> </div>
</div> </div>
<!-- Statistic Card 3 --> <div class="col-lg-3 col-6 mb-3">
<div class="col"> <div class="small-box bg-warning shadow-sm h-100" style="border-radius: 15px;">
<div class="small-box bg-warning"> <div class="inner p-4 text-white"><h3>{{ $pendingCount ?? 0 }}</h3><p>Total Dokumen Pending</p></div>
<div class="inner text-white"> <div class="icon"><i class="fas fa-file-signature"></i></div>
<h3>Rp {{ number_format($forms['vehicle'], 0, ',', '.') }}</h3>
<p>Approved Vehicle Running Cost Expenses</p>
</div>
<div class="icon">
<i class="fa fa-car" aria-hidden="true"></i>
</div>
<a href="{{ route('forms.vehicle') }}" class="small-box-footer text-white">
<span class="text-white">More info</span> <i class="fas fa-arrow-circle-right text-white"></i>
</a>
</div> </div>
</div> </div>
<!-- Statistic Card 4 --> <div class="col-lg-3 col-6 mb-3">
<div class="col"> <div class="small-box bg-danger shadow-sm h-100" style="border-radius: 15px;">
<div class="small-box bg-purple"> <div class="inner p-4">
<div class="inner"> <h3>{{ $totalKategori ?? 0 }}</h3>
<h3>Rp {{ number_format($forms['entertainment'], 0, ',', '.') }}</h3> <p>Master Kategori</p>
<p>Approved Entertainment Presentation Expenses</p>
</div> </div>
<div class="icon"> <div class="icon"><i class="fas fa-tags"></i></div>
<i class="fa fa-desktop" aria-hidden="true"></i>
</div>
<a href="{{ route('forms.entertainment') }}" class="small-box-footer">
More info <i class="fas fa-arrow-circle-right"></i>
</a>
</div>
</div>
<!-- Statistic Card 5 -->
<div class="col">
<div class="small-box bg-danger">
<div class="inner">
<h3>Rp {{ number_format($forms['meeting'], 0, ',', '.') }}</h3>
<p>Approved Meeting Seminar Expenses</p>
</div>
<div class="icon">
<i class="fa fa-users" aria-hidden="true"></i>
</div>
<a href="{{ route('forms.meeting') }}" class="small-box-footer">
More info <i class="fas fa-arrow-circle-right"></i>
</a>
</div>
</div>
<!-- Statistic Card 6 -->
<div class="col">
<div class="small-box bg-pink">
<div class="inner">
<h3>Rp {{ number_format($forms['others'], 0, ',', '.') }}</h3>
<p>Approved Other Expenses</p>
</div>
<div class="icon">
<i class="fa fa-bars" aria-hidden="true"></i>
</div>
<a href="{{ route('forms.other') }}" class="small-box-footer">
More info <i class="fas fa-arrow-circle-right"></i>
</a>
</div> </div>
</div> </div>
</div> </div>
<!-- Welcome Card -->
<div class="card card-primary card-outline mt-4"> {{-- FILTER KHUSUS GRAFIK PERFORMA --}}
<div class="card-body text-center"> <div class="card shadow-sm mb-4 mt-2" style="border-radius: 15px; background-color: #f8f9fa; border-left: 5px solid #17a2b8;">
<p class="lead">Welcome to {{ config('app.name') }} (development version).</p> <div class="card-body py-2 px-3">
<form action="{{ route('admin.dashboard') }}" method="GET" id="chart-filter-form" class="m-0">
<input type="hidden" name="region_id" value="{{ request('region_id') }}">
<input type="hidden" name="cabang_id" value="{{ request('cabang_id') }}">
<input type="hidden" name="category" value="{{ request('category') }}">
<input type="hidden" name="search" value="{{ request('search') }}">
<div class="d-flex justify-content-between align-items-center flex-wrap">
<h6 class="m-0 font-weight-bold text-info"><i class="fas fa-filter mr-2"></i>Filter Performa Closing Expense</h6>
<div class="d-flex align-items-center mt-2 mt-md-0">
<label class="small font-weight-bold text-muted mr-2 mb-0">Bulan:</label>
<select name="chart_month" class="form-control form-control-sm mr-3" style="width: 130px;" onchange="this.form.submit()">
<option value="">Semua Bulan</option>
@foreach(range(1, 12) as $m)
<option value="{{ sprintf('%02d', $m) }}" {{ (request('chart_month') ?? date('m')) == sprintf('%02d', $m) ? 'selected' : '' }}>
{{ Carbon\Carbon::create()->month($m)->translatedFormat('F') }}
</option>
@endforeach
</select>
<label class="small font-weight-bold text-muted mr-2 mb-0">Tahun:</label>
<select name="chart_year" class="form-control form-control-sm" style="width: 100px;" onchange="this.form.submit()">
@php $currentYear = date('Y'); @endphp
@for($y = $currentYear; $y >= 2023; $y--)
<option value="{{ $y }}" {{ (request('chart_year') ?? date('Y')) == $y ? 'selected' : '' }}>{{ $y }}</option>
@endfor
</select>
</div>
</div>
</form>
</div>
</div>
{{-- 3. GRAFIK PERFORMA CLOSING EXPENSE --}}
<h5 class="section-title mt-4">Respon Speed Expense</h5>
<div class="row mt-2">
<div class="col-md-6 mb-4">
<div class="card shadow-sm h-100" style="border-radius: 15px;">
<div class="card-header bg-white border-0 pt-3">
<h3 class="card-title font-weight-bold text-success text-uppercase small"><i class="fas fa-bolt mr-2"></i> 5 Cabang Tercepat</h3>
</div>
<div class="card-body">
<div class="chart-container"><canvas id="chartFastest"></canvas></div>
</div> </div>
</div> </div>
</div> </div>
</section> <div class="col-md-6 mb-4">
<div class="card shadow-sm h-100" style="border-radius: 15px;">
<div class="card-header bg-white border-0 pt-3">
<h3 class="card-title font-weight-bold text-danger text-uppercase small"><i class="fas fa-snail mr-2"></i> 5 Cabang Terlama</h3>
</div>
<div class="card-body">
<div class="chart-container"><canvas id="chartSlowest"></canvas></div>
</div>
</div>
</div>
</div>
{{-- 4. FILTER PANEL TABEL PENDING --}}
<div class="card shadow-sm mb-4" style="border-radius: 15px;">
<div class="card-body py-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="font-weight-bold text-muted m-0 small text-uppercase">Tabel Monitoring Dokumen Pending</h6>
{{-- Container Tombol Excel --}}
<div id="export-button-container"></div>
</div>
<form action="{{ route('admin.dashboard') }}" method="GET" id="filter-form">
<input type="hidden" name="chart_month" value="{{ request('chart_month') }}">
<input type="hidden" name="chart_year" value="{{ request('chart_year') }}">
<div class="row align-items-end">
<div class="col-md-3 mb-2 mb-md-0">
<label class="small font-weight-bold text-muted text-uppercase">Region</label>
<select name="region_id" class="form-control form-control-sm select2" onchange="this.form.submit()">
<option value="">Semua Region</option>
@foreach($regions as $reg)
<option value="{{ $reg->id }}" {{ request('region_id') == $reg->id ? 'selected' : '' }}>{{ $reg->name }}</option>
@endforeach
</select>
</div>
<div class="col-md-3 mb-2 mb-md-0">
<label class="small font-weight-bold text-muted text-uppercase">Cabang</label>
<select name="cabang_id" class="form-control form-control-sm select2" onchange="this.form.submit()">
<option value="">Semua Cabang</option>
@foreach($cabangOptions as $cab)
<option value="{{ $cab->id }}" {{ request('cabang_id') == $cab->id ? 'selected' : '' }}>{{ $cab->name }}</option>
@endforeach
</select>
</div>
<div class="col-md-3 mb-2 mb-md-0">
<label class="small font-weight-bold text-muted text-uppercase">Highlight Kategori</label>
<select name="category" class="form-control form-control-sm" onchange="this.form.submit()">
<option value="">Pilih Kategori</option>
@php
$categories = [
'up_country' => 'Up Country',
'vehicle' => 'Vehicle Running Cost',
'entertainment' => 'Entertainment',
'meeting' => 'Meeting Seminar',
'others' => 'Others'
];
@endphp
@foreach($categories as $key => $val)
<option value="{{ $key }}" {{ request('category') == $key ? 'selected' : '' }}>{{ $val }}</option>
@endforeach
</select>
</div>
<div class="col-md-3">
<div class="d-flex">
<input type="text" name="search" class="form-control form-control-sm mr-2" placeholder="Cari..." value="{{ request('search') }}">
<button type="submit" class="btn btn-sm btn-primary px-3 shadow-sm">Filter</button>
<a href="{{ route('admin.dashboard') }}" class="btn btn-sm btn-secondary ml-1 shadow-sm" title="Reset Filter"><i class="fas fa-sync-alt"></i></a>
</div>
</div>
</div>
</form>
</div>
</div>
{{-- 5. TABEL MONITORING --}}
<div class="card shadow-sm" style="border-radius: 15px; overflow: hidden;">
<div class="card-body p-0 table-responsive">
<table id="table-monitoring" class="table table-bordered m-0">
<thead class="bg-light text-center small text-uppercase font-weight-bold">
<tr>
<th rowspan="2" class="align-middle">
<a href="{{ request()->fullUrlWithQuery(['sort' => 'region', 'direction' => request('direction') == 'asc' ? 'desc' : 'asc']) }}">Region <i class="fas fa-sort ml-1 opacity-50"></i></a>
</th>
<th rowspan="2" class="align-middle text-left">
<a href="{{ request()->fullUrlWithQuery(['sort' => 'name', 'direction' => request('direction') == 'asc' ? 'desc' : 'asc']) }}">Cabang <i class="fas fa-sort ml-1 opacity-50"></i></a>
</th>
<th colspan="5" class="bg-white text-center align-middle">Rincian Dokumen Pending</th>
<th rowspan="2" class="align-middle text-center">
<a href="{{ request()->fullUrlWithQuery(['sort' => 'total_pending', 'direction' => request('direction') == 'asc' ? 'desc' : 'asc']) }}">Total <i class="fas fa-sort ml-1 opacity-50"></i></a>
</th>
<th rowspan="2" class="align-middle no-export text-center">Aksi</th>
</tr>
<tr class="bg-white">
<th class="{{ request('category') == 'up_country' ? 'bg-warning' : '' }}">UC</th>
<th class="{{ request('category') == 'vehicle' ? 'bg-warning' : '' }}">VC</th>
<th class="{{ request('category') == 'entertainment' ? 'bg-warning' : '' }}">EN</th>
<th class="{{ request('category') == 'meeting' ? 'bg-warning' : '' }}">MT</th>
<th class="{{ request('category') == 'others' ? 'bg-warning' : '' }}">OT</th>
</tr>
</thead>
<tbody class="text-center">
@php $currentRegion = null; @endphp
@forelse($cabangDetails as $detail)
@if($currentRegion != ($detail->region_id ?? null))
<tr class="row-region-group">
<td colspan="9" class="text-left font-weight-bold text-primary text-uppercase" style="letter-spacing: 1px;">
<i class="fas fa-map-marked-alt mr-2 text-warning"></i> REGION: {{ $detail->region->name ?? 'TANPA REGION' }}
</td>
</tr>
@php $currentRegion = $detail->region_id ?? null; @endphp
@endif
<tr>
<td class="text-left small text-muted"><i class="fas fa-level-up-alt fa-rotate-90 text-muted mr-1 ml-2"></i> {{ $detail->region->name ?? '-' }}</td>
<td class="text-left font-weight-bold text-dark">{{ $detail->name }}</td>
<td>{{ $detail->pending_details['up_country'] ?? 0 }}</td>
<td>{{ $detail->pending_details['vehicle'] ?? 0 }}</td>
<td>{{ $detail->pending_details['entertainment'] ?? 0 }}</td>
<td>{{ $detail->pending_details['meeting'] ?? 0 }}</td>
<td>{{ $detail->pending_details['others'] ?? 0 }}</td>
<td class="bg-light font-weight-bold text-center align-middle">{{ $detail->total_pending }}</td>
<td class="no-export text-center align-middle">
<a href="{{ route('admin.dashboard.show', $detail->id) }}" class="btn btn-xs btn-info shadow-sm" title="Lihat Detail"><i class="fas fa-eye"></i></a>
</td>
</tr>
@empty
<tr>
<td colspan="9" class="py-5 text-center text-muted">
<i class="fas fa-info-circle mr-2"></i> Data tidak ditemukan untuk filter ini.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="card-footer bg-white border-0 py-3">
<div class="d-flex justify-content-between align-items-center">
@if(isset($cabangDetails) && method_exists($cabangDetails, 'firstItem'))
<div class="small text-muted">Menampilkan <b>{{ $cabangDetails->firstItem() ?? 0 }}</b> - <b>{{ $cabangDetails->lastItem() ?? 0 }}</b> dari <b>{{ $cabangDetails->total() ?? 0 }}</b> Cabang.</div>
<div>{{ $cabangDetails->appends(request()->query())->links('pagination::bootstrap-4') }}</div>
@endif
</div>
</div>
</div>
</div>
@endsection
@section('scripts')
{{-- Library Wajib DataTables --}}
<script src="https://cdn.datatables.net/1.13.7/js/jquery.dataTables.min.js"></script>
<script src="https://cdn.datatables.net/1.13.7/js/dataTables.bootstrap4.min.js"></script>
{{-- Library Wajib untuk Tombol Export --}}
<script src="https://cdn.datatables.net/buttons/2.4.2/js/dataTables.buttons.min.js"></script>
<script src="https://cdn.datatables.net/buttons/2.4.2/js/buttons.bootstrap4.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script src="https://cdn.datatables.net/buttons/2.4.2/js/buttons.html5.min.js"></script>
{{-- Library Grafik --}}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.0.0"></script>
{{-- Library Select2 untuk keindahan select input --}}
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<script>
// 1. LIVE CLOCK
function updateClock() {
const now = new Date();
const options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' };
const clockEl = document.getElementById('live-clock');
if(clockEl) clockEl.innerText = now.toLocaleDateString('id-ID', options) + ' WIB';
}
setInterval(updateClock, 1000); updateClock();
$(document).ready(function() {
// Inisialisasi Select2
$('.select2').select2({
theme: 'bootstrap4',
width: '100%'
});
// Hindari pesan error alert bawaan browser
$.fn.dataTable.ext.errMode = 'none';
// Inisialisasi DataTable
var table = $('#table-monitoring').DataTable({
paging: false,
searching: false,
info: false,
ordering: false,
autoWidth: false,
buttons: [
{
extend: 'excelHtml5',
text: '<i class="fas fa-file-excel mr-1"></i> Export Excel',
className: 'btn btn-sm btn-success font-weight-bold shadow-sm',
title: 'Laporan Pending - {{ $periodeLabel ?? "Mei 2026" }}',
exportOptions: {
columns: ':not(.no-export)'
}
}
]
});
// Append tombol ke container
if (table.buttons().container().length) {
table.buttons().container().appendTo('#export-button-container');
}
});
// 3. CHARTS PERFORMA
Chart.register(ChartDataLabels);
const configChart = (ctxId, labels, data, color, rawDurations) => {
const ctx = document.getElementById(ctxId);
if(!ctx || !labels || labels.length === 0) return;
new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{ data: data, backgroundColor: color, borderRadius: 8, barThickness: 20 }]
},
options: {
indexAxis: 'y', responsive: true, maintainAspectRatio: false,
layout: { padding: { right: 85 } },
plugins: {
legend: { display: false },
datalabels: {
anchor: 'end', align: 'right', color: '#444', font: { weight: 'bold', size: 10 },
formatter: (val, ctx) => rawDurations[ctx.dataIndex] || ''
}
},
scales: { x: { display: false }, y: { ticks: { font: { size: 10, weight: 'bold' } } } }
}
});
};
configChart('chartFastest', {!! json_encode($chartFastest['labels'] ?? []) !!}, {!! json_encode($chartFastest['data'] ?? []) !!}, '#28a745', {!! json_encode($chartFastest['raw'] ?? []) !!});
configChart('chartSlowest', {!! json_encode($chartSlowest['labels'] ?? []) !!}, {!! json_encode($chartSlowest['data'] ?? []) !!}, '#dc3545', {!! json_encode($chartSlowest['raw'] ?? []) !!});
</script>
@endsection @endsection
@@ -0,0 +1,42 @@
@extends('layouts.app')
@section('admin-content')
<div class="container-fluid">
<div class="card shadow-sm mt-3">
<div class="card-header bg-info text-white">
<h3 class="card-title">Detail Pending: {{ $cabang->name }} ({{ $cabang->region->name ?? '' }})</h3>
<div class="card-tools">
<a href="{{ route('admin.dashboard') }}" class="btn btn-tool text-white"><i class="fas fa-times"></i></a>
</div>
</div>
<div class="card-body">
{{-- Loop menggunakan variabel $data yang dikirim dari controller --}}
@foreach($data as $label => $items)
@if($items->count() > 0)
<h5 class="mt-4 text-uppercase font-weight-bold text-secondary">{{ $label }}</h5>
<table class="table table-sm table-striped">
<thead>
<tr>
<th>No. Expense</th>
<th>Tanggal</th>
<th>Total</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@foreach($items as $item)
<tr>
<td>{{ $item->expense_number }}</td>
<td>{{ \Carbon\Carbon::parse($item->tanggal)->format('d/m/Y') }}</td>
<td>Rp {{ number_format($item->total, 0, ',', '.') }}</td>
<td><span class="badge badge-warning">{{ $item->status }}</span></td>
</tr>
@endforeach
</tbody>
</table>
@endif
@endforeach
</div>
</div>
</div>
@endsection
@@ -6,6 +6,7 @@
@section('admin-content') @section('admin-content')
<script src="https://cdn.jsdelivr.net/npm/autonumeric@4.6.0/dist/autoNumeric.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/autonumeric@4.6.0/dist/autoNumeric.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<section class="content-header"> <section class="content-header">
<div class="container-fluid"> <div class="container-fluid">
@@ -16,7 +17,6 @@
<div class="col-sm-6"> <div class="col-sm-6">
<ol class="breadcrumb float-sm-right"> <ol class="breadcrumb float-sm-right">
<li class="breadcrumb-item"><a href="{{ route('dashboard.index') }}">Dashboard</a></li> <li class="breadcrumb-item"><a href="{{ route('dashboard.index') }}">Dashboard</a></li>
</li>
</ol> </ol>
</div> </div>
</div> </div>
@@ -57,7 +57,7 @@
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Jenis <span class="font-italic font-weight-normal">(required)</span></label> <label class="form-label">Jenis <span class="font-italic font-weight-normal">(required)</span></label>
<select class="form-control form-control-md" name="jenis" required > <select class="form-control form-control-md" name="jenis" required >
<option value="">Pilih Jenis</option> <option value="" disabled {{ old('jenis') == '' ? 'selected' : '' }}>Pilih Jenis</option>
<option value="entertainment" {{ old('jenis') == 'entertainment' ? 'selected' : '' }} >Entertainment</option> <option value="entertainment" {{ old('jenis') == 'entertainment' ? 'selected' : '' }} >Entertainment</option>
<option value="presentation" {{ old('jenis') == 'presentation' ? 'selected' : '' }}>Presentation</option> <option value="presentation" {{ old('jenis') == 'presentation' ? 'selected' : '' }}>Presentation</option>
<option value="sponsorship" {{ old('jenis') == 'sponsorship' ? 'selected' : '' }}>Sponsorship</option> <option value="sponsorship" {{ old('jenis') == 'sponsorship' ? 'selected' : '' }}>Sponsorship</option>
@@ -78,13 +78,25 @@
<label class="form-label">Alamat <span class="font-italic font-weight-normal">(required)</span></label> <label class="form-label">Alamat <span class="font-italic font-weight-normal">(required)</span></label>
<input type="text" class="form-control" name="alamat" id="alamat" required value="{{ old('alamat') }}"> <input type="text" class="form-control" name="alamat" id="alamat" required value="{{ old('alamat') }}">
</div> </div>
<div class="mb-3">
<label class="form-label">Total <span class="font-italic font-weight-normal">(required)</span></label> <div class="row">
<input type="string" class="form-control" name="total" id="total" required value="{{ old('total') }}"> <div class="col-md-4">
<label>Nama Perusahaan <span class="font-italic font-weight-normal">(required)</span></label>
<input type="text" name="nama_perusahaan" class="form-control" placeholder="Contoh: PT. Maju Jaya" required value="{{ old('nama_perusahaan') }}">
</div> </div>
<div class="mb-3"> <div class="col-md-4">
<label class="form-label">Bukti Total <span class="font-italic font-weight-normal">(required)</span></label> <label>Jabatan <span class="font-italic font-weight-normal">(required)</span></label>
<input type="file" class="form-control" name="bukti_total" value="{{ old('bukti_total') }}" required> <input type="text" name="jabatan" class="form-control" placeholder="Contoh: Manager Operasional" required value="{{ old('jabatan') }}">
</div>
<div class="col-md-4">
<label>Jenis Usaha <span class="font-italic font-weight-normal">(required)</span></label>
<input type="text" name="jenis_usaha" class="form-control" placeholder="Contoh: Farmasi / Manufaktur" required value="{{ old('jenis_usaha') }}">
</div>
</div>
<div class="mb-3 mt-3">
<label class="form-label">Total <span class="font-italic font-weight-normal">(required)</span></label>
<input type="text" class="form-control" name="total" id="total" required value="{{ old('total') }}">
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Keterangan <span class="font-italic font-weight-normal">(required)</span></label> <label class="form-label">Keterangan <span class="font-italic font-weight-normal">(required)</span></label>
@@ -92,7 +104,39 @@
</div> </div>
</div> </div>
<button type="submit" class="btn btn-primary ml-2">Submit</button> <div class="col-12">
<hr class="my-4">
<div class="d-flex justify-content-between align-items-center mb-2">
<h5 class="mb-0">Lampiran</h5>
<button type="button" class="btn btn-sm btn-primary" id="entertainment-add-attachment-row">
<i class="fas fa-plus mr-1"></i> Tambah Attachment
</button>
</div>
<div class="table-responsive">
<table class="table table-bordered table-striped mb-0" id="entertainment-new-attachments-table" data-next-index="0">
<thead class="bg-light">
<tr>
<th style="width: 30%;">Kategori</th>
<th>File</th>
<th style="width: 120px;" class="text-center">Preview</th>
<th style="width: 80px;" class="text-center">Aksi</th>
</tr>
</thead>
<tbody>
<tr class="entertainment-empty-row">
<td colspan="4" class="text-center text-muted">Belum ada attachment.</td>
</tr>
</tbody>
</table>
</div>
<small class="text-muted d-block mt-2">
Maksimum 10 MB per file. Tidak diperbolehkan: {{ implode(', ', $entertainmentBlockedExtensions ?? []) }}.
</small>
</div>
<div class="col-12 text-right mt-4">
<button type="submit" class="btn btn-primary">Submit</button>
</div>
</div> </div>
</form> </form>
@@ -100,7 +144,7 @@
<div class="spinner-wrapper"> <div class="spinner-wrapper">
<div class="spinner-border text-primary" role="status"> <div class="spinner-border text-primary" role="status">
</div> </div>
<p>Submitting, please wait...</p> <p class="mt-2">Submitting, please wait...</p>
</div> </div>
</div> </div>
</div> </div>
@@ -109,18 +153,50 @@
</section> </section>
<script> <script>
new AutoNumeric('#total', { $(document).ready(function () {
// 1. Inisialisasi AutoNumeric
const totalInput = new AutoNumeric('#total', {
digitGroupSeparator: '.', // Pemisah ribuan digitGroupSeparator: '.', // Pemisah ribuan
decimalCharacter: ',', // Karakter desimal (tidak digunakan karena tanpa desimal) decimalCharacter: ',', // Karakter desimal
currencySymbol: 'Rp.', // Simbol mata uang currencySymbol: 'Rp. ', // Simbol mata uang
decimalPlaces: 0, // Tidak ada angka desimal decimalPlaces: 0, // Tidak ada angka desimal
unformatOnSubmit: true // Nilai asli tanpa format saat dikirimkan minimumValue: '0', // Cegah minus
unformatOnSubmit: true // Nilai asli tanpa format saat dikirimkan
}); });
document.getElementById('expense-form').addEventListener('submit', function (e) { // 2. Mencegah Scroll Mouse Wheel pada Input Nominal
const spinnerOverlay = document.getElementById('loading-spinner-overlay'); $('#total').on('wheel font-wheel', function(e) {
spinnerOverlay.classList.remove('d-none'); // Show overlay e.preventDefault();
spinnerOverlay.classList.add('d-flex'); // Use flexbox for centering $(this).blur();
});
// 3. Interseptor Form Submit untuk Validasi Maksimal Rp 1 Juta
$('#expense-form').on('submit', function (e) {
e.preventDefault(); // Tahan pengiriman sementara
const form = this;
const spinnerOverlay = $('#loading-spinner-overlay');
// Ambil nilai nominal mentah dari AutoNumeric
const totalExpense = totalInput.getNumber() || 0;
// Cek Aturan Bisnis: Tidak Boleh Lebih dari Rp 1.000.000
if (totalExpense > 1000000) {
Swal.fire({
title: 'Nominal Melebihi Batas!',
text: `Pengajuan Anda sebesar Rp ${new Intl.NumberFormat('id-ID').format(totalExpense)} melebihi batas maksimal yang diizinkan yaitu Rp 1.000.000. Mohon sesuaikan nominal Anda.`,
icon: 'error',
confirmButtonColor: '#d33',
confirmButtonText: 'Revisi Angka'
});
} else {
// Lolos Validasi -> Tampilkan Spinner & Eksekusi Submit
spinnerOverlay.removeClass('d-none').addClass('d-flex');
form.submit();
}
});
}); });
</script> </script>
@include('backend.pages.forms.entertainment.partials.attachment-modal')
@include('backend.pages.forms.entertainment.partials.attachment-scripts')
@endsection @endsection
@@ -6,6 +6,7 @@
@section('admin-content') @section('admin-content')
<script src="https://cdn.jsdelivr.net/npm/autonumeric@4.6.0/dist/autoNumeric.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/autonumeric@4.6.0/dist/autoNumeric.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<section class="content-header"> <section class="content-header">
<div class="container-fluid"> <div class="container-fluid">
@@ -16,12 +17,12 @@
<div class="col-sm-6"> <div class="col-sm-6">
<ol class="breadcrumb float-sm-right"> <ol class="breadcrumb float-sm-right">
<li class="breadcrumb-item"><a href="{{ route('dashboard.index') }}">Dashboard</a></li> <li class="breadcrumb-item"><a href="{{ route('dashboard.index') }}">Dashboard</a></li>
</li>
</ol> </ol>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<style> <style>
#loading-spinner-overlay { #loading-spinner-overlay {
position: fixed; position: fixed;
@@ -29,18 +30,15 @@
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
background-color: rgba(0, 0, 0, 0.5); /* Semi-transparent black background */ background-color: rgba(0, 0, 0, 0.5);
z-index: 9999; /* High z-index to cover everything */ z-index: 9999;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
} }
.spinner-wrapper { text-align: center; color: white; }
.spinner-wrapper {
text-align: center;
color: white;
}
</style> </style>
<section class="content"> <section class="content">
<div class="container-fluid"> <div class="container-fluid">
<div class="card card-primary card-outline"> <div class="card card-primary card-outline">
@@ -53,7 +51,8 @@
<div class="col-lg-6"> <div class="col-lg-6">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Tanggal <span class="font-italic font-weight-normal">(required)</span></label> <label class="form-label">Tanggal <span class="font-italic font-weight-normal">(required)</span></label>
<input type="date" class="form-control" name="tanggal" required value="{{ $form->tanggal }}"> {{-- Diubah menjadi input date murni --}}
<input type="date" class="form-control" name="tanggal" required value="{{ \Carbon\Carbon::parse($form->tanggal)->format('Y-m-d') }}">
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Jenis <span class="font-italic font-weight-normal">(required)</span></label> <label class="form-label">Jenis <span class="font-italic font-weight-normal">(required)</span></label>
@@ -79,13 +78,25 @@
<label class="form-label">Alamat <span class="font-italic font-weight-normal">(required)</span></label> <label class="form-label">Alamat <span class="font-italic font-weight-normal">(required)</span></label>
<input type="text" class="form-control" name="alamat" id="alamat" required value="{{ $form->alamat }}"> <input type="text" class="form-control" name="alamat" id="alamat" required value="{{ $form->alamat }}">
</div> </div>
<div class="mb-3">
<label class="form-label">Total <span class="font-italic font-weight-normal">(required)</span></label> <div class="row">
<input type="string" class="form-control" name="total" id="total" required value="{{ $form->total }}"> <div class="col-md-4">
<label>Nama Perusahaan <span class="font-italic font-weight-normal">(required)</span></label>
<input type="text" name="nama_perusahaan" class="form-control" required value="{{ old('nama_perusahaan', $form->nama_perusahaan) }}">
</div> </div>
<div class="mb-3"> <div class="col-md-4">
<label class="form-label">Bukti Total <span class="font-italic font-weight-normal">(optional)</span></label> <label>Jabatan <span class="font-italic font-weight-normal">(required)</span></label>
<input type="file" class="form-control" name="bukti_total" value="{{ old('bukti_total') }}"> <input type="text" name="jabatan" class="form-control" required value="{{ old('jabatan', $form->jabatan) }}">
</div>
<div class="col-md-4">
<label>Jenis Usaha <span class="font-italic font-weight-normal">(required)</span></label>
<input type="text" name="jenis_usaha" class="form-control" required value="{{ old('jenis_usaha', $form->jenis_usaha) }}">
</div>
</div>
<div class="mb-3 mt-3">
<label class="form-label">Total <span class="font-italic font-weight-normal">(required)</span></label>
<input type="text" class="form-control" name="total" id="total" required value="{{ $form->total }}">
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Keterangan <span class="font-italic font-weight-normal">(required)</span></label> <label class="form-label">Keterangan <span class="font-italic font-weight-normal">(required)</span></label>
@@ -93,14 +104,85 @@
</div> </div>
</div> </div>
<button type="submit" class="btn btn-primary ml-2">Update</button> {{-- Lampiran Existing --}}
<div class="col-12">
<hr class="my-4">
<h5 class="mb-3">Lampiran Saat Ini</h5>
<div class="table-responsive">
<table class="table table-bordered table-striped mb-0" id="entertainment-existing-attachments-table">
<thead class="bg-light">
<tr>
<th style="width: 25%;">Kategori</th>
<th>Nama File</th>
<th style="width: 120px;" class="text-center">Preview</th>
<th style="width: 120px;" class="text-center">Download</th>
<th style="width: 100px;" class="text-center">Aksi</th>
</tr>
</thead>
<tbody>
@forelse ($attachments as $attachment)
<tr class="entertainment-attachment-row">
<td>{{ ucwords(str_replace('_', ' ', $attachment['file_category'])) }}</td>
<td>{{ $attachment['filename'] }}</td>
<td class="text-center">
<button type="button" class="btn btn-sm btn-outline-secondary entertainment-preview-trigger"
data-preview-type="{{ $attachment['preview_type'] }}"
data-preview-source="{{ $attachment['preview_url'] }}"
data-download-url="{{ $attachment['download_url'] }}"
data-filename="{{ $attachment['filename'] }}">Preview</button>
</td>
<td class="text-center">
<a href="{{ $attachment['download_url'] }}" class="btn btn-sm btn-outline-success" target="_blank">Download</a>
</td>
<td class="text-center">
<button type="button" class="btn btn-sm btn-outline-danger entertainment-delete-existing-attachment"
data-delete-url="{{ route('forms.entertainment.attachments.destroy', [$form->id, $attachment['id']]) }}">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
@empty
<tr class="text-center text-muted"><td colspan="5">Belum ada lampiran tersimpan.</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
{{-- Lampiran Baru --}}
<div class="col-12 mt-4">
<div class="d-flex justify-content-between align-items-center mb-2">
<h5 class="mb-0">Tambah Lampiran Baru</h5>
<button type="button" class="btn btn-sm btn-primary" id="entertainment-add-attachment-row">
<i class="fas fa-plus mr-1"></i> Tambah Attachment
</button>
</div>
<div class="table-responsive">
<table class="table table-bordered align-middle" id="entertainment-new-attachments-table">
<thead class="bg-light">
<tr>
<th style="width: 30%;">Kategori</th>
<th>File</th>
<th style="width: 120px;" class="text-center">Preview</th>
<th style="width: 80px;" class="text-center">Aksi</th>
</tr>
</thead>
<tbody>
<tr class="entertainment-empty-row"><td colspan="4" class="text-center text-muted">Belum ada lampiran baru.</td></tr>
</tbody>
</table>
</div>
</div>
<div class="col-12 text-right mt-4">
<button type="submit" class="btn btn-primary">Update</button>
</div>
</div> </div>
</form> </form>
<div id="loading-spinner-overlay" class="d-none"> <div id="loading-spinner-overlay" class="d-none">
<div class="spinner-wrapper"> <div class="spinner-wrapper">
<div class="spinner-border text-primary" role="status"> <div class="spinner-border text-primary" role="status"></div>
</div>
<p>Submitting, please wait...</p> <p>Submitting, please wait...</p>
</div> </div>
</div> </div>
@@ -110,18 +192,37 @@
</section> </section>
<script> <script>
new AutoNumeric('#total', { $(document).ready(function() {
digitGroupSeparator: '.', // Pemisah ribuan // 1. Inisialisasi AutoNumeric Aman
decimalCharacter: ',', // Karakter desimal (tidak digunakan karena tanpa desimal) const totalInput = new AutoNumeric('#total', {
currencySymbol: 'Rp.', // Simbol mata uang digitGroupSeparator: '.',
decimalPlaces: 0, // Tidak ada angka desimal decimalCharacter: ',',
unformatOnSubmit: true // Nilai asli tanpa format saat dikirimkan currencySymbol: 'Rp. ',
decimalPlaces: 0,
minimumValue: '0',
unformatOnSubmit: true
}); });
document.getElementById('expense-form').addEventListener('submit', function (e) { // 2. Cegah Scroll Wheel pada Input
const spinnerOverlay = document.getElementById('loading-spinner-overlay'); $('#total').on('wheel', function(e) { e.preventDefault(); $(this).blur(); });
spinnerOverlay.classList.remove('d-none'); // Show overlay
spinnerOverlay.classList.add('d-flex'); // Use flexbox for centering // 3. Interseptor Form Submit dengan Validasi 1 Juta
$('#expense-form').on('submit', function (e) {
const totalExpense = totalInput.getNumber() || 0;
if (totalExpense > 1000000) {
e.preventDefault();
Swal.fire({
title: 'Nominal Melebihi Batas!',
text: 'Total pengajuan melebihi batas Rp 1.000.000. Mohon revisi nominal Anda.',
icon: 'error'
});
} else {
$('#loading-spinner-overlay').removeClass('d-none').addClass('d-flex');
}
});
}); });
</script> </script>
@include('backend.pages.forms.entertainment.partials.attachment-modal')
@include('backend.pages.forms.entertainment.partials.attachment-scripts')
@endsection @endsection
@@ -6,16 +6,72 @@
@section('admin-content') @section('admin-content')
<style> <style>
/* Style Awal Anda */
#outstandingCard { #outstandingCard {
cursor: pointer; cursor: pointer;
} }
#outstandingCard.active { #outstandingCard.active {
box-shadow: 0 0 0 0.25rem rgba(255, 255, 255, 0.35); box-shadow: 0 0 0 0.25rem rgba(255, 255, 255, 0.35);
} }
/* Style Baru: Mini Status Cards (Seragam dengan Up Country) */
.status-card {
cursor: pointer;
transition: all 0.2s ease-in-out;
border: 1px solid rgba(0,0,0,0.08);
border-radius: 6px;
background-color: #fff;
}
.status-card:hover { transform: translateY(-2px); box-shadow: 0 4px 8px rgba(0,0,0,0.1); }
.status-card.active-filter {
border: 2px solid #343a40;
box-shadow: 0 2px 10px rgba(0,0,0,0.15);
}
.mini-stat-box {
padding: 6px 10px;
display: flex;
align-items: center;
justify-content: space-between;
min-height: 45px;
}
.mini-stat-box h6 { margin: 0; font-weight: 800; font-size: 0.95rem; color: #333; }
.mini-stat-box p { margin: 0; font-size: 0.65rem; text-transform: uppercase; color: #777; font-weight: 600; }
/* Indikator Warna Kiri */
.status-card[data-status="On Progress"] { border-left: 3px solid #ffc107; }
.status-card[data-status="Approved 1"] { border-left: 3px solid #17a2b8; }
.status-card[data-status="Approved 2"] { border-left: 3px solid #007bff; }
.status-card[data-status="Final Approved"] { border-left: 3px solid #28a745; }
.status-card[data-status="Rejected"] { border-left: 3px solid #dc3545; }
.status-card[data-status="Closed"] { border-left: 3px solid #6c757d; }
</style> </style>
@php @php
$outstandingIdCollection = collect($outstandingIds ?? []); $outstandingIdCollection = collect($outstandingIds ?? []);
// ==========================================
// FILTER PERIODE BERJALAN KHUSUS UNTUK MINI CARDS
// (Sinkronisasi Angka Realtime Siklus Tanggal 11-13)
// ==========================================
$startDayIndex = (int) env('STARTING_DATE', 11);
$closingDayIndex = (int) env('CLOSING_DATE', 13);
$nowIndex = \Carbon\Carbon::now();
if ($nowIndex->day >= $startDayIndex) {
$startPeriodIndex = \Carbon\Carbon::create($nowIndex->year, $nowIndex->month, $startDayIndex)->startOfDay();
$endPeriodIndex = \Carbon\Carbon::create($nowIndex->copy()->addMonth()->year, $nowIndex->copy()->addMonth()->month, $closingDayIndex)->endOfDay();
} else {
$startPeriodIndex = \Carbon\Carbon::create($nowIndex->copy()->subMonth()->year, $nowIndex->copy()->subMonth()->month, $startDayIndex)->startOfDay();
$endPeriodIndex = \Carbon\Carbon::create($nowIndex->year, $nowIndex->month, $closingDayIndex)->endOfDay();
}
// Filter koleksi data pengajuan murni yang berada dalam rentang aktif berjalan
$currentPeriodForms = $forms->filter(function($form) use ($startPeriodIndex, $endPeriodIndex) {
$tanggalForm = \Carbon\Carbon::parse($form->tanggal);
return $tanggalForm->between($startPeriodIndex, $endPeriodIndex);
});
@endphp @endphp
<section class="content-header"> <section class="content-header">
<div class="container-fluid"> <div class="container-fluid">
<div class="row mb-2"> <div class="row mb-2">
@@ -25,62 +81,51 @@
<div class="col-sm-6"> <div class="col-sm-6">
<ol class="breadcrumb float-sm-right"> <ol class="breadcrumb float-sm-right">
<li class="breadcrumb-item"><a href="{{ route('dashboard.index') }}">Dashboard</a></li> <li class="breadcrumb-item"><a href="{{ route('dashboard.index') }}">Dashboard</a></li>
</li>
</ol> </ol>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<section class="content"> <section class="content">
<div class="container-fluid"> <div class="container-fluid">
<div class="row row-cols-1 row-cols-md-2 row-cols-xl-5 g-3"> <div class="row row-cols-1 row-cols-md-2 row-cols-xl-5 g-3">
<!-- Total Expense -->
<div class="col d-flex"> <div class="col d-flex">
<div class="small-box bg-info flex-fill"> <div class="small-box bg-info flex-fill">
<div class="inner"> <div class="inner">
<h3>Rp {{ number_format($forms->sum('total'), 0, ',', '.') }}</h3> <h3>Rp {{ number_format($forms->sum('total'), 0, ',', '.') }}</h3>
<p>Total Expense</p> <p>Total Expense</p>
</div> </div>
<div class="icon"> <div class="icon"><i class="fas fa-wallet"></i></div>
<i class="fas fa-wallet"></i>
</div> </div>
</div> </div>
</div>
<!-- Approved Expense -->
<div class="col d-flex"> <div class="col d-flex">
<div class="small-box bg-success flex-fill"> <div class="small-box bg-success flex-fill">
<div class="inner"> <div class="inner">
<h3>Rp {{ number_format($forms->whereIn('status', ['Approved', 'Closed'])->sum('approved_total'), 0, ',', '.') }}</h3> <h3>Rp {{ number_format($forms->whereIn('status', ['Approved', 'Closed', 'Final Approved'])->sum('approved_total'), 0, ',', '.') }}</h3>
<p>Approved Expenses</p> <p>Approved Expenses</p>
</div> </div>
<div class="icon"> <div class="icon"><i class="fas fa-check-circle"></i></div>
<i class="fas fa-check-circle"></i>
</div> </div>
</div> </div>
</div>
<!-- Pending Expense -->
<div class="col d-flex"> <div class="col d-flex">
<div class="small-box bg-warning flex-fill"> <div class="small-box bg-warning flex-fill">
<div class="inner"> <div class="inner">
<h3 class="text-white">Rp {{ number_format($forms->where('status', 'On Progress')->sum('total'), 0, ',', '.') }}</h3> <h3 class="text-white">Rp {{ number_format($forms->where('status', 'On Progress')->sum('total'), 0, ',', '.') }}</h3>
<p class="text-white">Pending Expenses</p> <p class="text-white">Pending Expenses</p>
</div> </div>
<div class="icon"> <div class="icon"><i class="fas fa-hourglass-half"></i></div>
<i class="fas fa-hourglass-half"></i>
</div> </div>
</div> </div>
</div>
<!-- Outstanding Expense -->
<div class="col d-flex"> <div class="col d-flex">
<div class="small-box bg-danger flex-fill"> <div class="small-box bg-danger flex-fill" id="outstandingCard">
<div class="inner"> <div class="inner">
<h3>{{ $outstandingCount }}</h3> <h3>{{ $outstandingCount }}</h3>
<p>Outstanding Expenses</p> <p>Outstanding Expenses</p>
<p class="mb-0"><small>Total Rp {{ number_format($outstandingTotal, 0, ',', '.') }}</small></p> <p class="mb-0"><small>Total Rp {{ number_format($outstandingTotal, 0, ',', '.') }}</small></p>
<p class="mb-0 filter-hint"><small>Klik untuk menampilkan detail</small></p>
</div> </div>
<div class="icon"> <div class="icon"><i class="fas fa-exclamation-triangle"></i></div>
<i class="fas fa-exclamation-triangle"></i>
</div>
</div> </div>
</div> </div>
@if($availableBudget > 0) @if($availableBudget > 0)
@@ -90,15 +135,28 @@
<h3 class="text-white">Rp {{ number_format($availableBudget, 0, ',', '.') }}</h3> <h3 class="text-white">Rp {{ number_format($availableBudget, 0, ',', '.') }}</h3>
<p class="text-white">Available Budget</p> <p class="text-white">Available Budget</p>
</div> </div>
<div class="icon"> <div class="icon"><i class="fas fa-money-bill"></i></div>
<i class="fas fa-money-bill"></i>
</div>
</div> </div>
</div> </div>
@endif @endif
</div> </div>
</div> </div>
</section> </section>
<section class="content mt-2">
<div class="container-fluid">
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-xl-7 g-2 mb-3">
<div class="col"><div class="status-card active-filter" data-status="all"><div class="mini-stat-box"><div><p>Semua</p><h6>{{ $currentPeriodForms->count() }}</h6></div><i class="fas fa-list-ul text-muted"></i></div></div></div>
<div class="col"><div class="status-card" data-status="On Progress"><div class="mini-stat-box"><div><p>Progress</p><h6>{{ $currentPeriodForms->where('status', 'On Progress')->count() }}</h6></div><i class="fas fa-sync text-warning fa-xs"></i></div></div></div>
<div class="col"><div class="status-card" data-status="Approved 1"><div class="mini-stat-box"><div><p>App 1</p><h6>{{ $currentPeriodForms->where('status', 'Approved 1')->count() }}</h6></div><i class="fas fa-user-check text-info fa-xs"></i></div></div></div>
<div class="col"><div class="status-card" data-status="Approved 2"><div class="mini-stat-box"><div><p>App 2</p><h6>{{ $currentPeriodForms->where('status', 'Approved 2')->count() }}</h6></div><i class="fas fa-users text-primary fa-xs"></i></div></div></div>
<div class="col"><div class="status-card" data-status="Final Approved"><div class="mini-stat-box"><div><p>Final</p><h6>{{ $currentPeriodForms->where('status', 'Final Approved')->count() }}</h6></div><i class="fas fa-check-double text-success fa-xs"></i></div></div></div>
<div class="col"><div class="status-card" data-status="Rejected"><div class="mini-stat-box"><div><p>Reject</p><h6>{{ $currentPeriodForms->where('status', 'Rejected')->count() }}</h6></div><i class="fas fa-ban text-danger fa-xs"></i></div></div></div>
<div class="col"><div class="status-card" data-status="Closed"><div class="mini-stat-box"><div><p>Closed</p><h6>{{ $currentPeriodForms->where('status', 'Closed')->count() }}</h6></div><i class="fas fa-lock text-secondary fa-xs"></i></div></div></div>
</div>
</div>
</section>
<section class="content"> <section class="content">
<div class="container-fluid"> <div class="container-fluid">
<div class="card card-primary card-outline"> <div class="card card-primary card-outline">
@@ -106,18 +164,12 @@
<h4 class="header-title float-left">List Data Forms Entertainment & Presentation</h4> <h4 class="header-title float-left">List Data Forms Entertainment & Presentation</h4>
<p class="float-right mb-2"> <p class="float-right mb-2">
@if (auth()->user()->can('forms.entertainment.create')) @if (auth()->user()->can('forms.entertainment.create'))
<a class="btn btn-primary text-white" href="{{ route('forms.entertainment.create') }}"> <a class="btn btn-primary text-white" href="{{ route('forms.entertainment.create') }}">Add New Expense</a>
Add New Expense Entertainment & Presentation
</a>
@endif @endif
</p> </p>
<div class="clearfix"></div> <div class="clearfix"></div>
<div class="dataTables_wrapper dt-bootstrap4 mt-2">
@include('backend.layouts.partials.messages')
<div class="table-responsive"> <div class="table-responsive">
<table id="dataTable" <table id="dataTable" class="table table-bordered table-striped table-head-fixed dataTable dtr-inline" style="width:100%">
class="table table-bordered table-striped table-head-fixed dataTable dtr-inline"
style="width:100%">
<thead class="bg-light text-capitalize"> <thead class="bg-light text-capitalize">
<tr> <tr>
<th>#</th> <th>#</th>
@@ -133,25 +185,20 @@
<th>Bukti</th> <th>Bukti</th>
<th>Account Number</th> <th>Account Number</th>
<th>Status</th> <th>Status</th>
@if (auth()->user()->can('approval.view')) @if (auth()->user()->can('approval.view')) <th>Approval 1</th> @endif
<th>Approval 1</th> @if (auth()->user()->can('approval2.view')) <th>Approval 2</th> @endif
@endif @if (auth()->user()->can('final_approval.view')) <th>Final Approval</th> @endif
@if (auth()->user()->can('approval2.view'))
<th>Approval 2</th>
@endif
@if (auth()->user()->can('final_approval.view'))
<th>Final Approval</th>
@endif
<th>Aksi</th> <th>Aksi</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@php @php
// SUNTIKAN PERBAIKAN: Impor Class Helper NextCloud ke dalam scope Blade View
use App\Helpers\NextCloudHelper; use App\Helpers\NextCloudHelper;
$role = auth()->user()->getRoleNames()[0]; $role = auth()->user()->getRoleNames()[0];
@endphp @endphp
@foreach ($forms as $item) @foreach ($forms as $item)
<tr data-outstanding="{{ $outstandingIdCollection->contains($item->id) ? 1 : 0 }}"> <tr data-id="{{ $item->id }}" data-status="{{ $item->status }}" data-date="{{ \Carbon\Carbon::parse($item->tanggal)->format('Y-m-d') }}" data-outstanding="{{ $outstandingIdCollection->contains($item->id) ? '1' : '0' }}">
<td>{{ $loop->iteration }}</td> <td>{{ $loop->iteration }}</td>
<td>{{ $item->expense_number }}</td> <td>{{ $item->expense_number }}</td>
<td>{{ $item->user->name }}</td> <td>{{ $item->user->name }}</td>
@@ -164,132 +211,67 @@
<td>{{ number_format($item->approved_total, 0, ',', '.') }}</td> <td>{{ number_format($item->approved_total, 0, ',', '.') }}</td>
<td> <td>
@if ($item->bukti_total) @if ($item->bukti_total)
<a href="{{ NextCloudHelper::getFileUrl($item->bukti_total) }}" target="_blank"> <a href="{{ NextCloudHelper::getFileUrl($item->bukti_total) }}" target="_blank">Download</a>
Download @else - @endif
</a>
@else
-
@endif
</td> </td>
<td>{{ $item->account_number }}</td> <td>{{ $item->account_number }}</td>
<td> <td>
@if ($item->status == 'On Progress') @if ($item->status == 'On Progress')
<span class="badge badge-warning text-white">{{ $item->status }}</span> <span class="badge badge-warning text-white">{{ $item->status }}</span>
@elseif ($item->status == 'Approved 1' || $item->status == 'Approved 2' || $item->status == 'Final Approved') @elseif (in_array($item->status, ['Approved 1', 'Approved 2', 'Final Approved']))
<span class="badge badge-success text-white">{{ $item->status }}</span> <span class="badge badge-success text-white">{{ $item->status }}</span>
@else @else
<span class="badge badge-danger text-white">{{ $item->status }}</span> <span class="badge badge-danger text-white">{{ $item->status }}</span>
@endif @endif
</td> </td>
@if (auth()->user()->can('approval.view')) @if (auth()->user()->can('approval.view'))
<td> <td>
{{-- approve / reject button --}} <div class="d-none">
@if (auth()->user()->can('approval.approve')) @if (auth()->user()->can('approval.approve') && $item->status == 'On Progress')
@if ($item->status == 'On Progress') <button type="button" class="btn btn-success btn-sm final-approve-modal" data-id="{{ route('forms.entertainment.approve', $item->id) }}">Approve 1</button>
<form action="javascript:void(0);" class="d-inline"> <button type="button" class="btn btn-danger btn-sm open-reject-modal" data-action="{{ route('forms.entertainment.reject', $item->id) }}">Reject</button>
<button type="button"
class="btn btn-success btn-sm final-approve-modal"
data-id="{{ route('forms.entertainment.approve', $item->id) }}">
Approve 1
</button>
</form>
<button type="button"
class="btn btn-danger btn-sm open-reject-modal"
data-action="{{ route('forms.entertainment.reject', $item->id) }}">
Reject
</button>
@endif
@endif
@if ($item->approved_at)
{{ \Carbon\Carbon::parse($item->approved_at)->locale('id')->isoFormat('D MMMM Y HH:mm') }}
@endif @endif
</div>
@if ($item->approved_at) {{ \Carbon\Carbon::parse($item->approved_at)->format('d/m/y H:i') }} @endif
</td> </td>
@endif @endif
@if (auth()->user()->can('approval2.view')) @if (auth()->user()->can('approval2.view'))
<td> <td>
{{-- approve / reject button --}} <div class="d-none">
@if (auth()->user()->can('approval2.approve')) @if (auth()->user()->can('approval2.approve') && $item->status == 'Approved 1' && $item->approved_at)
@if ($item->status == 'Approved 1' && $item->approved_at) <button type="button" class="btn btn-success btn-sm open-approve-modal" data-id="{{ route('forms.entertainment.approve2', $item->id) }}">Approve 2</button>
<form action="javascript:void(0);" class="d-inline"> <button type="button" class="btn btn-danger btn-sm open-reject-modal" data-action="{{ route('forms.entertainment.reject', $item->id) }}">Reject</button>
<button type="button"
class="btn btn-success btn-sm open-approve-modal"
data-id="{{ route('forms.entertainment.approve2', $item->id) }}">
Approve 2
</button>
</form>
<button type="button"
class="btn btn-danger btn-sm open-reject-modal"
data-action="{{ route('forms.entertainment.reject', $item->id) }}">
Reject
</button>
@endif
@endif
@if ($item->approved2_at)
{{ \Carbon\Carbon::parse($item->approved2_at)->locale('id')->isoFormat('D MMMM Y HH:mm') }}
@endif @endif
</div>
@if ($item->approved2_at) {{ \Carbon\Carbon::parse($item->approved2_at)->format('d/m/y H:i') }} @endif
</td> </td>
@endif @endif
@if(auth()->user()->can('final_approval.view')) @if(auth()->user()->can('final_approval.view'))
<td> <td>
{{-- approve / reject button --}} <div class="d-none">
@if(auth()->user()->can('final_approval.approve')) @if(auth()->user()->can('final_approval.approve'))
@if ($item->status == 'Approved 2' && $item->approved2_at && !$item->final_approved_at) @if ($item->status == 'Approved 2' && $item->approved2_at && !$item->final_approved_at)
<form action="javascript:void(0);" class="d-inline"> <button type="button" class="btn btn-success btn-sm open-approve-modal" data-id="{{ route('forms.entertainment.final-approve', $item->id) }}">Final Approve</button>
<button type="button" <button type="button" class="btn btn-danger btn-sm open-reject-modal" data-action="{{ route('forms.entertainment.reject', $item->id) }}">Reject</button>
class="btn btn-success btn-sm open-approve-modal" @elseif ($item->final_approved_at && auth()->user()->can('final_approval.open'))
data-id="{{ route('forms.entertainment.final-approve', $item->id) }}"> <form action="{{ route('forms.entertainment.open', $item->id) }}" method="POST" class="d-inline" onsubmit="return confirm('Open item?');">
Final Approve @csrf @method('PUT') <button type="submit" class="btn btn-success btn-sm">Open</button>
</button>
</form>
@else
@if ($item->final_approved_at && auth()->user()->can('final_approval.open'))
<form action="{{ route('forms.entertainment.open', $item->id) }}" method="POST" class="d-inline" onsubmit="return confirm('Are you sure you want to approve this item?');">
@csrf
@method('PUT')
<button type="submit" class="btn btn-success btn-sm">
Open
</button>
</form> </form>
@endif @endif
@endif @endif
@endif </div>
@if ($item->final_approved_at) @if ($item->final_approved_at) {{ \Carbon\Carbon::parse($item->final_approved_at)->format('d/m/y H:i') }} @endif
{{ \Carbon\Carbon::parse($item->final_approved_at)->locale('id')->isoFormat('D MMMM Y HH:mm') }}
@endif
</td> </td>
@endif @endif
<td> <td>
@if (auth()->user()->can('forms.entertainment.view')) @if (auth()->user()->can('forms.entertainment.view')) <a href="{{ route('forms.entertainment.view', $item->id) }}" class="btn btn-primary btn-sm"><i class="fas fa-eye text-white"></i></a> @endif
<a href="{{ route('forms.entertainment.view', $item->id) }}" class="btn btn-primary btn-sm"> @php $canEdit = auth()->user()->can('forms.entertainment.edit') && (!in_array($role, ['Medical Representatif', 'Area Manager Cabang']) || $item->status == 'On Progress'); @endphp
<i class="fas fa-eye text-white"></i> @if ($canEdit) <a href="{{ route('forms.entertainment.edit', $item->id) }}" class="btn btn-warning btn-sm"><i class="fas fa-edit text-white"></i></a> @endif
</a>
@endif
@php
$canEdit = false;
if (auth()->user()->can('forms.entertainment.edit')) {
if (in_array($role, ['Medical Representatif', 'Area Manager Cabang'])) {
$canEdit = $item->status == 'On Progress';
} else {
$canEdit = true;
}
}
@endphp
@if ($canEdit)
<a href="{{ route('forms.entertainment.edit', $item->id) }}" class="btn btn-warning btn-sm">
<i class="fas fa-edit text-white"></i>
</a>
@endif
@if (($role != 'Medical Representatif' && auth()->user()->can('forms.entertainment.delete')) || ($role == 'Medical Representatif' && $item->status == 'On Progress')) @if (($role != 'Medical Representatif' && auth()->user()->can('forms.entertainment.delete')) || ($role == 'Medical Representatif' && $item->status == 'On Progress'))
<form action="{{ route('forms.entertainment.destroy', $item->id) }}" method="POST" class="d-inline" onsubmit="return confirm('Are you sure you want to delete this item?');"> <form action="{{ route('forms.entertainment.destroy', $item->id) }}" method="POST" class="d-inline" onsubmit="return confirm('Delete item?');">
@csrf @csrf @method('DELETE') <button type="submit" class="btn btn-danger btn-sm"><i class="fas fa-trash"></i></button>
@method('DELETE')
<button type="submit" class="btn btn-danger btn-sm">
<i class="fas fa-trash"></i>
</button>
</form> </form>
@endif @endif
</td> </td>
@@ -301,382 +283,203 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</section> </section>
<section>
<div class="modal fade" id="approveModal" tabindex="-1" role="dialog" aria-labelledby="approveModalLabel" aria-hidden="true"> {{-- MODAL CONTAINER DI BAWAH (TETAP UTUH UNTUK KEBUTUHAN AJAX ROUTING) --}}
<div class="modal fade" id="approveModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document"> <div class="modal-dialog modal-lg" role="document">
<div class="modal-content"> <div class="modal-content">
<form id="approveForm" method="POST" action=""> <form id="approveForm" method="POST" action="">
@csrf @csrf @method('PUT')
@method('PUT') <div class="modal-header"><h5 class="modal-title">Approve Item</h5><button type="button" class="close" data-dismiss="modal">&times;</button></div>
<div class="modal-header">
<h5 class="modal-title" id="approveModalLabel">Approve Item</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body"> <div class="modal-body">
<!-- Loading spinner --> <div id="loadingSpinner" class="d-flex justify-content-center align-items-center" style="height: 200px;"><div class="spinner-border text-primary" role="status"></div></div>
<div id="loadingSpinner" class="d-flex justify-content-center align-items-center" style="height: 200px;">
<div class="spinner-border text-primary" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
<!-- Modal content (hidden initially) -->
<div id="modalContent" class="d-none"> <div id="modalContent" class="d-none">
<p>Please review the details below:</p>
<ul class="list-group"> <ul class="list-group">
<li class="list-group-item"> <li class="list-group-item"><strong>No Expense:</strong> <span id="expense_number"></span></li>
<strong>Expense Number:</strong> <span id="expense_number"></span> <li class="list-group-item"><strong>User:</strong> <span id="user"></span></li>
</li> <li class="list-group-item"><strong>NIK/NPWP:</strong> <span id="nik_or_npwp"></span></li>
<li class="list-group-item"> <li class="list-group-item"><strong>Penerima:</strong> <span id="name"></span></li>
<strong>User:</strong> <span id="user"></span> <li class="list-group-item"><strong>Jenis:</strong> <span id="jenis"></span></li>
</li> <li class="list-group-item"><strong>Nominal:</strong> <span id="total_value"></span></li>
<li class="list-group-item">
<strong>NIK/NPWP Penerima:</strong> <span id="nik_or_npwp"></span>
</li>
<li class="list-group-item">
<strong>Tanggal:</strong> <span id="tanggal"></span>
</li>
<li class="list-group-item">
<strong>Nama Penerima:</strong> <span id="name"></span>
</li>
<li class="list-group-item">
<strong>Jenis:</strong> <span id="jenis"></span>
</li>
<li class="list-group-item">
<strong>Keterangan:</strong> <span id="keterangan"></span>
</li>
<li class="list-group-item">
<strong>Bukti Total:</strong> <span id="bukti_total"></span>
</li>
<li class="list-group-item">
<strong>Nominal Total:</strong> <span id="total_value"></span>
</li>
</ul> </ul>
</div> </div>
</div> </div>
<div class="modal-footer"><button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button><button type="submit" class="btn btn-primary">Approve</button></div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Approve</button>
</div>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
</section>
<section> <div class="modal fade" id="finalApproveModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal fade" id="finalApproveModal" tabindex="-1" role="dialog" aria-labelledby="finalApproveModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document"> <div class="modal-dialog modal-lg" role="document">
<div class="modal-content"> <div class="modal-content">
<form id="finalApproveForm" method="POST" action=""> <form id="finalApproveForm" method="POST" action="">
@csrf @csrf @method('PUT')
@method('PUT') <div class="modal-header"><h5 class="modal-title">Final Approve Item</h5><button type="button" class="close" data-dismiss="modal">&times;</button></div>
<div class="modal-header">
<h5 class="modal-title" id="approveModalLabel">Approve Item</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body"> <div class="modal-body">
<!-- Loading spinner --> <div id="finalLoadingSpinner" class="d-flex justify-content-center align-items-center" style="height: 200px;"><div class="spinner-border text-primary" role="status"></div></div>
<div id="finalLoadingSpinner" class="d-flex justify-content-center align-items-center" style="height: 200px;">
<div class="spinner-border text-primary" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
<!-- Modal content (hidden initially) -->
<div id="finalModalContent" class="d-none"> <div id="finalModalContent" class="d-none">
<p>Please review the details below:</p> <ul class="list-group mb-3">
<ul class="list-group"> <li class="list-group-item"><strong>NIK/NPWP:</strong> <span id="final_nik_or_npwp"></span></li>
<li class="list-group-item"> <li class="list-group-item"><strong>Penerima:</strong> <span id="final_name"></span></li>
<strong>Expense Number:</strong> <span id="final_expense_number"></span>
</li>
<li class="list-group-item">
<strong>User:</strong> <span id="final_user"></span>
</li>
<li class="list-group-item">
<strong>NIK/NPWP Penerima:</strong> <span id="final_nik_or_npwp"></span>
</li>
<li class="list-group-item">
<strong>Tanggal:</strong> <span id="final_tanggal"></span>
</li>
<li class="list-group-item">
<strong>Nama Penerima:</strong> <span id="final_name"></span>
</li>
<li class="list-group-item">
<strong>Jenis:</strong> <span id="final_jenis"></span>
</li>
<li class="list-group-item">
<strong>Keterangan:</strong> <span id="final_keterangan"></span>
</li>
<li class="list-group-item">
<strong>Bukti Total:</strong> <span id="final_bukti_total"></span>
</li>
</ul> </ul>
<p class="mt-2">Please select the items you want to approve:</p> <div class="custom-control custom-checkbox mb-3"><input type="checkbox" class="custom-control-input" id="final_total" name="total" required><label class="custom-control-label" for="final_total">Total (<span id="final_total_value"></span>)</label></div>
<ul class="list-group list-unstyled"> <div class="form-group"><label>Total Approved</label><input type="text" class="form-control font-weight-bold" id="total" name="total" value="0" readonly></div>
<li>
<div class="form-group">
<div class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input" id="final_total" name="total" required>
<label class="custom-control-label" for="final_total">Total (<span id="final_total_value"></span>)</label>
</div> </div>
</div> </div>
</li> <div class="modal-footer"><button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button><button type="submit" class="btn btn-primary">Approve</button></div>
</ul>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Approve</button>
</div>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
</section>
<section> <div class="modal fade" id="rejectModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal fade" id="rejectModal" tabindex="-1" role="dialog" aria-labelledby="rejectModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document"> <div class="modal-dialog" role="document">
<div class="modal-content"> <div class="modal-content">
<form id="rejectForm" method="POST" action=""> <form id="rejectForm" method="POST" action="">
@csrf @csrf @method('PUT')
@method('PUT') <div class="modal-header"><h5 class="modal-title">Reject Expense</h5><button type="button" class="close" data-dismiss="modal">&times;</button></div>
<div class="modal-header">
<h5 class="modal-title" id="rejectModalLabel">Reject Expense</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body"> <div class="modal-body">
<div class="form-group"> <div class="form-group"><label>Remarks <span class="text-danger">*</span></label><textarea class="form-control" name="remarks" id="rejectRemarks" rows="3" required></textarea></div>
<label for="rejectRemarks">Remarks <span class="text-danger">*</span></label>
<textarea class="form-control" name="remarks" id="rejectRemarks" rows="3" required></textarea>
</div>
<p class="mb-0 text-muted small">Pengajuan akan ditolak setelah Anda mengirimkan catatan ini.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-danger">Reject</button>
</div> </div>
<div class="modal-footer"><button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button><button type="submit" class="btn btn-danger">Reject</button></div>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
</section>
@endsection @endsection
@section('scripts') @section('scripts')
<script> <script>
$(document).ready(function () { $(document).ready(function () {
// Uncheck all checkboxes on page refresh
$('.custom-control-input').prop('checked', false); $('.custom-control-input').prop('checked', false);
if ($('#dataTable').length) { if ($('#dataTable').length) {
let outstandingFilterActive = false; let outstandingFilterActive = false;
const outstandingFilter = function(settings, data, dataIndex) { let statusFilterActive = 'all';
if (!outstandingFilterActive) {
return true;
}
const row = settings.aoData[dataIndex].nTr;
return $(row).data('outstanding') === 1 || $(row).data('outstanding') === '1';
};
$.fn.dataTable.ext.search.push(outstandingFilter); // Custom Search DataTables (Filter Sinkron: Status + Outstanding + Rentang Tanggal 11 - 13)
$.fn.dataTable.ext.search.push(function(settings, data, dataIndex) {
const row = $(settings.aoData[dataIndex].nTr);
const rowStatus = row.attr('data-status');
const isOutstanding = row.attr('data-outstanding') === '1' || row.attr('data-outstanding') === 1;
const rowDateStr = row.attr('data-date'); // Format: YYYY-MM-DD
// 1. Outstanding Filter Terpilih (Abaikan Tanggal Berjalan)
if (outstandingFilterActive) {
return isOutstanding;
}
// 2. Pembatasan Jendela Waktu Siklus Aktif 11 s/d 13 Bulanan (Berlaku Mutlak)
if (rowDateStr) {
const dateParts = rowDateStr.split('-');
const rowDate = new Date(dateParts[0], dateParts[1] - 1, dateParts[2], 12, 0, 0); // Kunci aman timezone
const now = new Date();
let startPeriod, endPeriod;
const startDay = 11;
const closingDay = 13;
if (now.getDate() >= startDay) {
startPeriod = new Date(now.getFullYear(), now.getMonth(), startDay, 0, 0, 0);
endPeriod = new Date(now.getFullYear(), now.getMonth() + 1, closingDay, 23, 59, 59);
} else {
startPeriod = new Date(now.getFullYear(), now.getMonth() - 1, startDay, 0, 0, 0);
endPeriod = new Date(now.getFullYear(), now.getMonth(), closingDay, 23, 59, 59);
}
if (rowDate < startPeriod || rowDate > endPeriod) {
return false;
}
}
// 3. Filter Kategori Status Card
if (statusFilterActive !== 'all' && rowStatus !== statusFilterActive) {
return false;
}
return true;
});
const table = $('#dataTable').DataTable({ const table = $('#dataTable').DataTable({
responsive: false, responsive: false,
dom: 'Blfrtip', dom: 'Blfrtip',
lengthMenu: [ [10, 50, 100, -1], [10, 50, 100, "All"] ], // Control the entries dropdown lengthMenu: [ [10, 50, 100, -1], [10, 50, 100, "All"] ],
buttons: [ buttons: [ { extend: 'excel', text: 'Excel' }, { extend: 'pdf', text: 'PDF', orientation: 'landscape' }, 'colvis' ]
{
extend: 'excel',
text: 'Excel'
},
{
extend: 'pdf',
text: 'PDF',
orientation: 'landscape',
pageSize: 'A4',
exportOptions: {
columns: ':visible'
}
},
{
extend: 'print',
text: 'Print',
orientation: 'landscape',
exportOptions: {
columns: ':visible'
}
},
'colvis'
]
}); });
const $outstandingCard = $('#outstandingCard'); // Klik Mini Status Card
const updateCardHint = (isActive) => { $('.status-card').on('click', function() {
const text = isActive ? 'Klik untuk menampilkan semua data' : 'Klik untuk menampilkan detail'; outstandingFilterActive = false;
$outstandingCard.find('.filter-hint small').text(text); $('#outstandingCard').removeClass('active');
}; $('#outstandingCard').find('.filter-hint small').text('Klik untuk detail');
updateCardHint(outstandingFilterActive);
$('.status-card').removeClass('active-filter');
$(this).addClass('active-filter');
statusFilterActive = $(this).data('status');
table.draw();
});
// Klik Outstanding Card Merah
const $outstandingCard = $('#outstandingCard');
$outstandingCard.on('click', function () { $outstandingCard.on('click', function () {
outstandingFilterActive = !outstandingFilterActive; outstandingFilterActive = !outstandingFilterActive;
$outstandingCard.toggleClass('active', outstandingFilterActive); $(this).toggleClass('active', outstandingFilterActive);
if(outstandingFilterActive) {
$('.status-card').removeClass('active-filter');
$('.status-card[data-status="all"]').addClass('active-filter');
statusFilterActive = 'all';
}
table.draw(); table.draw();
updateCardHint(outstandingFilterActive); $(this).find('.filter-hint small').text(outstandingFilterActive ? 'Tampilkan semua data' : 'Klik untuk detail');
}); });
} }
// Handle Reject Button Click
$(document).on('click', '.open-reject-modal', function () { $(document).on('click', '.open-reject-modal', function () {
const rejectUrl = $(this).data('action'); $('#rejectForm').attr('action', $(this).data('action'));
$('#rejectForm').attr('action', rejectUrl); $('#rejectRemarks').val('').focus();
$('#rejectRemarks').val('');
$('#rejectModal').modal('show'); $('#rejectModal').modal('show');
}); });
$('#rejectModal').on('shown.bs.modal', function () {
$('#rejectRemarks').trigger('focus');
});
$('#rejectModal').on('hidden.bs.modal', function () {
$('#rejectForm').attr('action', '');
$('#rejectRemarks').val('');
});
// Handle Approve Button Click
$(document).on('click', '.open-approve-modal', function() { $(document).on('click', '.open-approve-modal', function() {
const approveUrl = $(this).data('id'); const url = $(this).data('id');
$('#loadingSpinner').show(); $('#modalContent').addClass('d-none');
// Show spinner and hide content initially $('#approveForm').attr('action', url); $('#approveModal').modal('show');
$('#loadingSpinner').show(); $.get(url.replace(/approve2|final-approve/g, 'detail'), function (data) {
$('#modalContent').addClass('d-none'); const fmt = new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', minimumFractionDigits: 0 });
$('#total_value').text(fmt.format(data.total));
$('#approveForm').attr('action', approveUrl); // Set the form action $('#user').text(data.user.name); $('#expense_number').text(data.expense_number);
$('#loadingSpinner').addClass('d-flex'); $('#nik_or_npwp').text(data.nik_or_npwp); $('#name').text(data.name);
$('#approveModal').modal('show'); // Show the modal $('#jenis').text(data.jenis); $('#keterangan').text(data.keterangan);
$('#loadingSpinner').hide(); $('#modalContent').removeClass('d-none');
// Get detail from /forms/entertainment/detail/{id}
$.get(approveUrl.replace(/approve2|final-approve/g, 'detail'), function (data) {
const formatter = new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0
});
$('#total_value').text(formatter.format(data.total));
// Detail data
$('#user').text(data.user.name);
$('#expense_number').text(data.expense_number);
$('#tanggal').text(new Date(data.created_at).toLocaleDateString('id-ID', {
year: 'numeric',
month: 'long',
day: 'numeric'
}));
$('#nik_or_npwp').text(data.nik_or_npwp);
$('#name').text(data.name);
$('#jenis').text(data.jenis);
$('#keterangan').text(data.keterangan);
$('#bukti_total').html(data.total ? '<a href="' + data.total + '" target="_blank">Download</a>' : '-');
// Hide spinner and show content
$('#loadingSpinner').hide();
$('#loadingSpinner').removeClass('d-flex');
$('#loadingSpinner').addClass('d-none');
$('#modalContent').removeClass('d-none');
// Reset the total to zero
$('#total').val(0);
}); });
}); });
// Handle Approve Button Click
$(document).on('click', '.final-approve-modal', function() { $(document).on('click', '.final-approve-modal', function() {
const approveUrl = $(this).data('id'); const url = $(this).data('id');
$('#finalLoadingSpinner').show(); $('#finalModalContent').addClass('d-none');
// Show spinner and hide content initially $('#finalApproveForm').attr('action', url); $('#finalApproveModal').modal('show');
$('#finalLoadingSpinner').show(); $.get(url.replace('approve', 'detail'), function (data) {
$('#finalModalContent').addClass('d-none'); const fmt = new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', minimumFractionDigits: 0 });
$('#final_total_value').text(fmt.format(data.total));
$('#finalApproveForm').attr('action', approveUrl); // Set the form action
$('#finalLoadingSpinner').addClass('d-flex');
$('#finalApproveModal').modal('show'); // Show the modal
// Get detail from /forms/entertainment/detail/{id}
$.get(approveUrl.replace('approve', 'detail'), function (data) {
console.log(data);
const formatter = new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0
});
$('#final_total_value').text(formatter.format(data.total));
// Assign data values to checkboxes
$('#final_total').data('value', data.total); $('#final_total').data('value', data.total);
$('#final_nik_or_npwp').text(data.nik_or_npwp); $('#final_name').text(data.name);
// Detail data $('#finalLoadingSpinner').hide(); $('#finalModalContent').removeClass('d-none');
$('#final_user').text(data.user.name);
$('#final_expense_number').text(data.expense_number);
$('#final_tanggal').text(new Date(data.created_at).toLocaleDateString('id-ID', {
year: 'numeric',
month: 'long',
day: 'numeric'
}));
$('#final_nik_or_npwp').text(data.nik_or_npwp);
$('#final_name').text(data.name);
$('#final_jenis').text(data.jenis);
$('#final_keterangan').text(data.keterangan);
$('#final_bukti_total').html(data.bukti_total ? '<a href="' + data.bukti_total + '" target="_blank">Download</a>' : '-');
// Hide spinner and show content
$('#finalLoadingSpinner').hide();
$('#finalLoadingSpinner').removeClass('d-flex');
$('#finalLoadingSpinner').addClass('d-none');
$('#finalModalContent').removeClass('d-none');
// Reset the total to zero
$('#total').val(0); $('#total').val(0);
}); });
}); });
// Update total dynamically
$('.custom-control-input').on('change', function () { $('.custom-control-input').on('change', function () {
let total = 0; let total = 0;
$('.custom-control-input:checked').each(function () { $('.custom-control-input:checked').each(function () { total += parseFloat($(this).data('value') || 0); });
total += parseFloat($(this).data('value') || 0); const fmt = new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', minimumFractionDigits: 0 });
$('#total').val(fmt.format(total));
}); });
const formatter = new Intl.NumberFormat('id-ID', { $('#finalApproveModal').on('hidden.bs.modal', function () { $('.custom-control-input').prop('checked', false); $('#total').val('0'); });
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
}); });
</script>
$('#total').val(formatter.format(total));
});
// Uncheck all checkboxes when the modal is closed
$('#finalApproveModal').on('hidden.bs.modal', function () {
$('.custom-control-input').prop('checked', false); // Uncheck all checkboxes
$('#total').val('0'); // Reset total to 0
});
});
</script>
@endsection @endsection
@@ -0,0 +1,24 @@
<div class="modal fade" id="entertainmentAttachmentPreviewModal" tabindex="-1" role="dialog" aria-labelledby="entertainmentAttachmentPreviewModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title mb-0" id="entertainmentAttachmentPreviewModalLabel">Preview Lampiran</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="entertainment-preview-image d-none text-center">
<img src="" alt="Preview Lampiran" class="img-fluid">
</div>
<div class="entertainment-preview-pdf d-none">
<object data="" type="application/pdf" width="100%" height="500px"></object>
</div>
<div class="entertainment-preview-fallback d-none text-center">
<p class="mb-2">Preview tidak tersedia untuk file ini.</p>
<button type="button" class="btn btn-primary entertainment-preview-download-direct">Download</button>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,394 @@
@php
$entertainmentAttachmentScriptConfig = [
'blockedExtensions' => array_map('strtolower', $entertainmentBlockedExtensions ?? []),
'categoryOptions' => $entertainmentAttachmentCategories ?? [],
];
@endphp
<script>
(function ($) {
'use strict';
const config = @json($entertainmentAttachmentScriptConfig);
const categoryOptions = config.categoryOptions || {};
const blockedExtensions = (config.blockedExtensions || []).map(function (ext) {
return (ext || '').toString().toLowerCase();
});
const maxFileSizeBytes = 10 * 1024 * 1024; // 10 MB
let attachmentIndex = Number($('#entertainment-new-attachments-table').data('next-index')) || 0;
const previewModal = $('#entertainmentAttachmentPreviewModal');
const imageWrapper = previewModal.find('.entertainment-preview-image');
const pdfWrapper = previewModal.find('.entertainment-preview-pdf');
const fallbackWrapper = previewModal.find('.entertainment-preview-fallback');
const pdfObject = pdfWrapper.find('object');
const modalImage = imageWrapper.find('img');
const fallbackDownloadBtn = fallbackWrapper.find('.entertainment-preview-download-direct');
function showAlert(message, icon) {
if (typeof Swal !== 'undefined') {
Swal.fire({
icon: icon || 'error',
title: icon === 'success' ? 'Berhasil' : 'Perhatian',
text: message,
});
} else {
alert(message);
}
}
function renderCategoryOptions(selectedValue) {
let optionsHtml = '<option value="" disabled' + (selectedValue ? '' : ' selected') + '>Pilih kategori</option>';
Object.keys(categoryOptions).forEach(function (value) {
const label = categoryOptions[value];
const selected = value === selectedValue ? ' selected' : '';
optionsHtml += '<option value="' + value + '"' + selected + '>' + label + '</option>';
});
return optionsHtml;
}
function ensureEmptyRowState($tbody) {
if ($tbody.find('.entertainment-attachment-row').length === 0) {
$tbody.html(
'<tr class="entertainment-empty-row">' +
'<td colspan="4" class="text-center text-muted">Belum ada attachment.</td>' +
'</tr>'
);
}
}
function cleanupPreviewButton($button) {
const objectUrl = $button.data('objectUrl');
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
$button
.data('previewType', null)
.data('previewSource', null)
.data('downloadUrl', null)
.data('objectUrl', null)
.data('filename', null);
}
function resetAttachmentRow($row) {
const $previewButton = $row.find('.entertainment-preview-trigger');
cleanupPreviewButton($previewButton);
$previewButton.addClass('d-none');
$row.find('.entertainment-selected-filename').text('');
$row.find('.entertainment-new-attachment-file').val('');
}
function getFileExtension(filename) {
const parts = filename.split('.');
return parts.length > 1 ? parts.pop().toLowerCase() : '';
}
function getPreviewType(extension) {
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].indexOf(extension) !== -1) {
return 'image';
}
if (extension === 'pdf') {
return 'pdf';
}
return 'other';
}
function validateFile(file) {
if (!file) {
return { valid: false, message: 'File tidak ditemukan.' };
}
if (file.size > maxFileSizeBytes) {
return { valid: false, message: 'Ukuran file melebihi 10 MB.' };
}
const extension = getFileExtension(file.name);
if (blockedExtensions.indexOf(extension) !== -1) {
return { valid: false, message: 'Tipe file tidak diperbolehkan.' };
}
return { valid: true, message: '' };
}
function downloadFile(url, filename) {
if (!url) {
showAlert('File tidak tersedia untuk diunduh.');
return;
}
const link = document.createElement('a');
link.href = url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
if (filename) {
link.download = filename;
}
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function showPreviewModal() {
if (!previewModal.length) {
return false;
}
if (typeof previewModal.modal === 'function') {
previewModal.modal('show');
return true;
}
if (window.bootstrap && typeof window.bootstrap.Modal === 'function') {
let modalInstance = null;
if (typeof window.bootstrap.Modal.getOrCreateInstance === 'function') {
modalInstance = window.bootstrap.Modal.getOrCreateInstance(previewModal[0]);
} else {
modalInstance = new window.bootstrap.Modal(previewModal[0]);
}
if (modalInstance && typeof modalInstance.show === 'function') {
modalInstance.show();
return true;
}
}
return false;
}
function openPreviewModal(config) {
const filename = config.filename || 'Lampiran';
modalImage.attr('src', '');
pdfObject.attr('data', '');
fallbackDownloadBtn.data('downloadUrl', null).data('filename', filename);
imageWrapper.addClass('d-none');
pdfWrapper.addClass('d-none');
fallbackWrapper.addClass('d-none');
if (config.type === 'image' && config.source) {
modalImage.attr('src', config.source);
imageWrapper.removeClass('d-none');
} else if (config.type === 'pdf' && config.source) {
pdfObject.attr('data', config.source);
pdfWrapper.removeClass('d-none');
} else {
fallbackDownloadBtn.data('downloadUrl', config.downloadUrl || null);
fallbackWrapper.removeClass('d-none');
}
$('#entertainmentAttachmentPreviewModalLabel').text(filename);
const isModalShown = showPreviewModal();
if (!isModalShown) {
if (config.downloadUrl) {
downloadFile(config.downloadUrl, filename);
} else {
showAlert('Preview tidak tersedia tanpa dukungan Bootstrap modal.');
}
}
}
fallbackDownloadBtn.on('click', function () {
const url = $(this).data('downloadUrl');
const filename = $(this).data('filename');
downloadFile(url, filename);
});
window.addAttachmentRow = function () {
const $table = $('#entertainment-new-attachments-table');
if (!$table.length) {
return;
}
const $tbody = $table.find('tbody');
if ($tbody.find('.entertainment-empty-row').length) {
$tbody.empty();
}
const index = attachmentIndex++;
const optionsHtml = renderCategoryOptions(null);
const rowHtml =
'<tr class="entertainment-attachment-row" data-index="' + index + '">' +
'<td>' +
'<select class="form-control" name="attachments[' + index + '][file_category]">' +
optionsHtml +
'</select>' +
'</td>' +
'<td>' +
'<input type="file" class="form-control entertainment-new-attachment-file" name="attachments[' + index + '][file_path]">' +
'<small class="form-text text-muted entertainment-selected-filename"></small>' +
'</td>' +
'<td class="text-center">' +
'<button type="button" class="btn btn-sm btn-outline-secondary entertainment-preview-trigger d-none">' +
'Preview' +
'</button>' +
'</td>' +
'<td class="text-center">' +
'<button type="button" class="btn btn-sm btn-outline-danger entertainment-remove-attachment-row">' +
'<i class="fas fa-trash"></i>' +
'</button>' +
'</td>' +
'</tr>';
$tbody.append(rowHtml);
};
window.removeAttachmentRow = function (trigger) {
const $row = $(trigger).closest('tr');
const $tbody = $row.closest('tbody');
resetAttachmentRow($row);
$row.remove();
ensureEmptyRowState($tbody);
};
window.previewAttachment = function (trigger) {
const $button = $(trigger);
const type = $button.data('previewType');
const source = $button.data('previewSource');
const downloadUrl = $button.data('downloadUrl');
const filename = $button.data('filename');
if (type === 'image' && source) {
openPreviewModal({ type: 'image', source: source, filename: filename });
return;
}
if (type === 'pdf' && source) {
openPreviewModal({ type: 'pdf', source: source, filename: filename });
return;
}
if (downloadUrl) {
downloadFile(downloadUrl, filename);
return;
}
showAlert('Preview tidak tersedia untuk lampiran ini.');
};
$(document).on('change', '.entertainment-new-attachment-file', function () {
const file = this.files && this.files[0];
const $row = $(this).closest('tr');
const $previewButton = $row.find('.entertainment-preview-trigger');
cleanupPreviewButton($previewButton);
$previewButton.addClass('d-none');
$row.find('.entertainment-selected-filename').text('');
if (!file) {
return;
}
const validation = validateFile(file);
if (!validation.valid) {
showAlert(validation.message);
$(this).val('');
return;
}
const filename = file.name;
$row.find('.entertainment-selected-filename').text(filename);
const extension = getFileExtension(filename);
const previewType = getPreviewType(extension);
$previewButton.data('previewType', previewType);
$previewButton.data('filename', filename);
if (previewType === 'image') {
const reader = new FileReader();
reader.onload = function (event) {
$previewButton.data('previewSource', event.target.result);
};
reader.readAsDataURL(file);
} else if (previewType === 'pdf') {
const objectUrl = URL.createObjectURL(file);
$previewButton.data('previewSource', objectUrl);
$previewButton.data('downloadUrl', objectUrl);
$previewButton.data('objectUrl', objectUrl);
} else {
const objectUrl = URL.createObjectURL(file);
$previewButton.data('downloadUrl', objectUrl);
$previewButton.data('objectUrl', objectUrl);
// Non-media files should trigger download immediately on preview click.
}
$previewButton.removeClass('d-none');
});
$(document).on('click', '.entertainment-preview-trigger', function () {
previewAttachment(this);
});
$(document).on('click', '#entertainment-add-attachment-row', function () {
addAttachmentRow();
});
$(document).on('click', '.entertainment-remove-attachment-row', function () {
removeAttachmentRow(this);
});
$(document).on('click', '.entertainment-delete-existing-attachment', function () {
const $button = $(this);
const deleteUrl = $button.data('deleteUrl');
const $row = $button.closest('tr');
if (!deleteUrl) {
return;
}
const proceed = function () {
$.ajax({
url: deleteUrl,
method: 'DELETE',
data: {
_token: $('meta[name="csrf-token"]').attr('content')
},
success: function (response) {
showAlert(response.message || 'Attachment berhasil dihapus.', 'success');
$row.remove();
ensureEmptyRowState($('#entertainment-existing-attachments-table tbody'));
},
error: function (xhr) {
const message = (xhr.responseJSON && xhr.responseJSON.message) ? xhr.responseJSON.message : 'Gagal menghapus attachment.';
showAlert(message);
}
});
};
if (typeof Swal !== 'undefined') {
Swal.fire({
title: 'Hapus lampiran?',
text: 'Lampiran akan dihapus secara permanen.',
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Ya, hapus',
cancelButtonText: 'Batal',
}).then(function (result) {
if (result.isConfirmed) {
proceed();
}
});
} else if (confirm('Hapus lampiran ini?')) {
proceed();
}
});
$(function () {
const $newAttachmentsTable = $('#entertainment-new-attachments-table');
if ($newAttachmentsTable.length) {
const $tbody = $newAttachmentsTable.find('tbody');
if (!$tbody.find('.entertainment-attachment-row').length) {
addAttachmentRow();
}
}
ensureEmptyRowState($('#entertainment-existing-attachments-table tbody'));
});
})(jQuery);
</script>
@@ -55,6 +55,10 @@
<label class="form-label">NPWP / NIK</label> <label class="form-label">NPWP / NIK</label>
<input type="text" class="form-control" name="nik_or_npwp" id="nik_or_npwp" readonly value="{{ $form->nik_or_npwp }}"> <input type="text" class="form-control" name="nik_or_npwp" id="nik_or_npwp" readonly value="{{ $form->nik_or_npwp }}">
</div> </div>
<div class="mb-3">
<label class="form-label">Nama Perusahaan</label>
<input type="text" class="form-control" name="nama_perusahaan" id="nama_perusahaan" readonly value="{{ $form->nama_perusahaan }}">
</div>
</div> </div>
<div class="col-lg-6"> <div class="col-lg-6">
@@ -62,20 +66,70 @@
<label class="form-label">Alamat</label> <label class="form-label">Alamat</label>
<input type="text" class="form-control" name="alamat" id="alamat" readonly value="{{ $form->alamat }}"> <input type="text" class="form-control" name="alamat" id="alamat" readonly value="{{ $form->alamat }}">
</div> </div>
<div class="mb-3">
<label class="form-label">Jabatan</label>
<input type="text" class="form-control" name="jabatan" id="jabatan" readonly value="{{ $form->jabatan }}">
</div>
<div class="mb-3">
<label class="form-label">Jenis Usaha</label>
<input type="text" class="form-control" name="jenis_usaha" id="jenis_usaha" readonly value="{{ $form->jenis_usaha }}">
</div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Total</label> <label class="form-label">Total</label>
<input type="string" class="form-control" name="total" id="total" readonly value="{{ $form->total }}"> <input type="string" class="form-control" name="total" id="total" readonly value="{{ $form->total }}">
</div> </div>
<div class="mb-3">
<label class="form-label">Bukti Total</label><br>
<a href="{{ $form->bukti_total }}" target="_blank">Lihat Bukti</a>
</div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Keterangan</label> <label class="form-label">Keterangan</label>
<textarea class="form-control" name="keterangan" readonly>{{ $form->keterangan }}</textarea> <textarea class="form-control" name="keterangan" readonly>{{ $form->keterangan }}</textarea>
</div> </div>
</div> </div>
<div class="col-lg-12">
<hr class="my-4">
<h5 class="mb-3">Lampiran</h5>
<div class="table-responsive">
<table class="table table-bordered table-striped mb-0" id="entertainment-existing-attachments-table">
<thead class="bg-light">
<tr>
<th style="width: 30%;">Kategori</th>
<th>Nama File</th>
<th style="width: 120px;" class="text-center">Preview</th>
<th style="width: 120px;" class="text-center">Download</th>
</tr>
</thead>
<tbody>
@forelse ($attachments ?? [] as $attachment)
<tr class="entertainment-attachment-row">
<td>{{ $attachment['category_label'] ?? '-' }}</td>
<td>{{ $attachment['filename'] }}</td>
<td class="text-center">
<button type="button"
class="btn btn-sm btn-outline-secondary entertainment-preview-trigger"
data-preview-type="{{ $attachment['preview_type'] }}"
data-preview-source="{{ $attachment['preview_url'] ?? '' }}"
data-download-url="{{ $attachment['download_url'] ?? '' }}"
data-filename="{{ $attachment['filename'] }}">
Preview
</button>
</td>
<td class="text-center">
@if (!empty($attachment['download_url']))
<a href="{{ $attachment['download_url'] }}" class="btn btn-sm btn-outline-success" target="_blank" rel="noopener">Download</a>
@else
<span class="text-muted">Tidak tersedia</span>
@endif
</td>
</tr>
@empty
<tr class="entertainment-empty-row">
<td colspan="4" class="text-center text-muted">Belum ada attachment.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
<div class="col-lg-12"> <div class="col-lg-12">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Status</label> <label class="form-label">Status</label>
@@ -125,40 +179,47 @@
</button> </button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<!-- Loading spinner -->
<div id="loadingSpinner" class="d-flex justify-content-center align-items-center" style="height: 200px;"> <div id="loadingSpinner" class="d-flex justify-content-center align-items-center" style="height: 200px;">
<div class="spinner-border text-primary" role="status"> <div class="spinner-border text-primary" role="status">
<span class="sr-only">Loading...</span> <span class="sr-only">Loading...</span>
</div> </div>
</div> </div>
<!-- Modal content (hidden initially) -->
<div id="modalContent" class="d-none"> <div id="modalContent" class="d-none">
<p>Please review the details below:</p> <p>Please review the details below:</p>
<ul class="list-group"> <ul class="list-group">
<li class="list-group-item"> <li class="list-group-item">
<strong>Expense Number:</strong> <span id="expense_number"></span> <strong>Expense Number:</strong> <span id="modal_expense_number"></span>
</li> </li>
<li class="list-group-item"> <li class="list-group-item">
<strong>User:</strong> <span id="user"></span> <strong>User:</strong> <span id="modal_user"></span>
</li> </li>
<li class="list-group-item"> <li class="list-group-item">
<strong>NIK/NPWP Penerima:</strong> <span id="nik_or_npwp"></span> <strong>NIK/NPWP Penerima:</strong> <span id="modal_nik_or_npwp"></span>
</li> </li>
<li class="list-group-item"> <li class="list-group-item">
<strong>Tanggal:</strong> <span id="tanggal"></span> <strong>Nama Perusahaan:</strong> <span id="modal_nama_perusahaan"></span>
</li> </li>
<li class="list-group-item"> <li class="list-group-item">
<strong>Nama Penerima:</strong> <span id="name"></span> <strong>Jabatan:</strong> <span id="modal_jabatan"></span>
</li> </li>
<li class="list-group-item"> <li class="list-group-item">
<strong>Jenis:</strong> <span id="jenis"></span> <strong>Jenis Usaha:</strong> <span id="modal_jenis_usaha"></span>
</li> </li>
<li class="list-group-item"> <li class="list-group-item">
<strong>Keterangan:</strong> <span id="keterangan"></span> <strong>Tanggal:</strong> <span id="modal_tanggal"></span>
</li> </li>
<li class="list-group-item"> <li class="list-group-item">
<strong>Bukti Total:</strong> <span id="bukti_total"></span> <strong>Nama Penerima:</strong> <span id="modal_name"></span>
</li>
<li class="list-group-item">
<strong>Jenis:</strong> <span id="modal_jenis"></span>
</li>
<li class="list-group-item">
<strong>Keterangan:</strong> <span id="modal_keterangan"></span>
</li>
<li class="list-group-item">
<strong>Bukti Total:</strong> <span id="modal_bukti_total"></span>
</li> </li>
<li class="list-group-item"> <li class="list-group-item">
<strong>Nominal Total:</strong> <span id="total_value"></span> <strong>Nominal Total:</strong> <span id="total_value"></span>
@@ -184,20 +245,18 @@
@csrf @csrf
@method('PUT') @method('PUT')
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="approveModalLabel">Approve Item</h5> <h5 class="modal-title" id="finalApproveModalLabel">Approve Item</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close"> <button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span> <span aria-hidden="true">&times;</span>
</button> </button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<!-- Loading spinner -->
<div id="finalLoadingSpinner" class="d-flex justify-content-center align-items-center" style="height: 200px;"> <div id="finalLoadingSpinner" class="d-flex justify-content-center align-items-center" style="height: 200px;">
<div class="spinner-border text-primary" role="status"> <div class="spinner-border text-primary" role="status">
<span class="sr-only">Loading...</span> <span class="sr-only">Loading...</span>
</div> </div>
</div> </div>
<!-- Modal content (hidden initially) -->
<div id="finalModalContent" class="d-none"> <div id="finalModalContent" class="d-none">
<p>Please review the details below:</p> <p>Please review the details below:</p>
<ul class="list-group"> <ul class="list-group">
@@ -210,6 +269,15 @@
<li class="list-group-item"> <li class="list-group-item">
<strong>NIK/NPWP Penerima:</strong> <span id="final_nik_or_npwp"></span> <strong>NIK/NPWP Penerima:</strong> <span id="final_nik_or_npwp"></span>
</li> </li>
<li class="list-group-item">
<strong>Nama Perusahaan:</strong> <span id="final_nama_perusahaan"></span>
</li>
<li class="list-group-item">
<strong>Jabatan:</strong> <span id="final_jabatan"></span>
</li>
<li class="list-group-item">
<strong>Jenis Usaha:</strong> <span id="final_jenis_usaha"></span>
</li>
<li class="list-group-item"> <li class="list-group-item">
<strong>Tanggal:</strong> <span id="final_tanggal"></span> <strong>Tanggal:</strong> <span id="final_tanggal"></span>
</li> </li>
@@ -287,7 +355,7 @@
decimalCharacter: ',', // Karakter desimal (tidak digunakan karena tanpa desimal) decimalCharacter: ',', // Karakter desimal (tidak digunakan karena tanpa desimal)
currencySymbol: 'Rp.', // Simbol mata uang currencySymbol: 'Rp.', // Simbol mata uang
decimalPlaces: 0, // Tidak ada angka desimal decimalPlaces: 0, // Tidak ada angka desimal
unformatOnSubmit: true // Nilai asli tanpa format saat dikirimkan unformatOnSubmit: true // Nilai asli tanpa format saat dikirimkan
}); });
</script> </script>
@@ -330,20 +398,26 @@
$('#total_value').text(formatter.format(data.total)); $('#total_value').text(formatter.format(data.total));
// Detail data // Detail data
$('#user').text(data.user.name); $('#modal_user').text(data.user.name);
$('#expense_number').text(data.expense_number); $('#modal_expense_number').text(data.expense_number);
$('#tanggal').text(new Date(data.created_at).toLocaleDateString('id-ID', { $('#modal_tanggal').text(new Date(data.created_at).toLocaleDateString('id-ID', {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric' day: 'numeric'
})); }));
$('#nik_or_npwp').text(data.nik_or_npwp); $('#modal_nik_or_npwp').text(data.nik_or_npwp);
$('#name').text(data.name);
$('#jenis').text(data.jenis);
$('#keterangan').text(data.keterangan);
$('#bukti_total').html(data.total ? '<a href="' + data.total + '" target="_blank">Download</a>' : '-'); // Tambahan Mapping Data Perusahaan
$('#modal_nama_perusahaan').text(data.nama_perusahaan || '-');
$('#modal_jabatan').text(data.jabatan || '-');
$('#modal_jenis_usaha').text(data.jenis_usaha || '-');
$('#modal_name').text(data.name);
$('#modal_jenis').text(data.jenis);
$('#modal_keterangan').text(data.keterangan);
$('#modal_bukti_total').html(data.total ? '<a href="' + data.total + '" target="_blank">Download</a>' : '-');
// Hide spinner and show content // Hide spinner and show content
$('#loadingSpinner').hide(); $('#loadingSpinner').hide();
@@ -392,11 +466,16 @@
})); }));
$('#final_nik_or_npwp').text(data.nik_or_npwp); $('#final_nik_or_npwp').text(data.nik_or_npwp);
// Tambahan Mapping Data Perusahaan
$('#final_nama_perusahaan').text(data.nama_perusahaan || '-');
$('#final_jabatan').text(data.jabatan || '-');
$('#final_jenis_usaha').text(data.jenis_usaha || '-');
$('#final_name').text(data.name); $('#final_name').text(data.name);
$('#final_jenis').text(data.jenis); $('#final_jenis').text(data.jenis);
$('#final_keterangan').text(data.keterangan); $('#final_keterangan').text(data.keterangan);
$('#final_bukti_total').html(data.bukti_total ? '<a href="' + data.bukti_total + '" target="_blank">Download</a>' : '-'); $('#final_bukti_total').html(data.bukti_total ? '<a href="' + data.bukti_total + '" target="_blank">Download</a>' : '-');
// Hide spinner and show content // Hide spinner and show content
@@ -426,11 +505,13 @@
$('#total').val(formatter.format(total)); $('#total').val(formatter.format(total));
}); });
// Uncheck all checkboxes when the modal is closed // Uncheck all checkboxes when the modal is closed
$('#finalApproveModal').on('hidden.bs.modal', function () { $('#finalApproveModal').on('hidden.bs.modal', function () {
$('.custom-control-input').prop('checked', false); // Uncheck all checkboxes $('.custom-control-input').prop('checked', false); // Uncheck all checkboxes
$('#total').val('0'); // Reset total to 0 $('#total').val('0'); // Reset total to 0
}); });
</script> </script>
@include('backend.pages.forms.entertainment.partials.attachment-modal')
@include('backend.pages.forms.entertainment.partials.attachment-scripts')
@endsection @endsection
@@ -41,6 +41,10 @@
color: white; color: white;
} }
</style> </style>
@php
$attachmentCategoryLabels = $meetingAttachmentCategories ?? [];
$attachmentCategories = $meetingAttachmentCategoryKeys ?? array_keys($attachmentCategoryLabels);
@endphp
<section class="content"> <section class="content">
<div class="container-fluid"> <div class="container-fluid">
<div class="card card-primary card-outline"> <div class="card card-primary card-outline">
@@ -58,10 +62,6 @@
<label class="form-label">Allowance <span class="font-italic font-weight-normal">(optional)</span></label> <label class="form-label">Allowance <span class="font-italic font-weight-normal">(optional)</span></label>
<input type="text" class="form-control" name="allowance" id="allowance" value="{{ old('allowance') }}"> <input type="text" class="form-control" name="allowance" id="allowance" value="{{ old('allowance') }}">
</div> </div>
<div class="mb-3">
<label class="form-label">Bukti Allowance <span class="font-italic font-weight-normal">(optional)</span></label>
<input type="file" class="form-control" name="bukti_allowance" value="{{ old('bukti_allowance') }}">
</div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Transport Antar Kota <span class="font-italic font-weight-normal">(optional)</span></label> <label class="form-label">Transport Antar Kota <span class="font-italic font-weight-normal">(optional)</span></label>
<input type="text" class="form-control" name="transport_ankot" id="transport_ankot" value="{{ old('transport_ankot') }}"> <input type="text" class="form-control" name="transport_ankot" id="transport_ankot" value="{{ old('transport_ankot') }}">
@@ -69,21 +69,45 @@
</div> </div>
<div class="col-lg-6"> <div class="col-lg-6">
<div class="mb-3">
<label class="form-label">Bukti Transport Antar Kota <span class="font-italic font-weight-normal">(optional)</span></label>
<input type="file" class="form-control" name="bukti_transport_ankot" value="{{ old('bukti_transport_ankot') }}">
</div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Hotel <span class="font-italic font-weight-normal">(optional)</span></label> <label class="form-label">Hotel <span class="font-italic font-weight-normal">(optional)</span></label>
<input type="text" class="form-control" name="hotel" id="hotel" value="{{ old('hotel') }}"> <input type="text" class="form-control" name="hotel" id="hotel" value="{{ old('hotel') }}">
</div> </div>
<div class="mb-3">
<label class="form-label">Bukti Hotel <span class="font-italic font-weight-normal">(optional)</span></label>
<input type="file" class="form-control" name="bukti_hotel" value="{{ old('bukti_hotel') }}">
</div>
</div> </div>
<button type="submit" class="btn btn-primary ml-2">Submit</button> <div class="col-12">
<hr class="my-4">
<div class="d-flex justify-content-between align-items-center mb-2">
<h5 class="mb-0">Lampiran</h5>
<button type="button" class="btn btn-sm btn-primary" id="meeting-add-attachment-row">
<i class="fas fa-plus mr-1"></i> Tambah Lampiran
</button>
</div>
<div class="table-responsive">
<table class="table table-bordered table-striped mb-0" id="meeting-new-attachments-table" data-next-index="0">
<thead class="bg-light">
<tr>
<th style="width: 30%;">Kategori</th>
<th>File</th>
<th style="width: 120px;" class="text-center">Pratinjau</th>
<th style="width: 80px;" class="text-center">Aksi</th>
</tr>
</thead>
<tbody>
<tr class="meeting-empty-row">
<td colspan="4" class="text-center text-muted">Belum ada lampiran.</td>
</tr>
</tbody>
</table>
</div>
<small class="text-muted d-block mt-2">
Maksimum 10 MB per file. Tidak diperbolehkan: {{ implode(', ', $meetingBlockedExtensions ?? []) }}.
</small>
</div>
<div class="col-12 text-right mt-4">
<button type="submit" class="btn btn-primary">Submit</button>
</div>
</div> </div>
</form> </form>
@@ -91,7 +115,7 @@
<div class="spinner-wrapper"> <div class="spinner-wrapper">
<div class="spinner-border text-primary" role="status"> <div class="spinner-border text-primary" role="status">
</div> </div>
<p>Submitting, please wait...</p> <p>Sedang mengirim, mohon tunggu...</p>
</div> </div>
</div> </div>
</div> </div>
@@ -130,4 +154,7 @@
spinnerOverlay.classList.add('d-flex'); // Use flexbox for centering spinnerOverlay.classList.add('d-flex'); // Use flexbox for centering
}); });
</script> </script>
@include('backend.pages.forms.meeting.partials.attachment-modal')
@include('backend.pages.forms.meeting.partials.attachment-scripts')
@endsection @endsection
@@ -59,10 +59,6 @@
<label class="form-label">Allowance <span class="font-italic font-weight-normal">(optional)</span></label> <label class="form-label">Allowance <span class="font-italic font-weight-normal">(optional)</span></label>
<input type="text" class="form-control" name="allowance" id="allowance" value="{{ $form->allowance }}"> <input type="text" class="form-control" name="allowance" id="allowance" value="{{ $form->allowance }}">
</div> </div>
<div class="mb-3">
<label class="form-label">Bukti Allowance <span class="font-italic font-weight-normal">(optional)</span></label>
<input type="file" class="form-control" name="bukti_allowance" value="{{ old('bukti_allowance') }}">
</div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Transport Antar Kota <span class="font-italic font-weight-normal">(optional)</span></label> <label class="form-label">Transport Antar Kota <span class="font-italic font-weight-normal">(optional)</span></label>
<input type="text" class="form-control" name="transport_ankot" id="transport_ankot" value="{{ $form->transport_ankot }}"> <input type="text" class="form-control" name="transport_ankot" id="transport_ankot" value="{{ $form->transport_ankot }}">
@@ -70,21 +66,108 @@
</div> </div>
<div class="col-lg-6"> <div class="col-lg-6">
<div class="mb-3">
<label class="form-label">Bukti Transport Antar Kota <span class="font-italic font-weight-normal">(optional)</span></label>
<input type="file" class="form-control" name="bukti_transport_ankot" value="{{ old('bukti_transport_ankot') }}">
</div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Hotel <span class="font-italic font-weight-normal">(optional)</span></label> <label class="form-label">Hotel <span class="font-italic font-weight-normal">(optional)</span></label>
<input type="text" class="form-control" name="hotel" id="hotel" value="{{ $form->hotel }}"> <input type="text" class="form-control" name="hotel" id="hotel" value="{{ $form->hotel }}">
</div> </div>
<div class="mb-3"> </div>
<label class="form-label">Bukti Hotel <span class="font-italic font-weight-normal">(optional)</span></label>
<input type="file" class="form-control" name="bukti_hotel" value="{{ old('bukti_hotel') }}"> <div class="col-12">
<hr class="my-4">
<h5 class="mb-3">Lampiran Saat Ini</h5>
<div class="table-responsive">
<table class="table table-bordered table-striped mb-0" id="meeting-existing-attachments-table">
<thead class="bg-light">
<tr>
<th style="width: 25%;">Kategori</th>
<th>Nama File</th>
<th style="width: 120px;" class="text-center">Pratinjau</th>
<th style="width: 120px;" class="text-center">Unduh</th>
<th style="width: 100px;" class="text-center">Hapus</th>
</tr>
</thead>
<tbody>
@php
$attachmentCategoryLabels = $meetingAttachmentCategories ?? [];
@endphp
@forelse ($attachments as $attachment)
<tr class="meeting-attachment-row">
<td>{{ $attachment['category_label'] ?? '-' }}</td>
<td>{{ $attachment['filename'] }}</td>
<td class="text-center">
<button type="button"
class="btn btn-sm btn-outline-secondary meeting-preview-trigger"
data-preview-type="{{ $attachment['preview_type'] }}"
data-preview-source="{{ $attachment['preview_url'] ?? '' }}"
data-download-url="{{ $attachment['download_url'] ?? '' }}"
data-filename="{{ $attachment['filename'] }}">
Pratinjau
</button>
</td>
<td class="text-center">
@if (!empty($attachment['download_url']))
<a href="{{ $attachment['download_url'] }}" class="btn btn-sm btn-outline-success" target="_blank" rel="noopener">
Unduh
</a>
@else
<span class="text-muted">Tidak tersedia</span>
@endif
</td>
<td class="text-center">
@if (!empty($attachment['can_delete']))
<button type="button"
class="btn btn-sm btn-outline-danger meeting-delete-existing-attachment"
data-delete-url="{{ route('forms.meeting.attachments.destroy', [$form->id, $attachment['id']]) }}">
<i class="fas fa-trash"></i>
</button>
@else
<span class="text-muted">Tidak tersedia</span>
@endif
</td>
</tr>
@empty
<tr class="meeting-empty-row">
<td colspan="5" class="text-center text-muted">Belum ada lampiran.</td>
</tr>
@endforelse
</tbody>
</table>
</div> </div>
</div> </div>
<button type="submit" class="btn btn-primary ml-2">Update</button> <div class="col-12">
<hr class="my-4">
<div class="d-flex justify-content-between align-items-center mb-2">
<h5 class="mb-0">Tambah Lampiran Baru</h5>
<button type="button" class="btn btn-sm btn-primary" id="meeting-add-attachment-row">
<i class="fas fa-plus mr-1"></i> Tambah Lampiran
</button>
</div>
<div class="table-responsive">
<table class="table table-bordered table-striped mb-0" id="meeting-new-attachments-table" data-next-index="0">
<thead class="bg-light">
<tr>
<th style="width: 30%;">Kategori</th>
<th>File</th>
<th style="width: 120px;" class="text-center">Pratinjau</th>
<th style="width: 80px;" class="text-center">Aksi</th>
</tr>
</thead>
<tbody>
<tr class="meeting-empty-row">
<td colspan="4" class="text-center text-muted">Belum ada lampiran.</td>
</tr>
</tbody>
</table>
</div>
<small class="text-muted d-block mt-2">
Maksimum 10 MB per file. Tidak diperbolehkan: {{ implode(', ', $meetingBlockedExtensions ?? []) }}.
</small>
</div>
<div class="col-12 text-right mt-4">
<button type="submit" class="btn btn-primary">Update</button>
</div>
</div> </div>
</form> </form>
@@ -92,7 +175,7 @@
<div class="spinner-wrapper"> <div class="spinner-wrapper">
<div class="spinner-border text-primary" role="status"> <div class="spinner-border text-primary" role="status">
</div> </div>
<p>Submitting, please wait...</p> <p>Sedang mengirim, mohon tunggu...</p>
</div> </div>
</div> </div>
</div> </div>
@@ -131,4 +214,7 @@
spinnerOverlay.classList.add('d-flex'); // Use flexbox for centering spinnerOverlay.classList.add('d-flex'); // Use flexbox for centering
}); });
</script> </script>
@include('backend.pages.forms.meeting.partials.attachment-modal')
@include('backend.pages.forms.meeting.partials.attachment-scripts')
@endsection @endsection
@@ -6,16 +6,72 @@
@section('admin-content') @section('admin-content')
<style> <style>
/* Style Awal Anda */
#outstandingCard { #outstandingCard {
cursor: pointer; cursor: pointer;
} }
#outstandingCard.active { #outstandingCard.active {
box-shadow: 0 0 0 0.25rem rgba(255, 255, 255, 0.35); box-shadow: 0 0 0 0.25rem rgba(255, 255, 255, 0.35);
} }
/* Style Baru: Mini Status Cards (Seragam dengan Up Country) */
.status-card {
cursor: pointer;
transition: all 0.2s ease-in-out;
border: 1px solid rgba(0,0,0,0.08);
border-radius: 6px;
background-color: #fff;
}
.status-card:hover { transform: translateY(-2px); box-shadow: 0 4px 8px rgba(0,0,0,0.1); }
.status-card.active-filter {
border: 2px solid #343a40;
box-shadow: 0 2px 10px rgba(0,0,0,0.15);
}
.mini-stat-box {
padding: 6px 10px;
display: flex;
align-items: center;
justify-content: space-between;
min-height: 45px;
}
.mini-stat-box h6 { margin: 0; font-weight: 800; font-size: 0.95rem; color: #333; }
.mini-stat-box p { margin: 0; font-size: 0.65rem; text-transform: uppercase; color: #777; font-weight: 600; }
/* Indikator Warna Kiri */
.status-card[data-status="On Progress"] { border-left: 3px solid #ffc107; }
.status-card[data-status="Approved 1"] { border-left: 3px solid #17a2b8; }
.status-card[data-status="Approved 2"] { border-left: 3px solid #007bff; }
.status-card[data-status="Final Approved"] { border-left: 3px solid #28a745; }
.status-card[data-status="Rejected"] { border-left: 3px solid #dc3545; }
.status-card[data-status="Closed"] { border-left: 3px solid #6c757d; }
</style> </style>
@php @php
$outstandingIdCollection = collect($outstandingIds ?? []); $outstandingIdCollection = collect($outstandingIds ?? []);
// ==========================================
// FILTER PERIODE BERJALAN KHUSUS UNTUK MINI CARDS
// (Agar Angka sinkron dengan tabel filter JS di bawah)
// ==========================================
$startDayIndex = (int) env('STARTING_DATE', 11);
$closingDayIndex = (int) env('CLOSING_DATE', 13);
$nowIndex = \Carbon\Carbon::now();
if ($nowIndex->day >= $startDayIndex) {
$startPeriodIndex = \Carbon\Carbon::create($nowIndex->year, $nowIndex->month, $startDayIndex)->startOfDay();
$endPeriodIndex = \Carbon\Carbon::create($nowIndex->copy()->addMonth()->year, $nowIndex->copy()->addMonth()->month, $closingDayIndex)->endOfDay();
} else {
$startPeriodIndex = \Carbon\Carbon::create($nowIndex->copy()->subMonth()->year, $nowIndex->copy()->subMonth()->month, $startDayIndex)->startOfDay();
$endPeriodIndex = \Carbon\Carbon::create($nowIndex->year, $nowIndex->month, $closingDayIndex)->endOfDay();
}
// Penampung data pengajuan MURNI HANYA DI PERIODE BERJALAN SAAT INI
$currentPeriodForms = $forms->filter(function($form) use ($startPeriodIndex, $endPeriodIndex) {
$tanggalForm = \Carbon\Carbon::parse($form->tanggal);
return $tanggalForm->between($startPeriodIndex, $endPeriodIndex);
});
@endphp @endphp
<section class="content-header"> <section class="content-header">
<div class="container-fluid"> <div class="container-fluid">
<div class="row mb-2"> <div class="row mb-2">
@@ -25,52 +81,42 @@
<div class="col-sm-6"> <div class="col-sm-6">
<ol class="breadcrumb float-sm-right"> <ol class="breadcrumb float-sm-right">
<li class="breadcrumb-item"><a href="{{ route('dashboard.index') }}">Dashboard</a></li> <li class="breadcrumb-item"><a href="{{ route('dashboard.index') }}">Dashboard</a></li>
</li>
</ol> </ol>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<section class="content"> <section class="content">
<div class="container-fluid"> <div class="container-fluid">
<div class="row row-cols-1 row-cols-md-2 row-cols-xl-5 g-3"> <div class="row row-cols-1 row-cols-md-2 row-cols-xl-5 g-3">
<!-- Total Expense -->
<div class="col d-flex"> <div class="col d-flex">
<div class="small-box bg-info flex-fill"> <div class="small-box bg-info flex-fill">
<div class="inner"> <div class="inner">
<h3>Rp {{ number_format($forms->sum('total'), 0, ',', '.') }}</h3> <h3>Rp {{ number_format($forms->sum('total'), 0, ',', '.') }}</h3>
<p>Total Expense</p> <p>Total Expense</p>
</div> </div>
<div class="icon"> <div class="icon"><i class="fas fa-wallet"></i></div>
<i class="fas fa-wallet"></i>
</div> </div>
</div> </div>
</div>
<!-- Approved Expense -->
<div class="col d-flex"> <div class="col d-flex">
<div class="small-box bg-success flex-fill"> <div class="small-box bg-success flex-fill">
<div class="inner"> <div class="inner">
<h3>Rp {{ number_format($forms->whereIn('status', ['Approved', 'Closed'])->sum('approved_total'), 0, ',', '.') }}</h3> <h3>Rp {{ number_format($forms->whereIn('status', ['Approved', 'Closed'])->sum('approved_total'), 0, ',', '.') }}</h3>
<p>Approved Expenses</p> <p>Approved Expenses</p>
</div> </div>
<div class="icon"> <div class="icon"><i class="fas fa-check-circle"></i></div>
<i class="fas fa-check-circle"></i>
</div> </div>
</div> </div>
</div>
<!-- Pending Expense -->
<div class="col d-flex"> <div class="col d-flex">
<div class="small-box bg-warning flex-fill"> <div class="small-box bg-warning flex-fill">
<div class="inner"> <div class="inner">
<h3 class="text-white">Rp {{ number_format($forms->where('status', 'On Progress')->sum('total'), 0, ',', '.') }}</h3> <h3 class="text-white">Rp {{ number_format($forms->where('status', 'On Progress')->sum('total'), 0, ',', '.') }}</h3>
<p class="text-white">Pending Expenses</p> <p class="text-white">Pending Expenses</p>
</div> </div>
<div class="icon"> <div class="icon"><i class="fas fa-hourglass-half"></i></div>
<i class="fas fa-hourglass-half"></i>
</div> </div>
</div> </div>
</div>
<!-- Outstanding Expense -->
<div class="col d-flex"> <div class="col d-flex">
<div class="small-box bg-danger flex-fill" id="outstandingCard"> <div class="small-box bg-danger flex-fill" id="outstandingCard">
<div class="inner"> <div class="inner">
@@ -79,9 +125,7 @@
<p class="mb-0"><small>Total Rp {{ number_format($outstandingTotal, 0, ',', '.') }}</small></p> <p class="mb-0"><small>Total Rp {{ number_format($outstandingTotal, 0, ',', '.') }}</small></p>
<p class="mb-0 filter-hint"><small>Klik untuk menampilkan detail</small></p> <p class="mb-0 filter-hint"><small>Klik untuk menampilkan detail</small></p>
</div> </div>
<div class="icon"> <div class="icon"><i class="fas fa-exclamation-triangle"></i></div>
<i class="fas fa-exclamation-triangle"></i>
</div>
</div> </div>
</div> </div>
@if($availableBudget > 0) @if($availableBudget > 0)
@@ -91,15 +135,28 @@
<h3 class="text-white">Rp {{ number_format($availableBudget, 0, ',', '.') }}</h3> <h3 class="text-white">Rp {{ number_format($availableBudget, 0, ',', '.') }}</h3>
<p class="text-white">Available Budget</p> <p class="text-white">Available Budget</p>
</div> </div>
<div class="icon"> <div class="icon"><i class="fas fa-money-bill"></i></div>
<i class="fas fa-money-bill"></i>
</div>
</div> </div>
</div> </div>
@endif @endif
</div> </div>
</div> </div>
</section> </section>
<section class="content mt-2">
<div class="container-fluid">
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-xl-7 g-2 mb-3">
<div class="col"><div class="status-card active-filter" data-status="all"><div class="mini-stat-box"><div><p>Semua</p><h6>{{ $currentPeriodForms->count() }}</h6></div><i class="fas fa-list-ul text-muted"></i></div></div></div>
<div class="col"><div class="status-card" data-status="On Progress"><div class="mini-stat-box"><div><p>Progress</p><h6>{{ $currentPeriodForms->where('status', 'On Progress')->count() }}</h6></div><i class="fas fa-sync text-warning fa-xs"></i></div></div></div>
<div class="col"><div class="status-card" data-status="Approved 1"><div class="mini-stat-box"><div><p>App 1</p><h6>{{ $currentPeriodForms->where('status', 'Approved 1')->count() }}</h6></div><i class="fas fa-user-check text-info fa-xs"></i></div></div></div>
<div class="col"><div class="status-card" data-status="Approved 2"><div class="mini-stat-box"><div><p>App 2</p><h6>{{ $currentPeriodForms->where('status', 'Approved 2')->count() }}</h6></div><i class="fas fa-users text-primary fa-xs"></i></div></div></div>
<div class="col"><div class="status-card" data-status="Final Approved"><div class="mini-stat-box"><div><p>Final</p><h6>{{ $currentPeriodForms->where('status', 'Final Approved')->count() }}</h6></div><i class="fas fa-check-double text-success fa-xs"></i></div></div></div>
<div class="col"><div class="status-card" data-status="Rejected"><div class="mini-stat-box"><div><p>Reject</p><h6>{{ $currentPeriodForms->where('status', 'Rejected')->count() }}</h6></div><i class="fas fa-ban text-danger fa-xs"></i></div></div></div>
<div class="col"><div class="status-card" data-status="Closed"><div class="mini-stat-box"><div><p>Closed</p><h6>{{ $currentPeriodForms->where('status', 'Closed')->count() }}</h6></div><i class="fas fa-lock text-secondary fa-xs"></i></div></div></div>
</div>
</div>
</section>
<section class="content"> <section class="content">
<div class="container-fluid"> <div class="container-fluid">
<div class="card card-primary card-outline"> <div class="card card-primary card-outline">
@@ -107,18 +164,14 @@
<h4 class="header-title float-left">List Data Forms Meeting Seminar Anda</h4> <h4 class="header-title float-left">List Data Forms Meeting Seminar Anda</h4>
<p class="float-right mb-2"> <p class="float-right mb-2">
@if (auth()->user()->can('forms.meeting.create')) @if (auth()->user()->can('forms.meeting.create'))
<a class="btn btn-primary text-white" href="{{ route('forms.meeting.create') }}"> <a class="btn btn-primary text-white" href="{{ route('forms.meeting.create') }}">Add New Expense</a>
Add New Expense Meeting Seminar
</a>
@endif @endif
</p> </p>
<div class="clearfix"></div> <div class="clearfix"></div>
<div class="dataTables_wrapper dt-bootstrap4 mt-2"> <div class="dataTables_wrapper dt-bootstrap4 mt-2">
@include('backend.layouts.partials.messages') @include('backend.layouts.partials.messages')
<div class="table-responsive"> <div class="table-responsive">
<table id="dataTable" <table id="dataTable" class="table table-bordered table-striped table-head-fixed dataTable dtr-inline" style="width:100%">
class="table table-bordered table-striped table-head-fixed dataTable dtr-inline"
style="width:100%">
<thead class="bg-light text-capitalize"> <thead class="bg-light text-capitalize">
<tr> <tr>
<th>#</th> <th>#</th>
@@ -133,15 +186,9 @@
<th>Total Approved</th> <th>Total Approved</th>
<th>Account Number</th> <th>Account Number</th>
<th>Status</th> <th>Status</th>
@if (auth()->user()->can('approval.view')) @if (auth()->user()->can('approval.view')) <th>Approval 1</th> @endif
<th>Approval 1</th> @if (auth()->user()->can('approval2.view')) <th>Approval 2</th> @endif
@endif @if (auth()->user()->can('final_approval.view')) <th>Final Approval</th> @endif
@if (auth()->user()->can('approval2.view'))
<th>Approval 2</th>
@endif
@if (auth()->user()->can('final_approval.view'))
<th>Final Approval</th>
@endif
<th>Aksi</th> <th>Aksi</th>
</tr> </tr>
</thead> </thead>
@@ -151,12 +198,12 @@
$role = auth()->user()->getRoleNames()[0]; $role = auth()->user()->getRoleNames()[0];
@endphp @endphp
@foreach ($forms as $item) @foreach ($forms as $item)
<tr data-outstanding="{{ $outstandingIdCollection->contains($item->id) ? 1 : 0 }}"> <tr data-id="{{ $item->id }}" data-status="{{ $item->status }}" data-date="{{ \Carbon\Carbon::parse($item->tanggal)->format('Y-m-d') }}" data-outstanding="{{ $outstandingIdCollection->contains($item->id) ? '1' : '0' }}">
<td>{{ $loop->iteration }}</td> <td>{{ $loop->iteration }}</td>
<td>{{ $item->expense_number }}</td> <td>{{ $item->expense_number }}</td>
<td>{{ $item->user->name }}</td> <td>{{ $item->user->name }}</td>
<td>{{ $item->user->cabang->cabang->name }}</td> <td>{{ $item->user->cabang->cabang->name }}</td>
<td>{{ \Carbon\Carbon::parse($item->tanggal)->locale('id')->isoFormat('D MMMM Y') }}</td> <td>{{ \Carbon\Carbon::parse($item->tanggal)->locale('id')->isoFormat('dddd, D MMMM Y HH:mm:ss') }}</td>
<td>{{ number_format($item->allowance, 0, ',', '.') }}</td> <td>{{ number_format($item->allowance, 0, ',', '.') }}</td>
<td>{{ number_format($item->transport_ankot, 0, ',', '.') }}</td> <td>{{ number_format($item->transport_ankot, 0, ',', '.') }}</td>
<td>{{ number_format($item->hotel, 0, ',', '.') }}</td> <td>{{ number_format($item->hotel, 0, ',', '.') }}</td>
@@ -166,121 +213,67 @@
<td> <td>
@if ($item->status == 'On Progress') @if ($item->status == 'On Progress')
<span class="badge badge-warning text-white">{{ $item->status }}</span> <span class="badge badge-warning text-white">{{ $item->status }}</span>
@elseif ($item->status == 'Approved 1' || $item->status == 'Approved 2' || $item->status == 'Final Approved') @elseif (in_array($item->status, ['Approved 1', 'Approved 2', 'Final Approved']))
<span class="badge badge-success text-white">{{ $item->status }}</span> <span class="badge badge-success text-white">{{ $item->status }}</span>
@else @else
<span class="badge badge-danger text-white">{{ $item->status }}</span> <span class="badge badge-danger text-white">{{ $item->status }}</span>
@endif @endif
</td> </td>
{{-- SELEKTIF MENYEMBUNYIKAN TOMBOL APPROVE/REJECT DIRECT DI INDEX TANPA MENGHAPUS LOGIKA KODE --}}
@if (auth()->user()->can('approval.view')) @if (auth()->user()->can('approval.view'))
<td> <td>
{{-- approve / reject button --}} <div class="d-none">
@if (auth()->user()->can('approval.approve')) @if (auth()->user()->can('approval.approve') && $item->status == 'On Progress')
@if ($item->status == 'On Progress') <button type="button" class="btn btn-success btn-sm final-approve-modal" data-id="{{ route('forms.meeting.approve', $item->id) }}">Approve 1</button>
<form action="javascript:void(0);" class="d-inline"> <button type="button" class="btn btn-danger btn-sm open-reject-modal" data-action="{{ route('forms.meeting.reject', $item->id) }}">Reject</button>
<button type="button"
class="btn btn-success btn-sm final-approve-modal"
data-id="{{ route('forms.meeting.approve', $item->id) }}">
Approve 1
</button>
</form>
<button type="button"
class="btn btn-danger btn-sm open-reject-modal"
data-action="{{ route('forms.meeting.reject', $item->id) }}">
Reject
</button>
@endif
@endif
@if ($item->approved_at)
{{ \Carbon\Carbon::parse($item->approved_at)->locale('id')->isoFormat('D MMMM Y HH:mm') }}
@endif @endif
</div>
@if ($item->approved_at) <br><small>{{ \Carbon\Carbon::parse($item->approved_at)->format('d/m/y H:i') }}</small> @endif
</td> </td>
@endif @endif
@if (auth()->user()->can('approval2.view')) @if (auth()->user()->can('approval2.view'))
<td> <td>
{{-- approve / reject button --}} <div class="d-none">
@if (auth()->user()->can('approval2.approve')) @if (auth()->user()->can('approval2.approve') && $item->status == 'Approved 1' && $item->approved_at)
@if ($item->status == 'Approved 1' && $item->approved_at) <button type="button" class="btn btn-success btn-sm open-approve-modal" data-id="{{ route('forms.meeting.approve2', $item->id) }}">Approve 2</button>
<form action="javascript:void(0);" class="d-inline"> <button type="button" class="btn btn-danger btn-sm open-reject-modal" data-action="{{ route('forms.meeting.reject', $item->id) }}">Reject</button>
<button type="button"
class="btn btn-success btn-sm open-approve-modal"
data-id="{{ route('forms.meeting.approve2', $item->id) }}">
Approve 2
</button>
</form>
<button type="button"
class="btn btn-danger btn-sm open-reject-modal"
data-action="{{ route('forms.meeting.reject', $item->id) }}">
Reject
</button>
@endif
@endif
@if ($item->approved2_at)
{{ \Carbon\Carbon::parse($item->approved2_at)->locale('id')->isoFormat('D MMMM Y HH:mm') }}
@endif @endif
</div>
@if ($item->approved2_at) <br><small>{{ \Carbon\Carbon::parse($item->approved2_at)->format('d/m/y H:i') }}</small> @endif
</td> </td>
@endif @endif
@if(auth()->user()->can('final_approval.view')) @if(auth()->user()->can('final_approval.view'))
<td> <td>
{{-- approve / reject button --}} <div class="d-none">
@if(auth()->user()->can('final_approval.approve')) @if(auth()->user()->can('final_approval.approve'))
@if ($item->status == 'Approved 2' && $item->approved2_at && !$item->final_approved_at) @if ($item->status == 'Approved 2' && $item->approved2_at && !$item->final_approved_at)
<form action="javascript:void(0);" class="d-inline"> <button type="button" class="btn btn-success btn-sm open-approve-modal" data-id="{{ route('forms.meeting.final-approve', $item->id) }}">Final Approve</button>
<button type="button" <button type="button" class="btn btn-danger btn-sm open-reject-modal" data-action="{{ route('forms.meeting.reject', $item->id) }}">Reject</button>
class="btn btn-success btn-sm open-approve-modal" @elseif ($item->final_approved_at && auth()->user()->can('final_approval.open'))
data-id="{{ route('forms.meeting.final-approve', $item->id) }}"> <form action="{{ route('forms.meeting.open', $item->id) }}" method="POST" class="d-inline" onsubmit="return confirm('Confirm Open?');">
Final Approve @csrf @method('PUT') <button type="submit" class="btn btn-success btn-sm">Open</button>
</button>
</form>
@else
@if ($item->final_approved_at && auth()->user()->can('final_approval.open'))
<form action="{{ route('forms.meeting.open', $item->id) }}" method="POST" class="d-inline"onsubmit="return confirm('Are you sure you want to approve this item?');">
@csrf
@method('PUT')
<button type="submit" class="btn btn-success btn-sm">
Open
</button>
</form> </form>
@endif @endif
@endif @endif
@endif </div>
@if ($item->final_approved_at) @if ($item->final_approved_at) <br><small>{{ \Carbon\Carbon::parse($item->final_approved_at)->format('d/m/y H:i') }}</small> @endif
{{ \Carbon\Carbon::parse($item->final_approved_at)->locale('id')->isoFormat('D MMMM Y HH:mm') }}
@endif
</td> </td>
@endif @endif
<td> <td>
@if (auth()->user()->can('forms.meeting.view')) @if (auth()->user()->can('forms.meeting.view'))
<a href="{{ route('forms.meeting.view', $item->id) }}" class="btn btn-primary btn-sm"> <a href="{{ route('forms.meeting.view', $item->id) }}" class="btn btn-primary btn-sm"><i class="fas fa-eye text-white"></i></a>
<i class="fas fa-eye text-white"></i>
</a>
@endif @endif
@php @php
$canEdit = false; $canEdit = auth()->user()->can('forms.meeting.edit') && (!in_array($role, ['Medical Representatif', 'Area Manager Cabang']) || $item->status == 'On Progress');
if (auth()->user()->can('forms.meeting.edit')) {
if (in_array($role, ['Medical Representatif', 'Area Manager Cabang'])) {
$canEdit = $item->status == 'On Progress';
} else {
$canEdit = true;
}
}
@endphp @endphp
@if ($canEdit) @if ($canEdit)
<a href="{{ route('forms.meeting.edit', $item->id) }}" class="btn btn-warning btn-sm"> <a href="{{ route('forms.meeting.edit', $item->id) }}" class="btn btn-warning btn-sm"><i class="fas fa-edit text-white"></i></a>
<i class="fas fa-edit text-white"></i>
</a>
@endif @endif
@if (($role != 'Medical Representatif' && auth()->user()->can('forms.meeting.delete')) || ($role == 'Medical Representatif' && $item->status == 'On Progress')) @if (($role != 'Medical Representatif' && auth()->user()->can('forms.meeting.delete')) || ($role == 'Medical Representatif' && $item->status == 'On Progress'))
<form action="{{ route('forms.meeting.destroy', $item->id) }}" method="POST" class="d-inline" onsubmit="return confirm('Are you sure you want to delete this item?');"> <form action="{{ route('forms.meeting.destroy', $item->id) }}" method="POST" class="d-inline" onsubmit="return confirm('Delete?');">
@csrf @csrf @method('DELETE') <button type="submit" class="btn btn-danger btn-sm"><i class="fas fa-trash"></i></button>
@method('DELETE')
<button type="submit" class="btn btn-danger btn-sm">
<i class="fas fa-trash"></i>
</button>
</form> </form>
@endif @endif
</td> </td>
@@ -294,401 +287,232 @@
</div> </div>
</div> </div>
</section> </section>
<section>
<div class="modal fade" id="approveModal" tabindex="-1" role="dialog" aria-labelledby="approveModalLabel" aria-hidden="true"> {{-- MODAL CONTAINER DI BAWAH (TETAP UTUH UNTUK KEBUTUHAN ROUTING AJAX) --}}
<div class="modal fade" id="approveModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document"> <div class="modal-dialog modal-lg" role="document">
<div class="modal-content"> <div class="modal-content">
<form id="approveForm" method="POST" action=""> <form id="approveForm" method="POST" action="">
@csrf @csrf @method('PUT')
@method('PUT')
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="approveModalLabel">Approve Item</h5> <h5 class="modal-title" id="approveModalLabel">Approve Item</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close"> <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<span aria-hidden="true">&times;</span>
</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<!-- Loading spinner --> <div id="loadingSpinner" class="d-flex justify-content-center align-items-center" style="height: 200px;"><div class="spinner-border text-primary" role="status"><span class="sr-only">Loading...</span></div></div>
<div id="loadingSpinner" class="d-flex justify-content-center align-items-center" style="height: 200px;">
<div class="spinner-border text-primary" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
<!-- Modal content (hidden initially) -->
<div id="modalContent" class="d-none"> <div id="modalContent" class="d-none">
<p>Please review the details below:</p> <p>Please review the details below:</p>
<ul class="list-group"> <ul class="list-group">
<li class="list-group-item"> <li class="list-group-item"><strong>Expense Number:</strong> <span id="expense_number"></span></li>
<strong>Expense Number:</strong> <span id="expense_number"></span> <li class="list-group-item"><strong>User:</strong> <span id="user"></span></li>
</li> <li class="list-group-item"><strong>Tanggal:</strong> <span id="tanggal"></span></li>
<li class="list-group-item"> <li class="list-group-item"><strong>Allowance:</strong> <span id="allowance_value"></span> (<span id="bukti_allowance"></span>)</li>
<strong>User:</strong> <span id="user"></span> <li class="list-group-item"><strong>Transport Ankot:</strong> <span id="transport_ankot_value"></span> (<span id="bukti_transport_ankot"></span>)</li>
</li> <li class="list-group-item"><strong>Hotel:</strong> <span id="hotel_value"></span> (<span id="bukti_hotel"></span>)</li>
<li class="list-group-item">
<strong>Tipe:</strong> Meeting / Seminar
</li>
<li class="list-group-item">
<strong>Tanggal:</strong> <span id="tanggal"></span>
</li>
<li class="list-group-item">
<strong>Nominal Allowance:</strong> <span id="allowance_value"></span>
</li>
<li class="list-group-item">
<strong>Bukti Allowance:</strong> <span id="bukti_allowance"></span>
</li>
<li class="list-group-item">
<strong>Nominal Transport Antar Kota:</strong> <span id="transport_ankot_value"></span>
</li>
<li class="list-group-item">
<strong>Bukti Transport Antar Kota:</strong> <span id="bukti_transport_ankot"></span>
</li>
<li class="list-group-item">
<strong>Nominal Hotel:</strong> <span id="hotel_value"></span>
</li>
<li class="list-group-item">
<strong>Bukti Hotel:</strong> <span id="bukti_hotel"></span>
</li>
</ul> </ul>
</div> </div>
</div> </div>
<div class="modal-footer"><button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button><button type="submit" class="btn btn-primary">Approve</button></div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Approve</button>
</div>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
</section>
<section> <div class="modal fade" id="finalApproveModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal fade" id="finalApproveModal" tabindex="-1" role="dialog" aria-labelledby="finalApproveModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document"> <div class="modal-dialog modal-lg" role="document">
<div class="modal-content"> <div class="modal-content">
<form id="finalApproveForm" method="POST" action=""> <form id="finalApproveForm" method="POST" action="">
@csrf @csrf @method('PUT')
@method('PUT')
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="approveModalLabel">Approve Item</h5> <h5 class="modal-title" id="finalApproveModalLabel">Final Approve Item</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close"> <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<span aria-hidden="true">&times;</span>
</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<!-- Loading spinner --> <div id="finalLoadingSpinner" class="d-flex justify-content-center align-items-center" style="height: 200px;"><div class="spinner-border text-primary" role="status"><span class="sr-only">Loading...</span></div></div>
<div id="finalLoadingSpinner" class="d-flex justify-content-center align-items-center" style="height: 200px;">
<div class="spinner-border text-primary" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
<!-- Modal content (hidden initially) -->
<div id="finalModalContent" class="d-none"> <div id="finalModalContent" class="d-none">
<p>Please review the details below:</p> <p>Please review the details below:</p>
<ul class="list-group"> <ul class="list-group">
<li class="list-group-item"> <li class="list-group-item"><strong>Expense:</strong> <span id="final_expense_number"></span></li>
<strong>Expense Number:</strong> <span id="final_expense_number"></span> <li class="list-group-item"><strong>User:</strong> <span id="final_user"></span></li>
</li> <li class="list-group-item"><strong>Tanggal:</strong> <span id="final_tanggal"></span></li>
<li class="list-group-item"> <li class="list-group-item"><strong>Bukti Allowance:</strong> <span id="final_bukti_allowance"></span></li>
<strong>User:</strong> <span id="final_user"></span> <li class="list-group-item"><strong>Bukti Transport:</strong> <span id="final_bukti_transport_ankot"></span></li>
</li> <li class="list-group-item"><strong>Bukti Hotel:</strong> <span id="final_bukti_hotel"></span></li>
<li class="list-group-item">
<strong>Tipe:</strong> Meeting / Seminar
</li>
<li class="list-group-item">
<strong>Tanggal:</strong> <span id="final_tanggal"></span>
</li>
<li class="list-group-item">
<strong>Bukti Allowance:</strong> <span id="final_bukti_allowance"></span>
</li>
<li class="list-group-item">
<strong>Bukti Transport Antar Kota:</strong> <span id="final_bukti_transport_ankot"></span>
</li>
<li class="list-group-item">
<strong>Bukti Hotel:</strong> <span id="final_bukti_hotel"></span>
</li>
</ul> </ul>
<p class="mt-2">Please select the items you want to approve:</p> <div class="custom-control custom-checkbox mb-2"><input type="checkbox" class="custom-control-input" id="final_allowance" name="allowance"><label class="custom-control-label" for="final_allowance">Allowance (<span id="final_allowance_value"></span>)</label></div>
<ul class="list-group list-unstyled"> <div class="custom-control custom-checkbox mb-2"><input type="checkbox" class="custom-control-input" id="final_transport_ankot" name="transport_ankot"><label class="custom-control-label" for="final_transport_ankot">Transport Ankot (<span id="final_transport_ankot_value"></span>)</label></div>
<li> <div class="custom-control custom-checkbox mb-3"><input type="checkbox" class="custom-control-input" id="final_hotel" name="hotel"><label class="custom-control-label" for="final_hotel">Hotel (<span id="final_hotel_value"></span>)</label></div>
<div class="form-group"> <div class="form-group"><label>Total Approved</label><input type="text" class="form-control font-weight-bold" id="total" name="total" value="0" readonly></div>
<div class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input" id="final_allowance" name="allowance">
<label class="custom-control-label" for="final_allowance">Allowance (<span id="final_allowance_value"></span>)</label>
</div> </div>
</div> </div>
</li> <div class="modal-footer"><button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button><button type="submit" class="btn btn-primary">Approve</button></div>
<li>
<div class="form-group">
<div class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input" id="final_transport_ankot" name="transport_ankot">
<label class="custom-control-label" for="final_transport_ankot">Transport Ankot (<span id="final_transport_ankot_value"></span>)</label>
</div>
</div>
</li>
<li>
<div class="form-group">
<div class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input" id="final_hotel" name="hotel">
<label class="custom-control-label" for="final_hotel">Hotel (<span id="final_hotel_value"></span>)</label>
</div>
</div>
</li>
<li>
<div class="form-group">
<label for="total">Total</label>
<input type="text" class="form-control" id="total" name="total" value="0" readonly>
</div>
</li>
</ul>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Approve</button>
</div>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
</section>
<section> <div class="modal fade" id="rejectModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal fade" id="rejectModal" tabindex="-1" role="dialog" aria-labelledby="rejectModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document"> <div class="modal-dialog" role="document">
<div class="modal-content"> <div class="modal-content">
<form id="rejectForm" method="POST" action=""> <form id="rejectForm" method="POST" action="">
@csrf @csrf @method('PUT')
@method('PUT')
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="rejectModalLabel">Reject Expense</h5> <h5 class="modal-title" id="rejectModalLabel">Reject Expense</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close"> <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<span aria-hidden="true">&times;</span>
</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="form-group"> <div class="form-group"><label>Remarks <span class="text-danger">*</span></label><textarea class="form-control" name="remarks" id="rejectRemarks" rows="3" required></textarea></div>
<label for="rejectRemarks">Remarks <span class="text-danger">*</span></label>
<textarea class="form-control" name="remarks" id="rejectRemarks" rows="3" required></textarea>
</div>
<p class="mb-0 text-muted small">Pengajuan akan ditolak setelah Anda mengirimkan catatan ini.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-danger">Reject</button>
</div> </div>
<div class="modal-footer"><button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button><button type="submit" class="btn btn-danger">Reject</button></div>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
</section>
@endsection @endsection
@section('scripts') @section('scripts')
<script> <script>
$(document).ready(function () { $(document).ready(function () {
// Uncheck all checkboxes on page refresh // Uncheck all checkboxes on page refresh
$('.custom-control-input').prop('checked', false); $('.custom-control-input').prop('checked', false);
if ($('#dataTable').length) { if ($('#dataTable').length) {
let outstandingFilterActive = false; let outstandingFilterActive = false;
const outstandingFilter = function(settings, data, dataIndex) { let statusFilterActive = 'all';
if (!outstandingFilterActive) {
return true;
}
const row = settings.aoData[dataIndex].nTr;
return $(row).data('outstanding') === 1 || $(row).data('outstanding') === '1';
};
$.fn.dataTable.ext.search.push(outstandingFilter); // Custom Search DataTables (Filter Sinkron: Status + Outstanding + Siklus Periode Berjalan 11 - 13)
$.fn.dataTable.ext.search.push(function(settings, data, dataIndex) {
const row = $(settings.aoData[dataIndex].nTr);
const rowStatus = row.attr('data-status');
const isOutstanding = row.attr('data-outstanding') === '1' || row.attr('data-outstanding') === 1;
const rowDateStr = row.attr('data-date'); // Format: YYYY-MM-DD
// 1. Prioritas Pertama: Outstanding Filter Murni
if (outstandingFilterActive) {
return isOutstanding;
}
// 2. Prioritas Kedua: Aturan Jendela Waktu (Cut-off) Berlaku MUTLAK Untuk Semua Mini Card
if (rowDateStr) {
const dateParts = rowDateStr.split('-');
const rowDate = new Date(dateParts[0], dateParts[1] - 1, dateParts[2], 12, 0, 0);
const now = new Date();
let startPeriod, endPeriod;
const startDay = 11;
const closingDay = 13;
if (now.getDate() >= startDay) {
startPeriod = new Date(now.getFullYear(), now.getMonth(), startDay, 0, 0, 0);
endPeriod = new Date(now.getFullYear(), now.getMonth() + 1, closingDay, 23, 59, 59);
} else {
startPeriod = new Date(now.getFullYear(), now.getMonth() - 1, startDay, 0, 0, 0);
endPeriod = new Date(now.getFullYear(), now.getMonth(), closingDay, 23, 59, 59);
}
// TENGGELAMKAN ROW jika tidak berada di dalam siklus berjalan
if (rowDate < startPeriod || rowDate > endPeriod) {
return false;
}
}
// 3. Prioritas Ketiga: Filter Status Setelah Melewati Karantina Tanggal
if (statusFilterActive !== 'all' && rowStatus !== statusFilterActive) {
return false;
}
return true;
});
const table = $('#dataTable').DataTable({ const table = $('#dataTable').DataTable({
responsive: false, responsive: false, dom: 'Blfrtip',
dom: 'Blfrtip', lengthMenu: [ [10, 50, 100, -1], [10, 50, 100, "All"] ],
lengthMenu: [ [10, 50, 100, -1], [10, 50, 100, "All"] ], // Control the entries dropdown buttons: [ { extend: 'excel', text: 'Excel' }, { extend: 'pdf', text: 'PDF', orientation: 'landscape' }, 'colvis' ]
buttons: [ });
{
extend: 'excel', $('.status-card').on('click', function() {
text: 'Excel' outstandingFilterActive = false;
}, $('#outstandingCard').removeClass('active');
{ $('#outstandingCard').find('.filter-hint small').text('Klik untuk detail');
extend: 'pdf',
text: 'PDF', $('.status-card').removeClass('active-filter');
orientation: 'landscape', $(this).addClass('active-filter');
pageSize: 'A4', statusFilterActive = $(this).data('status');
exportOptions: { table.draw();
columns: ':visible'
}
},
{
extend: 'print',
text: 'Print',
orientation: 'landscape',
exportOptions: {
columns: ':visible'
}
},
'colvis'
]
}); });
const $outstandingCard = $('#outstandingCard'); const $outstandingCard = $('#outstandingCard');
const updateCardHint = (isActive) => {
const text = isActive ? 'Klik untuk menampilkan semua data' : 'Klik untuk menampilkan detail';
$outstandingCard.find('.filter-hint small').text(text);
};
updateCardHint(outstandingFilterActive);
$outstandingCard.on('click', function () { $outstandingCard.on('click', function () {
outstandingFilterActive = !outstandingFilterActive; outstandingFilterActive = !outstandingFilterActive;
$outstandingCard.toggleClass('active', outstandingFilterActive); $(this).toggleClass('active', outstandingFilterActive);
if(outstandingFilterActive) {
$('.status-card').removeClass('active-filter');
$('.status-card[data-status="all"]').addClass('active-filter');
statusFilterActive = 'all';
}
table.draw(); table.draw();
updateCardHint(outstandingFilterActive); $(this).find('.filter-hint small').text(outstandingFilterActive ? 'Tampilkan semua data' : 'Klik untuk detail');
}); });
} }
// Handle Reject Button Click
$(document).on('click', '.open-reject-modal', function () { $(document).on('click', '.open-reject-modal', function () {
const rejectUrl = $(this).data('action'); $('#rejectForm').attr('action', $(this).data('action'));
$('#rejectForm').attr('action', rejectUrl); $('#rejectRemarks').val('').focus();
$('#rejectRemarks').val('');
$('#rejectModal').modal('show'); $('#rejectModal').modal('show');
}); });
$('#rejectModal').on('shown.bs.modal', function () {
$('#rejectRemarks').trigger('focus');
});
$('#rejectModal').on('hidden.bs.modal', function () {
$('#rejectForm').attr('action', '');
$('#rejectRemarks').val('');
});
// Handle Approve Button Click
$(document).on('click', '.open-approve-modal', function() { $(document).on('click', '.open-approve-modal', function() {
const approveUrl = $(this).data('id'); const url = $(this).data('id');
$('#loadingSpinner').show(); $('#modalContent').addClass('d-none');
// Show spinner and hide content initially $('#approveForm').attr('action', url); $('#approveModal').modal('show');
$('#loadingSpinner').show(); $.get(url.replace(/approve2|final-approve/g, 'detail'), function (data) {
$('#modalContent').addClass('d-none'); const fmt = new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', minimumFractionDigits: 0 });
$('#allowance_value').text(fmt.format(data.allowance));
$('#approveForm').attr('action', approveUrl); // Set the form action $('#transport_ankot_value').text(fmt.format(data.transport_ankot));
$('#loadingSpinner').addClass('d-flex'); $('#hotel_value').text(fmt.format(data.hotel));
$('#approveModal').modal('show'); // Show the modal $('#user').text(data.user.name); $('#expense_number').text(data.expense_number);
$('#tanggal').text(new Date(data.tanggal).toLocaleDateString('id-ID', {year:'numeric', month:'long', day:'numeric'}));
// Get detail from /forms/meeting/detail/{id} $('#bukti_allowance').html(data.bukti_allowance ? '<a href="'+data.bukti_allowance+'" target="_blank">View</a>' : '-');
$.get(approveUrl.replace(/approve2|final-approve/g, 'detail'), function (data) { $('#bukti_transport_ankot').html(data.bukti_transport_ankot ? '<a href="'+data.bukti_transport_ankot+'" target="_blank">View</a>' : '-');
const formatter = new Intl.NumberFormat('id-ID', { $('#bukti_hotel').html(data.bukti_hotel ? '<a href="'+data.bukti_hotel+'" target="_blank">View</a>' : '-');
style: 'currency', $('#loadingSpinner').hide(); $('#modalContent').removeClass('d-none');
currency: 'IDR',
minimumFractionDigits: 0
});
$('#allowance_value').text(formatter.format(data.allowance));
$('#transport_ankot_value').text(formatter.format(data.transport_ankot));
$('#hotel_value').text(formatter.format(data.hotel));
// Detail data
$('#user').text(data.user.name);
$('#expense_number').text(data.expense_number);
$('#tanggal').text(new Date(data.tanggal).toLocaleDateString('id-ID', {
year: 'numeric',
month: 'long',
day: 'numeric'
}));
$('#bukti_allowance').html(data.bukti_allowance ? '<a href="' + data.bukti_allowance + '" target="_blank">Download</a>' : '-');
$('#bukti_transport_ankot').html(data.bukti_transport_ankot ? '<a href="' + data.bukti_transport_ankot + '" target="_blank">Download</a>' : '-');
$('#bukti_hotel').html(data.bukti_hotel ? '<a href="' + data.bukti_hotel + '" target="_blank">Download</a>' : '-');
// Hide spinner and show content
$('#loadingSpinner').hide();
$('#loadingSpinner').removeClass('d-flex');
$('#loadingSpinner').addClass('d-none');
$('#modalContent').removeClass('d-none');
// Reset the total to zero
$('#total').val(0);
}); });
}); });
// Handle Approve Button Click
$(document).on('click', '.final-approve-modal', function() { $(document).on('click', '.final-approve-modal', function() {
const approveUrl = $(this).data('id'); const url = $(this).data('id');
$('#finalLoadingSpinner').show(); $('#finalModalContent').addClass('d-none');
// Show spinner and hide content initially $('#finalApproveForm').attr('action', url); $('#finalApproveModal').modal('show');
$('#finalLoadingSpinner').show(); $.get(url.replace('approve', 'detail'), function (data) {
$('#finalModalContent').addClass('d-none'); const fmt = new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', minimumFractionDigits: 0 });
$('#final_allowance_value').text(fmt.format(data.allowance));
$('#finalApproveForm').attr('action', approveUrl); // Set the form action $('#final_transport_ankot_value').text(fmt.format(data.transport_ankot));
$('#finalLoadingSpinner').addClass('d-flex'); $('#final_hotel_value').text(fmt.format(data.hotel));
$('#finalApproveModal').modal('show'); // Show the modal
// Get detail from /forms/meeting/detail/{id}
$.get(approveUrl.replace('approve', 'detail'), function (data) {
console.log(data);
const formatter = new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0
});
$('#final_allowance_value').text(formatter.format(data.allowance));
$('#final_transport_ankot_value').text(formatter.format(data.transport_ankot));
$('#final_hotel_value').text(formatter.format(data.hotel));
// Assign data values to checkboxes
$('#final_allowance').data('value', data.allowance); $('#final_allowance').data('value', data.allowance);
$('#final_transport_ankot').data('value', data.transport_ankot); $('#final_transport_ankot').data('value', data.transport_ankot);
$('#final_hotel').data('value', data.hotel); $('#final_hotel').data('value', data.hotel);
$('#final_user').text(data.user.name); $('#final_expense_number').text(data.expense_number);
// Detail data $('#final_tanggal').text(new Date(data.tanggal).toLocaleDateString('id-ID', {year:'numeric', month:'long', day:'numeric'}));
$('#final_user').text(data.user.name); $('#final_bukti_allowance').html(data.bukti_allowance ? '<a href="'+data.bukti_allowance+'" target="_blank">View</a>' : '-');
$('#final_expense_number').text(data.expense_number); $('#final_bukti_transport_ankot').html(data.bukti_transport_ankot ? '<a href="'+data.bukti_transport_ankot+'" target="_blank">View</a>' : '-');
$('#final_tanggal').text(new Date(data.tanggal).toLocaleDateString('id-ID', { $('#final_bukti_hotel').html(data.bukti_hotel ? '<a href="'+data.bukti_hotel+'" target="_blank">View</a>' : '-');
year: 'numeric', $('#finalLoadingSpinner').hide(); $('#finalModalContent').removeClass('d-none');
month: 'long',
day: 'numeric'
}));
$('#final_bukti_allowance').html(data.bukti_allowance ? '<a href="' + data.bukti_allowance + '" target="_blank">Download</a>' : '-');
$('#final_bukti_transport_ankot').html(data.bukti_transport_ankot ? '<a href="' + data.bukti_transport_ankot + '" target="_blank">Download</a>' : '-');
$('#final_bukti_hotel').html(data.bukti_hotel ? '<a href="' + data.bukti_hotel + '" target="_blank">Download</a>' : '-');
// Hide spinner and show content
$('#finalLoadingSpinner').hide();
$('#finalLoadingSpinner').removeClass('d-flex');
$('#finalLoadingSpinner').addClass('d-none');
$('#finalModalContent').removeClass('d-none');
// Reset the total to zero
$('#total').val(0); $('#total').val(0);
}); });
}); });
// Update total dynamically
$('.custom-control-input').on('change', function () { $('.custom-control-input').on('change', function () {
let total = 0; let total = 0;
$('.custom-control-input:checked').each(function () { $('.custom-control-input:checked').each(function () { total += parseFloat($(this).data('value') || 0); });
total += parseFloat($(this).data('value') || 0); const fmt = new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', minimumFractionDigits: 0 });
$('#total').val(fmt.format(total));
}); });
const formatter = new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
});
$('#total').val(formatter.format(total));
});
// Uncheck all checkboxes when the modal is closed
$('#finalApproveModal').on('hidden.bs.modal', function () { $('#finalApproveModal').on('hidden.bs.modal', function () {
$('.custom-control-input').prop('checked', false); // Uncheck all checkboxes $('.custom-control-input').prop('checked', false); $('#total').val('0');
$('#total').val('0'); // Reset total to 0
}); });
}); });
</script> </script>
@endsection @endsection
@@ -0,0 +1,24 @@
<div class="modal fade" id="meetingAttachmentPreviewModal" tabindex="-1" role="dialog" aria-labelledby="meetingAttachmentPreviewModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title mb-0" id="meetingAttachmentPreviewModalLabel">Pratinjau Lampiran</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="meeting-preview-image d-none text-center">
<img src="" alt="Pratinjau Lampiran" class="img-fluid">
</div>
<div class="meeting-preview-pdf d-none">
<object data="" type="application/pdf" width="100%" height="500px"></object>
</div>
<div class="meeting-preview-fallback d-none text-center">
<p class="mb-2">Preview tidak tersedia untuk file ini.</p>
<button type="button" class="btn btn-primary meeting-preview-download-direct">Unduh</button>
</div>
</div>
</div>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More