diff --git a/README.md b/README.md index 6851ea9..dc3af8e 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,366 @@ -

Laravel Logo

+# Asset Management System -

-Build Status -Total Downloads -Latest Stable Version -License -

+![Asset Management System](public/build/assets/images/brand-logos/toggle-logo.png) -## About Laravel +Sistem Manajemen Aset berbasis Laravel untuk pencatatan aset fisik end-to-end: **registrasi aset + identifikasi (QR/RFID/NFC), movement, disposal + reverse, maintenance, audit/stocktake, approval workflow, laporan (Excel/PDF), changelog, dan pengaturan sistem berbasis database**. -Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: +Project ini dibuat dengan fokus pada **best practice**, **clean code**, dan siap untuk dikembangkan menjadi open-source. -- [Simple, fast routing engine](https://laravel.com/docs/routing). -- [Powerful dependency injection container](https://laravel.com/docs/container). -- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage. -- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent). -- Database agnostic [schema migrations](https://laravel.com/docs/migrations). -- [Robust background job processing](https://laravel.com/docs/queues). -- [Real-time event broadcasting](https://laravel.com/docs/broadcasting). +## Daftar Isi -Laravel is accessible, powerful, and provides tools required for large, robust applications. +- [Fitur Utama](#fitur-utama) +- [Arsitektur Singkat](#arsitektur-singkat) +- [Kebutuhan Sistem](#kebutuhan-sistem) +- [Quick Start (Local Development)](#quick-start-local-development) +- [Setup Wizard (First Run)](#setup-wizard-first-run) +- [Akun Default (Sample Data)](#akun-default-sample-data) +- [RBAC (Role & Permission)](#rbac-role--permission) +- [System Settings (Database-driven)](#system-settings-database-driven) +- [Identifikasi Aset (QR/RFID/NFC)](#identifikasi-aset-qrrfidnfc) +- [Transaksi Aset](#transaksi-aset) +- [Approval Workflow](#approval-workflow) +- [Authentication, 2FA, Session Timeout](#authentication-2fa-session-timeout) +- [Audit Trail & Logging](#audit-trail--logging) +- [Import/Export Aset](#importexport-aset) +- [Laporan (Excel & PDF)](#laporan-excel--pdf) +- [Landing Page & Public Asset View (QR Scan)](#landing-page--public-asset-view-qr-scan) +- [Notifikasi Email (SMTP)](#notifikasi-email-smtp) +- [Notifikasi Terjadwal (Command)](#notifikasi-terjadwal-command) +- [Maintenance Mode (Read-only)](#maintenance-mode-read-only) +- [Testing](#testing) +- [Deployment Notes](#deployment-notes) +- [Contributing](#contributing) +- [Dokumentasi Lengkap](#dokumentasi-lengkap) -## Learning Laravel +## Fitur Utama -Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. +**1) User Management + RBAC (Spatie Permission)** +- Role & permission berbasis UUID +- Pembatasan akses per modul melalui middleware `permission:*` -You may also try the [Laravel Bootcamp](https://bootcamp.laravel.com), where you will be guided through building a modern Laravel application from scratch. +**2) Master Data** +- Status Aset, Kelas Aset, Unit, Departemen, Person in Charge, Pengguna Aset, Kategori, Lokasi, Warranty, Vendor Contract -If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library. +**3) Manajemen Aset** +- Registrasi aset dengan kode otomatis +- Foto/thumbnail aset (multi foto) tersimpan di storage +- QR code otomatis menuju halaman publik detail aset (tanpa login) +- RFID/NFC tag (opsional + bisa auto-generate via setting) +- Arsip/retensi + soft delete + restore -## Laravel Sponsors +**4) Transaksi Aset** +- Movement (mutasi lokasi/departemen/user) +- Disposal + Reverse Disposal +- Audit/Stocktake +- Maintenance (planned/in_progress/completed + dukungan flow approval `pending`) -We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com). +**5) Approval Workflow** +- Permintaan approval untuk movement/disposal/maintenance (pending/approved/rejected) +- Halaman approvals untuk approver -### Premium Partners +**6) Laporan** +- Export Excel (multi sheet) + PDF untuk aset dan transaksi +- Filter laporan (date range, lokasi, dsb.) sesuai halaman laporan -- **[Vehikl](https://vehikl.com/)** -- **[Tighten Co.](https://tighten.co)** -- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)** -- **[64 Robots](https://64robots.com)** -- **[Curotec](https://www.curotec.com/services/technologies/laravel/)** -- **[DevSquad](https://devsquad.com/hire-laravel-developers)** -- **[Redberry](https://redberry.international/laravel-development/)** -- **[Active Logic](https://activelogic.com)** +**7) Landing & Dokumentasi** +- Landing one-page untuk memperkenalkan sistem +- Public asset view untuk QR scan: `GET /asset-view/{asset}` +- Dokumentasi API internal app: `GET /api-docs` + +## Arsitektur Singkat + +- **Framework:** Laravel +- **Template UI:** Vyzor (Bootstrap) +- **Settings:** disimpan di tabel `settings` dan diakses melalui `App\Services\System\SystemSettingService` +- **Audit log:** file log khusus channel `asset_activity` (bukan database) +- **UUID:** hampir seluruh tabel inti menggunakan UUID sebagai primary key + +Folder penting: +- `app/Services/` → business logic (tanpa repository pattern) +- `app/Http/Controllers/` → controller tipis, delegasi ke service +- `database/seeders/` → sample data +- `resources/views/` → Blade UI + +## Kebutuhan Sistem + +- PHP 8.2+ (sesuaikan dengan `composer.json`) +- Composer +- Node.js + NPM (Vite build) +- Database: MySQL/MariaDB (production) / SQLite (testing) +- (Opsional) Laragon untuk dev di Windows + +## Quick Start (Local Development) + +1) Install dependency PHP & JS +```bash +composer install +npm install +``` + +2) Siapkan `.env` +```bash +cp .env.example .env +php artisan key:generate +``` + +3) Jalankan Vite +```bash +npm run dev +``` + +4) Jalankan aplikasi +```bash +php artisan serve +``` + +5) Buat symbolic link storage (wajib untuk foto aset & QR) +```bash +php artisan storage:link +``` + +## Setup Wizard (First Run) + +Saat aplikasi pertama kali dijalankan dan **koneksi database belum siap**, middleware akan mengarahkan ke wizard: +- `GET /setup` + +Wizard terdiri dari 2 tahap besar: + +1) **Database Setup** +- Aplikasi akan menampilkan panduan mengisi `.env` (DB_HOST, DB_DATABASE, DB_USERNAME, DB_PASSWORD) +- Tombol migrasi tersedia: + - `Migrate & Seed Minimal (RBAC)` → seed `RbacSeeder` + `SettingSeeder` + - `Migrate + Sample Data (Full Seed)` → seed `DatabaseSeeder` + +2) **Wizard Aplikasi (4 langkah)** +- Step 1: buat user admin +- Step 2: isi nilai setting (form dibentuk dari data di tabel `settings`) +- Step 3: buat master data dasar +- Step 4: buat aset pertama (sample) + +## Akun Default (Sample Data) + +Jika memilih **Full Seed**, akun admin default dibuat oleh `database/seeders/RbacSeeder.php`: +- Email: `admin@example.com` +- Password: `password123` + +## RBAC (Role & Permission) + +RBAC memakai `spatie/laravel-permission` yang sudah disesuaikan untuk UUID: +- Primary key role/permission: `uuid` +- Pivot model: `model_uuid` (lihat `config/permission.php`) + +Permission dasar (seed): +- `settings.manage` +- `users.manage`, `roles.manage`, `permissions.manage` +- `assets.view`, `assets.manage` +- `movements.manage`, `disposals.manage`, `audits.manage`, `maintenance.manage` +- `reports.view`, `approvals.manage` + +Role dasar (seed): +- `super-admin`, `asset-manager`, `auditor`, `maintenance`, `viewer` + +Catatan scoping data: +- Model `Asset` memiliki `scopeForUser()` dan `isVisibleTo()` untuk pembatasan berbasis departemen/lokasi. +- (Opsional) Permission `assets.view_all` dapat ditambahkan untuk bypass scoping (lihat `app/Models/Asset.php`). + +## System Settings (Database-driven) + +Setting disimpan di tabel `settings` dan dapat dikelola lewat: +- Dashboard: `GET /dashboard/settings` +- Setup Wizard step 2: `GET /setup?step=2` + +Setting dibaca via `App\Services\System\SystemSettingService`: +- Default berasal dari `config/system.php` +- Nilai DB akan override default + +Contoh setting penting (lihat `database/seeders/SettingSeeder.php`): +- `asset.code_prefix`, `asset.qr_enabled`, `asset.qr_format`, `asset.qr_size` +- `asset.rfid_enabled`, `asset.nfc_enabled`, `asset.rfid_auto_generate`, `asset.nfc_auto_generate` +- `maintenance.readonly_mode` +- `security.audit_log`, `security.session_idle_minutes`, `security.two_factor_enforced` +- `integration.api_key`, `integration.webhook_*` +- `asset.import_*` (import limit/dedupe/default lookup) + +## Identifikasi Aset (QR/RFID/NFC) + +**QR Code** +- Dibuat otomatis saat aset dibuat/di-update (jika `asset.qr_enabled = true`) +- Tersimpan di `storage/app/public/qr/` +- Isi QR mengarah ke public asset view: `route('assets.public.show')` (`/asset-view/{asset}`) + +**RFID/NFC** +- Disimpan sebagai `rfid_tag` dan `nfc_tag` di tabel `assets` +- Bisa diwajibkan (`asset.rfid_required` / `asset.nfc_required`) dan/atau auto-generate (`asset.rfid_auto_generate` / `asset.nfc_auto_generate`) oleh `AssetService`. + +## Transaksi Aset + +1) **Movement** +- Membuat record `asset_movements` +- Jika auto-approve aktif, aset akan ikut berubah: lokasi/departemen/user + +2) **Disposal** +- Membuat record `asset_disposals` +- Jika approved, aset diarahkan ke status `DISPOSED` (jika status ini ada) +- Reverse disposal mengembalikan status/lokasi/departemen sebelumnya + +3) **Audit/Stocktake** +- Membuat record `asset_audits` (matched/missing/damaged) + +4) **Maintenance** +- Membuat record `asset_maintenances` +- Flow status: `pending` (menunggu approval) → `planned` → `in_progress` → `completed` + +## Approval Workflow + +Approval disimpan di tabel `asset_approval_requests` (morph ke movement/disposal/maintenance): +- Status: `pending`, `approved`, `rejected` + +Halaman approvals: +- `GET /dashboard/approvals` (butuh permission `approvals.manage`) + +## Authentication, 2FA, Session Timeout + +**Login/Register** +- Login: `GET /login` / `POST /login` +- Register: `GET /register` / `POST /register` +- Logout: `POST /logout` + +**2FA (TOTP 6 digit)** +- User dapat mengaktifkan 2FA dari menu profile: + - `GET /dashboard/profile` +- Flow login: + - Jika `two_factor_enabled = true`, setelah password benar user akan diarahkan ke: + - `GET /two-factor-challenge` + - User memasukkan 6 digit dari aplikasi authenticator (Google Authenticator/1Password/Authy) melalui: + - `POST /two-factor-challenge` + +**Session Timeout (idle timeout)** +- Middleware `session.timeout` memaksa logout ketika tidak ada aktivitas selama X menit. +- Setting terkait: + - `security.session_idle_minutes` + +## Audit Trail & Logging + +**1) Request Audit (End-to-end)** +- Middleware `audit.request` mencatat request non-GET beserta durasi response. +- Disimpan via `ActivityLogger::audit()`. + +**2) Log Aktivitas Domain** +- Helper: + - `asset_activity_log(...)` + - `asset_request_log(...)` +- Service: `app/Services/Logging/ActivityLogger.php` + +**Output log** +- Channel: `asset_activity` +- Lokasi: `storage/logs/asset-activity.log` (daily rotation) +- ENV yang relevan: + - `ASSET_LOG_FILE`, `ASSET_LOG_LEVEL`, `ASSET_LOG_DAYS` + +## Import/Export Aset + +**Import** +- UI import ada di halaman daftar aset (`/dashboard/assets`) +- Mendukung: + - `Preview` (tanpa menyimpan) + ringkasan create/update/invalid + - `Import` langsung (create/update) + - Gambar dari: + - URL (`image_url`) *(jika `asset.import_allow_remote_images = true`)* + - ZIP upload (`images_zip`) dengan mapping nama file (`image_file`) + +Sample file import tersedia: +- `public/samples/assets_import_sample.csv` + +**Export** +- CSV export (limit 2000 rows) tersedia via route: + - `GET /dashboard/assets-export` + +## Laporan (Excel & PDF) + +Menu laporan: +- `GET /dashboard/reports/assets` +- `GET /dashboard/reports/movements` +- `GET /dashboard/reports/disposals` +- `GET /dashboard/reports/audits` + +Export: +- Excel: multi-sheet (termasuk relasi utama) +- PDF: layout khusus report + +## Landing Page & Public Asset View (QR Scan) + +Landing: +- `GET /` atau `GET /landing` + +Public asset view (tanpa login): +- `GET /asset-view/{asset}` + +Transaksi dari halaman public (wajib login + permission terkait): +- Movement: `POST /asset-view/{asset}/movement` +- Disposal: `POST /asset-view/{asset}/disposal` +- Maintenance: `POST /asset-view/{asset}/maintenance` + +## Notifikasi Email (SMTP) + +Notifikasi saat approval menggunakan **Laravel Notifications** (channel `mail`): +- Approver menerima email “approval requested” +- Requester menerima email “approval approved/rejected” + +Agar email terkirim, konfigurasi SMTP pada `.env` (contoh): +```env +MAIL_MAILER=smtp +MAIL_HOST=smtp.mailtrap.io +MAIL_PORT=2525 +MAIL_USERNAME=your_username +MAIL_PASSWORD=your_password +MAIL_ENCRYPTION=tls +MAIL_FROM_ADDRESS="noreply@example.com" +MAIL_FROM_NAME="Asset Management System" +``` + +## Notifikasi Terjadwal (Command) + +Tersedia command untuk reminder sederhana (mencatat ke log `asset_activity`): +```bash +php artisan asset:notify-due +``` + +Anda bisa menjadwalkannya via cron (contoh setiap hari jam 08:00): +```cron +0 8 * * * cd /path/to/project && php artisan asset:notify-due >> /dev/null 2>&1 +``` + +## Maintenance Mode (Read-only) + +Jika setting `maintenance.readonly_mode = true`: +- Middleware `maintenance.readonly` akan memblokir aksi tulis. +- Dashboard menampilkan badge “Mode Read-only Aktif”. + +## Testing + +Menjalankan test (SQLite): +```bash +php artisan test +``` + +## Deployment Notes + +Checklist singkat production: +- `APP_ENV=production`, `APP_DEBUG=false`, `APP_KEY` sudah terisi +- `php artisan migrate --force` +- `php artisan storage:link` +- `npm run build` (atau gunakan build artifact yang sudah ada di `public/build`) +- Pastikan permission folder: + - `storage/` dan `bootstrap/cache/` writable ## Contributing -Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). +Kontribusi sangat diterima. +- Gunakan style & struktur yang sudah ada (controller tipis, logic di service) +- Tambahkan test untuk fitur baru bila memungkinkan +- Buat PR kecil dan fokus per fitur -## Code of Conduct +## Dokumentasi Lengkap -In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). - -## Security Vulnerabilities - -If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. - -## License - -The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). +Dokumentasi teknis terperinci (alur, setting, workflow, integrasi, notifikasi, dsb.) tersedia di: +- `documentation/guide.md` diff --git a/app/Http/Controllers/DashboardsController.php b/app/Http/Controllers/DashboardsController.php index 079de1e..d1456c7 100644 --- a/app/Http/Controllers/DashboardsController.php +++ b/app/Http/Controllers/DashboardsController.php @@ -2,66 +2,24 @@ namespace App\Http\Controllers; -use Illuminate\Http\Request; -use App\Models\Asset; -use App\Models\AssetStatus; -use App\Models\AssetLocation; -use App\Models\AssetMovement; -use App\Models\AssetDisposal; -use App\Models\AssetAudit; -use Carbon\Carbon; +use App\Services\Dashboard\DashboardService; +use Illuminate\View\View; class DashboardsController extends Controller { - - public function index() + public function __construct(private readonly DashboardService $dashboard) { - $now = Carbon::now(); - $last30 = $now->copy()->subDays(30); - - $totalAssets = Asset::count(); - $movementCount = AssetMovement::where('created_at', '>=', $last30)->count(); - $disposalCount = AssetDisposal::whereNull('reversed_at')->count(); - $auditIssues = AssetAudit::whereIn('status', ['missing','damaged'])->count(); - - $byStatus = AssetStatus::orderBy('name') - ->get() - ->map(function ($status) { - return [ - 'label' => $status->name, - 'value' => $status->assets()->count(), - ]; - }); - - $byLocation = AssetLocation::orderBy('name') - ->get() - ->map(function ($loc) { - return [ - 'label' => $loc->name, - 'value' => $loc->assets()->count(), - ]; - })->filter(fn($row) => $row['value'] > 0)->values(); - - $recentAudits = AssetAudit::with('asset') - ->latest() - ->take(5) - ->get(); - - $recentDisposals = AssetDisposal::with('asset') - ->latest() - ->take(5) - ->get(); - - return view('pages.dashboards.index', compact( - 'totalAssets', - 'movementCount', - 'disposalCount', - 'auditIssues', - 'byStatus', - 'byLocation', - 'recentAudits', - 'recentDisposals' - )); } + public function index(): View + { + $user = auth()->user(); + $data = $this->dashboard->build($user); + + // Backward compatible variable names (dipakai view lama). + $data['movementCount'] = $data['movementCount30'] ?? 0; + $data['disposalCount'] = $data['activeDisposals'] ?? 0; + + return view('pages.dashboards.index', $data); + } } diff --git a/app/Http/Middleware/SessionTimeout.php b/app/Http/Middleware/SessionTimeout.php index 0f1da43..38af48e 100644 --- a/app/Http/Middleware/SessionTimeout.php +++ b/app/Http/Middleware/SessionTimeout.php @@ -11,7 +11,10 @@ class SessionTimeout public function handle(Request $request, Closure $next): Response { if ($request->user()) { - $timeout = config('system.security.session_idle_timeout_minutes', 30); + // Mengikuti key setting yang disimpan di database (`security.session_idle_minutes`). + // Fallback ke key lama jika ada, agar backward compatible. + $timeout = (int) (config('system.security.session_idle_minutes') + ?? config('system.security.session_idle_timeout_minutes', 30)); $last = session('last_active_at'); if ($last && now()->diffInMinutes($last) >= $timeout) { diff --git a/app/Services/Dashboard/DashboardService.php b/app/Services/Dashboard/DashboardService.php new file mode 100644 index 0000000..dcb99d1 --- /dev/null +++ b/app/Services/Dashboard/DashboardService.php @@ -0,0 +1,234 @@ +id); + + return Cache::remember($cacheKey, now()->addMinutes(2), function () use ($user) { + $now = Carbon::now(); + $last30 = $now->copy()->subDays(30); + $trendDays = 14; + $trendStart = $now->copy()->subDays($trendDays - 1)->startOfDay(); + + $warrantyDays = (int) $this->settings->get('asset.warranty_reminder_days', 30); + $lowStockThreshold = (int) $this->settings->get('asset.consumable_low_stock_threshold', 5); + + $assetsQuery = Asset::query()->forUser($user); + + $totalAssets = (clone $assetsQuery)->count(); + $archivedAssets = (clone $assetsQuery)->whereNotNull('archived_at')->count(); + $softDeletedAssets = (clone $assetsQuery)->onlyTrashed()->count(); + + $activeDisposals = AssetDisposal::query() + ->whereNull('reversed_at') + ->where('status', 'approved') + ->whereHas('asset', fn ($q) => $q->forUser($user)) + ->count(); + + $movementCount30 = AssetMovement::query() + ->where('created_at', '>=', $last30) + ->where('status', 'approved') + ->whereHas('asset', fn ($q) => $q->forUser($user)) + ->count(); + + $auditIssues = AssetAudit::query() + ->whereIn('status', ['missing', 'damaged']) + ->whereHas('asset', fn ($q) => $q->forUser($user)) + ->count(); + + $warrantyExpiring = (clone $assetsQuery) + ->whereNotNull('warranty_end') + ->whereBetween('warranty_end', [$now->toDateString(), $now->copy()->addDays($warrantyDays)->toDateString()]) + ->count(); + + $maintenanceOpen = AssetMaintenance::query() + ->whereIn('status', ['planned', 'in_progress', 'pending']) + ->whereHas('asset', fn ($q) => $q->forUser($user)) + ->count(); + + $lowStockConsumables = (clone $assetsQuery) + ->where('is_consumable', true) + ->whereNotNull('available_quantity') + ->where('available_quantity', '<=', $lowStockThreshold) + ->count(); + + $pendingApprovalsCount = AssetApprovalRequest::query() + ->where('status', 'pending') + ->count(); + + $byStatus = AssetStatus::query() + ->withCount(['assets as assets_count' => fn ($q) => $q->forUser($user)]) + ->orderBy('name') + ->get() + ->map(fn ($status) => ['label' => $status->name, 'value' => (int) $status->assets_count]) + ->values(); + + $byLocation = AssetLocation::query() + ->withCount(['assets as assets_count' => fn ($q) => $q->forUser($user)]) + ->orderByDesc('assets_count') + ->orderBy('name') + ->get() + ->map(fn ($loc) => ['label' => $loc->name, 'value' => (int) $loc->assets_count]) + ->filter(fn ($row) => $row['value'] > 0) + ->take(10) + ->values(); + + $byCategory = AssetCategory::query() + ->withCount(['assets as assets_count' => fn ($q) => $q->forUser($user)]) + ->orderByDesc('assets_count') + ->orderBy('name') + ->get() + ->map(fn ($cat) => ['label' => $cat->name, 'value' => (int) $cat->assets_count]) + ->filter(fn ($row) => $row['value'] > 0) + ->take(10) + ->values(); + + $trendLabels = $this->buildTrendLabels($trendStart, $trendDays); + $movementTrend = $this->buildDailyTrend( + AssetMovement::query() + ->where('status', 'approved') + ->whereHas('asset', fn ($q) => $q->forUser($user)), + 'created_at', + $trendStart, + $trendDays + ); + $disposalTrend = $this->buildDailyTrend( + AssetDisposal::query() + ->where('status', 'approved') + ->whereHas('asset', fn ($q) => $q->forUser($user)), + 'created_at', + $trendStart, + $trendDays + ); + $auditIssueTrend = $this->buildDailyTrend( + AssetAudit::query() + ->whereIn('status', ['missing', 'damaged']) + ->whereHas('asset', fn ($q) => $q->forUser($user)), + 'created_at', + $trendStart, + $trendDays + ); + + $recentAudits = AssetAudit::with(['asset']) + ->whereHas('asset', fn ($q) => $q->forUser($user)) + ->latest() + ->take(5) + ->get(); + + $recentDisposals = AssetDisposal::with(['asset']) + ->whereHas('asset', fn ($q) => $q->forUser($user)) + ->latest() + ->take(5) + ->get(); + + $recentMovements = AssetMovement::with(['asset', 'fromLocation', 'toLocation', 'fromDepartment', 'toDepartment', 'fromUser', 'toUser']) + ->whereHas('asset', fn ($q) => $q->forUser($user)) + ->latest() + ->take(5) + ->get(); + + $pendingApprovals = AssetApprovalRequest::with(['approvable', 'requester']) + ->where('status', 'pending') + ->latest() + ->take(5) + ->get(); + + $maintenanceDueSoon = AssetMaintenance::with('asset') + ->whereIn('status', ['planned', 'in_progress', 'pending']) + ->whereNotNull('performed_at') + ->whereBetween('performed_at', [$now->copy()->subDays(1), $now->copy()->addDays(7)]) + ->whereHas('asset', fn ($q) => $q->forUser($user)) + ->orderBy('performed_at') + ->take(5) + ->get(); + + $readonlyMode = (bool) $this->settings->get('maintenance.readonly_mode', false); + + return compact( + 'totalAssets', + 'archivedAssets', + 'softDeletedAssets', + 'movementCount30', + 'activeDisposals', + 'auditIssues', + 'warrantyExpiring', + 'maintenanceOpen', + 'lowStockConsumables', + 'pendingApprovalsCount', + 'byStatus', + 'byLocation', + 'byCategory', + 'trendLabels', + 'movementTrend', + 'disposalTrend', + 'auditIssueTrend', + 'recentAudits', + 'recentDisposals', + 'recentMovements', + 'pendingApprovals', + 'maintenanceDueSoon', + 'readonlyMode' + ); + }); + } + + private function buildTrendLabels(Carbon $start, int $days): array + { + return collect(range(0, $days - 1)) + ->map(fn ($i) => $start->copy()->addDays($i)->format('d/m')) + ->all(); + } + + /** + * Menghasilkan array count per hari (panjang = $days) untuk dataset chart. + */ + private function buildDailyTrend($query, string $dateColumn, Carbon $start, int $days): array + { + $end = $start->copy()->addDays($days)->endOfDay(); + + /** @var Collection $raw */ + $raw = $query + ->whereBetween($dateColumn, [$start, $end]) + ->selectRaw('DATE(' . $dateColumn . ') as d, COUNT(*) as c') + ->groupByRaw('DATE(' . $dateColumn . ')') + ->pluck('c', 'd') + ->map(fn ($v) => (int) $v); + + return collect(range(0, $days - 1)) + ->map(function ($i) use ($start, $raw) { + $key = $start->copy()->addDays($i)->toDateString(); + return (int) ($raw[$key] ?? 0); + }) + ->all(); + } +} diff --git a/database/seeders/AssetSeeder.php b/database/seeders/AssetSeeder.php index 2d30297..4794e57 100644 --- a/database/seeders/AssetSeeder.php +++ b/database/seeders/AssetSeeder.php @@ -110,15 +110,23 @@ class AssetSeeder extends Seeder // Download 2 foto berbeda untuk tiap aset dari pool. $pics = collect($imagePool)->shuffle()->take(2); foreach ($pics as $idx => $url) { - $response = Http::get($url); - if ($response->ok()) { - $path = "assets/{$asset->id}/photo-{$idx}.jpg"; - Storage::disk('public')->put($path, $response->body()); - AssetPhoto::create([ - 'asset_id' => $asset->id, - 'path' => $path, - 'is_primary' => $idx === 0 && $asset->photos()->count() === 0, - ]); + try { + $response = Http::retry(3, 1000) + ->connectTimeout(10) + ->timeout(20) + ->get($url); + if ($response->ok()) { + $path = "assets/{$asset->id}/photo-{$idx}.jpg"; + Storage::disk('public')->put($path, $response->body()); + AssetPhoto::create([ + 'asset_id' => $asset->id, + 'path' => $path, + 'is_primary' => $idx === 0 && $asset->photos()->count() === 0, + ]); + } + } catch (\Throwable $e) { + // Jika download gagal (timeout/SSL/diblok), seeder tetap lanjut tanpa foto. + continue; } } } @@ -161,15 +169,22 @@ class AssetSeeder extends Seeder // Gunakan foto generik untuk consumable $url = 'https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?auto=format&fit=crop&w=800&q=80'; - $response = Http::get($url); - if ($response->ok()) { - $path = "assets/{$asset->id}/photo-consumable.jpg"; - Storage::disk('public')->put($path, $response->body()); - AssetPhoto::create([ - 'asset_id' => $asset->id, - 'path' => $path, - 'is_primary' => true, - ]); + try { + $response = Http::retry(3, 1000) + ->connectTimeout(10) + ->timeout(20) + ->get($url); + if ($response->ok()) { + $path = "assets/{$asset->id}/photo-consumable.jpg"; + Storage::disk('public')->put($path, $response->body()); + AssetPhoto::create([ + 'asset_id' => $asset->id, + 'path' => $path, + 'is_primary' => true, + ]); + } + } catch (\Throwable $e) { + // ignore } } } diff --git a/documentation/guide.md b/documentation/guide.md new file mode 100644 index 0000000..7f2e633 --- /dev/null +++ b/documentation/guide.md @@ -0,0 +1,149 @@ +# Dokumentasi Lengkap Asset Management System + +Dokumen ini menjadi referensi utama untuk developer/ops yang ingin memahami alur, konfigurasi, dan fitur lengkap sistem. + +## Isi Singkat +- [Arsitektur Ringkas](#arsitektur-ringkas) +- [Alur Penggunaan](#alur-penggunaan) +- [Setup & Wizard](#setup--wizard) +- [Authentication, 2FA, Session Timeout](#authentication-2fa-session-timeout) +- [RBAC](#rbac) +- [System Settings (Referensi)](#system-settings-referensi) +- [Identifikasi Aset (QR/RFID/NFC)](#identifikasi-aset-qrrfidnfc) +- [Transaksi & Approval Workflow](#transaksi--approval-workflow) +- [Maintenance & Read-only Mode](#maintenance--read-only-mode) +- [Logging & Audit Trail](#logging--audit-trail) +- [Import/Export & Bulk Data](#importexport--bulk-data) +- [Laporan (Excel/PDF)](#laporan-excelpdf) +- [Integrasi API & Webhook](#integrasi-api--webhook) +- [Notifikasi](#notifikasi) +- [Public Asset View (QR Scan)](#public-asset-view-qr-scan) +- [Queues & Kinerja](#queues--kinerja) +- [Testing](#testing) +- [Deployment Checklist](#deployment-checklist) + +## Arsitektur Ringkas +- **Laravel** + template **Vyzor (Bootstrap)**. +- **Service-first**: logic domain di `app/Services/*`, controller tipis. +- **UUID** sebagai primary key (role/permission juga sudah di-custom untuk UUID). +- **Settings** berbasis DB di tabel `settings`, dibaca via `App\Services\System\SystemSettingService`. +- **Log** ke file khusus channel `asset_activity` (bukan DB). + +## Alur Penggunaan +1. Buka aplikasi; jika DB belum siap, otomatis redirect ke **/setup**. +2. Jalankan wizard: + - Step DB: migrate + seed minimal/full. + - Step 1: buat admin. + - Step 2: isi nilai setting (form dari data di DB). + - Step 3: generate master data dasar. + - Step 4: buat aset sample (opsional). +3. Login ke dashboard (`/dashboard/index`), cek ringkasan di dashboard. +4. Kelola master data (status, kelas, kategori, lokasi, PIC, dsb.). +5. Input aset + foto, QR otomatis di-generate jika diaktifkan. +6. Jalankan transaksi (movement, disposal, maintenance, audit) sesuai izin. +7. Approval (jika workflow pending) dikerjakan di menu **Approvals**. +8. Export laporan (Excel/PDF) atau lakukan import massal jika perlu. + +## Setup & Wizard +- **Route**: `GET /setup`, `POST /setup`, `POST /setup/migrate`. +- Bila koneksi DB gagal → tampilkan panduan isi `.env` + tombol migrate (minimal/full seed). +- Wizard langkah 1–4 (admin, setting, master, aset sample). +- Middleware `EnsureDatabaseReady` mencegah akses normal sebelum DB siap. + +## Authentication, 2FA, Session Timeout +- Login: `/login`, Register: `/register`, Logout: `POST /logout`. +- **2FA**: setelah password benar, user yang aktifkan 2FA akan diminta kode 6 digit di `/two-factor-challenge`. +- **Session timeout**: middleware `session.timeout` memakai setting `security.session_idle_minutes`. + +## RBAC +- Paket `spatie/laravel-permission` dengan UUID (lihat `config/permission.php`). +- Permissions utama: `settings.manage`, `users.manage`, `roles.manage`, `permissions.manage`, `assets.view`, `assets.manage`, `movements.manage`, `disposals.manage`, `audits.manage`, `maintenance.manage`, `reports.view`, `approvals.manage`. +- Role seed: `super-admin`, `asset-manager`, `auditor`, `maintenance`, `viewer`. +- Scoping data aset: `Asset::forUser($user)` membatasi berdasar departemen/lokasi kecuali user punya `assets.view_all`. + +## System Settings (Referensi) +Semua ada di tabel `settings` (seeded oleh `SettingSeeder`). Contoh kunci penting: +- **Aplikasi/UI**: `application.name`, `application.timezone`, `ui.date_format`, `ui.time_format`, `ui.table_page_size`. +- **Aset & Identifikasi**: `asset.code_prefix`, `asset.qr_enabled`, `asset.qr_format`, `asset.qr_size`, `asset.rfid_enabled`, `asset.nfc_enabled`, `asset.rfid_auto_generate`, `asset.nfc_auto_generate`, `asset.warranty_reminder_days`. +- **Workflow**: `asset.workflow.auto_approve_movement|disposal|maintenance`. +- **Import**: `asset.import_max_rows`, `asset.import_allow_remote_images`, `asset.import_dedupe_priority`, `asset.import_default_status/category/location`. +- **Retensi/Arsip**: `asset.archive_retention_days`, `asset.soft_delete_retention_days`. +- **Security**: `security.audit_log`, `security.session_idle_minutes`, `security.two_factor_enforced`. +- **Notification**: `notification.email_enabled`, `notification.slack_webhook_url`, `notification.import_result_email`. +- **Maintenance mode**: `maintenance.readonly_mode`. +- **Integrasi**: `integration.api_key`, `integration.webhook_enabled`, `integration.webhook_url`, `integration.webhook_secret`, `integration.api_rate_limit`. + +## Identifikasi Aset (QR/RFID/NFC) +- QR otomatis saat create/update aset (jika `asset.qr_enabled = true`), disimpan di `storage/app/public/qr/`, isi QR: `route('assets.public.show')`. +- RFID/NFC: field `rfid_tag`, `nfc_tag`. Bisa diwajibkan (`asset.rfid_required`, `asset.nfc_required`) dan auto-generate (`asset.rfid_auto_generate`, `asset.nfc_auto_generate`) oleh `AssetService`. + +## Transaksi & Approval Workflow +- **Movement**: ubah lokasi/departemen/user; jika auto-approve OFF → status `pending` dan butuh approval. +- **Disposal**: status `approved` akan mengubah status aset ke `DISPOSED` (jika ada); bisa di-reverse. +- **Maintenance**: status awal `pending`/`planned`, lanjut `in_progress`, `completed`. +- **Audit/Stocktake**: status `matched/missing/damaged`. +- **Approval**: tersimpan di `asset_approval_requests`, dikelola via `ApprovalService`, notifikasi email ke approver & requester. +- Menu approvals: `/dashboard/approvals`. + +## Maintenance & Read-only Mode +- Jika `maintenance.readonly_mode = true`, middleware `maintenance.readonly` memblok aksi tulis dan dashboard menampilkan badge peringatan. + +## Logging & Audit Trail +- **Request audit**: middleware `audit.request` mencatat semua non-GET beserta durasi dan status. +- **Helper**: `asset_activity_log()`, `asset_request_log()` → channel `asset_activity` (`storage/logs/asset-activity.log`, daily). +- **ActivityLogger**: masking field sensitif (password, token) dan memotong body terlalu panjang. + +## Import/Export & Bulk Data +- **Import**: modal di `/dashboard/assets`, mendukung: + - Preview (tanpa commit) + ringkasan create/update/invalid. + - Import langsung. + - Gambar dari URL (jika `asset.import_allow_remote_images`) atau ZIP (`images_zip` + kolom `image_file`). +- Sample CSV: `public/samples/assets_import_sample.csv`. +- **Export**: CSV via `GET /dashboard/assets-export` (limit 2000 rows). +- Dedupe berdasarkan setting `asset.import_dedupe_priority` (code/serial). + +## Laporan (Excel/PDF) +- Menu laporan: `/dashboard/reports/assets|movements|disposals|audits`. +- Export: + - Excel multi-sheet (aset + relasi utama, dll.) + - PDF via DomPDF. +- Filter mengikuti halaman laporan (date range, lokasi, dsb.). + +## Integrasi API & Webhook +- Public API key di `integration.api_key`; middleware `VerifyApiKey` (header `X-Api-Key`). +- Webhook event aset di `App\Services\Integration\WebhookDispatcher` (respect `integration.webhook_enabled/url/secret/timeout`). +- Dokumentasi API disediakan di `GET /api-docs`. + +## Notifikasi +- Email notifikasi approval: `ApprovalRequestedNotification`, `ApprovalDecisionNotification`. +- SMTP wajib dikonfigurasi di `.env` (MAIL_*). Jika `notification.email_enabled=false`, notifikasi email tidak dikirim. +- Contoh cron reminder sederhana: `php artisan asset:notify-due` (garansi akan habis, maintenance due, audit overdue) → tambahkan ke cron jika perlu. + +## Public Asset View (QR Scan) +- Route: `GET /asset-view/{asset}` (tanpa login). +- Di halaman ini tersedia tombol transaksi (movement/disposal/maintenance) dalam bentuk modal jika user login & punya permission; jika tidak, hanya view data + foto aset. + +## Queues & Kinerja +- Import besar dianjurkan dijalankan di queue (kini masih sinkron); arsitektur kode disiapkan agar mudah dialihkan ke job queue jika volume tinggi. +- Gunakan `php artisan queue:work` untuk job lain (notifikasi/email) jika diubah ke asynchronous. +- Pagination server-side diterapkan pada tabel besar (misal approvals, master, transaksi). +- Index DB sudah disiapkan di migrasi utama (cek migrasi masing-masing tabel). + +## Testing +- Jalankan semua test: +```bash +php artisan test +``` +- Test penting: + - `tests/Feature/DashboardPageTest.php` + - `tests/Feature/AssetTransactionFlowTest.php` + - `tests/Feature/SystemSettingsPageTest.php` + +## Deployment Checklist +- `APP_ENV=production`, `APP_DEBUG=false`, `APP_KEY` terisi. +- Konfigurasi DB + jalankan `php artisan migrate --force`. +- `php artisan storage:link`. +- Build frontend: `npm run build` (atau gunakan artifact `public/build` yang sudah ada). +- Permission folder: `storage/` dan `bootstrap/cache/` writable. +- Set SMTP (MAIL_*), dan cron untuk queue (jika dipakai) / `asset:notify-due` (opsional). + diff --git a/resources/assets/images/landing/cta.jpg b/resources/assets/images/landing/cta.jpg new file mode 100644 index 0000000..38c3e55 Binary files /dev/null and b/resources/assets/images/landing/cta.jpg differ diff --git a/resources/assets/images/landing/feature.jpg b/resources/assets/images/landing/feature.jpg new file mode 100644 index 0000000..d80875b Binary files /dev/null and b/resources/assets/images/landing/feature.jpg differ diff --git a/resources/assets/images/landing/flow.jpg b/resources/assets/images/landing/flow.jpg new file mode 100644 index 0000000..399348d Binary files /dev/null and b/resources/assets/images/landing/flow.jpg differ diff --git a/resources/assets/images/landing/hero.jpg b/resources/assets/images/landing/hero.jpg new file mode 100644 index 0000000..b66ee17 Binary files /dev/null and b/resources/assets/images/landing/hero.jpg differ diff --git a/resources/views/layouts/components/landingpage/footer.blade.php b/resources/views/layouts/components/landingpage/footer.blade.php index 43d693e..495711b 100644 --- a/resources/views/layouts/components/landingpage/footer.blade.php +++ b/resources/views/layouts/components/landingpage/footer.blade.php @@ -18,7 +18,7 @@
-

+

Lorem ipsum dolor sit amet consectetur adipisicing elit. Reprehenderit et magnam, fuga est mollitia eius, quo illum illo inventore optio aut quas omnis rem. Dolores accusantium aspernatur minus ea incidunt.

@@ -158,4 +158,4 @@
- \ No newline at end of file + diff --git a/resources/views/layouts/components/landingpage/main-header.blade.php b/resources/views/layouts/components/landingpage/main-header.blade.php index caff1c7..11bf5eb 100644 --- a/resources/views/layouts/components/landingpage/main-header.blade.php +++ b/resources/views/layouts/components/landingpage/main-header.blade.php @@ -10,7 +10,7 @@
@@ -274,7 +197,7 @@
Alur End-to-End
Dari registrasi sampai audit & perawatan
- Ikuti alur standar manajemen aset: daftar & QR, movement, disposal/reverse, maintenance, audit, dan laporan. + Ikuti alur standar manajemen aset: setup awal, identifikasi (QR/RFID/NFC), transaksi, approval, audit, dan laporan.
@@ -290,8 +213,8 @@
-
Registrasi & QR
- Kode aset otomatis, QR untuk scan cepat, link ke detail & transaksi. +
Setup & Registrasi
+ Wizard setup, setting, master data, lalu registrasi aset dengan kode otomatis.
@@ -342,7 +265,41 @@
Audit & Laporan
- Audit fisik, status matched/missing/damaged, laporan Excel/PDF multi-sheet. + Audit fisik (scan QR/NFC), changelog, dan laporan Excel/PDF siap kirim. +
+ + + + +
+
+
+
+
+ + + +
+
+
Approval & Audit Trail
+ Movement/disposal/maintenance dapat butuh approval; notifikasi email & jejak audit tercatat. +
+
+
+
+
+
+
+
+
+
+ + + +
+
+
Import & Skalabilitas
+ Import besar diproses via queue + status batch, pagination server-side, dan indexing DB.
@@ -352,7 +309,7 @@
- + Alur Manajemen Aset
@@ -360,272 +317,14 @@ - -
-
- -
-
-
-
-

Transform Your Workflow Today

- Unlock all the powerful features of our admin template.
Purchase now or try the demo to see it in action!
-
-
- View Demo - -
-
-
-
- - - -
-
-
-
Our Pricing
-
Choose the Plan That Fits Your Needs
-
- Select the right fit and enjoy seamless access to all features. -
-
- -
-
-
-
-
-
-
-
-
Basic
-
-

$15

- / Month -
-
    -
  • - Access to core features -
  • -
  • - 5GB storage -
  • -
  • - Basic customer support -
  • -
  • - 1 user access -
  • -
  • - Email notifications -
  • -
-
- 25% Off -
- -
-
-
-
-
-
-
-
-
Pro
-
-

$45

- / Month -
-
    -
  • - All Basic Plan Features -
  • -
  • - 50GB storage -
  • -
  • - Priority customer support -
  • -
  • - 5 user access -
  • -
  • - Advanced analytics -
  • -
-
- 40% Off -
- -
-
-
-
-
-
-
-
-
Enterprise
-
-

$99

- / Month -
-
    -
  • - All Pro Plan Features -
  • -
  • - Unlimited storage -
  • -
  • - Dedicated account manager -
  • -
  • - 20 user access -
  • -
  • - Customizable workflows -
  • -
-
- 50% Off -
- -
-
-
-
-
-
-
-
-
-
-
-
-
Basic
-
-

$150

- / Year -
-
    -
  • - Access to core features -
  • -
  • - 5GB storage -
  • -
  • - Basic customer support -
  • -
  • - 1 user access -
  • -
  • - Email notifications -
  • -
-
- 25% Off -
- -
-
-
-
-
-
-
-
-
Pro
-
-

$450

- / Year -
-
    -
  • - All Basic Plan Features -
  • -
  • - 50GB storage -
  • -
  • - Priority customer support -
  • -
  • - 5 user access -
  • -
  • - Advanced analytics -
  • -
-
- 40% Off -
- -
-
-
-
-
-
-
-
-
Enterprise
-
-

$990

- / Year -
-
    -
  • - All Pro Plan Features -
  • -
  • - Unlimited storage -
  • -
  • - Dedicated account manager -
  • -
  • - 20 user access -
  • -
  • - Customizable workflows -
  • -
-
- 50% Off -
- -
-
-
-
-
-
-
-
-
-
- - -
+
-
FAQ's
-
Need Help? Find Your Answers Here
+
FAQ
+
Pertanyaan Umum
- Browse through common questions to get quick solutions. We're here to help! + Ringkasan jawaban untuk alur, fitur, dan konfigurasi sistem manajemen aset.
@@ -634,72 +333,72 @@

- You can easily customize the dashboard by dragging and dropping widgets. Go to the settings menu and select 'Customize Dashboard' to rearrange the layout. + Saat database belum siap, aplikasi otomatis mengarahkan ke Wizard Setup. Anda membuat admin, mengisi setting (termasuk SMTP), lalu master data, dan aset pertama.

- Yes! Our admin template supports seamless integrations with third-party apps. You can easily connect tools like Google Analytics, CRM software, and more. + Ya. Tersedia Public REST API (header X-Api-Key) dan webhook event aset (movement/disposal/audit/maintenance). Dokumentasi ada di halaman API Docs.

- Absolutely! The admin template is fully responsive and optimized for mobile, ensuring that it works perfectly on smartphones and tablets. + QR bisa diaktifkan/nonaktifkan lewat setting. RFID/NFC juga opsional, dan dapat dibuat otomatis berdasarkan kode aset (auto-generate) atau diwajibkan (required) sesuai kebutuhan perusahaan.

- You can manage user roles and permissions under the 'User Management' section. Simply assign roles like Admin, Manager, or Viewer to control access. + Untuk movement/disposal/maintenance, sistem dapat menandai transaksi sebagai pending. Approver menerima notifikasi email (SMTP setting) dan dapat approve/reject. Semua keputusan dicatat sebagai audit trail.

- Yes, you can easily export reports as CSV, PDF, or Excel files. Just click the export button at the top of the reports page." + Sistem menyediakan preview import dan deduplikasi. Jika jumlah baris melewati threshold, import otomatis diantrikan ke queue dan statusnya tercatat di batch (queued/processing/success/failed). Gambar bisa dari URL atau ZIP.

- Notifications can be enabled under the 'Settings' section. You can choose to receive real-time alerts via email or in-app for important updates. + Isi setting SMTP di menu Settings (mail.host/port/username/password/encryption/from). Setelah itu jalankan queue worker (php artisan queue:work) agar email notifikasi terkirim.
@@ -710,6 +409,31 @@
+ +
+
+ Get Started +
+
+
+
+

Mulai kelola aset dengan rapi, terukur, dan siap audit

+ Setup cepat, role & permission, approval, audit trail, import besar via queue, dan integrasi API/webhook. +
+
+ @auth + Dashboard + @else + Buat Akun + @endauth + API Docs +
+
+
+
+ + + {{--
@@ -1227,6 +951,7 @@
+ --}} @endsection diff --git a/tests/Feature/AssetTransactionFlowTest.php b/tests/Feature/AssetTransactionFlowTest.php index 86ab513..726d85b 100644 --- a/tests/Feature/AssetTransactionFlowTest.php +++ b/tests/Feature/AssetTransactionFlowTest.php @@ -9,112 +9,135 @@ use App\Models\AssetMovement; use App\Models\AssetStatus; use App\Models\AssetUser; use App\Models\Department; +use App\Models\Permission; use App\Models\User; -use App\Services\Asset\AssetService; use Illuminate\Foundation\Testing\RefreshDatabase; -use Spatie\Permission\Models\Permission; +use Illuminate\Support\Str; use Tests\TestCase; class AssetTransactionFlowTest extends TestCase { use RefreshDatabase; - private function userWithPermission(string ): User + private function userWithPermission(string $permission): User { - Permission::findOrCreate(, 'web'); - = User::factory()->create(); - ->givePermissionTo(); - return ; + Permission::findOrCreate($permission, 'web'); + + $user = User::factory()->create(); + $user->givePermissionTo($permission); + + return $user; } private function baseAsset(): Asset { - = AssetStatus::create(['name' => 'Active', 'code' => 'ACTIVE']); - = AssetLocation::create(['name' => 'Loc A', 'code' => 'L-A']); - = Department::create(['name' => 'Dept A', 'code' => 'D-A']); - = AssetUser::create(['name' => 'User A']); + $status = AssetStatus::create(['name' => 'Active', 'code' => 'ACTIVE']); + $loc = AssetLocation::create(['name' => 'Loc A', 'code' => 'L-A']); + $dept = Department::create(['name' => 'Dept A', 'code' => 'D-A']); + $assetUser = AssetUser::create(['name' => 'User A']); return Asset::create([ 'code' => 'AST-00001', 'name' => 'Asset Test', - 'asset_status_id' => ->id, - 'asset_location_id' => ->id, - 'department_id' => ->id, - 'asset_user_id' => ->id, - 'qr_token' => 'tok-1', + 'asset_status_id' => $status->id, + 'asset_location_id' => $loc->id, + 'department_id' => $dept->id, + 'asset_user_id' => $assetUser->id, + 'qr_token' => (string) Str::uuid(), ]); } - public function test_movement_flow_updates_asset_and_marks_approved() + public function test_movement_flow_updates_asset_and_marks_approved_when_auto_approve(): void { config(['system.workflow.require_approval.movement' => false]); - = app(AssetService::class); - = ->baseAsset(); - = AssetLocation::create(['name' => 'Loc B', 'code' => 'L-B']); - = Department::create(['name' => 'Dept B', 'code' => 'D-B']); - = AssetUser::create(['name' => 'User B']); + $user = $this->userWithPermission('movements.manage'); + $asset = $this->baseAsset(); - = ->move(, [ - 'to_location_id' => ->id, - 'to_department_id' => ->id, - 'to_asset_user_id' => ->id, + $toLoc = AssetLocation::create(['name' => 'Loc B', 'code' => 'L-B']); + $toDept = Department::create(['name' => 'Dept B', 'code' => 'D-B']); + $toUser = AssetUser::create(['name' => 'User B']); + + $response = $this->actingAs($user)->post(route('assets.movements.store', $asset), [ + 'to_location_id' => $toLoc->id, + 'to_department_id' => $toDept->id, + 'to_asset_user_id' => $toUser->id, 'notes' => 'Test move', ]); - ->refresh(); - ->assertEquals('approved', ->status); - ->assertEquals(->id, ->asset_location_id); - ->assertEquals(->id, ->department_id); - ->assertEquals(->id, ->asset_user_id); + $response->assertSessionHasNoErrors(); + + $asset->refresh(); + $this->assertEquals($toLoc->id, $asset->asset_location_id); + $this->assertEquals($toDept->id, $asset->department_id); + $this->assertEquals($toUser->id, $asset->asset_user_id); + + $movement = AssetMovement::first(); + $this->assertNotNull($movement); + $this->assertEquals('approved', $movement->status); + $this->assertNotNull($movement->approved_at); + $this->assertEquals($user->id, $movement->approved_by); } - public function test_disposal_requires_permission_and_sets_disposed_status() + public function test_disposal_requires_permission_and_sets_disposed_status_when_auto_approve(): void { config(['system.workflow.require_approval.disposal' => false]); - = app(AssetService::class); - = ->baseAsset(); - // tanpa permission -> 403 - ->actingAs(User::factory()->create()); - = ->post(route('assets.disposals.store', ), [ - 'reason' => 'Rusak', - ]); - ->assertForbidden(); - - // dengan permission - = ->userWithPermission('disposals.manage'); - ->actingAs(); - = ->post(route('assets.disposals.store', ), [ - 'reason' => 'Rusak', - ]); - ->assertSessionHasNoErrors(); - - ->refresh(); - = AssetDisposal::first(); - ->assertEquals('approved', ->status); - ->assertEquals('DISPOSED', ->status?->code ?? null); - } - - public function test_reverse_restores_asset_and_marks_disposal_reversed() - { - config(['system.workflow.require_approval.disposal' => false]); - = app(AssetService::class); - = ->baseAsset(); - = ->asset_location_id; - = ->department_id; - - // buat status disposed untuk update status + // Buat status disposed agar service mengubah status aset. AssetStatus::firstOrCreate(['code' => 'DISPOSED'], ['name' => 'Disposed']); - = ->dispose(, ['reason' => 'Rusak']); - ->reverseDisposal(, 'Tes reverse'); + $asset = $this->baseAsset(); - ->refresh(); - ->refresh(); + // Tanpa permission: harus 403 dari middleware `permission:disposals.manage`. + $response = $this->actingAs(User::factory()->create())->post(route('assets.disposals.store', $asset), [ + 'reason' => 'Rusak', + ]); + $response->assertForbidden(); - ->assertEquals(, ->asset_location_id); - ->assertEquals(, ->department_id); - ->assertEquals('reversed', ->status); + // Dengan permission: berhasil, status disposal approved, asset menjadi DISPOSED. + $user = $this->userWithPermission('disposals.manage'); + $response = $this->actingAs($user)->post(route('assets.disposals.store', $asset), [ + 'reason' => 'Rusak', + 'notes' => 'Pengujian disposal', + ]); + $response->assertSessionHasNoErrors(); + + $asset->refresh(); + $disposal = AssetDisposal::first(); + $this->assertNotNull($disposal); + $this->assertEquals('approved', $disposal->status); + $this->assertEquals('DISPOSED', optional($asset->status)->code); } -} \ No newline at end of file + + public function test_reverse_restores_asset_and_marks_disposal_reversed(): void + { + config(['system.workflow.require_approval.disposal' => false]); + AssetStatus::firstOrCreate(['code' => 'DISPOSED'], ['name' => 'Disposed']); + + $user = $this->userWithPermission('disposals.manage'); + $asset = $this->baseAsset(); + $previousStatusId = $asset->asset_status_id; + $previousLocationId = $asset->asset_location_id; + $previousDepartmentId = $asset->department_id; + + $this->actingAs($user)->post(route('assets.disposals.store', $asset), [ + 'reason' => 'Rusak', + ])->assertSessionHasNoErrors(); + + $disposal = AssetDisposal::first(); + $this->assertNotNull($disposal); + + $this->actingAs($user)->post(route('assets.disposals.reverse', $disposal), [ + 'notes' => 'Tes reverse', + ])->assertSessionHasNoErrors(); + + $asset->refresh(); + $disposal->refresh(); + + $this->assertEquals($previousStatusId, $asset->asset_status_id); + $this->assertEquals($previousLocationId, $asset->asset_location_id); + $this->assertEquals($previousDepartmentId, $asset->department_id); + $this->assertEquals('reversed', $disposal->status); + $this->assertNotNull($disposal->reversed_at); + } +} diff --git a/tests/Feature/DashboardPageTest.php b/tests/Feature/DashboardPageTest.php new file mode 100644 index 0000000..74a67f6 --- /dev/null +++ b/tests/Feature/DashboardPageTest.php @@ -0,0 +1,108 @@ +create(); + + $statusActive = AssetStatus::create(['name' => 'Active', 'code' => 'ACTIVE']); + $statusRepair = AssetStatus::create(['name' => 'Repair', 'code' => 'REPAIR']); + + $locHq = AssetLocation::create(['name' => 'HQ', 'code' => 'HQ']); + $locBranch = AssetLocation::create(['name' => 'Branch', 'code' => 'BR']); + + $asset1 = Asset::create([ + 'code' => 'AST-001', + 'name' => 'Laptop A', + 'asset_status_id' => $statusActive->id, + 'asset_location_id' => $locHq->id, + 'qr_token' => (string) Str::uuid(), + 'warranty_end' => now()->addDays(7)->toDateString(), + 'cost' => 10000000, + ]); + $asset2 = Asset::create([ + 'code' => 'AST-002', + 'name' => 'Printer B', + 'asset_status_id' => $statusRepair->id, + 'asset_location_id' => $locBranch->id, + 'qr_token' => (string) Str::uuid(), + 'warranty_end' => now()->addDays(10)->toDateString(), + 'cost' => 2500000, + ]); + $asset3 = Asset::create([ + 'code' => 'AST-003', + 'name' => 'Tinta Printer', + 'asset_status_id' => $statusActive->id, + 'asset_location_id' => $locHq->id, + 'qr_token' => (string) Str::uuid(), + 'is_consumable' => true, + 'quantity' => 10, + 'available_quantity' => 3, + ]); + + // Arsip & trash (soft delete). + $asset2->update(['archived_at' => now()]); + $asset3->delete(); + + // Aktivitas: movement/disposal/audit issue/maintenance. + $movement = AssetMovement::create([ + 'asset_id' => $asset1->id, + 'status' => 'approved', + 'performed_at' => now()->subDays(2), + ]); + AssetDisposal::create([ + 'asset_id' => $asset1->id, + 'status' => 'approved', + 'disposed_at' => now()->subDays(1), + 'reversed_at' => null, + ]); + AssetAudit::create([ + 'asset_id' => $asset1->id, + 'status' => 'missing', + 'audited_at' => now()->subDays(1), + ]); + AssetMaintenance::create([ + 'asset_id' => $asset1->id, + 'status' => 'planned', + 'performed_at' => now()->addDays(2), + 'description' => 'Preventive maintenance', + ]); + + AssetApprovalRequest::create([ + 'approvable_id' => $movement->id, + 'approvable_type' => AssetMovement::class, + 'type' => 'movement', + 'status' => 'pending', + 'requested_by' => $user->id, + ]); + + $response = $this->actingAs($user)->get(route('index')); + + $response->assertStatus(200); + $response->assertSee('Dashboard'); + $response->assertViewHas('totalAssets', 2); + $response->assertViewHas('archivedAssets', 1); + $response->assertViewHas('softDeletedAssets', 1); + $response->assertViewHas('pendingApprovalsCount', 1); + $response->assertViewHas('warrantyExpiring', 2); + $response->assertViewHas('auditIssues', 1); + } +}