diff --git a/app/Http/Controllers/Api/ReportsController.php b/app/Http/Controllers/Api/ReportsController.php index 9f583d560f67..59ad1dc0024d 100644 --- a/app/Http/Controllers/Api/ReportsController.php +++ b/app/Http/Controllers/Api/ReportsController.php @@ -4,7 +4,10 @@ use App\Http\Controllers\Controller; use App\Http\Transformers\ActionlogsTransformer; +use App\Http\Transformers\ExpiringItemsTransformer; use App\Models\Actionlog; +use App\Models\Asset; +use App\Models\License; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -100,4 +103,31 @@ public function index(Request $request): JsonResponse|array return response()->json((new ActionlogsTransformer)->transformActionlogs($actionlogs, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE); } + + public function expiringAssetsReport(Request $request) + { + $this->authorize('reports.view'); + $days = $request->input('days', 30); + $assets = Asset::getExpiringWarrantyOrEol($days); + + return response()->json( + (new ExpiringItemsTransformer)->transformAssets($assets, $assets->count()) + ); + } + + public function expiringLicensesReport(Request $request) + { + $this->authorize('reports.view'); + + $days = (int)$request->input('days', 30); + $includeExpired = $request->boolean('include_expired', false); + + $licenses = License::query() + ->expiringLicenses($days, $includeExpired) + ->get(); + + return response()->json( + (new ExpiringItemsTransformer)->transformLicenses($licenses, $licenses->count()) + ); + } } diff --git a/app/Http/Controllers/ReportsController.php b/app/Http/Controllers/ReportsController.php index 4dd5537ada79..86b13aeeaad8 100644 --- a/app/Http/Controllers/ReportsController.php +++ b/app/Http/Controllers/ReportsController.php @@ -128,6 +128,23 @@ public function getDeprecationReport(): View return view('reports/depreciation')->with('depreciations', $depreciations); } + public function getExpiringItemsReport(): View + { + $this->authorize('reports.view'); + $settings = Setting::getSettings(); + $alert_interval = $settings->alert_interval; + $assets_count = Asset::getExpiringWarrantyOrEol($alert_interval)->count(); + + $licenses_count = License::query() + ->expiringLicenses($alert_interval) + ->with(['manufacturer', 'category']) + ->orderBy('expiration_date', 'ASC') + ->orderBy('termination_date', 'ASC') + ->count(); + + return view('reports.expiring_items', compact('assets_count', 'licenses_count')); + } + /** * Exports the depreciations to CSV * diff --git a/app/Http/Transformers/ExpiringItemsTransformer.php b/app/Http/Transformers/ExpiringItemsTransformer.php new file mode 100644 index 000000000000..6e19bf05dff5 --- /dev/null +++ b/app/Http/Transformers/ExpiringItemsTransformer.php @@ -0,0 +1,53 @@ + $asset->id, + 'asset_tag' => $asset->asset_tag, + 'model' => $asset->model->name ?? '', + 'model_number' => $asset->model->model_number ?? '', + 'purchase_date' => Helper::getFormattedDateObject($asset->purchase_date, 'date'), + 'eol_rate' => (($asset->asset_eol_date != '') && ($asset->purchase_date != '')) ? (int)Carbon::parse($asset->asset_eol_date)->diffInMonths($asset->purchase_date, true) . ' months' : null, + 'eol_date' => Helper::getFormattedDateObject($asset->eol_date, 'date'), + 'warranty_expires' => $asset->warranty_expires ? $asset->warranty_expires_formatted_date .' ('.$asset->warranty_expires_diff_for_humans.')' : '', + ]; + } + + return [ + 'total' => $total, + 'rows' => $rows, + ]; + } + + public function transformLicenses($licenses, $total) + { + $rows = []; + + foreach ($licenses as $license) { + + $rows[] = [ + 'id' => $license->id, + 'name' => $license->name, + 'purchase_date' => $license->purchase_date_formatted ?? null, + 'expiration' => $license->expires_formatted_date ? $license->expires_formatted_date . ($license->expires_diff_for_humans ? ' ('.$license->expires_diff_for_humans.')' : '') : null, + 'termination_date' => $license->terminates_formatted_date ? $license->terminates_formatted_date . ($license->terminates_diff_for_humans ? ' ('.$license->terminates_diff_for_humans.')' : '') : null, + ]; + } + + return [ + 'total' => $total, + 'rows' => $rows, + ]; + } +} \ No newline at end of file diff --git a/app/Models/License.php b/app/Models/License.php index 4e914fc83882..3c457ce92a2b 100755 --- a/app/Models/License.php +++ b/app/Models/License.php @@ -884,25 +884,28 @@ public function scopeExpiredLicenses($query) */ public function scopeExpiringLicenses($query, $days = 60, $includeExpired = false) { - return $query// The termination date is null or within range - ->where(function ($query) use ($days) { - $query->whereNull('termination_date') - ->orWhereBetween('termination_date', [Carbon::now(), Carbon::now()->addDays($days)]); - }) - ->where(function ($query) use ($days, $includeExpired) { + $now = now()->startOfDay(); + $end = now()->copy()->addDays($days)->endOfDay(); + + return $query // The termination date is null or within range + ->where(function ($query) use ($now, $end) { + $query->whereNull('termination_date') + ->orWhereBetween('termination_date', [$now, $end]); + }) + ->where(function ($query) use ($now, $end, $includeExpired) { $query->whereNotNull('expiration_date') // Handle expiring licenses without termination dates - ->where(function ($query) use ($days, $includeExpired) { + ->where(function ($query) use ($now, $end, $includeExpired) { $query->whereNull('termination_date') - ->whereBetween('expiration_date', [Carbon::now(), Carbon::now()->addDays($days)]) - // include expired licenses if requested - ->when($includeExpired, function ($query) { - $query->orwhereDate('expiration_date', '<=', Carbon::now()); + ->whereBetween('expiration_date', [$now, $end]) + //include expired licenses if requested + ->when($includeExpired, function ($query) use ($now, $end) { + $query->orwhereDate('expiration_date', '<=', $now); }); }) // Handle expiring licenses with termination dates in the future - ->orWhere(function ($query) use ($days) { - $query->whereBetween('termination_date', [Carbon::now(), Carbon::now()->addDays($days)]); + ->orWhere(function ($query) use ($now, $end) { + $query->whereBetween('termination_date', [$now, $end]); }); }); } diff --git a/app/Presenters/ExpiringItemsPresenter.php b/app/Presenters/ExpiringItemsPresenter.php new file mode 100644 index 000000000000..8659255377e1 --- /dev/null +++ b/app/Presenters/ExpiringItemsPresenter.php @@ -0,0 +1,157 @@ + 'id', + 'searchable' => true, + 'sortable' => true, + 'title' => trans('general.id'), + 'visible' => true, + ], + [ + 'field' => 'asset_tag', + 'searchable' => true, + 'sortable' => true, + 'title' => trans('admin/hardware/form.tag'), + 'visible' => true, + ], + [ + 'field' => 'model', + 'searchable' => true, + 'sortable' => true, + 'title' => trans('admin/hardware/form.model'), + 'visible' => true, + ], + [ + 'field' => 'model_number', + 'searchable' => true, + 'sortable' => true, + 'title' => trans('general.model_no'), + 'visible' => true, + ], + [ + 'field' => 'purchase_date', + 'searchable' => true, + 'sortable' => true, + 'title' => trans('general.purchase_date'), + 'visible' => true, + 'formatter' => 'dateDisplayFormatter', + ], + [ + 'field' => 'eol_rate', + 'searchable' => true, + 'sortable' => true, + 'title' => trans('admin/hardware/form.eol_rate'), + 'visible' => true, + ], + [ + 'field' => 'eol_date', + 'searchable' => true, + 'sortable' => true, + 'title' => trans('admin/hardware/form.eol_date'), + 'visible' => true, + 'formatter' => 'dateDisplayFormatter', + ], + [ + 'field' => 'warranty_expires', + 'searchable' => true, + 'sortable' => true, + 'title' => trans('admin/hardware/form.warranty_expires'), + 'visible' => true, + ], + ]; + + return json_encode($layout); + } + + /** + * JSON column layout for expiring licenses table. + * + * @return string + */ + public static function licensesDataTableLayout() + { + $layout = [ + [ + 'field' => 'id', + 'searchable' => true, + 'sortable' => true, + 'title' => trans('general.id'), + 'visible' => true, + ], + [ + 'field' => 'name', + 'searchable' => true, + 'sortable' => true, + 'title' => trans('general.name'), + 'visible' => true, + ], + [ + 'field' => 'purchase_date', + 'searchable' => true, + 'sortable' => true, + 'title' => trans('general.purchase_date'), + 'visible' => true, + ], + [ + 'field' => 'expiration', + 'searchable' => true, + 'sortable' => true, + 'title' => trans('admin/licenses/form.expiration'), + 'visible' => true, + ], + [ + 'field' => 'termination_date', + 'searchable' => true, + 'sortable' => true, + 'title' => trans('admin/licenses/form.termination_date'), + 'visible' => true, + ], + ]; + + return json_encode($layout); + } + + /** + * Combined report payload. + * + * @param array $assets + * @param array $licenses + * @param int $days + * @param bool $includeExpired + * @return array + */ + public static function reportData(array $assets, array $licenses, int $days, bool $includeExpired) + { + return [ + 'assets' => $assets, + 'licenses' => $licenses, + 'meta' => [ + 'days' => $days, + 'include_expired' => $includeExpired, + 'asset_count' => count($assets), + 'license_count' => count($licenses), + ], + 'table_layouts' => [ + 'assets' => json_decode(self::assetsDataTableLayout(), true), + 'licenses' => json_decode(self::licensesDataTableLayout(), true), + ], + ]; + } +} \ No newline at end of file diff --git a/resources/lang/en-US/general.php b/resources/lang/en-US/general.php index 95f7bd62c71f..df2047b4e812 100644 --- a/resources/lang/en-US/general.php +++ b/resources/lang/en-US/general.php @@ -144,6 +144,7 @@ 'exclude_archived' => 'Exclude Archived Assets', 'exclude_deleted' => 'Exclude Deleted Assets', 'example' => 'Example: ', + 'Expiring_Items_Report' => "Expiring Items Report", 'files' => 'Files', 'file_name' => 'File Name', 'file_type' => 'File Type', diff --git a/resources/views/layouts/default.blade.php b/resources/views/layouts/default.blade.php index da28178628de..2a5660a81ed4 100644 --- a/resources/views/layouts/default.blade.php +++ b/resources/views/layouts/default.blade.php @@ -1719,6 +1719,11 @@ {{ trans('general.depreciation_report') }} +
  • is('reports/expiring-items') ? ' class="active"' : '') !!}}> + + {{ trans('general.Expiring_Items_Report') }} + +
  • is('reports/licenses') ? ' class="active"' : '') !!}}> {{ trans('general.license_report') }} diff --git a/resources/views/reports/expiring_items.blade.php b/resources/views/reports/expiring_items.blade.php new file mode 100644 index 000000000000..9c718de518b2 --- /dev/null +++ b/resources/views/reports/expiring_items.blade.php @@ -0,0 +1,97 @@ + +@extends('layouts/default') + +@section('title') + {{ trans('general.Expiring_Items_Report') }} + @parent +@stop + +@section('content') +
    + +
    +@stop + +@section('moar_scripts') + @include ('partials.bootstrap-table', ['search' => true, 'show-export' => false,]) +@endsection \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index 4f3df3b4a7c6..a2b722c4b70f 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1298,7 +1298,21 @@ 'index' ] )->name('api.activity.index'); - }); // end reports api routes + + Route::get('expiring-assets', + [ + Api\ReportsController::class, + 'expiringAssetsReport', + ])->name('api.expiring-assets'); + + Route::get('expiring-licenses', + [ + Api\ReportsController::class, + 'expiringLicensesReport', + ])->name('api.expiring-licenses'); + });// end reports api routes + + diff --git a/routes/web.php b/routes/web.php index 4488f7a7bf05..79ee9a86cbd2 100644 --- a/routes/web.php +++ b/routes/web.php @@ -493,7 +493,12 @@ $trail->parent('home') ->push(trans('general.depreciation_report'), route('reports/depreciation'))); - + Route::get( + 'expiring-items', [ReportsController::class, 'getExpiringItemsReport']) + ->name('reports/expiring-items') + ->breadcrumbs(fn (Trail $trail) => + $trail->parent('home') + ->push(trans('general.Expiring_Items_Report'), route('reports/expiring-items'))); // Is this still used?? Route::get( 'export/depreciation', [ReportsController::class, 'exportDeprecationReport']) diff --git a/tests/Feature/Reporting/ExpiringItemsTest.php b/tests/Feature/Reporting/ExpiringItemsTest.php new file mode 100644 index 000000000000..32c0ab5e9b31 --- /dev/null +++ b/tests/Feature/Reporting/ExpiringItemsTest.php @@ -0,0 +1,45 @@ +create(); + + $this->actingAsForApi($user) + ->getJson(route('api.expiring-assets')) + ->assertForbidden(); + } + + public function test_user_with_reports_view_can_access_expiring_assets_api() + { + $user = User::factory()->canViewReports()->create(); + + $this->actingAsForApi($user) + ->getJson(route('api.expiring-assets')) + ->assertOk(); + } + + public function test_user_without_reports_view_cannot_access_expiring_licenses_api() + { + $user = User::factory()->create(); + + $this->actingAsForApi($user) + ->getJson(route('api.expiring-licenses')) + ->assertForbidden(); + } + + public function test_user_with_reports_view_can_access_expiring_licenses_api() + { + $user = User::factory()->canViewReports()->create(); + + $this->actingAsForApi($user) + ->getJson(route('api.expiring-licenses')) + ->assertOk(); + } +} \ No newline at end of file