diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index da97eefe..cc0288ff 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -26,20 +26,19 @@
"customizations": {
"vscode": {
"extensions": [
- "atommaterial.a-file-icon-vscode",
"GitHub.copilot",
"GitHub.copilot-chat",
+ "lyn-inc.HTML-Speed-Viewer",
"oderwat.indent-rainbow",
"laravel.vscode-laravel",
"open-southeners.laravel-pint",
"shd101wyy.markdown-preview-enhanced",
"unifiedjs.vscode-mdx",
- "mintlify.mintlify-snippets",
"Cardinal90.multi-cursor-case-preserve",
"bmewburn.vscode-intelephense-client",
+ "MehediDracula.php-namespace-resolver",
"fabiospampinato.vscode-todo-plus",
- "AntiAntiSepticeye.vscode-color-picker",
- "MehediDracula.php-namespace-resolver"
+ "AntiAntiSepticeye.vscode-color-picker"
]
}
}
diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml
index 501b71aa..ea3746c4 100644
--- a/.devcontainer/docker-compose.yml
+++ b/.devcontainer/docker-compose.yml
@@ -12,20 +12,27 @@ services:
depends_on:
- mariadb
- phpmyadmin
+ labels:
+ - dev.orbstack.domains=simpede.local
+
mariadb:
image: 'mariadb:10.11'
ports:
- 3306:3306
environment:
- MYSQL_ROOT_PASSWORD: 'root'
- MYSQL_ROOT_HOST: 'mariadb'
- MYSQL_DATABASE: 'simpede'
- MYSQL_USER: 'homestead'
- MYSQL_PASSWORD: 'secret'
- MYSQL_ALLOW_EMPTY_PASSWORD: 1
+ MYSQL_ROOT_PASSWORD: 'root'
+ MYSQL_ROOT_HOST: 'mariadb'
+ MYSQL_DATABASE: 'simpede'
+ MYSQL_USER: 'homestead'
+ MYSQL_PASSWORD: 'secret'
+ MYSQL_ALLOW_EMPTY_PASSWORD: 1
+
phpmyadmin:
- image: 'phpmyadmin:latest'
- ports:
- - 8080:80
- environment:
- - PMA_ARBITRARY=1
+ image: 'phpmyadmin:latest'
+ ports:
+ - 8080:80
+ environment:
+ - PMA_ARBITRARY=1
+ - UPLOAD_LIMIT=64M
+ labels:
+ - dev.orbstack.domains=phpmyadmin.local
diff --git a/.devcontainer/docker/app/Dockerfile b/.devcontainer/docker/app/Dockerfile
index a93ae85d..daea51db 100644
--- a/.devcontainer/docker/app/Dockerfile
+++ b/.devcontainer/docker/app/Dockerfile
@@ -1,5 +1,9 @@
FROM thecodingmachine/php:8.4-v4-apache-node22
ENV PHP_EXTENSIONS="mysqli gd pdo_mysql intl imagick imap ldap xdebug msgpack pcov bcmath"
+USER root
+RUN apt-get update && apt-get install -y mariadb-client && rm -rf /var/lib/apt/lists/*
+USER docker
+
WORKDIR /simpede
diff --git a/.devcontainer/setup.sh b/.devcontainer/setup.sh
index 6ba3e479..1e9ef643 100644
--- a/.devcontainer/setup.sh
+++ b/.devcontainer/setup.sh
@@ -5,7 +5,7 @@ cp .env.example .env
echo --- Update .env with desired values ...
# Define new values
-new_db_connection="DB_CONNECTION=mysql"
+new_db_connection="DB_CONNECTION=mariadb"
new_db_host="DB_HOST=mariadb"
new_db_port="DB_PORT=3306"
new_db_database="DB_DATABASE=simpede"
@@ -23,9 +23,6 @@ sed -i "s/^DB_USERNAME=.*/${new_db_username}/" .env
sed -i "s/^DB_PASSWORD=.*/${new_db_password}/" .env
echo "--- Install dependencies ..."
-composer install
-
-echo "--- Generate the application key ..."
-php artisan key:generate
+composer update
echo "--- SETUP DONE ---"
diff --git a/.env.example b/.env.example
index 56040573..c9d8d6ec 100644
--- a/.env.example
+++ b/.env.example
@@ -14,10 +14,10 @@ APP_MAINTENANCE_DRIVER=file
BCRYPT_ROUNDS=12
-LOG_CHANNEL="null"
+LOG_CHANNEL=database
LOG_STACK=single
-LOG_DEPRECATIONS_CHANNEL="null"
-LOG_LEVEL=debug
+LOG_DEPRECATIONS_CHANNEL=database
+LOG_LEVEL=warning
DB_CONNECTION=mariadb
DB_HOST=mysql
@@ -63,7 +63,7 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
-NOVA_INITIAL_YEAR=2024
+INITIAL_YEAR=2024
# CONFIG SATKER
SATKER_KAB="Kabupaten Hulu Sungai Tengah"
@@ -80,14 +80,6 @@ SATKER_REKENING="652074285781000"
FONNTE_TOKEN = "your_token"
FONNTE_NUMBER = "your_number"
-# SENTRY
-SENTRY_LARAVEL_DSN=https://examplePublicKey@o0.ingest.sentry.io/0
-SENTRY_ORGANIZATION_ID=exampleOrganizationId
-SENTRY_PROJECT_ID=exampleProjectId
-SENTRY_AUTH_TOKEN=exampleAuthToken
-SENTRY_TRACES_SAMPLE_RATE=1.0
-SENTRY_PROFILES_SAMPLE_RATE=1.0
-
## COMPOSER
COMPOSER = "composer"
COMPOSER_HOME = "../../.cache/composer"
@@ -98,3 +90,10 @@ DISK_INODE_LIMIT=400000
## APPLICATION UPDATE
AUTO_UPDATE=true
+
+## GOOGLE DRIVE
+GOOGLE_DRIVE_CLIENT_ID=
+GOOGLE_DRIVE_CLIENT_SECRET=
+GOOGLE_DRIVE_REFRESH_TOKEN=
+GOOGLE_DRIVE_FOLDER=
+BACKUP_ARCHIVE_PASSWORD=
diff --git a/README.md b/README.md
index cd01df65..f4d88300 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,4 @@
-
-
+
[](https://github.styleci.io/repos/840671846?branch=main)
@@ -20,6 +19,8 @@ Simpede adalah aplikasi untuk membantu ketatausahaan. Fitur:
- Kalender Kegiatan: Fitur yang menampilkan kalender kegiatan,deadline dan tanggal penting lainnya.
- Reminder: Fitur untuk mengirimkan reminder deadline kegiatan (Aktualisasi Latsar Ilman 'Mimin' Maulana)
- Pengelolaan SAKIP: fitur untuk pencatatan realisasi kinerja, kendala dan solusi, rencana dan pelaksanaan tindak lanjut dalam rangka pencapaian target kinerja.
+- Pulsa Kegiatan:Fitur yang disediakan ntuk membuat secara otomatis Tanda terima pulsa dan melakukan rekapitulasi bulanan Penggantian Pulsa yang diterima oleh Mitra
+- Digital Payment: Monitoring Penggunaan ATM dan KKP (Kartu Kredit Pemerintah)
## Requirement
Dibuat menggunakan Laravel 12 dan memerlukan ekstensi server berikut:
@@ -42,8 +43,12 @@ Dibuat menggunakan Laravel 12 dan memerlukan ekstensi server berikut:
Akun Fonnte
- Buat akun di website [fonnte.com](https://fonnte.com/)
-Akun Sentry
-- Buat akun di website [sentry.io](https://sentry.io/)
+Sediakan 1 akun Google Drive Baru untuk Backup Aplikasi dan buat folder Simpede
+
+Buat Google Drive API
+- [Getting your Client ID and Secret](https://github.com/laravelwebdev/laravel-google-drive-demo/blob/master/README/1-getting-your-dlient-id-and-secret.md)
+- [Getting your Refresh Token](https://github.com/laravelwebdev/laravel-google-drive-demo/blob/master/README/2-getting-your-refresh-token.md)
+
## Deployment
@@ -72,7 +77,7 @@ Rekomendasi shared hosting murah:
* `DB_PASSWORD`: Password database.
* `APP_ENV`: Set menjadi `production`.
* `APP_DEBUG`: Set menjadi `false`.
- * `LOG_CHANNEL`: set menjadi `"null"`
+ * `LOG_CHANNEL`: set menjadi `database`
* `INITIAL_YEAR`: set menjadi tahun pertama aplikasi digunakan
- Ubah seluruh setting di bagian `# CONFIG SATKER` pada file `.env` sesuai dengan satker Anda.
@@ -88,13 +93,13 @@ Aplikasi ini menggunakan Whatsapp API dari [Fonnte](https://fonnte.com) agar bis
```bash
https://domainanda/webhook.php (Sesuaikan dengan nama domain Anda)
```
-## Setup Sentry
-Aplikasi ini menggunakan website [Sentry](https://sentry.io/) sebagai sarana untuk memonitor error dan performa.
-Ubah setting Sentry pada file `.env`
-* `SENTRY_LARAVEL_DSN`: Isi DSN Sentry yang anda miliki.
-* `SENTRY_ORGANIZATION_ID`: Isi Sentry Organization id Anda
-* `SENTRY_PROJECT_ID`: Project id
-* `SENTRY_AUTH_TOKEN`: Auth token
+## Setup Google Drive
+Aplikasi ini menggunakan google drive untuk menyimpan backup harian.
+Ubah setting Google Drive pada file `.env`
+* `GOOGLE_DRIVE_CLIENT_ID` : isi dengan Client ID
+* `GOOGLE_DRIVE_CLIENT_SECRET` : Isi dengan Client Secret
+* `GOOGLE_DRIVE_REFRESH_TOKEN` : Isi dengan Refresh Token
+* `GOOGLE_DRIVE_FOLDER`: isi dengan folder google drive untuk menyimpan backup, pastikan di dalam nya dibuat folder Simpede
## Install
- Lakukan installasi aplikasi:
@@ -107,6 +112,10 @@ Setting Cron Job dengan pengaturan tiap jam untuk menjalankan perintah
```bash
php artisan schedule:run >> /dev/null 2>&1
```
+Setting Cron Job dengan pengaturan tiap 30 menit (atau disesuaikan) untuk menjalankan perintah
+```bash
+php artisan queue:work >> /dev/null 2>&1
+```
## Maintenance Mode
@@ -119,5 +128,9 @@ php artisan schedule:run >> /dev/null 2>&1
php artisan maintenance stop
```
-
+## Restore Backup
+Untuk melakukan restore jalankan command:
+```bash
+ php artisan simpede:restore
+```
diff --git a/app/Console/Commands/CleanTemp.php b/app/Console/Commands/CleanTemp.php
new file mode 100644
index 00000000..9d1d5e82
--- /dev/null
+++ b/app/Console/Commands/CleanTemp.php
@@ -0,0 +1,36 @@
+info("Temporary files in '{$folderPath}' cleaned successfully.");
+ }
+}
diff --git a/app/Console/Commands/ClearActionEventsLogs.php b/app/Console/Commands/ClearActionEventsLogs.php
index b699780e..c6525265 100644
--- a/app/Console/Commands/ClearActionEventsLogs.php
+++ b/app/Console/Commands/ClearActionEventsLogs.php
@@ -29,5 +29,7 @@ public function handle()
DB::table('action_events')
->where('status', 'finished')
->delete();
+
+ $this->info('Finished action events logs cleared successfully.');
}
}
diff --git a/app/Console/Commands/ClearErrorLog.php b/app/Console/Commands/ClearErrorLog.php
new file mode 100644
index 00000000..b7a8cd6f
--- /dev/null
+++ b/app/Console/Commands/ClearErrorLog.php
@@ -0,0 +1,32 @@
+delete();
+ $this->info('Error log cleared successfully.');
+ }
+}
diff --git a/app/Console/Commands/Install.php b/app/Console/Commands/Install.php
index 1ffd7387..60a19147 100644
--- a/app/Console/Commands/Install.php
+++ b/app/Console/Commands/Install.php
@@ -37,7 +37,8 @@ public function handle()
return;
}
$this->info('Memulai proses installasi');
- $this->call('key:generate');
+ $this->info('Setting Applications key');
+ $this->call('key:generate', ['--ansi' => true]);
$this->call('migrate:fresh');
$this->info('Membuat User Admin. Silakan Masukkan data Admin Sementara');
$this->call('nova:user');
@@ -63,12 +64,12 @@ public function handle()
[
'nama' => 'Template Kerangka Acuan Kerja',
'jenis' => 'kak',
- 'file' => 'ReAdPXzRYWqgpho3W0mX4U3rxg3UfZ3F4MmKlxsP.docx',
+ 'file' => 'kli5jt06bmTBJnLoQiQQN5md2iujNW317bGeQ46E.docx',
],
[
'nama' => 'Template SPJ',
'jenis' => 'spj',
- 'file' => 'd2z8X186YFymCM29dPa84LH7rTrqljrmfhmyc7C4.docx',
+ 'file' => '8UaqlK0dwmtx64Ih8scHARLKk9yXEtAsMsDhAyqT.docx',
],
[
'nama' => 'Template SK Petugas',
@@ -180,6 +181,11 @@ public function handle()
'jenis' => 'import',
'file' => '6xGSAprFh0YgkReR9xCUt9xvKKyXMzv1bQ83IGNy.xlsx',
],
+ [
+ 'nama' => 'Template Tanda Terima Pulsa',
+ 'jenis' => 'pulsa',
+ 'file' => 'AuX5vZ1DGxJvIJksXLGfXO35fBRFCR9BdAI1bZH5.docx',
+ ],
];
foreach ($templates as $template) {
diff --git a/app/Console/Commands/SendReminder.php b/app/Console/Commands/SendReminder.php
index 9cbaedaa..794eba2c 100644
--- a/app/Console/Commands/SendReminder.php
+++ b/app/Console/Commands/SendReminder.php
@@ -28,8 +28,17 @@ class SendReminder extends Command
public function handle()
{
$reminders = DaftarReminder::getRemindersForToday();
+ $result = [];
foreach ($reminders as $reminder) {
Helper::sendReminder($reminder);
}
+
+ if (! empty($result) && count(array_filter($result, function ($v) {
+ return $v !== true;
+ })) === 0) {
+ $this->info('Scheduled reminders sent successfully.');
+ } else {
+ $this->error('Failed to send some scheduled reminders.');
+ }
}
}
diff --git a/app/Console/Commands/SimpedeBackup.php b/app/Console/Commands/SimpedeBackup.php
new file mode 100644
index 00000000..a27e74cb
--- /dev/null
+++ b/app/Console/Commands/SimpedeBackup.php
@@ -0,0 +1,50 @@
+argument('action');
+
+ $backupDisks = config('backup.backup.destination.disks', []);
+
+ if ($action === 'create') {
+ $this->call('simpede:clean-temp');
+ $this->call('action-events:clear');
+ $this->call('backup:run');
+ $this->call('backup:clean');
+ foreach ($backupDisks as $disk) {
+ Storage::disk($disk)->getAdapter()->emptyTrash([]);
+ }
+ } elseif ($action === 'clean') {
+ $this->call('backup:clean');
+ foreach ($backupDisks as $disk) {
+ Storage::disk($disk)->getAdapter()->emptyTrash([]);
+ }
+ } else {
+ $this->error('Invalid action. Use "create" or "clean".');
+ }
+ }
+}
diff --git a/app/Console/Commands/SimpedeCache.php b/app/Console/Commands/SimpedeCache.php
index 2dac77ef..43315ebf 100644
--- a/app/Console/Commands/SimpedeCache.php
+++ b/app/Console/Commands/SimpedeCache.php
@@ -2,6 +2,7 @@
namespace App\Console\Commands;
+use App\Models\Announcement;
use App\Models\DataPegawai;
use App\Models\DerajatNaskah;
use App\Models\Dipa;
@@ -9,11 +10,13 @@
use App\Models\JenisBelanja;
use App\Models\JenisKontrak;
use App\Models\JenisNaskah;
+use App\Models\JenisPulsa;
use App\Models\KamusAnggaran;
use App\Models\KepkaMitra;
use App\Models\KodeArsip;
use App\Models\KodeBank;
use App\Models\KodeNaskah;
+use App\Models\LimitPulsa;
use App\Models\MasterBarangPemeliharaan;
use App\Models\MasterPersediaan;
use App\Models\MasterWilayah;
@@ -23,11 +26,14 @@
use App\Models\Pengelola;
use App\Models\RateTranslok;
use App\Models\SkTranslok;
+use App\Models\TargetKkp;
use App\Models\TargetSerapanAnggaran;
use App\Models\TataNaskah;
use App\Models\Template;
use App\Models\UnitKerja;
use App\Models\User;
+use App\Models\UserEksternal;
+use App\Models\WhatsappGroup;
use Illuminate\Console\Command;
class SimpedeCache extends Command
@@ -51,6 +57,7 @@ class SimpedeCache extends Command
*/
public function handle()
{
+ Announcement::cache()->updateAll();
DataPegawai::cache()->updateAll();
DerajatNaskah::cache()->updateAll();
Dipa::cache()->updateAll();
@@ -58,24 +65,31 @@ public function handle()
JenisBelanja::cache()->updateAll();
JenisKontrak::cache()->updateAll();
JenisNaskah::cache()->updateAll();
+ JenisPulsa::cache()->updateAll();
KamusAnggaran::cache()->updateAll();
KepkaMitra::cache()->updateAll();
KodeArsip::cache()->updateAll();
+ KodeBank::cache()->updateAll();
KodeNaskah::cache()->updateAll();
+ LimitPulsa::cache()->updateAll();
MasterBarangPemeliharaan::cache()->updateAll();
MasterPersediaan::cache()->updateAll();
+ MasterWilayah::cache()->updateAll();
MataAnggaran::cache()->updateAll();
Mitra::cache()->updateAll();
NaskahDefault::cache()->updateAll();
Pengelola::cache()->updateAll();
+ RateTranslok::cache()->updateAll();
+ SkTranslok::cache()->updateAll();
+ TargetKkp::cache()->updateAll();
TargetSerapanAnggaran::cache()->updateAll();
TataNaskah::cache()->updateAll();
Template::cache()->updateAll();
UnitKerja::cache()->updateAll();
User::cache()->updateAll();
- KodeBank::cache()->updateAll();
- MasterWilayah::cache()->updateAll();
- SkTranslok::cache()->updateAll();
- RateTranslok::cache()->updateAll();
+ UserEksternal::cache()->updateAll();
+ WhatsappGroup::cache()->updateAll();
+
+ $this->info('All cache updated successfully.');
}
}
diff --git a/app/Console/Commands/SimpedeRestore.php b/app/Console/Commands/SimpedeRestore.php
new file mode 100644
index 00000000..4597d3d4
--- /dev/null
+++ b/app/Console/Commands/SimpedeRestore.php
@@ -0,0 +1,714 @@
+option('list')) {
+ return $this->listBackups();
+ }
+
+ $this->info('🔄 Complete Backup Restore Tool');
+ $this->line('');
+
+ if (! $this->option('force')) {
+ $this->warn('⚠️ WARNING: This will restore your database AND files from backup!');
+ $this->warn('⚠️ This operation will overwrite your current data and files.');
+ $this->line('');
+
+ if (! $this->confirm('Are you sure you want to continue?')) {
+ $this->info('❌ Restore operation cancelled.');
+
+ return 1;
+ }
+ }
+
+ $disk = $this->option('disk') ?? config('backup.backup.destination.disks')[0];
+ $backup = $this->option('backup');
+ $tempDir = null;
+ $success = true;
+ $this->call('maintenance', ['action' => 'start']);
+ try {
+ $backupFile = $this->findBackupFile($disk, $backup);
+ if (! $backupFile) {
+ $this->error('❌ Backup file not found!');
+
+ return 1;
+ }
+
+ $this->info('📁 Using backup: '.basename($backupFile));
+ $this->line('');
+
+ $tempDir = $this->extractBackup($disk, $backupFile);
+ if (! $tempDir) {
+ $this->error('❌ Failed to extract backup!');
+
+ return 1;
+ }
+
+ // Check database dumps
+ $sqlFiles = File::glob($tempDir.'/db-dumps/*.sql') ?: [];
+ $hasDatabase = ! empty($sqlFiles);
+
+ // Restore database
+ if ($hasDatabase && ! $this->option('only-files')) {
+ $this->info('🗄️ Restoring database...');
+ $success = $this->restoreDatabase($tempDir) && $success;
+ } elseif (! $hasDatabase) {
+ $this->info('ℹ️ No database found in backup.');
+ $success = false;
+ } elseif ($this->option('only-files')) {
+ $this->info('ℹ️ Skipping database restore (files-only mode).');
+ }
+
+ // Restore files
+ $hasOtherFolders = collect(File::directories($tempDir))
+ ->contains(fn ($dir) => basename($dir) !== 'db-dumps');
+
+ if ($hasOtherFolders && ! $this->option('only-db')) {
+ $this->info('📁 Restoring files...');
+ $success = $this->restoreFiles($tempDir) && $success;
+ } elseif ($this->option('only-db')) {
+ $this->info('ℹ️ Skipping file restore (database-only mode).');
+ } elseif (! $hasOtherFolders) {
+ $this->info('ℹ️ No files found in backup.');
+ }
+
+ if ($success) {
+ $this->info('');
+ $this->info('✅ Complete restore finished successfully!');
+ $this->info('🎉 Your application is ready to use!');
+ $this->line('');
+ $this->info('💡 Recommended next steps:');
+ $this->line(' • Check file permissions');
+ $this->line(' • Test critical functionality');
+ } else {
+ $this->error('❌ Restore failed!');
+ }
+ } catch (Exception $e) {
+ $this->error('❌ Restore failed with error: '.$e->getMessage());
+ $success = false;
+ } finally {
+ if ($tempDir) {
+ $this->cleanup($tempDir);
+ }
+ $this->call('simpede:update');
+ $this->call('maintenance', ['action' => 'stop']);
+ }
+
+ return $success ? 0 : 1;
+ }
+
+ private function findBackupFile($disk, $backup = null)
+ {
+ $backupName = config('backup.backup.name');
+ $backupPath = $backupName;
+
+ if ($backup) {
+ // Specific backup file - check if it's already a full path or just filename
+ if (str_contains($backup, '/')) {
+ // Full path provided
+ $path = $backup;
+ } else {
+ // Just filename, construct path in backup directory
+ $path = "{$backupPath}/{$backup}";
+ }
+
+ if (Storage::disk($disk)->exists($path)) {
+ return $path;
+ }
+
+ // If not found, try looking for the file directly in the backup directory
+ // This handles cases where the backup name might be different
+ $files = Storage::disk($disk)->files($backupPath);
+ foreach ($files as $file) {
+ if (basename($file) === $backup) {
+ return $file;
+ }
+ }
+
+ return null;
+ }
+
+ // Find latest backup
+ if (! Storage::disk($disk)->exists($backupPath)) {
+ return null;
+ }
+
+ $files = Storage::disk($disk)->files($backupPath);
+ $backups = array_filter($files, fn ($file) => str_ends_with($file, '.zip'));
+
+ if (empty($backups)) {
+ return null;
+ }
+
+ // Sort by date (newest first)
+ usort($backups, fn ($a, $b) => Storage::disk($disk)->lastModified($b) - Storage::disk($disk)->lastModified($a));
+
+ return $backups[0];
+ }
+
+ private function extractBackup($disk, $backupFile)
+ {
+ $tempBase = storage_path('app/temp-restore');
+ $tempDir = $tempBase.'-'.time();
+ File::makeDirectory($tempDir, 0755, true);
+
+ // Download backup file to temp location
+ $localBackupPath = $tempDir.'/backup.zip';
+ $backupContent = Storage::disk($disk)->get($backupFile);
+ File::put($localBackupPath, $backupContent);
+
+ // Extract ZIP file
+ $zip = new ZipArchive;
+ if ($zip->open($localBackupPath) !== true) {
+ $this->error('❌ Failed to open backup ZIP file');
+ File::deleteDirectory($tempDir);
+
+ return null;
+ }
+
+ // Check if backup requires password
+ $password = env('BACKUP_ARCHIVE_PASSWORD');
+ if (! $password) {
+ // Try alternative methods to get the password
+ $password = config('backup.backup.password');
+ }
+ if (! $password) {
+ // Try reading directly from .env file
+ $envPath = base_path('.env');
+ if (file_exists($envPath)) {
+ $envContent = file_get_contents($envPath);
+ if (preg_match('/BACKUP_ARCHIVE_PASSWORD=(.+)/', $envContent, $matches)) {
+ $password = trim($matches[1]);
+ // Remove quotes if present
+ $password = trim($password, '"\'');
+ }
+ }
+ }
+
+ if ($password) {
+ $zip->setPassword($password);
+ $this->info('🔐 Using configured backup password');
+ } else {
+ $this->warn('⚠️ No backup password found - trying without password');
+ }
+
+ if ($zip->extractTo($tempDir) === true) {
+ $zip->close();
+
+ // Remove the zip file
+ File::delete($localBackupPath);
+
+ $this->info('✅ Backup extracted successfully');
+
+ // Debug: Show what's in the extracted backup
+ $this->info('📁 Backup contents:');
+ $this->listBackupContents($tempDir);
+
+ return $tempDir;
+ } else {
+ $zip->close();
+ $this->error('❌ Failed to extract backup ZIP file (check password if encrypted)');
+ File::deleteDirectory($tempDir);
+
+ return null;
+ }
+ }
+
+ private function restoreDatabase($tempDir)
+ {
+ try {
+ // Find the database dump file
+ $dbFiles = File::glob($tempDir.'/db-dumps/*.sql');
+ $dbFile = $dbFiles[0];
+ $this->info('🗄️ Found database dump: '.basename($dbFile));
+
+ $this->warn('🗑️ Dropping all existing tables...');
+ $this->dropAllTables();
+
+ // Restore the database
+ $this->info('🚀 Restoring database from dump...');
+ $connection = $this->option('connection') ?: config('database.default');
+
+ if ($this->importDatabaseDump($dbFile, $connection)) {
+ $this->info('✅ Database restored successfully');
+
+ return true;
+ } else {
+ $this->error('❌ Database restore failed');
+
+ return false;
+ }
+ } catch (Exception $e) {
+ $this->error('❌ Database restore error: '.$e->getMessage());
+
+ return false;
+ }
+ }
+
+ private function restoreFiles($tempDir)
+ {
+ $restored = 0;
+ $failed = 0;
+
+ try {
+ // Ambil mapping folder source => target dari config
+ $folders = config('backup.backup.source.files.include'); // array: 'folder' => targetPath
+
+ if (empty($folders)) {
+ $this->error('❌ No folders configured to restore');
+
+ return false;
+ }
+
+ // Cari semua folder di backup
+ $backupPaths = $this->findBackupPaths($tempDir);
+
+ foreach ($folders as $folder => $targetPath) {
+ // Cek apakah folder ada di backup
+ $sourcePath = $backupPaths[$folder] ?? null;
+ if (! $sourcePath) {
+ $this->warn("⚠️ Folder {$folder} not found in backup");
+ $failed++;
+
+ continue;
+ }
+
+ $this->info("🔄 Restoring {$folder} to {$targetPath}");
+ if ($this->restoreDirectory($sourcePath, $targetPath)) {
+ $this->info("✅ Restored {$folder}");
+ $restored++;
+ } else {
+ $this->warn("⚠️ Failed to restore {$folder}");
+ $failed++;
+ }
+ }
+
+ if ($restored > 0) {
+ $this->info('🔧 Fixing file permissions...');
+ $this->fixPermissions();
+ }
+
+ $this->info("📊 File restoration completed: {$restored} successful, {$failed} failed");
+
+ return $failed === 0;
+ } catch (Exception $e) {
+ $this->error('❌ File restoration failed with error: '.$e->getMessage());
+
+ return false;
+ }
+ }
+
+ private function findBackupPaths($tempDir)
+ {
+ $paths = [];
+
+ // Ambil daftar folder/file dari config
+ $includes = config('backup.backup.source.files.include', []);
+
+ foreach ($includes as $relative) {
+ $possiblePath = $tempDir.'/'.ltrim($relative, '/');
+
+ if (File::exists($possiblePath) && File::isDirectory($possiblePath)) {
+ $this->info('✅ Found directory: '.basename($possiblePath));
+ $paths[] = $possiblePath;
+ } else {
+ // Kalau tidak ada di lokasi umum, cari rekursif
+ $found = $this->findDirectoryRecursively($tempDir, basename($relative));
+ if ($found) {
+ $this->info('✅ Found directory recursively: '.basename($found));
+ $paths[] = $found;
+ } else {
+ $this->warn("⚠️ Directory not found: {$relative}");
+ }
+ }
+ }
+
+ return $paths;
+ }
+
+ private function findDirectoryRecursively($dir, $targetDir)
+ {
+ $items = File::directories($dir);
+
+ foreach ($items as $item) {
+ if (basename($item) === $targetDir) {
+ return $item;
+ }
+
+ $found = $this->findDirectoryRecursively($item, $targetDir);
+ if ($found) {
+ return $found;
+ }
+ }
+
+ return null;
+ }
+
+ private function restoreDirectory($source, $destination)
+ {
+ try {
+ // Create destination directory if it doesn't exist
+ if (! File::exists($destination)) {
+ File::makeDirectory($destination, 0755, true);
+ }
+
+ // Copy files recursively, merging with existing content
+ $this->copyDirectoryContents($source, $destination);
+
+ return true;
+ } catch (Exception $e) {
+ $this->error("Error restoring {$destination}: ".$e->getMessage());
+
+ return false;
+ }
+ }
+
+ private function copyDirectoryContents($source, $destination)
+ {
+ $iterator = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($source, \RecursiveDirectoryIterator::SKIP_DOTS),
+ \RecursiveIteratorIterator::SELF_FIRST
+ );
+
+ foreach ($iterator as $item) {
+ $target = $destination.DIRECTORY_SEPARATOR.$iterator->getSubPathName();
+
+ if ($item->isDir()) {
+ if (! File::exists($target)) {
+ File::makeDirectory($target, 0755, true);
+ }
+ } else {
+ // Create parent directory if it doesn't exist
+ $targetDir = dirname($target);
+ if (! File::exists($targetDir)) {
+ File::makeDirectory($targetDir, 0755, true);
+ }
+
+ // Copy the file
+ File::copy($item->getPathname(), $target);
+ }
+ }
+ }
+
+ private function fixPermissions()
+ {
+ try {
+ $dirPerms = 0755;
+ $filePerms = 0644;
+
+ // Fix permissions for web-accessible directories
+ $webDirs = ['public/uploads', 'public/download'];
+ foreach ($webDirs as $relativeDir) {
+ $dir = base_path($relativeDir);
+ if (File::exists($dir)) {
+ chmod($dir, $dirPerms);
+ $this->setDirectoryPermissions($dir, $dirPerms, $filePerms);
+ }
+ }
+
+ // Fix permissions for storage directories
+ $storageDirs = ['storage/app', 'storage/plugins'];
+ foreach ($storageDirs as $relativeDir) {
+ $dir = base_path($relativeDir);
+ if (File::exists($dir)) {
+ chmod($dir, $dirPerms);
+ $this->setDirectoryPermissions($dir, $dirPerms, $filePerms);
+ }
+ }
+
+ $this->info('✅ File permissions updated');
+ } catch (Exception $e) {
+ $this->warn('⚠️ Could not fix all permissions: '.$e->getMessage());
+ }
+ }
+
+ private function setDirectoryPermissions($directory, $dirPerms, $filePerms)
+ {
+ $iterator = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($directory, \RecursiveDirectoryIterator::SKIP_DOTS)
+ );
+
+ foreach ($iterator as $item) {
+ if ($item->isDir()) {
+ chmod($item->getPathname(), $dirPerms);
+ } else {
+ chmod($item->getPathname(), $filePerms);
+ }
+ }
+ }
+
+ private function listBackups()
+ {
+ $this->info('📋 Available Backups');
+ $this->line('');
+
+ $disks = config('backup.backup.destination.disks');
+ $backupName = config('backup.backup.name');
+
+ foreach ($disks as $disk) {
+ $this->info("💾 Disk: {$disk}");
+
+ try {
+ if (! Storage::disk($disk)->exists($backupName)) {
+ $this->line(' No backups found');
+
+ continue;
+ }
+
+ $files = Storage::disk($disk)->files($backupName);
+ $backups = array_filter($files, fn ($file) => str_ends_with($file, '.zip'));
+
+ if (empty($backups)) {
+ $this->line(' No backup files found');
+
+ continue;
+ }
+
+ // Sort by date (newest first)
+ usort($backups, fn ($a, $b) => Storage::disk($disk)->lastModified($b) - Storage::disk($disk)->lastModified($a));
+
+ foreach (array_slice($backups, 0, 10) as $backup) {
+ $size = $this->formatBytes(Storage::disk($disk)->size($backup));
+ $date = date('Y-m-d H:i:s', Storage::disk($disk)->lastModified($backup));
+ $filename = basename($backup);
+ $this->line(" 📁 {$filename} ({$size}) - {$date}");
+ }
+
+ if (count($backups) > 10) {
+ $this->line(' ... and '.(count($backups) - 10).' more backups');
+ }
+ } catch (Exception $e) {
+ $this->line(' Error accessing disk: '.$e->getMessage());
+ }
+
+ $this->line('');
+ }
+
+ $this->info('💡 To restore a specific backup:');
+ $this->line(' php artisan simpede:restore --backup="filename.zip"');
+
+ return 0;
+ }
+
+ private function cleanup($tempDir)
+ {
+ if (File::exists($tempDir)) {
+ File::deleteDirectory($tempDir);
+ // Ensure directory is removed if still exists (sometimes File::deleteDirectory leaves empty folder)
+ if (is_dir($tempDir)) {
+ rmdir($tempDir);
+ }
+ $this->info('🧹 Cleaned up temporary files');
+ }
+ }
+
+ private function formatBytes($bytes, $precision = 2)
+ {
+ $units = ['B', 'KB', 'MB', 'GB', 'TB'];
+
+ for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) {
+ $bytes /= 1024;
+ }
+
+ return round($bytes, $precision).' '.$units[$i];
+ }
+
+ private function dropAllTables()
+ {
+ try {
+ $connection = $this->option('connection') ?: config('database.default');
+ $db = DB::connection($connection);
+
+ // Get all table names
+ $tables = $db->select('SHOW TABLES');
+ $tableNames = array_map(function ($table) {
+ return array_values((array) $table)[0];
+ }, $tables);
+
+ if (empty($tableNames)) {
+ $this->info('ℹ️ No tables to drop');
+
+ return true;
+ }
+
+ // Disable foreign key checks
+ $db->statement('SET FOREIGN_KEY_CHECKS = 0');
+
+ foreach ($tableNames as $table) {
+ $this->line("🗑️ Dropping table: {$table}");
+ $db->statement("DROP TABLE IF EXISTS `{$table}`");
+ }
+
+ // Re-enable foreign key checks
+ $db->statement('SET FOREIGN_KEY_CHECKS = 1');
+
+ $this->info('✅ All tables dropped successfully');
+
+ return true;
+ } catch (Exception $e) {
+ $this->error('❌ Failed to drop tables: '.$e->getMessage());
+
+ return false;
+ }
+ }
+
+ private function listBackupContents($tempDir, $maxDepth = 3)
+ {
+ $this->listDirectoryContents($tempDir, '', $maxDepth);
+ }
+
+ private function listDirectoryContents($dir, $prefix = '', $maxDepth = 3, $currentDepth = 0)
+ {
+ if ($currentDepth >= $maxDepth) {
+ $this->line($prefix.'└── ... (max depth reached)');
+
+ return;
+ }
+
+ $items = File::files($dir);
+ $directories = File::directories($dir);
+
+ $allItems = array_merge($directories, $items);
+
+ foreach ($allItems as $index => $item) {
+ $isLast = ($index === count($allItems) - 1);
+ $symbol = $isLast ? '└── ' : '├── ';
+ $name = basename($item);
+
+ if (File::isDirectory($item)) {
+ $this->line($prefix.$symbol.$name.'/');
+ if ($currentDepth < $maxDepth - 1) {
+ $this->listDirectoryContents($item, $prefix.($isLast ? ' ' : '│ '), $maxDepth, $currentDepth + 1);
+ }
+ } else {
+ $size = $this->formatBytes(File::size($item));
+ $this->line($prefix.$symbol.$name.' ('.$size.')');
+ }
+ }
+ }
+
+ private function importDatabaseDump($dumpFile, $connection)
+ {
+ try {
+ if (! File::exists($dumpFile)) {
+ echo "❌ Dump file not found: $dumpFile\n";
+
+ return false;
+ }
+ $config = Config::get("database.connections.$connection");
+
+ $username = $config['username'] ?? null;
+ $password = $config['password'] ?? null;
+ $database = $config['database'] ?? null;
+ $host = $config['host'] ?? '127.0.0.1';
+ $port = $config['port'] ?? null;
+ file_put_contents(
+ $dumpFile,
+ preg_replace('/M!999999\\\\- /', 'M!999999 ', file_get_contents($dumpFile))
+ );
+
+ switch ($connection) {
+ case 'mysql':
+ case 'mariadb':
+ $bin = $connection === 'mariadb' ? 'mariadb' : 'mysql';
+ $passwordOption = $password ? "-p$password" : '';
+ $command = sprintf(
+ '%s -u%s %s -h%s -P%s %s < %s',
+ $bin,
+ $username,
+ $passwordOption,
+ $host,
+ $port ?: 3306,
+ $database,
+ escapeshellarg($dumpFile)
+ );
+ break;
+
+ case 'pgsql':
+ case 'postgresql':
+ $envPassword = $password ? "PGPASSWORD=\"$password\" " : '';
+ $command = sprintf(
+ '%spsql -U %s -h %s -p %s -d %s -f %s',
+ $envPassword,
+ $username,
+ $host,
+ $port ?: 5432,
+ $database,
+ escapeshellarg($dumpFile)
+ );
+ break;
+
+ case 'mongodb':
+ $auth = $username ? "--username=$username --password=$password" : '';
+ $port = $port ?: 27017;
+ $command = sprintf(
+ 'mongorestore --host %s --port %s %s --db %s %s --drop',
+ $host,
+ $port,
+ $auth,
+ $database,
+ escapeshellarg($dumpFile)
+ );
+ break;
+
+ default:
+ echo "❌ Driver [$connection] is not supported for restore.\n";
+
+ return false;
+ }
+
+ echo "▶ Running command: $command\n";
+ exec($command, $output, $resultCode);
+
+ if ($resultCode === 0) {
+ echo "✅ Database successfully restored from $dumpFile\n";
+
+ return true;
+ }
+
+ echo "❌ Restore failed. Output:\n".implode("\n", $output)."\n";
+
+ return false;
+ } catch (\Throwable $e) {
+ echo '⚠️ Error occurred: '.$e->getMessage()."\n";
+
+ return false;
+ }
+ }
+}
diff --git a/app/Console/Commands/Update.php b/app/Console/Commands/Update.php
index a6184821..48e6b8b3 100644
--- a/app/Console/Commands/Update.php
+++ b/app/Console/Commands/Update.php
@@ -2,8 +2,8 @@
namespace App\Console\Commands;
+use App\Helpers\SimpedeUpdater;
use Illuminate\Console\Command;
-use Symfony\Component\Process\Process;
class Update extends Command
{
@@ -26,32 +26,20 @@ class Update extends Command
*/
public function handle()
{
- $error = false;
- try {
- $this->call('maintenance', ['action' => 'start']);
- $process = new Process(['git', 'pull', 'origin', 'main']);
- $process->run();
- if (! $process->isSuccessful()) {
- $error = true;
- }
+ $this->info('Melakukan Pembaharuan Alikasi... Silakan Tunggu');
+ $messages = SimpedeUpdater::getOutput($this->option('dev'));
+ $status = null;
+ foreach ($messages as $key => $message) {
+ if ($key === 'success') {
+ $status = $message;
- $composer = config('app.composer');
- $home = config('app.composer_home');
- $devFlag = $this->option('dev') ? '' : '--no-dev';
- $process = Process::fromShellCommandline("$composer update $devFlag", base_path(), ['COMPOSER_HOME' => $home]);
- $process->run();
- if (! $process->isSuccessful()) {
- $error = true;
+ continue;
}
-
- $process = Process::fromShellCommandline("$composer clear-cache", base_path(), ['COMPOSER_HOME' => $home]);
- $process->run();
- if (! $process->isSuccessful()) {
- $error = true;
+ if ($message !== null && $message !== '' && $key !== 'success') {
+ $this->line($message);
}
- } finally {
- $error ? $this->error('Update Gagal!') : $this->info('Update Sukses! ');
- $this->call('maintenance', ['action' => 'stop']);
}
+ $status ? $this->info('UPDATE SUKSES.') :
+ $this->error('UPDATE GAGAL.');
}
}
diff --git a/app/Helpers/Api.php b/app/Helpers/Api.php
index 2f82002e..aa93a2e6 100644
--- a/app/Helpers/Api.php
+++ b/app/Helpers/Api.php
@@ -2,39 +2,11 @@
namespace App\Helpers;
-use GuzzleHttp\Client;
+use Illuminate\Support\Facades\Storage;
use Symfony\Component\Process\Process;
class Api
{
- /**
- * Get unresolved issues from Sentry.
- *
- * @return array
- */
- public static function getSentryUnresolvedIssues()
- {
- $organization = config('app.sentry_organization');
- $project = config('app.sentry_project');
- $token = config('app.sentry_token');
-
- $client = new Client;
- try {
- $response = $client->request('GET', 'https://sentry.io/api/0/projects/'.$organization.'/'.$project.'/issues/', [
- 'headers' => [
- 'Authorization' => 'Bearer '.$token,
- ],
- 'query' => [
- 'query' => 'is:unresolved',
- ],
- ]);
-
- return json_decode($response->getBody()->getContents(), true);
- } catch (\Exception $e) {
- return [];
- }
- }
-
/**
* Get outdated packages from Composer.
*
@@ -54,4 +26,31 @@ public static function getComposerOutdatedPackages($flag = '--no-dev')
return $data['installed'] ?? [];
}
+
+ public static function getGoogleDriveDownloadLink($path)
+ {
+ $backupPath = config('backup.backup.name')."/{$path}";
+ $fileId = self::getFileIdFromPath($backupPath);
+
+ return "https://drive.google.com/uc?export=download&id={$fileId}";
+ }
+
+ private static function getFileIdFromPath(string $path): ?string
+ {
+ $adapter = Storage::disk('google')->getAdapter();
+ $service = $adapter->getService();
+
+ $filename = basename($path);
+
+ $results = $service->files->listFiles([
+ 'q' => "name = '{$filename}' and trashed = false",
+ 'fields' => 'files(id, name)',
+ ]);
+
+ foreach ($results->getFiles() as $file) {
+ return $file->id;
+ }
+
+ return null;
+ }
}
diff --git a/app/Helpers/Cetak.php b/app/Helpers/Cetak.php
index 8dce81cc..9b09992b 100644
--- a/app/Helpers/Cetak.php
+++ b/app/Helpers/Cetak.php
@@ -9,6 +9,7 @@
use App\Models\DaftarPemeliharaan;
use App\Models\DaftarPenilaianReward;
use App\Models\DaftarPesertaPerjalanan;
+use App\Models\DaftarPulsaMitra;
use App\Models\Dipa;
use App\Models\HonorKegiatan;
use App\Models\KerangkaAcuan;
@@ -19,6 +20,7 @@
use App\Models\PembelianPersediaan;
use App\Models\PerjalananDinas;
use App\Models\PermintaanPersediaan;
+use App\Models\PulsaKegiatan;
use App\Models\RapatInternal;
use App\Models\RewardPegawai;
use App\Models\SpesifikasiKerangkaAcuan;
@@ -85,9 +87,9 @@ public static function getTemplate(string $jenis, $id, $template_id, $tanggal, $
Settings::setOutputEscapingEnabled(true);
$templateProcessor = new TemplateProcessor(Helper::getTemplatePathById($template_id)['path']);
if ($tanggal || $pengelola) {
- $data = call_user_func('App\Helpers\Cetak::'.$jenis, $id, $tanggal, $pengelola);
+ $data = call_user_func([self::class, $jenis], $id, $tanggal, $pengelola);
} else {
- $data = call_user_func('App\Helpers\Cetak::'.$jenis, $id);
+ $data = call_user_func([self::class, $jenis], $id);
}
if ($jenis === 'kak') {
$templateProcessor->cloneRowAndSetValues('anggaran_no', Helper::formatAnggaran($data['anggaran']));
@@ -174,7 +176,37 @@ public static function getTemplate(string $jenis, $id, $template_id, $tanggal, $
unset($data['daftar_honor_mitra']);
HonorKegiatan::where('id', $id)->update(['status' => 'dicetak']);
}
+ if ($jenis === 'pulsa') {
+ $templateProcessor->cloneRowAndSetValues('spj_no', $data['daftar_pulsa_mitra']);
+ $detailAnggarans = ['kegiatan', 'kro', 'ro', 'komponen', 'sub', 'akun', 'detail'];
+ foreach ($detailAnggarans as $detailAnggaran) {
+ if (Str::of($data[$detailAnggaran])->contains('edit manual karena belum ada di POK')) {
+ $detail = new TextRun;
+ $detail->addText(Str::of($data[$detailAnggaran])->before('edit manual karena belum ada di POK'));
+ $detail->addText('edit manual karena belum ada di POK', ['color' => 'red']);
+ $templateProcessor->setComplexValue($detailAnggaran, $detail);
+ unset($data[$detailAnggaran]);
+ }
+ }
+ $dummies = $data['daftar_pulsa_mitra'];
+ unset($data['daftar_pulsa_mitra']);
+ PulsaKegiatan::where('id', $id)->update(['status' => 'selesai']);
+ }
$templateProcessor->setValues($data);
+ if ($jenis === 'pulsa') {
+ foreach ($dummies as $dummy) {
+ $templateProcessor->setImageValue(
+ $dummy['nik'],
+ [
+ 'path' => Storage::disk('pulsa')->path($dummy['bukti']),
+ 'width' => '',
+ 'height' => '6.5cm',
+ 'ratio' => true,
+ ]
+ );
+ }
+ unset($dummies);
+ }
return $templateProcessor;
}
@@ -503,6 +535,44 @@ public static function spj($id)
];
}
+ public static function pulsa($id)
+ {
+ $data = PulsaKegiatan::find($id);
+ $mataanggaran = Helper::getMataAnggaranById($data->mata_anggaran_id);
+ $mak = optional($mataanggaran)->mak;
+ $koordinator = Helper::getPegawaiByUserId($data->koordinator_user_id);
+ $ppk = Helper::getPegawaiByUserId($data->ppk_user_id);
+ $harga = DaftarPulsaMitra::where('pulsa_kegiatan_id', $id)->sum('harga');
+
+ return [
+ 'kabupaten' => config('satker.kabupaten'),
+ 'u_kabupaten' => strtoupper(config('satker.kabupaten')),
+ 'alamat_satker' => config('satker.alamat'),
+ 'telepon_satker' => config('satker.telepon'),
+ 'website' => config('satker.website'),
+ 'email' => config('satker.email'),
+ 'tanggal_spj' => Helper::terbilangTanggal($data->tanggal),
+ 'ibukota' => config('satker.ibukota'),
+ 'nama_kegiatan' => $data->kegiatan,
+ 'detail' => optional($mataanggaran)->uraian,
+ 'bulan' => Helper::terbilangBulan($data->bulan),
+ 'mak' => $mak,
+ 'kegiatan' => Helper::getDetailAnggaran($mak, 'kegiatan'),
+ 'kro' => Helper::getDetailAnggaran($mak, 'kro'),
+ 'ro' => Helper::getDetailAnggaran($mak, 'ro'),
+ 'komponen' => Helper::getDetailAnggaran($mak, 'komponen'),
+ 'sub' => Helper::getDetailAnggaran($mak, 'sub'),
+ 'akun' => Helper::getDetailAnggaran($mak, 'akun'),
+ 'daftar_pulsa_mitra' => Helper::makeSpjPulsaMitra($id),
+ 'ketua' => optional($koordinator)->name,
+ 'nipketua' => optional($koordinator)->nip,
+ 'ppk' => optional($ppk)->name,
+ 'nipppk' => optional($ppk)->nip,
+ 'total_harga' => Helper::formatUang($harga),
+ 'terbilang_total' => Helper::terbilang($harga, 'uw', ' rupiah'),
+ ];
+ }
+
/**
* Format the values for the assignment letter.
*
@@ -938,6 +1008,24 @@ public static function validate($jenis, $model_id)
return 'Masih ada rekening yang kosong pada daftar SPJ ini.';
}
}
+ if ($jenis === 'pulsa') {
+ $pulsa = PulsaKegiatan::where('id', $model_id)->first();
+ if (is_null($pulsa->tanggal)) {
+ return 'Mohon lengkapi seluruh isian pada daftar pulsa ini sebelum mencetak!';
+ }
+ $notConfirmed = DaftarPulsaMitra::where('pulsa_kegiatan_id', $pulsa->id)
+ ->where('confirmed', false)
+ ->count();
+ $notUploaded = DaftarPulsaMitra::where('pulsa_kegiatan_id', $pulsa->id)
+ ->whereNull('file')
+ ->count();
+ if ($notConfirmed > 0) {
+ return 'Masih ada data nomor handphone yang belum dikonfirmasi pada daftar ini.';
+ }
+ if ($notUploaded > 0) {
+ return 'Masih ada bukti pulsa masuk yang belum diunggah pada daftar ini.';
+ }
+ }
if ($jenis === 'st') {
$honor = HonorKegiatan::where('id', $model_id)->first();
if (Helper::makeBaseListMitraAndPegawai($honor->id, $honor->tanggal_spj)->count() == 0) {
diff --git a/app/Helpers/Fonnte.php b/app/Helpers/Fonnte.php
index 80ea56a9..4ee12a81 100644
--- a/app/Helpers/Fonnte.php
+++ b/app/Helpers/Fonnte.php
@@ -35,28 +35,28 @@ public function __construct()
*/
protected function makeRequest($endpoint, $params = [])
{
- $token = $this->account_token;
-
- if (! $token) {
- return ['status' => false, 'error' => 'API token or device token is required.'];
+ if (! $this->account_token) {
+ return ['status' => false, 'error' => 'API token is required.'];
}
- // Gunakan JSON format dan pastikan Content-Type header benar
$response = Http::withHeaders([
- 'Authorization' => $token,
- 'Content-Type' => 'application/json', // Tambahkan header
+ 'Authorization' => $this->account_token,
+ 'Content-Type' => 'application/json',
])->post($endpoint, $params);
- if ($response->failed()) {
+ $body = $response->json();
+
+ if (! ($body['status'] ?? false)) {
return [
'status' => false,
- 'error' => $response->json()['reason'] ?? 'Unknown error occurred',
+ 'error' => $body['reason'] ?? 'Unknown error',
+ 'data' => $body,
];
}
return [
'status' => true,
- 'data' => $response->json(),
+ 'data' => $body,
];
}
diff --git a/app/Helpers/GoogleDriveQuota.php b/app/Helpers/GoogleDriveQuota.php
new file mode 100644
index 00000000..3e32b5e0
--- /dev/null
+++ b/app/Helpers/GoogleDriveQuota.php
@@ -0,0 +1,45 @@
+post('https://oauth2.googleapis.com/token', [
+ 'client_id' => $clientId,
+ 'client_secret' => $clientSecret,
+ 'refresh_token' => $refreshToken,
+ 'grant_type' => 'refresh_token',
+ ]);
+
+ if ($response->ok()) {
+ $accessToken = $response->json()['access_token'] ?? null;
+ if ($accessToken) {
+ $about = Http::withToken($accessToken)
+ ->get('https://www.googleapis.com/drive/v3/about?fields=storageQuota')
+ ->json();
+
+ $limit = $about['storageQuota']['limit'] ?? 0;
+ $usage = $about['storageQuota']['usage'] ?? 0;
+
+ $total = $limit ? round($limit / (1024 ** 3), 2) : 0;
+ $used = $usage ? round($usage / (1024 ** 3), 2) : 0;
+ }
+ }
+
+ return [
+ 'used' => $used,
+ 'total' => $total,
+ ];
+ }
+}
diff --git a/app/Helpers/Helper.php b/app/Helpers/Helper.php
index 04a53b2f..66588792 100644
--- a/app/Helpers/Helper.php
+++ b/app/Helpers/Helper.php
@@ -5,6 +5,7 @@
use App\Models\DaftarHonorMitra;
use App\Models\DaftarHonorPegawai;
use App\Models\DaftarKegiatan;
+use App\Models\DaftarPulsaMitra;
use App\Models\DataPegawai;
use App\Models\DerajatNaskah;
use App\Models\Dipa;
@@ -12,12 +13,14 @@
use App\Models\HonorKegiatan;
use App\Models\JenisKontrak;
use App\Models\JenisNaskah;
+use App\Models\JenisPulsa;
use App\Models\KamusAnggaran;
use App\Models\KepkaMitra;
use App\Models\KerangkaAcuan;
use App\Models\KodeArsip;
use App\Models\KodeBank;
use App\Models\KodeNaskah;
+use App\Models\LimitPulsa;
use App\Models\MasterPersediaan;
use App\Models\MasterWilayah;
use App\Models\MataAnggaran;
@@ -26,9 +29,11 @@
use App\Models\Pengelola;
use App\Models\TataNaskah;
use App\Models\Template;
+use App\Models\UangPersediaan;
use App\Models\UnitKerja;
use App\Models\User;
use App\Models\WhatsappGroup;
+use DateTime;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
@@ -61,6 +66,15 @@ class Helper
'Lainnya' => 'Lainnya',
];
+ const JENIS_UP = [
+ 'GTUP NIHIL',
+ 'GUP NIHIL',
+ 'GUP KKP',
+ 'TUP',
+ 'GUP',
+ 'UP',
+ ];
+
const AKUN_PERJALANAN = [
'524111',
'524113',
@@ -68,6 +82,11 @@ class Helper
'524119',
];
+ const JENIS_DIGITAL_PAYMENT = [
+ 'atm' => 'ATM',
+ 'kkp' => 'KKP',
+ ];
+
const JENIS_KEGIATAN = [
'Libur' => 'Libur',
'Deadline' => 'Deadline',
@@ -159,6 +178,7 @@ class Helper
'undangan' => 'Undangan',
'daftar_hadir' => 'Daftar Hadir',
'notula' => 'Notula',
+ 'pulsa' => 'Tanda Terima Pulsa',
];
const JENIS_HONOR = [
@@ -440,7 +460,7 @@ public static function namaTanpaGelar($nama)
*/
public static function formatUang($angka)
{
- return number_format($angka, 0, ',', '.');
+ return number_format($angka ?? 0, 0, ',', '.');
}
/**
@@ -545,15 +565,19 @@ public static function terbilang($x, $style = 'uw', $suffix = '')
public static function getTanggalSebelum($tanggal_deadline, $jumlah_hari, $ref = 'h')
{
$tanggal_deadline = Carbon::parse($tanggal_deadline);
+
if ($ref === 'HK') {
$hariLibur = DaftarKegiatan::where('jenis', 'Libur')->pluck('awal')->toArray();
- $hariLibur = array_map(function ($date) {
- return Carbon::parse($date)->format('Y-m-d');
- }, $hariLibur);
+ $hariLibur = array_map(fn ($date) => Carbon::parse($date)->format('Y-m-d'), $hariLibur);
+
+ while ($tanggal_deadline->isWeekend() || in_array($tanggal_deadline->format('Y-m-d'), $hariLibur)) {
+ $tanggal_deadline->subDay();
+ }
$count = 0;
while ($count < $jumlah_hari) {
$tanggal_deadline->subDay();
+
if ($tanggal_deadline->isWeekend() || in_array($tanggal_deadline->format('Y-m-d'), $hariLibur)) {
continue;
}
@@ -610,9 +634,9 @@ public static function getMonthFromDate($tanggal)
* @param string $jam
* @return string
*/
- public static function formatJam($jam)
+ public static function formatJam($jam, $suffix = 'WITA')
{
- return date('H:i', strtotime($jam));
+ return date('H:i', strtotime($jam)).' '.$suffix;
}
/**
@@ -719,7 +743,9 @@ public static function parseFilterFromUrl($url, $filterUri, $filterKey, $default
)
);
- $filterValue = $filters[$filterKey];
+ if (isset($filters[$filterKey])) {
+ $filterValue = $filters[$filterKey];
+ }
}
return $filterValue;
@@ -742,7 +768,9 @@ public static function parseFilter($filter, $filterKey, $defaultValue = null)
true
));
- $filterValue = $filters[$filterKey];
+ if (isset($filters[$filterKey])) {
+ $filterValue = $filters[$filterKey];
+ }
return $filterValue;
}
@@ -762,6 +790,25 @@ public static function cekGanda($json, $key)
return $cek->isNotEmpty();
}
+ /**
+ * Retrieves the pulsa limit (sbml) for a specific kegiatan based on the given jenis_pulsa_id.
+ *
+ * This method fetches the JenisPulsa record from cache, finds the entry with the specified ID,
+ * and returns its 'sbml' value. If no matching record is found, it returns 0.
+ *
+ * @param int $jenis_pulsa_id The ID of the JenisPulsa to retrieve the limit for.
+ * @return int The pulsa limit (sbml) for the specified kegiatan, or 0 if not found.
+ */
+ public static function getLimitPulsaPerKegiatan($jenis_pulsa_id)
+ {
+ $limit = JenisPulsa::cache()
+ ->get('all')
+ ->where('id', $jenis_pulsa_id)
+ ->first();
+
+ return $limit ? $limit->sbml : 0;
+ }
+
/**
* Generate a document number.
*
@@ -938,6 +985,31 @@ public static function getMitraById($id)
return Mitra::cache()->get('all')->where('id', $id)->first();
}
+ /**
+ * Retrieve the ID of a Mitra by their NIK (Nomor Induk Kependudukan).
+ *
+ * This method fetches all Mitra records from the cache, filters them by the provided NIK,
+ * and returns the ID of the first matching Mitra.
+ *
+ * @param string $nik The NIK of the Mitra to search for.
+ * @return int|null The ID of the matching Mitra, or null if not found.
+ */
+ public static function getMitraIdByNik($nik)
+ {
+ return optional(Mitra::cache()->get('all')->where('nik', $nik)->first())->id;
+ }
+
+ /**
+ * Get jenis pulsa based on ID.
+ *
+ * @param int $id The jenis pulsa ID
+ * @return \App\Models\JenisPulsa|null
+ */
+ public static function getJenisPulsaById($id)
+ {
+ return JenisPulsa::cache()->get('all')->where('id', $id)->first();
+ }
+
/**
* Get bank code data based on ID.
*
@@ -1285,9 +1357,11 @@ public static function formatDaftarPenilaian($spek)
* @param array $spek
* @return array
*/
+ // --- Fungsi lengkap bebas N+1, alur & hasil sama ---
public static function formatDaftarPersediaan($id, $spek)
{
$stok = self::cekStokPersediaan($id, (session('year') - 1).'-12-31');
+
$spek->transform(function ($item, $index) use (&$stok) {
// Add serial number
$item['no'] = $index + 1;
@@ -1298,52 +1372,50 @@ public static function formatDaftarPersediaan($id, $spek)
);
// Get document number based on inventory item type
- $item['nomor_dokumen'] = match (get_class($item->barangPersediaanable)) {
- \App\Models\PembelianPersediaan::class => $item->barangPersediaanable
- ->bastNaskahKeluar->nomor,
- \App\Models\PermintaanPersediaan::class => $item->barangPersediaanable
- ->naskahKeluar->nomor,
- \App\Models\PersediaanMasuk::class => $item->barangPersediaanable
- ->naskahMasuk->nomor,
- \App\Models\PersediaanKeluar::class => $item->barangPersediaanable
- ->naskahKeluar->nomor,
+ $cls = get_class($item->barangPersediaanable);
+ $item['nomor_dokumen'] = match ($cls) {
+ \App\Models\PembelianPersediaan::class => $item->barangPersediaanable->bastNaskahKeluar->nomor,
+ \App\Models\PermintaanPersediaan::class => $item->barangPersediaanable->naskahKeluar->nomor,
+ \App\Models\PersediaanMasuk::class => $item->barangPersediaanable->naskahMasuk->nomor,
+ \App\Models\PersediaanKeluar::class => $item->barangPersediaanable->naskahKeluar->nomor,
};
// Get description based on inventory item type
- $item['uraian'] = match (get_class($item->barangPersediaanable)) {
- \App\Models\PembelianPersediaan::class => $item->barangPersediaanable
- ->rincian,
+ $item['uraian'] = match ($cls) {
+ \App\Models\PembelianPersediaan::class => $item->barangPersediaanable->rincian,
\App\Models\PermintaanPersediaan::class => 'Permintaan Persediaan oleh '.
- $item->barangPersediaanable->user->name.
- ' untuk '.
- $item->barangPersediaanable->kegiatan,
+ $item->barangPersediaanable->user->name.
+ ' untuk '.
+ $item->barangPersediaanable->kegiatan,
\App\Models\PersediaanMasuk::class => $item->barangPersediaanable->rincian,
\App\Models\PersediaanKeluar::class => $item->barangPersediaanable->rincian
};
// Calculate incoming and outgoing volume
- $item['masuk'] = match (get_class($item->barangPersediaanable)) {
- \App\Models\PembelianPersediaan::class => $item->volume,
+ $item['masuk'] = match ($cls) {
+ \App\Models\PembelianPersediaan::class,
\App\Models\PersediaanMasuk::class => $item->volume,
default => '-'
};
- $item['keluar'] = match (get_class($item->barangPersediaanable)) {
- \App\Models\PermintaanPersediaan::class => $item->volume,
+ $item['keluar'] = match ($cls) {
+ \App\Models\PermintaanPersediaan::class,
\App\Models\PersediaanKeluar::class => $item->volume,
default => '-'
};
// Calculate remaining stock
- $item['sisa'] = match (get_class($item->barangPersediaanable)) {
- \App\Models\PembelianPersediaan::class, \App\Models\PersediaanMasuk::class => $stok +
- $item['volume'],
+ $item['sisa'] = match ($cls) {
+ \App\Models\PembelianPersediaan::class,
+ \App\Models\PersediaanMasuk::class => $stok + $item['volume'],
\App\Models\PermintaanPersediaan::class,
\App\Models\PersediaanKeluar::class => $stok - $item['volume']
};
// Update stock
$stok = $item['sisa'];
+
+ // Bersihkan relasi dari output
unset($item->barangPersediaanable);
return $item;
@@ -1351,7 +1423,17 @@ public static function formatDaftarPersediaan($id, $spek)
$arrayspek = $spek->toArray();
- return empty($arrayspek) ? [['no' => 1, 'nomor_dokumen' => '-', 'tanggal_buku' => '-', 'uraian' => '-', 'masuk' => '-', 'keluar' => '-', 'sisa' => $stok]] : $arrayspek;
+ return empty($arrayspek)
+ ? [[
+ 'no' => 1,
+ 'nomor_dokumen' => '-',
+ 'tanggal_buku' => '-',
+ 'uraian' => '-',
+ 'masuk' => '-',
+ 'keluar' => '-',
+ 'sisa' => $stok,
+ ]]
+ : $arrayspek;
}
/**
@@ -1391,6 +1473,29 @@ public static function formatMitra($mitra)
return $mitra;
}
+ public static function formatPulsaMitra($mitra)
+ {
+ $mitra->transform(function ($item, $index) {
+ $mitra = self::getMitraById($item['mitra_id']);
+ $item['nama'] = optional($mitra)->nama;
+ $item['nik'] = optional($mitra)->nik;
+ $item['nik_tag'] = '${'.optional($mitra)->nik.'}';
+ $item['bukti'] = $item['file'];
+ unset($item['mitra_id']);
+ unset($item['id']);
+ unset($item['created_at']);
+ unset($item['updated_at']);
+ unset($item['pulsa_kegiatan_id']);
+ unset($item['volume']);
+ unset($item['file']);
+ unset($item['confirmed']);
+
+ return $item;
+ });
+
+ return $mitra;
+ }
+
/**
* Create a query for the model based on the quarter.
*
@@ -1526,6 +1631,22 @@ public static function makeSpjMitraAndPegawai($honor_kegiatan_id, $tanggal)
->toArray();
}
+ public static function makeSpjPulsaMitra($pulsa_kegiatan_id)
+ {
+ $mitra = DaftarPulsaMitra::where('pulsa_kegiatan_id', $pulsa_kegiatan_id)->get();
+ $formattedMitra = self::formatPulsaMitra($mitra);
+
+ return $formattedMitra
+ ->transform(function ($item, $index) {
+ $item['spj_no'] = $index + 1;
+ $item['nominal'] = self::formatUang($item['nominal']);
+ $item['harga'] = self::formatUang($item['harga']);
+
+ return $item;
+ })
+ ->toArray();
+ }
+
/**
* Check for empty bank account numbers in the SPJ list.
*
@@ -1874,6 +1995,42 @@ public static function getLatestTataNaskahId($tanggal)
return optional(TataNaskah::cache()->get('all')->where('tanggal', '<=', $tanggal)->sortByDesc('tanggal')->first())->id;
}
+ public static function getLatestUangPersediaan($tahun, array $jenis)
+ {
+ $dipa = Dipa::cache()
+ ->get('all')
+ ->where('tahun', $tahun)
+ ->first();
+ $dipaId = optional($dipa)->id;
+
+ $latestUp = UangPersediaan::where('dipa_id', $dipaId)
+ ->whereIn('jenis', $jenis)
+ ->whereNowOrPast('tanggal')
+ ->latest('tanggal')
+ ->first();
+
+ return $latestUp;
+ }
+
+ public static function getLatestUp($tahun)
+ {
+ return self::getLatestUangPersediaan($tahun, ['UP']);
+ }
+
+ public static function getLatestGup($tahun)
+ {
+ $gup = self::getLatestUangPersediaan($tahun, ['GUP']);
+
+ return $gup ?? self::getLatestUp($tahun);
+ }
+
+ public static function getLatestTup($tahun)
+ {
+ $tup = self::getLatestUangPersediaan($tahun, ['TUP', 'GTUP NIHIL']);
+
+ return $tup === 'GTUP NIHIL' ? null : $tup;
+ }
+
/**
* Get the latest Harga Satuan ID based on the given date.
*
@@ -1885,6 +2042,17 @@ public static function getLatestHargaSatuanId($tanggal)
return optional(self::getLatestHargaSatuan($tanggal))->id;
}
+ /**
+ * Get the latest Limit Pulsa ID based on the given date.
+ *
+ * @param string $tanggal
+ * @return string|null The latest Limit Pulsa ID or null if not found.
+ */
+ public static function getLatestLimitPulsaId($tanggal)
+ {
+ return optional(self::getLatestLimitPulsa($tanggal))->id;
+ }
+
/**
* Get the latest Harga Satuan based on the given date.
*
@@ -1896,6 +2064,20 @@ public static function getLatestHargaSatuan($tanggal)
return HargaSatuan::cache()->get('all')->where('tanggal', '<=', $tanggal)->sortByDesc('tanggal')->first();
}
+ /**
+ * Retrieves the latest LimitPulsa record with a 'tanggal' less than or equal to the specified date.
+ *
+ * This method fetches all LimitPulsa records from cache, filters them by the given date,
+ * sorts them in descending order by 'tanggal', and returns the first (latest) record.
+ *
+ * @param string|\DateTime $tanggal The date to filter records by (inclusive).
+ * @return LimitPulsa|null The latest LimitPulsa record matching the criteria, or null if none found.
+ */
+ public static function getLatestLimitPulsa($tanggal)
+ {
+ return LimitPulsa::cache()->get('all')->where('tanggal', '<=', $tanggal)->sortByDesc('tanggal')->first();
+ }
+
/**
* Create option values for the Derajat Naskah select field based on the given date.
*
@@ -2025,6 +2207,11 @@ public static function setOptionJenisKontrak($tanggal)
return self::setOptions(JenisKontrak::cache()->get('all')->where('harga_satuan_id', self::getLatestHargaSatuanId($tanggal)), 'id', 'jenis');
}
+ public static function setOptionJenisPulsa($tanggal)
+ {
+ return self::setOptions(JenisPulsa::cache()->get('all')->where('limit_pulsa_id', self::getLatestLimitPulsaId($tanggal)), 'id', 'jenis');
+ }
+
/**
* Create option values for the DIPA year select field.
*
@@ -2117,6 +2304,23 @@ public static function setOptionKepkaMitra($tahun)
return self::setOptions(KepkaMitra::cache()->get('all')->where('tahun', $tahun), 'id', 'nomor');
}
+ /**
+ * Generates options for Mitra based on the specified year.
+ *
+ * Retrieves the KepkaMitra ID for the given year, then fetches all Mitra records
+ * associated with that KepkaMitra ID. Returns the options array using the Mitra's
+ * 'id' as the key and 'nama' as the value.
+ *
+ * @param int|string $tahun The year to filter KepkaMitra records.
+ * @return array Options array with Mitra IDs as keys and names as values.
+ */
+ public static function setOptionsMitra($tahun)
+ {
+ $kepkaMitraId = optional(KepkaMitra::cache()->get('all')->where('tahun', $tahun)->first())->id;
+
+ return self::setOptions(Mitra::cache()->get('all')->where('kepka_mitra_id', $kepkaMitraId), 'id', 'nama', '', 'nik');
+ }
+
/**
* Set options for Mata Anggaran based on the given DIPA ID and MAK.
*
@@ -2155,7 +2359,7 @@ public static function setOptionBarangPersediaan()
public static function sendReminder($reminder, $method = 'auto')
{
$kegiatan = $reminder->daftarKegiatan;
- $hari = floor($kegiatan->awal->diffInDays($method === 'auto' ? $reminder->tanggal : now()));
+ $hari = floor(($method === 'auto' ? $reminder->tanggal : now())->diffInDays($kegiatan->awal, true));
$pesan = strtr($kegiatan->pesan, [
'{judul}' => $hari > 0 ? '[Reminder Deadline (H-'.$hari.')]' : '[Reminder Deadline]',
'{tanggal}' => self::terbilangTanggal($kegiatan->awal),
@@ -2164,9 +2368,15 @@ public static function sendReminder($reminder, $method = 'auto')
]);
$recipients = implode(',', collect($kegiatan->wa_group_id)->pluck('id')->toArray());
$response = Fonnte::make()->sendWhatsAppMessage($recipients, $pesan);
- $reminder->status = $response['data']['process'] ?? 'Gagal';
- $reminder->message_id = $response['data']['id'][0];
- $reminder->save();
+ if ($response['status']) {
+ $reminder->status = $response['data']['process'] ?? 'Gagal';
+ $reminder->message_id = $response['data']['id'][0];
+ $reminder->save();
+
+ return true;
+ } else {
+ return $response['error'] ?? 'Gagal Mengirim Reminder';
+ }
}
/**
@@ -2192,4 +2402,68 @@ public static function version(): string
return $version;
});
}
+
+ public static function hitungPeriodeGup($tanggalGup): array
+ {
+ if (isset($tanggalGup)) {
+ $date = $tanggalGup;
+
+ $bulan = (int) $date->format('m');
+ $tahun = (int) $date->format('Y');
+ $hari = (int) $date->format('d');
+
+ $daysInCurrentMonth = cal_days_in_month(CAL_GREGORIAN, $bulan, $tahun);
+
+ // Cari bulan berikutnya
+ $nextMonth = $bulan + 1;
+ $nextYear = $tahun;
+ if ($nextMonth > 12) {
+ $nextMonth = 1;
+ $nextYear++;
+ }
+ $daysInNextMonth = cal_days_in_month(CAL_GREGORIAN, $nextMonth, $nextYear);
+
+ if ($hari == $daysInCurrentMonth) {
+ // Jika tanggal terakhir bulan → pakai tanggal terakhir bulan berikutnya
+ $endDate = new DateTime("$nextYear-$nextMonth-$daysInNextMonth");
+ $days = $daysInNextMonth;
+ } else {
+ // Jika bukan tanggal terakhir → pakai tanggal sama di bulan berikutnya
+ // Hati-hati jika bulan berikutnya tidak punya tanggal tsb (misalnya 30 Feb)
+ $endDay = min($hari, $daysInNextMonth);
+ $endDate = new DateTime("$nextYear-$nextMonth-$endDay");
+ $days = $daysInCurrentMonth;
+ }
+
+ return [
+ 'awal' => Carbon::instance($date),
+ 'akhir' => Carbon::instance($endDate),
+ 'hari' => $days,
+ ];
+ }
+
+ return [
+ 'awal' => '-',
+ 'akhir' => '-',
+ 'hari' => 0,
+ ];
+ }
+
+ public static function setReminderForUangPersediaan($jenis, $tanggal)
+ {
+ $kegiatan = new DaftarKegiatan;
+ $kegiatan->jenis = 'Deadline';
+ $kegiatan->kegiatan = $jenis === 'gup' ? 'SPM Penggantian UP (GUP)' : 'SPM Pertanggungjawaban TUP (GTUP)';
+ $kegiatan->awal = $tanggal;
+ $kegiatan->akhir = $tanggal;
+ $kegiatan->wa_group_id = [['id' => '6287814885714-1605499798@g.us']];
+ $kegiatan->pesan = "*{judul}*\n\nDeadline : {tanggal}\nPerihal : {kegiatan}\nPenanggung jawab: *{pj}*\n\nMohon untuk segera membuat ".($jenis === 'gup' ? 'SPM Penggantian UP (GUP)' : 'SPM Pertanggungjawaban TUP (GTUP)')." sebelum tanggal ({tanggal}). Harap tetap memperhatikan jumlah minimum yang telah ditentukan.\n\nTerimakasih ✨✨";
+ $kegiatan->waktu_reminder = [
+ ['hari' => 3, 'referensi_waktu' => 'HK', 'waktu_kirim' => '08:00:00'],
+ ['hari' => 1, 'referensi_waktu' => 'HK', 'waktu_kirim' => '08:00:00'],
+ ];
+ $kegiatan->daftar_kegiatanable_id = 1;
+ $kegiatan->daftar_kegiatanable_type = 'App\\Models\\UnitKerja';
+ $kegiatan->save();
+ }
}
diff --git a/app/Helpers/HtmlGenerator.php b/app/Helpers/HtmlGenerator.php
new file mode 100644
index 00000000..b9b05726
--- /dev/null
+++ b/app/Helpers/HtmlGenerator.php
@@ -0,0 +1,132 @@
+
+
+
Bulan: '.Helper::terbilangBulan($model->bulan).'
+No | +Kegiatan | +Jumlah Honor | +
---|---|---|
'.$kegiatan['no'].' | +'.$kegiatan['kegiatan'].' | +'.Helper::formatUang($kegiatan['jumlah']).' | +
Bulan: '.Helper::terbilangBulan($model->bulan).'
+No | +Kegiatan | +Jumlah Pulsa | +
---|---|---|
'.$kegiatan['no'].' | +'.$kegiatan['kegiatan'].' | +'.Helper::formatUang($kegiatan['jumlah']).' | +
3&&void 0!==arguments[3]&&arguments[3];(t=Array.isArray(t)?t:[t]).forEach((function(t){e.forEach((function(e){var i=e,o=function(){return n[e]},a=function(t){return n[e]=t};"object"==typeof e&&(i=e.key,o=e.getter||o,a=e.setter||a),t[i]&&!r||(t[i]={get:o,set:a})}))}))},D=function(e){return function(t,n){e.addEventListener(t,n)}},S=function(e){return function(t,n){e.removeEventListener(t,n)}},L=function(e){return null!=e},M={opacity:1,scaleX:1,scaleY:1,translateX:0,translateY:0,rotateX:0,rotateY:0,rotateZ:0,originX:0,originY:0},P=function(e){var t=e.mixinConfig,n=e.viewProps,r=e.viewInternalAPI,i=e.viewExternalAPI,o=e.view,a=Object.assign({},n),l={};O(t,[r,i],n);var s=function(){return[n.translateX||0,n.translateY||0]},u=function(){return[n.scaleX||0,n.scaleY||0]},c=function(){return o.rect?g(o.rect,o.childViews,s(),u()):null};return r.rect={get:c},i.rect={get:c},t.forEach((function(e){n[e]=void 0===a[e]?M[e]:a[e]})),{write:function(){if(C(l,n))return N(o.element,n),Object.assign(l,Object.assign({},n)),!0},destroy:function(){}}},C=function(e,t){if(Object.keys(e).length!==Object.keys(t).length)return!0;for(var n in t)if(t[n]!==e[n])return!0;return!1},N=function(e,t){var n=t.opacity,r=t.perspective,i=t.translateX,o=t.translateY,a=t.scaleX,l=t.scaleY,s=t.rotateX,u=t.rotateY,c=t.rotateZ,d=t.originX,f=t.originY,p=t.width,m=t.height,E="",h="";(L(d)||L(f))&&(h+="transform-origin: "+(d||0)+"px "+(f||0)+"px;"),L(r)&&(E+="perspective("+r+"px) "),(L(i)||L(o))&&(E+="translate3d("+(i||0)+"px, "+(o||0)+"px, 0) "),(L(a)||L(l))&&(E+="scale3d("+(L(a)?a:1)+", "+(L(l)?l:1)+", 1) "),L(c)&&(E+="rotateZ("+c+"rad) "),L(s)&&(E+="rotateX("+s+"rad) "),L(u)&&(E+="rotateY("+u+"rad) "),E.length&&(h+="transform:"+E+";"),L(n)&&(h+="opacity:"+n+";",0===n&&(h+="visibility:hidden;"),n<1&&(h+="pointer-events:none;")),L(m)&&(h+="height:"+m+"px;"),L(p)&&(h+="width:"+p+"px;");var g=e.elementCurrentStyle||"";h.length===g.length&&h===g||(e.style.cssText=h,e.elementCurrentStyle=h)},G={styles:P,listeners:function(e){e.mixinConfig,e.viewProps,e.viewInternalAPI;var t=e.viewExternalAPI,n=(e.viewState,e.view),r=[],i=D(n.element),o=S(n.element);return t.on=function(e,t){r.push({type:e,fn:t}),i(e,t)},t.off=function(e,t){r.splice(r.findIndex((function(n){return n.type===e&&n.fn===t})),1),o(e,t)},{write:function(){return!0},destroy:function(){r.forEach((function(e){o(e.type,e.fn)}))}}},animations:function(e){var t=e.mixinConfig,n=e.viewProps,r=e.viewInternalAPI,o=e.viewExternalAPI,a=Object.assign({},n),l=[];return i(t,(function(e,t){var i=A(t);i&&(i.onupdate=function(t){n[e]=t},i.target=a[e],O([{key:e,setter:function(e){i.target!==e&&(i.target=e)},getter:function(){return n[e]}}],[r,o],n,!0),l.push(i))})),{write:function(e){var t=document.hidden,n=!0;return l.forEach((function(r){r.resting||(n=!1),r.interpolate(e,t)})),n},destroy:function(){}}},apis:function(e){var t=e.mixinConfig,n=e.viewProps,r=e.viewExternalAPI;O(t,r,n)}},x=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};return t.layoutCalculated||(e.paddingTop=parseInt(n.paddingTop,10)||0,e.marginTop=parseInt(n.marginTop,10)||0,e.marginRight=parseInt(n.marginRight,10)||0,e.marginBottom=parseInt(n.marginBottom,10)||0,e.marginLeft=parseInt(n.marginLeft,10)||0,t.layoutCalculated=!0),e.left=t.offsetLeft||0,e.top=t.offsetTop||0,e.width=t.offsetWidth||0,e.height=t.offsetHeight||0,e.right=e.left+e.width,e.bottom=e.top+e.height,e.scrollTop=t.scrollTop,e.hidden=null===t.offsetParent,e},k=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},t=e.tag,n=void 0===t?"div":t,r=e.name,i=void 0===r?null:r,a=e.attributes,l=void 0===a?{}:a,s=e.read,u=void 0===s?function(){}:s,m=e.write,E=void 0===m?function(){}:m,v=e.create,_=void 0===v?function(){}:v,I=e.destroy,T=void 0===I?function(){}:I,y=e.filterFrameActionsForChild,b=void 0===y?function(e,t){return t}:y,R=e.didCreateView,w=void 0===R?function(){}:R,A=e.didWriteView,O=void 0===A?function(){}:A,D=e.ignoreRect,S=void 0!==D&&D,L=e.ignoreRectUpdate,M=void 0!==L&&L,P=e.mixins,C=void 0===P?[]:P;return function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},r=c(n,"filepond--"+i,l),a=window.getComputedStyle(r,null),s=x(),m=null,v=!1,I=[],y=[],R={},A={},D=[E],L=[u],P=[T],N=function(){return r},k=function(){return I.concat()},F=function(){return R},U=function(e){return function(t,n){return t(e,n)}},B=function(){return m||(m=g(s,I,[0,0],[1,1]))},V=function(){m=null,I.forEach((function(e){return e._read()})),!(M&&s.width&&s.height)&&x(s,r,a);var e={root:X,props:t,rect:s};L.forEach((function(t){return t(e)}))},q=function(e,n,r){var i=0===n.length;return D.forEach((function(o){!1===o({props:t,root:X,actions:n,timestamp:e,shouldOptimize:r})&&(i=!1)})),y.forEach((function(t){!1===t.write(e)&&(i=!1)})),I.filter((function(e){return!!e.element.parentNode})).forEach((function(t){t._write(e,b(t,n),r)||(i=!1)})),I.forEach((function(t,o){t.element.parentNode||(X.appendChild(t.element,o),t._read(),t._write(e,b(t,n),r),i=!1)})),v=i,O({props:t,root:X,actions:n,timestamp:e}),i},z=function(){y.forEach((function(e){return e.destroy()})),P.forEach((function(e){e({root:X,props:t})})),I.forEach((function(e){return e._destroy()}))},W={element:{get:N},style:{get:function(){return a}},childViews:{get:k}},Y=Object.assign({},W,{rect:{get:B},ref:{get:F},is:function(e){return i===e},appendChild:d(r),createChildView:U(e),linkView:function(e){return I.push(e),e},unlinkView:function(e){I.splice(I.indexOf(e),1)},appendChildView:f(r,I),removeChildView:p(r,I),registerWriter:function(e){return D.push(e)},registerReader:function(e){return L.push(e)},registerDestroyer:function(e){return P.push(e)},invalidateLayout:function(){return r.layoutCalculated=!1},dispatch:e.dispatch,query:e.query}),j={element:{get:N},childViews:{get:k},rect:{get:B},resting:{get:function(){return v}},isRectIgnored:function(){return S},_read:V,_write:q,_destroy:z},H=Object.assign({},W,{rect:{get:function(){return s}}});Object.keys(C).sort((function(e,t){return"styles"===e?1:"styles"===t?-1:0})).forEach((function(e){var n=G[e]({mixinConfig:C[e],viewProps:t,viewState:A,viewInternalAPI:Y,viewExternalAPI:j,view:o(H)});n&&y.push(n)}));var X=o(Y);_({root:X,props:t});var Z=h(r);return I.forEach((function(e,t){X.appendChild(e.element,Z+t)})),w(X),o(j)}},F=function(e,t){var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:60,r="__framePainter";if(window[r])return window[r].readers.push(e),void window[r].writers.push(t);window[r]={readers:[e],writers:[t]};var i=window[r],o=1e3/n,a=null,l=null,s=null,u=null,c=function(){document.hidden?(s=function(){return window.setTimeout((function(){return d(performance.now())}),o)},u=function(){return window.clearTimeout(l)}):(s=function(){return window.requestAnimationFrame(d)},u=function(){return window.cancelAnimationFrame(l)})};document.addEventListener("visibilitychange",(function(){u&&u(),c(),d(performance.now())}));var d=function e(t){l=s(e),a||(a=t);var n=t-a;n<=o||(a=t-n%o,i.readers.forEach((function(e){return e()})),i.writers.forEach((function(e){return e(t)})))};return c(),d(performance.now()),{pause:function(){u(l)}}},U=function(e,t){return function(n){var r=n.root,i=n.props,o=n.actions,a=void 0===o?[]:o,l=n.timestamp,s=n.shouldOptimize;a.filter((function(t){return e[t.type]})).forEach((function(t){return e[t.type]({root:r,props:i,action:t.data,timestamp:l,shouldOptimize:s})})),t&&t({root:r,props:i,actions:a,timestamp:l,shouldOptimize:s})}},B=function(e,t){return t.parentNode.insertBefore(e,t)},V=function(e,t){return t.parentNode.insertBefore(e,t.nextSibling)},q=function(e){return Array.isArray(e)},z=function(e){return null==e},W=function(e){return e.trim()},Y=function(e){return""+e},j=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:",";return z(e)?[]:q(e)?e:Y(e).split(t).map(W).filter((function(e){return e.length}))},H=function(e){return"boolean"==typeof e},X=function(e){return H(e)?e:"true"===e},Z=function(e){return"string"==typeof e},Q=function(e){return I(e)?e:Z(e)?Y(e).replace(/[a-z]+/gi,""):0},K=function(e){return parseInt(Q(e),10)},J=function(e){return parseFloat(Q(e))},$=function(e){return I(e)&&isFinite(e)&&Math.floor(e)===e},ee=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:1e3;if($(e))return e;var n=Y(e).trim();return/MB$/i.test(n)?(n=n.replace(/MB$i/,"").trim(),K(n)*t*t):/KB/i.test(n)?(n=n.replace(/KB$i/,"").trim(),K(n)*t):K(n)},te=function(e){return"function"==typeof e},ne=function(e){for(var t=self,n=e.split("."),r=null;r=n.shift();)if(!(t=t[r]))return null;return t},re={process:"POST",patch:"PATCH",revert:"DELETE",fetch:"GET",restore:"GET",load:"GET"},ie=function(e){var t={};return t.url=Z(e)?e:e.url||"",t.timeout=e.timeout?parseInt(e.timeout,10):0,t.headers=e.headers?e.headers:{},i(re,(function(n){t[n]=oe(n,e[n],re[n],t.timeout,t.headers)})),t.process=e.process||Z(e)||e.url?t.process:null,t.remove=e.remove||null,delete t.headers,t},oe=function(e,t,n,r,i){if(null===t)return null;if("function"==typeof t)return t;var o={url:"GET"===n||"PATCH"===n?"?"+e+"=":"",method:n,headers:i,withCredentials:!1,timeout:r,onload:null,ondata:null,onerror:null};if(Z(t))return o.url=t,o;if(Object.assign(o,t),Z(o.headers)){var a=o.headers.split(/:(.+)/);o.headers={header:a[0],value:a[1]}}return o.withCredentials=X(o.withCredentials),o},ae=function(e){return null===e},le=function(e){return"object"==typeof e&&null!==e},se=function(e){return le(e)&&Z(e.url)&&le(e.process)&&le(e.revert)&&le(e.restore)&&le(e.fetch)},ue=function(e){return q(e)?"array":ae(e)?"null":$(e)?"int":/^[0-9]+ ?(?:GB|MB|KB)$/gi.test(e)?"bytes":se(e)?"api":typeof e},ce=function(e){return e.replace(/{\s*'/g,'{"').replace(/'\s*}/g,'"}').replace(/'\s*:/g,'":').replace(/:\s*'/g,':"').replace(/,\s*'/g,',"').replace(/'\s*,/g,'",')},de={array:j,boolean:X,int:function(e){return"bytes"===ue(e)?ee(e):K(e)},number:J,float:J,bytes:ee,string:function(e){return te(e)?e:Y(e)},function:function(e){return ne(e)},serverapi:function(e){return ie(e)},object:function(e){try{return JSON.parse(ce(e))}catch(e){return null}}},fe=function(e,t){return de[t](e)},pe=function(e,t,n){if(e===t)return e;var r=ue(e);if(r!==n){var i=fe(e,n);if(r=ue(i),null===i)throw'Trying to assign value with incorrect type to "'+option+'", allowed type: "'+n+'"';e=i}return e},me=function(e,t){var n=e;return{enumerable:!0,get:function(){return n},set:function(r){n=pe(r,e,t)}}},Ee=function(e){var t={};return i(e,(function(n){var r=e[n];t[n]=me(r[0],r[1])})),o(t)},he=function(e){return{items:[],listUpdateTimeout:null,itemUpdateTimeout:null,processingQueue:[],options:Ee(e)}},ge=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"-";return e.split(/(?=[A-Z])/).map((function(e){return e.toLowerCase()})).join(t)},ve=function(e,t){var n={};return i(t,(function(t){n[t]={get:function(){return e.getState().options[t]},set:function(n){e.dispatch("SET_"+ge(t,"_").toUpperCase(),{value:n})}}})),n},_e=function(e){return function(t,n,r){var o={};return i(e,(function(e){var n=ge(e,"_").toUpperCase();o["SET_"+n]=function(i){try{r.options[e]=i.value}catch(e){}t("DID_SET_"+n,{value:r.options[e]})}})),o}},Ie=function(e){return function(t){var n={};return i(e,(function(e){n["GET_"+ge(e,"_").toUpperCase()]=function(n){return t.options[e]}})),n}},Te={API:1,DROP:2,BROWSE:3,PASTE:4,NONE:5},ye=function(){return Math.random().toString(36).substring(2,11)};function be(e){this.wrapped=e}function Re(e){var t,n;function r(e,r){return new Promise((function(o,a){var l={key:e,arg:r,resolve:o,reject:a,next:null};n?n=n.next=l:(t=n=l,i(e,r))}))}function i(t,n){try{var r=e[t](n),a=r.value,l=a instanceof be;Promise.resolve(l?a.wrapped:a).then((function(e){l?i("next",e):o(r.done?"return":"normal",e)}),(function(e){i("throw",e)}))}catch(e){o("throw",e)}}function o(e,r){switch(e){case"return":t.resolve({value:r,done:!0});break;case"throw":t.reject(r);break;default:t.resolve({value:r,done:!1})}(t=t.next)?i(t.key,t.arg):n=null}this._invoke=r,"function"!=typeof e.return&&(this.return=void 0)}function we(e,t){if(null==e)return{};var n,r,i={},o=Object.keys(e);for(r=0;r