Compare commits

...

94 Commits

Author SHA1 Message Date
snipe a614f986f0 Added test for the fix I made in the prior commit 2026-05-12 13:07:16 +01:00
snipe f398a59d26 Fixed bug on API delete 2026-05-12 13:05:15 +01:00
snipe c8bd104268 Added comment for clarity 2026-05-12 13:00:51 +01:00
snipe bdc8fc8d4a Eager load license seat relations to avoid n+1 2026-05-12 12:41:45 +01:00
snipe 41be127489 Added bulk license action 2026-05-12 12:41:30 +01:00
snipe fdfae9593d Use isDeletable for delete ability status 2026-05-12 12:41:08 +01:00
snipe 6a21eb53c9 Fixed return type 2026-05-12 12:40:29 +01:00
snipe efde2b4672 Added checknbox to presneter 2026-05-12 12:40:08 +01:00
snipe daaa26cbf4 Added new text strings 2026-05-12 12:34:40 +01:00
snipe 3e7441562c Added bulk action menu 2026-05-12 12:34:31 +01:00
snipe 7b53fa5245 Added bulk delete route 2026-05-12 12:34:16 +01:00
snipe d35d46f5b4 Added tests 2026-05-12 12:34:03 +01:00
snipe 4c1bb7e0ac Merge remote-tracking branch 'origin/master' into develop 2026-05-11 19:45:01 +01:00
snipe 762ea9b4db One more try I guess 2026-05-11 19:44:41 +01:00
snipe b16970a61e Merge pull request #18629 from Godmartinz/update-print-invtentory-view-with-assigned2assets
Update print inventory view with indirect assignments table
2026-05-11 18:27:25 +01:00
snipe dadb9bd81e Merge remote-tracking branch 'origin/develop' 2026-05-11 16:12:03 +01:00
snipe 13dc7de660 Add enter to submit advanced search modal 2026-05-11 16:11:51 +01:00
snipe 003ea36e18 Merge remote-tracking branch 'origin/develop' 2026-05-11 16:04:10 +01:00
snipe f4bd2a68c9 Fix for compound is:not_null 2026-05-11 16:03:59 +01:00
snipe be4e75d4f7 Merge remote-tracking branch 'origin/develop' 2026-05-11 15:25:56 +01:00
snipe 538c21ce1e Merge pull request #19002 from grokability/fixed-crash-on-checkout-outside-of-company-id
Fixed crash on checkout outside of company via API
2026-05-11 15:25:27 +01:00
snipe 626cd6cb2e Fixed #18989 - better wrapping for auth self.profile checks 2026-05-11 15:25:03 +01:00
snipe 2a56f6573d Wrap the checkout in a transaction and add tests 2026-05-11 15:07:25 +01:00
snipe 6ee2dc1cd6 Merge pull request #18998 from uberbrady/fix_scim_error_email
Don't 500 on malformed emails input
2026-05-11 14:59:56 +01:00
snipe 3fcde8bd16 Prevent crash when trying to check out a component from another company if FMCS is on 2026-05-11 14:43:54 +01:00
snipe e2ff7a7bc7 Merge pull request #19000 from grokability/reports-index
🎥 Added reports index
2026-05-11 14:37:14 +01:00
snipe c7efd16517 Fixed progressbar color 2026-05-11 14:25:25 +01:00
snipe f2907f04d9 Added nicer border 2026-05-11 14:19:10 +01:00
snipe 7d98c267d5 More formatting tweaks 2026-05-11 14:13:16 +01:00
snipe 5bc6330c13 Messed with the boxes 2026-05-11 14:05:11 +01:00
snipe 1706ed597d Updated text 2026-05-11 14:05:03 +01:00
snipe 6e9ba28ef7 Tightened up query string 2026-05-11 13:26:35 +01:00
snipe 554d1a44de More shifting 2026-05-11 13:21:33 +01:00
snipe c0a8f4c1a4 Include withrashed() 2026-05-11 13:19:49 +01:00
snipe 08be9aac6d Added users chart 2026-05-11 13:08:23 +01:00
Brady Wetherington a51b17fb53 Don't 500 on malformed emails input 2026-05-11 13:07:16 +01:00
snipe 66d5618d60 Fixed links in summary box 2026-05-11 12:59:53 +01:00
snipe e16c2384fd Fixed some dark/light mode stuff 2026-05-11 12:55:25 +01:00
snipe b3323f08a0 More b0xen 2026-05-11 12:29:58 +01:00
snipe 7e63c2ef92 CSS is hard :( 2026-05-11 12:25:38 +01:00
snipe 7f65b6d598 Shifting stuff around again 2026-05-11 12:25:32 +01:00
snipe 8fb8f0a4d2 Edited queries in ReportsController 2026-05-11 12:24:05 +01:00
snipe 637dbc8d2a New strings 2026-05-11 12:23:47 +01:00
snipe 978990fdff Added progressbar 2026-05-11 12:23:41 +01:00
snipe 52a058e511 Breaking everything.. whee 2026-05-11 12:16:15 +01:00
snipe 64bea202c5 Switched layout, added chart 2026-05-11 12:01:45 +01:00
snipe 37f60993ca Added charts with date range picker 2026-05-11 11:45:34 +01:00
snipe 32717c67c7 Added reports index to sidenav 2026-05-11 11:45:20 +01:00
snipe 3681e3f025 Removed extranneous div 2026-05-11 11:45:08 +01:00
snipe 1d0f055349 Added new strings 2026-05-11 11:44:58 +01:00
snipe fb3024ca9c Added controller methods for reports 2026-05-11 11:44:52 +01:00
snipe 005c0ea9f6 Pint 2026-05-11 11:44:37 +01:00
snipe 7c3f1f3a84 Added routes 2026-05-11 11:44:29 +01:00
snipe 900e5209d9 Added claude.md :( 2026-05-11 10:21:30 +01:00
snipe 4fbf416d16 Merge remote-tracking branch 'origin/develop' 2026-05-11 09:56:31 +01:00
snipe 7b7d2c87fb Added League\Csv\EscapeFormula for a few more reports 2026-05-11 09:54:55 +01:00
snipe 6debb3a65d Added is_not: as search modifier 2026-05-11 09:46:05 +01:00
snipe 315ba49a1d Larger tag size 2026-05-11 09:43:42 +01:00
snipe ff57855038 Added EthicalCheck
Giving this a test drive
2026-05-08 17:10:40 +01:00
snipe da6e837578 Merge pull request #18991 from uberbrady/better_scim_errors
Fixed #18987 - fix SCIM error on mismapped fields
2026-05-08 14:25:02 +01:00
snipe a2d8f89162 Merge remote-tracking branch 'origin/develop' 2026-05-08 14:21:20 +01:00
snipe e36d65e695 Use carbon instead 2026-05-08 14:21:07 +01:00
snipe 34abf14cbe Merge remote-tracking branch 'origin/develop' 2026-05-08 14:12:29 +01:00
snipe dda7a4f22f Format dates in custom report 2026-05-08 14:12:15 +01:00
Brady Wetherington 283a885196 Get rid of 'setCode' and just use the constructor parameter instead 2026-05-08 13:15:37 +01:00
snipe d44aa3f16e Merge remote-tracking branch 'origin/develop' 2026-05-07 12:42:15 +01:00
snipe afb37981bf Merge remote-tracking branch 'origin/develop' 2026-05-07 12:07:46 +01:00
snipe 2b6518427a Merge remote-tracking branch 'origin/develop' 2026-05-07 11:11:16 +01:00
snipe 185e0073b3 Merge remote-tracking branch 'origin/develop' 2026-05-07 10:40:10 +01:00
snipe d0794ba71c Merge remote-tracking branch 'origin/develop' 2026-05-07 10:37:15 +01:00
snipe 1b42e2e138 Merge remote-tracking branch 'origin/develop' 2026-05-06 17:50:59 +01:00
snipe b4efabe82e Merge remote-tracking branch 'origin/develop' 2026-05-06 16:38:06 +01:00
snipe 9b37e95b58 Merge remote-tracking branch 'origin/develop' 2026-05-05 22:00:13 +01:00
snipe a92d8eeaab Merge remote-tracking branch 'origin/develop' 2026-05-05 20:37:03 +01:00
snipe e8dbb12ccc Merge remote-tracking branch 'origin/develop' 2026-05-05 13:22:59 +01:00
snipe 8a2cd19ea6 Merge remote-tracking branch 'origin/develop' 2026-05-05 10:58:55 +01:00
snipe afdf86ad0d Merge remote-tracking branch 'origin/develop' 2026-05-04 21:47:15 +01:00
snipe a5dae3f222 Merge remote-tracking branch 'origin/develop' 2026-05-04 20:55:35 +01:00
snipe 97765c08b1 Merge remote-tracking branch 'origin/develop' 2026-05-04 19:58:48 +01:00
snipe 6ad92556a1 Merge remote-tracking branch 'origin/develop' 2026-05-04 19:47:36 +01:00
snipe e2465ca2a7 Merge remote-tracking branch 'origin/develop' 2026-05-04 19:47:03 +01:00
snipe f5644928a8 Prod assets 2026-05-04 13:24:50 +01:00
Godfrey M 8747ff32dd Merge branch 'develop' into update-print-invtentory-view-with-assigned2assets
# Conflicts:
#	app/Http/Controllers/ProfileController.php
#	app/Http/Controllers/Users/UsersController.php
2026-03-17 16:11:16 -07:00
Godfrey M 4ddd2f1cf8 change indirect Asset name 2026-03-10 12:49:37 -07:00
Godfrey M 11c8fd4d4c update scope for directLicense.category" 2026-03-10 12:41:19 -07:00
Godfrey M ab04f3de93 use inventory scope, add quantity to print blade 2026-03-10 12:35:34 -07:00
Godfrey M 4c16796256 reduce query count to 52 2026-03-10 10:59:59 -07:00
Godfrey M 516771d948 update profile Controller print inventory 2026-03-10 09:50:00 -07:00
Godfrey M e25ea465c5 add ternary on variables in asset count" 2026-03-04 10:25:59 -08:00
Godfrey M 30ac3d1a26 fix display name of item" 2026-03-03 16:11:46 -08:00
Godfrey M e47c772230 cleaned up other tables in print view 2026-03-03 16:04:13 -08:00
Godfrey M 706b623d95 adds assets to indirect assignment table 2026-03-03 15:51:47 -08:00
Godfrey M a908a76f53 adds components to indirect assignment table 2026-03-03 15:15:45 -08:00
Godfrey M a2ec707f79 add licenses to indirect assignedment table 2026-03-03 12:53:37 -08:00
57 changed files with 2420 additions and 125975 deletions
+69
View File
@@ -0,0 +1,69 @@
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# EthicalCheck addresses the critical need to continuously security test APIs in development and in production.
# EthicalCheck provides the industrys only free & automated API security testing service that uncovers security vulnerabilities using OWASP API list.
# Developers relies on EthicalCheck to evaluate every update and release, ensuring that no APIs go to production with exploitable vulnerabilities.
# You develop the application and API, we bring complete and continuous security testing to you, accelerating development.
# Know your API and Applications are secure with EthicalCheck our free & automated API security testing service.
# How EthicalCheck works?
# EthicalCheck functions in the following simple steps.
# 1. Security Testing.
# Provide your OpenAPI specification or start with a public Postman collection URL.
# EthicalCheck instantly instrospects your API and creates a map of API endpoints for security testing.
# It then automatically creates hundreds of security tests that are non-intrusive to comprehensively and completely test for authentication, authorizations, and OWASP bugs your API. The tests addresses the OWASP API Security categories including OAuth 2.0, JWT, Rate Limit etc.
# 2. Reporting.
# EthicalCheck generates security test report that includes all the tested endpoints, coverage graph, exceptions, and vulnerabilities.
# Vulnerabilities are fully triaged, it contains CVSS score, severity, endpoint information, and OWASP tagging.
# This is a starter workflow to help you get started with EthicalCheck Actions
name: EthicalCheck-Workflow
# Controls when the workflow will run
on:
# Triggers the workflow on push or pull request events but only for the "master" branch
# Customize trigger events based on your DevSecOps processes.
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
schedule:
- cron: '35 17 * * 6'
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
permissions:
contents: read
jobs:
Trigger_EthicalCheck:
permissions:
security-events: write # for github/codeql-action/upload-sarif to upload SARIF results
actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status
runs-on: ubuntu-latest
steps:
- name: EthicalCheck Free & Automated API Security Testing Service
uses: apisec-inc/ethicalcheck-action@005fac321dd843682b1af6b72f30caaf9952c641
with:
# The OpenAPI Specification URL or Swagger Path or Public Postman collection URL.
oas-url: "http://netbanking.apisec.ai:8080/v2/api-docs"
# The email address to which the penetration test report will be sent.
email: "snipe@snipe.net"
sarif-result-file: "ethicalcheck-results.sarif"
- name: Upload sarif file to repository
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: ./ethicalcheck-results.sarif
+110
View File
@@ -0,0 +1,110 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Stack
- **PHP 8.2+** / **Laravel 12** (framework), **Laravel Mix** (webpack) for frontend assets
- **AdminLTE 2** / **Bootstrap 3** UI — Blade views, no Livewire/Inertia
- **Chart.js v2.9.4** — bundled at `public/js/dist/Chart.min.js`; use `horizontalBar` type (v2 API, not v3)
## Common Commands
```bash
# Run all tests
php artisan test
# or
vendor/bin/phpunit
# Run a single test file
php artisan test tests/Feature/Assets/AssetsTest.php
# Run a specific test method
php artisan test --filter testSomeMethod
# Build frontend assets (dev)
npm run dev
# Build for production
npm run prod
# Laravel Mix watch
npm run watch
# Tinker / REPL
php artisan tinker
# Clear caches after config/route changes
php artisan optimize:clear
```
Dev server is served via **Laravel Herd** (`herd coverage` for coverage reports).
## Architecture
### Controllers
Two parallel controller trees:
- `app/Http/Controllers/` — web/UI controllers (Blade views)
- `app/Http/Controllers/Api/` — REST API controllers (JSON, used by datatables + select2)
Subdirectory groupings: `Assets/`, `Licenses/`, `Users/`, `Accessories/`, `Consumables/`, `Components/`, `Kits/`, `Account/`, `Auth/`
### API Pattern
Every API controller returns data via a **Transformer** (`app/Http/Transformers/`). Never return raw model attributes from API controllers — always pass through the transformer. `DatatablesTransformer` wraps paginated results.
```php
return (new AssetsTransformer)->transformAssets($assets, $assets->count());
```
### Authorization
All authorization goes through **Policies** (`app/Policies/`). `CheckoutablePermissionsPolicy` is the base for assets/licenses/accessories/consumables — its `checkout()` / `checkin()` methods accept `$item = null` so you can use `@can('checkout', \App\Models\Asset::class)` without an instance.
### FMCS (Full Multiple Company Support)
`Setting::getSettings()->full_multiple_companies_support == '1'` gates company-scoped filtering. The select2 API endpoints (`selectlist()` methods) accept a `companyId` query param — apply it like this:
```php
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($request->filled('companyId'))) {
$query->where('table.company_id', $request->input('companyId'));
}
```
Pass `data-company-id="{{ $user->company_id }}"` in Blade to wire it to select2.
### Select2 AJAX Dropdowns
Use `class="js-data-ajax"` with `data-endpoint="hardware|licenses|consumables|..."`. `snipeit.js` auto-initializes these, forwarding `data-company-id` as `companyId` and `data-asset-status-type` as `statusType` to the API.
### Routes
All routes are in `routes/web.php` (UI) and `routes/api.php` (API). Breadcrumbs are defined inline using `->breadcrumbs(fn (Trail $trail) => ...)` from `tabuna/breadcrumbs`. Every UI route should have a breadcrumb.
Note: the `reports/unaccepted_assets` route is named with slashes, not dots — use `route('reports/unaccepted_assets')`.
### Translations
String keys live in `resources/lang/en-US/general.php` (and other files in that directory). Always add new UI strings as translation keys rather than hard-coding English.
### Checkout Redirect Flow
After checkout, `Helper::getRedirectOption()` reads `$request->redirect_option`. For redirecting back to the assigned user after checkout:
- Set `redirect_option=target` in the form
- Set `checkout_to_type=user` in the form
- Set `assigned_user={{ $user->id }}` in the form
### Key Helper Methods (`app/Helpers/Helper.php`)
- `Helper::deployableStatusLabelList()` — status labels for checkout forms
- `Helper::defaultChartColors()` — 10-color palette used in charts
- `Helper::getRedirectOption($request, $id, $table)` — post-checkout redirect logic
### Global View Variables
`$snipeSettings` is injected into all views via a service provider — no need to pass `Setting::getSettings()` from every controller. Use it directly in Blade.
## Testing
Tests live in `tests/Feature/` (organized by entity) and `tests/Unit/`. Feature tests hit the database; the test environment uses `array` cache/session/mail drivers. Tests use factories for data setup.
@@ -15,11 +15,13 @@ use App\Http\Transformers\SelectlistTransformer;
use App\Models\Accessory;
use App\Models\AccessoryCheckout;
use App\Models\Company;
use App\Models\Setting;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
class AccessoriesController extends Controller
{
@@ -300,40 +302,49 @@ class AccessoriesController extends Controller
{
$this->authorize('checkout', $accessory);
$target = $this->determineCheckoutTarget();
$accessory->checkout_qty = $request->input('checkout_qty', 1);
for ($i = 0; $i < $accessory->checkout_qty; $i++) {
$accessory_checkout = new AccessoryCheckout([
'accessory_id' => $accessory->id,
'created_at' => Carbon::now(),
'assigned_to' => $target->id,
'assigned_type' => $target::class,
'note' => $request->input('note'),
]);
$accessory_checkout->created_by = auth()->id();
$accessory_checkout->save();
$payload = [
'accessory_id' => $accessory->id,
'assigned_to' => $target->id,
'assigned_type' => $target::class,
'note' => $request->input('note'),
'created_by' => auth()->id(),
'pivot' => $accessory_checkout->id,
];
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($accessory->company_id !== $target->company_id)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
}
// Set this value to be able to pass the qty through to the event
event(new CheckoutableCheckedOut(
$accessory,
$target,
auth()->user(),
$request->input('note'),
[],
$accessory->checkout_qty,
));
$accessory->checkout_qty = $request->input('checkout_qty', 1);
$payload = null;
// Keep checkout rows and checkout log/event atomic to avoid ghost assignments.
DB::transaction(function () use ($accessory, $request, $target, &$payload): void {
for ($i = 0; $i < $accessory->checkout_qty; $i++) {
$accessory_checkout = new AccessoryCheckout([
'accessory_id' => $accessory->id,
'created_at' => Carbon::now(),
'assigned_to' => $target->id,
'assigned_type' => $target::class,
'note' => $request->input('note'),
]);
$accessory_checkout->created_by = auth()->id();
$accessory_checkout->save();
$payload = [
'accessory_id' => $accessory->id,
'assigned_to' => $target->id,
'assigned_type' => $target::class,
'note' => $request->input('note'),
'created_by' => auth()->id(),
'pivot' => $accessory_checkout->id,
];
}
// Set this value to be able to pass the qty through to the event.
event(new CheckoutableCheckedOut(
$accessory,
$target,
auth()->user(),
$request->input('note'),
[],
$accessory->checkout_qty,
));
});
return response()->json(Helper::formatStandardApiResponse('success', $payload, trans('admin/accessories/message.checkout.success')));
+119 -24
View File
@@ -706,18 +706,35 @@ class AssetsController extends Controller
}
}
if ($asset->save()) {
if ($request->input('assigned_user')) {
$target = User::find(request('assigned_user'));
} elseif ($request->input('assigned_asset')) {
$target = Asset::find(request('assigned_asset'));
} elseif ($request->input('assigned_location')) {
$target = Location::find(request('assigned_location'));
$target = $this->resolveCheckoutTargetForAssetMutation($request);
$requestedCheckout = $request->filled('assigned_user') || $request->filled('assigned_asset') || $request->filled('assigned_location');
if ($requestedCheckout && (! $target)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')));
}
if ($requestedCheckout) {
$companyMismatchResponse = $this->checkoutCompanyMismatchResponse($asset, $target);
if ($companyMismatchResponse) {
return $companyMismatchResponse;
}
if (isset($target)) {
$asset->checkOut($target, auth()->user(), date('Y-m-d H:i:s'), '', 'Checked out on asset creation', e($request->input('name')));
}
$stored = DB::transaction(function () use ($asset, $request, $target, $requestedCheckout): bool {
if (! $asset->save()) {
return false;
}
if ($requestedCheckout) {
// Keep create + optional checkout side effects atomic.
return $asset->checkOut($target, auth()->user(), date('Y-m-d H:i:s'), '', 'Checked out on asset creation', e($request->input('name')));
}
return true;
});
if ($stored) {
if ($asset->image) {
$asset->image = $asset->getImageUrl();
}
@@ -792,25 +809,54 @@ class AssetsController extends Controller
}
}
}
if ($asset->save()) {
if (($request->filled('assigned_user')) && ($target = User::find($request->input('assigned_user')))) {
$location = $target->location_id;
} elseif (($request->filled('assigned_asset')) && ($target = Asset::find($request->input('assigned_asset')))) {
$location = $target->location_id;
$target = $this->resolveCheckoutTargetForAssetMutation($request, $asset->id);
$requestedCheckout = $request->filled('assigned_user') || $request->filled('assigned_asset') || $request->filled('assigned_location');
Asset::where('assigned_type', Asset::class)->where('assigned_to', $asset->id)
->update(['location_id' => $target->location_id]);
} elseif (($request->filled('assigned_location')) && ($target = Location::find($request->input('assigned_location')))) {
$location = $target->id;
if ($requestedCheckout && (! $target)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')));
}
if ($requestedCheckout) {
$companyMismatchResponse = $this->checkoutCompanyMismatchResponse($asset, $target);
if ($companyMismatchResponse) {
return $companyMismatchResponse;
}
}
$updated = DB::transaction(function () use ($asset, $request, $target, $requestedCheckout): bool {
if (! $asset->save()) {
return false;
}
if (isset($target)) {
if ($requestedCheckout) {
// Using `->has` preserves the asset name if the name parameter was not included in request.
$asset_name = request()->has('name') ? request('name') : $asset->name;
$asset->checkOut($target, auth()->user(), date('Y-m-d H:i:s'), '', 'Checked out on asset update', $asset_name, $location);
$location = null;
if ($request->filled('assigned_user')) {
$location = $target->location_id;
} elseif ($request->filled('assigned_asset')) {
$location = $target->location_id;
} elseif ($request->filled('assigned_location')) {
$location = $target->id;
}
// Keep update + optional checkout side effects atomic.
if (! $asset->checkOut($target, auth()->user(), date('Y-m-d H:i:s'), '', 'Checked out on asset update', $asset_name, $location)) {
return false;
}
if ($request->filled('assigned_asset')) {
Asset::where('assigned_type', Asset::class)->where('assigned_to', $asset->id)
->update(['location_id' => $target->location_id]);
}
}
return true;
});
if ($updated) {
if ($asset->image) {
$asset->image = $asset->getImageUrl();
}
@@ -829,6 +875,36 @@ class AssetsController extends Controller
return response()->json(Helper::formatStandardApiResponse('error', null, $asset->getErrors()), 200);
}
private function resolveCheckoutTargetForAssetMutation(Request $request, ?int $assetId = null): User|Asset|Location|null
{
if ($request->filled('assigned_user')) {
return User::withoutGlobalScopes()->find($request->input('assigned_user'));
}
if ($request->filled('assigned_asset')) {
return Asset::withoutGlobalScopes()->where('id', '!=', $assetId)->find($request->input('assigned_asset'));
}
if ($request->filled('assigned_location')) {
return Location::withoutGlobalScopes()->find($request->input('assigned_location'));
}
return null;
}
private function checkoutCompanyMismatchResponse(Asset $asset, User|Asset|Location $target): ?JsonResponse
{
if ((Setting::getSettings()->full_multiple_companies_support == '1')
&& (! is_null($asset->company_id))
&& (! is_null($target->company_id))
&& ((int) $asset->company_id !== (int) $target->company_id)
) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
}
return null;
}
/**
* Delete a given asset (mark as deleted).
*
@@ -905,6 +981,7 @@ class AssetsController extends Controller
*/
public function checkoutByTag(AssetCheckoutRequest $request, $tag): JsonResponse
{
// Use the same hardened checkout path as ID-based checkout.
if ($asset = Asset::where('asset_tag', $tag)->first()) {
return $this->checkout($request, $asset->id);
}
@@ -940,19 +1017,22 @@ class AssetsController extends Controller
// This item is checked out to a location
if (request('checkout_to_type') == 'location') {
$target = Location::find(request('assigned_location'));
// Resolve unscoped target first so FMCS mismatch can be handled explicitly.
$target = Location::withoutGlobalScopes()->find(request('assigned_location'));
$asset->location_id = ($target) ? $target->id : '';
$error_payload['target_id'] = $request->input('assigned_location');
$error_payload['target_type'] = 'location';
} elseif (request('checkout_to_type') == 'asset') {
$target = Asset::where('id', '!=', $asset_id)->find(request('assigned_asset'));
// Resolve unscoped target first so FMCS mismatch can be handled explicitly.
$target = Asset::withoutGlobalScopes()->where('id', '!=', $asset_id)->find(request('assigned_asset'));
// Override with the asset's location_id if it has one
$asset->location_id = (($target) && (isset($target->location_id))) ? $target->location_id : '';
$error_payload['target_id'] = $request->input('assigned_asset');
$error_payload['target_type'] = 'asset';
} elseif (request('checkout_to_type') == 'user') {
// Fetch the target and set the asset's new location_id
$target = User::find(request('assigned_user'));
// Resolve unscoped target first so FMCS mismatch can be handled explicitly.
$target = User::withoutGlobalScopes()->find(request('assigned_user'));
$asset->location_id = (($target) && (isset($target->location_id))) ? $target->location_id : '';
$error_payload['target_id'] = $request->input('assigned_user');
$error_payload['target_type'] = 'user';
@@ -971,6 +1051,16 @@ class AssetsController extends Controller
return response()->json(Helper::formatStandardApiResponse('error', $error_payload, 'Checkout target for asset '.e($asset->asset_tag).' is invalid - '.$error_payload['target_type'].' does not exist.'));
}
// In FMCS mode, enforce explicit same-company target checks before mutating checkout state.
$targetCompanyId = data_get($target, 'company_id');
if ((Setting::getSettings()->full_multiple_companies_support == '1')
&& (! is_null($asset->company_id))
&& (! is_null($targetCompanyId))
&& ((int) $asset->company_id !== (int) $targetCompanyId)
) {
return response()->json(Helper::formatStandardApiResponse('error', $error_payload, trans('general.error_user_company')));
}
$checkout_at = request('checkout_at', date('Y-m-d H:i:s'));
$expected_checkin = request('expected_checkin', null);
$note = request('note', null);
@@ -985,7 +1075,12 @@ class AssetsController extends Controller
// $asset->location_id = $target->rtd_location_id;
// }
if ($asset->checkOut($target, auth()->user(), $checkout_at, $expected_checkin, $note, $asset_name, $asset->location_id)) {
// Keep checkout mutation + checkout logging/event side effects atomic.
$wasCheckedOut = DB::transaction(function () use ($asset, $target, $checkout_at, $expected_checkin, $note, $asset_name): bool {
return $asset->checkOut($target, auth()->user(), $checkout_at, $expected_checkin, $note, $asset_name, $asset->location_id);
});
if ($wasCheckedOut) {
return response()->json(Helper::formatStandardApiResponse('success', ['asset' => e($asset->asset_tag)], trans('admin/hardware/message.checkout.success')));
}
@@ -11,6 +11,7 @@ use App\Http\Transformers\ComponentsTransformer;
use App\Models\Asset;
use App\Models\Company;
use App\Models\Component;
use App\Models\Setting;
use Carbon\Carbon;
use Illuminate\Database\Query\Builder;
use Illuminate\Http\JsonResponse;
@@ -314,20 +315,33 @@ class ComponentsController extends Controller
}
if ($component->numRemaining() >= $request->input('assigned_qty')) {
// Resolve the raw target first, then enforce FMCS explicitly.
// Scoped lookup can hide cross-company records and lead to partial writes.
$asset = Asset::withoutGlobalScopes()->find($request->input('assigned_to'));
$asset = Asset::find($request->input('assigned_to'));
$component->assigned_to = $request->input('assigned_to');
if (! $asset) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')));
}
$component->assets()->attach($component->id, [
'component_id' => $component->id,
'created_at' => Carbon::now(),
'assigned_qty' => $request->input('assigned_qty', 1),
'created_by' => auth()->id(),
'asset_id' => $request->input('assigned_to'),
'note' => $request->input('note'),
]);
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($component->company_id !== $asset->company_id)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
}
$component->logCheckout($request->input('note'), $asset, null, [], $request->get('assigned_qty', 1));
// Keep pivot + action log in one transaction so checkout is all-or-nothing.
DB::transaction(function () use ($component, $request, $asset): void {
$component->assigned_to = $request->input('assigned_to');
$component->assets()->attach($component->id, [
'component_id' => $component->id,
'created_at' => Carbon::now(),
'assigned_qty' => $request->input('assigned_qty', 1),
'created_by' => auth()->id(),
'asset_id' => $request->input('assigned_to'),
'note' => $request->input('note'),
]);
$component->logCheckout($request->input('note'), $asset, null, [], $request->get('assigned_qty', 1));
});
return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/components/message.checkout.success')));
}
@@ -13,9 +13,11 @@ use App\Http\Transformers\ConsumablesTransformer;
use App\Http\Transformers\SelectlistTransformer;
use App\Models\Company;
use App\Models\Consumable;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class ConsumablesController extends Controller
{
@@ -306,34 +308,42 @@ class ConsumablesController extends Controller
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/consumables/message.checkout.unavailable', ['requested' => $consumable->checkout_qty, 'remaining' => $consumable->numRemaining()])));
}
// Check if the user exists - @TODO: this should probably be handled via validation, not here??
if (! $user = User::find($request->input('assigned_to'))) {
// Resolve the raw target first, then enforce FMCS explicitly.
// Scoped lookup can hide cross-company users and make failures ambiguous.
if (! $user = User::withoutGlobalScopes()->find($request->input('assigned_to'))) {
// Return error message
return response()->json(Helper::formatStandardApiResponse('error', null, 'No user found'));
}
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($consumable->company_id !== $user->company_id)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
}
// Update the consumable data
$consumable->assigned_to = $request->input('assigned_to');
for ($i = 0; $i < $consumable->checkout_qty; $i++) {
$consumable->users()->attach($consumable->id,
[
'consumable_id' => $consumable->id,
'created_by' => $user->id,
'assigned_to' => $request->input('assigned_to'),
'note' => $request->input('note'),
]
);
}
// Keep pivot writes and checkout log/event atomic to avoid partial checkout state.
DB::transaction(function () use ($consumable, $request, $user): void {
for ($i = 0; $i < $consumable->checkout_qty; $i++) {
$consumable->users()->attach($consumable->id,
[
'consumable_id' => $consumable->id,
'created_by' => $user->id,
'assigned_to' => $request->input('assigned_to'),
'note' => $request->input('note'),
]
);
}
event(new CheckoutableCheckedOut(
$consumable,
$user,
auth()->user(),
$request->input('note'),
[],
$consumable->checkout_qty,
));
event(new CheckoutableCheckedOut(
$consumable,
$user,
auth()->user(),
$request->input('note'),
[],
$consumable->checkout_qty,
));
});
return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/consumables/message.checkout.success')));
@@ -8,9 +8,11 @@ use App\Http\Transformers\LicenseSeatsTransformer;
use App\Models\Asset;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class LicenseSeatsController extends Controller
{
@@ -106,7 +108,8 @@ class LicenseSeatsController extends Controller
'prohibits:asset_id',
// must be a valid user or null to unassign
function ($attribute, $value, $fail) {
if (! is_null($value) && ! User::where('id', $value)->whereNull('deleted_at')->exists()) {
// Validate existence without company scopes; FMCS checks happen explicitly below.
if (! is_null($value) && ! User::withoutGlobalScopes()->where('id', $value)->whereNull('deleted_at')->exists()) {
$fail('The selected assigned_to is invalid.');
}
},
@@ -118,7 +121,8 @@ class LicenseSeatsController extends Controller
'prohibits:assigned_to',
// must be a valid asset or null to unassign
function ($attribute, $value, $fail) {
if (! is_null($value) && ! Asset::where('id', $value)->whereNull('deleted_at')->exists()) {
// Validate existence without company scopes; FMCS checks happen explicitly below.
if (! is_null($value) && ! Asset::withoutGlobalScopes()->where('id', $value)->whereNull('deleted_at')->exists()) {
$fail('The selected asset_id is invalid.');
}
},
@@ -139,6 +143,34 @@ class LicenseSeatsController extends Controller
return response()->json(Helper::formatStandardApiResponse('error', null, 'Seat does not belong to the specified license'));
}
$targetUser = null;
if (! is_null($request->input('assigned_to'))) {
// Resolve unscoped target so we can return a clean cross-company error instead of a hidden-not-found.
$targetUser = User::withoutGlobalScopes()->find($request->input('assigned_to'));
if (! $targetUser) {
return response()->json(Helper::formatStandardApiResponse('error', null, 'Target not found'));
}
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($license->company_id !== $targetUser->company_id)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
}
}
$targetAsset = null;
if (! is_null($request->input('asset_id'))) {
// Resolve unscoped target so FMCS company mismatch can be enforced explicitly.
$targetAsset = Asset::withoutGlobalScopes()->find($request->input('asset_id'));
if (! $targetAsset) {
return response()->json(Helper::formatStandardApiResponse('error', null, 'Target not found'));
}
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($license->company_id !== $targetAsset->company_id)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
}
}
$oldUser = $licenseSeat->user;
$oldAsset = $licenseSeat->asset;
@@ -166,11 +198,11 @@ class LicenseSeatsController extends Controller
// the logging functions expect only one "target". if both asset and user are present in the request,
// we simply let assets take precedence over users...
if ($licenseSeat->isDirty('assigned_to')) {
$target = $is_checkin ? $oldUser : User::find($licenseSeat->assigned_to);
$target = $is_checkin ? $oldUser : $targetUser;
}
if ($licenseSeat->isDirty('asset_id')) {
$target = $is_checkin ? $oldAsset : Asset::find($licenseSeat->asset_id);
$target = $is_checkin ? $oldAsset : $targetAsset;
}
if ($assignmentTouched && is_null($target)) {
@@ -181,13 +213,22 @@ class LicenseSeatsController extends Controller
}
}
if ($licenseSeat->save()) {
// Keep seat updates and checkout/checkin logging atomic to prevent partial state changes.
$updated = DB::transaction(function () use ($licenseSeat, $assignmentTouched, $is_checkin, $target, $request): bool {
if (! $licenseSeat->save()) {
return false;
}
if ($assignmentTouched) {
if ($is_checkin) {
if (! $licenseSeat->license->reassignable) {
$licenseSeat->unreassignable_seat = true;
$licenseSeat->save();
if (! $licenseSeat->save()) {
return false;
}
}
// todo: skip if target is null?
$licenseSeat->logCheckin($target, $licenseSeat->notes);
} else {
@@ -196,6 +237,10 @@ class LicenseSeatsController extends Controller
}
}
return true;
});
if ($updated) {
return response()->json(Helper::formatStandardApiResponse('success', $licenseSeat, trans('admin/licenses/message.update.success')));
}
@@ -28,7 +28,7 @@ class LicensesController extends Controller
{
$this->authorize('view', License::class);
$licenses = License::with('company', 'manufacturer', 'supplier', 'category', 'adminuser')->withCount('freeSeats as free_seats_count');
$licenses = License::with('company', 'manufacturer', 'supplier', 'category', 'adminuser', 'licenseSeatsRelation', 'assignedCount')->withCount('freeSeats as free_seats_count');
$settings = Setting::getSettings();
if ($request->input('status') == 'inactive') {
@@ -247,7 +247,7 @@ class LicensesController extends Controller
if ($license->assigned_seats_count == 0) {
// Delete the license and the associated license seats
DB::table('license_seats')
->where('id', $license->id)
->where('license_id', $license->id)
->update(['assigned_to' => null, 'asset_id' => null]);
$licenseSeats = $license->licenseseats();
@@ -6,8 +6,18 @@ use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Transformers\ActionlogsTransformer;
use App\Models\Accessory;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\Component;
use App\Models\Consumable;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\Maintenance;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class ReportsController extends Controller
@@ -125,4 +135,141 @@ class ReportsController extends Controller
return response()->json((new ActionlogsTransformer)->transformActionlogs($actionlogs, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
}
/**
* Returns time-series data for the reports overview charts.
*
* Accepts ?days=N (preset, default 30) OR ?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD.
* Also returns the immediately preceding period of equal length for comparison lines.
*/
public function activityChart(Request $request): JsonResponse
{
$this->authorize('reports.view');
$allowedDays = [7, 14, 30, 60, 90, 180, 365];
if ($request->filled('start_date') && $request->filled('end_date')) {
$curStart = Carbon::parse($request->input('start_date'))->startOfDay();
$curEnd = Carbon::parse($request->input('end_date'))->endOfDay();
if ($curEnd->lt($curStart)) {
[$curStart, $curEnd] = [$curEnd, $curStart];
}
$days = max(1, (int) $curStart->diffInDays($curEnd) + 1);
} else {
$days = in_array((int) $request->input('days'), $allowedDays) ? (int) $request->input('days') : 30;
$curEnd = Carbon::today()->endOfDay();
$curStart = Carbon::today()->subDays($days - 1)->startOfDay();
}
$prevEnd = $curStart->copy()->subSecond()->endOfDay();
$prevStart = $prevEnd->copy()->subDays($days - 1)->startOfDay();
$buildDates = function (Carbon $start, Carbon $end): array {
$dates = [];
for ($d = $start->copy(); $d->lte($end); $d->addDay()) {
$dates[] = $d->toDateString();
}
return $dates;
};
$curDates = $buildDates($curStart, $curEnd);
$prevDates = $buildDates($prevStart, $prevEnd);
$pluckAction = function (string $actionType, Carbon $start, Carbon $end): array {
return Actionlog::where('action_type', $actionType)
->whereBetween('created_at', [$start, $end])
->selectRaw('DATE(created_at) as date, COUNT(*) as count')
->groupBy('date')
->pluck('count', 'date')
->toArray();
};
// withTrashed() ensures records deleted after creation still appear in their creation-period counts.
$pluckCreated = function (string $modelClass, Carbon $start, Carbon $end): array {
return $modelClass::withTrashed()
->whereBetween('created_at', [$start, $end])
->selectRaw('DATE(created_at) as date, COUNT(*) as count')
->groupBy('date')
->pluck('count', 'date')
->toArray();
};
// Maintenance has no company_id column and no CompanyableTrait, so scope through
// its asset relationship — whereHas('asset') applies Asset's FMCS global scope.
$pluckMaintenances = function (Carbon $start, Carbon $end): array {
return Maintenance::withTrashed()
->whereHas('asset')
->whereBetween('maintenances.created_at', [$start, $end])
->selectRaw('DATE(maintenances.created_at) as date, COUNT(*) as count')
->groupBy('date')
->pluck('count', 'date')
->toArray();
};
// Filters by both action_type and item_type for per-category checkout/checkin counts.
$pluckActionByType = function (string $actionType, string $modelClass, Carbon $start, Carbon $end): array {
return Actionlog::where('action_type', $actionType)
->where('item_type', $modelClass)
->whereBetween('created_at', [$start, $end])
->selectRaw('DATE(created_at) as date, COUNT(*) as count')
->groupBy('date')
->pluck('count', 'date')
->toArray();
};
$pluckDeletedUsers = function (Carbon $start, Carbon $end): array {
return User::withTrashed()
->whereNotNull('deleted_at')
->whereBetween('deleted_at', [$start, $end])
->selectRaw('DATE(deleted_at) as date, COUNT(*) as count')
->groupBy('date')
->pluck('count', 'date')
->toArray();
};
// Catches both 'checkin' and 'checkin from' action types used across different item types.
$pluckCheckinsByType = function (string $modelClass, Carbon $start, Carbon $end): array {
return Actionlog::whereIn('action_type', ['checkin', 'checkin from'])
->where('item_type', $modelClass)
->whereBetween('created_at', [$start, $end])
->selectRaw('DATE(created_at) as date, COUNT(*) as count')
->groupBy('date')
->pluck('count', 'date')
->toArray();
};
$fill = fn (array $raw, array $dates) => array_map(fn ($d) => (int) ($raw[$d] ?? 0), $dates);
$datasets = [];
foreach ([
'new_users' => fn ($s, $e) => $pluckCreated(User::class, $s, $e),
'deleted_users' => fn ($s, $e) => $pluckDeletedUsers($s, $e),
'asset_checkouts' => fn ($s, $e) => $pluckActionByType('checkout', Asset::class, $s, $e),
'asset_checkins' => fn ($s, $e) => $pluckCheckinsByType(Asset::class, $s, $e),
'new_assets' => fn ($s, $e) => $pluckCreated(Asset::class, $s, $e),
'new_maintenances' => fn ($s, $e) => $pluckMaintenances($s, $e),
'new_audits' => fn ($s, $e) => $pluckAction('audit', $s, $e),
'component_checkouts' => fn ($s, $e) => $pluckActionByType('checkout', Component::class, $s, $e),
'component_checkins' => fn ($s, $e) => $pluckCheckinsByType(Component::class, $s, $e),
'new_components' => fn ($s, $e) => $pluckCreated(Component::class, $s, $e),
'consumable_checkouts' => fn ($s, $e) => $pluckActionByType('checkout', Consumable::class, $s, $e),
'consumable_checkins' => fn ($s, $e) => $pluckCheckinsByType(Consumable::class, $s, $e),
'new_consumables' => fn ($s, $e) => $pluckCreated(Consumable::class, $s, $e),
'license_checkouts' => fn ($s, $e) => $pluckActionByType('checkout', LicenseSeat::class, $s, $e),
'license_checkins' => fn ($s, $e) => $pluckCheckinsByType(LicenseSeat::class, $s, $e),
'new_licenses' => fn ($s, $e) => $pluckCreated(License::class, $s, $e),
'accessory_checkouts' => fn ($s, $e) => $pluckActionByType('checkout', Accessory::class, $s, $e),
'accessory_checkins' => fn ($s, $e) => $pluckCheckinsByType(Accessory::class, $s, $e),
'new_accessories' => fn ($s, $e) => $pluckCreated(Accessory::class, $s, $e),
] as $key => $query) {
$datasets[$key] = $fill($query($curStart, $curEnd), $curDates);
$datasets['prev_'.$key] = $fill($query($prevStart, $prevEnd), $prevDates);
}
return response()->json(array_merge([
'labels' => array_map(fn ($d) => Carbon::parse($d)->format('M j'), $curDates),
'prev_label' => $prevStart->format('M j').' '.$prevEnd->format('M j'),
], $datasets));
}
}
@@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers\Licenses;
use App\Http\Controllers\Controller;
use App\Models\License;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
class BulkLicensesController extends Controller
{
public function destroy(Request $request)
{
$this->authorize('delete', License::class);
$errors = [];
$success_count = 0;
foreach ($request->ids as $id) {
$license = License::find($id);
if (is_null($license)) {
$errors[] = trans('admin/licenses/message.does_not_exist');
continue;
}
if (! Gate::allows('delete', $license)) {
$errors[] = trans('general.insufficient_permissions');
continue;
}
if ($license->assigned_seats_count > 0) {
$errors[] = trans('admin/licenses/message.delete.bulk_checkout_warning', ['license_name' => $license->name]);
continue;
}
// Since assigned_seats_count == 0, all seats already have assigned_to and asset_id as null,
// so this update is effectively a no-op. It mirrors the single destroy() and is kept as a
// safety net. Bypassing Eloquent events here is intentional and safe — there is nothing
// assigned to trigger events on. Prior checkout/checkin history is preserved in action_log
// (keyed by LicenseSeat item_type/item_id) and remains accessible even after soft-delete.
DB::table('license_seats')
->where('license_id', $license->id)
->update(['assigned_to' => null, 'asset_id' => null]);
$license->licenseseats()->delete();
$license->delete();
$success_count++;
}
if (count($errors) > 0) {
if ($success_count > 0) {
return redirect()->route('licenses.index')
->with('success', trans_choice('admin/licenses/message.delete.partial_success', $success_count, ['count' => $success_count]))
->with('multi_error_messages', $errors);
}
return redirect()->route('licenses.index')->with('multi_error_messages', $errors);
}
return redirect()->route('licenses.index')->with('success', trans('admin/licenses/message.delete.bulk_success'));
}
}
@@ -12,6 +12,7 @@ use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use League\Csv\EscapeFormula;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
@@ -388,6 +389,8 @@ class LicensesController extends Controller
fputcsv($handle, $headers);
$formatter = new EscapeFormula('`');
foreach ($licenses as $license) {
// Add a new row with data
$values = [
@@ -419,7 +422,14 @@ class LicensesController extends Controller
$license->created_at,
];
fputcsv($handle, $values);
// CSV_ESCAPE_FORMULAS is set to false in the .env
if (config('app.escape_formulas') === false) {
fputcsv($handle, $values);
// CSV_ESCAPE_FORMULAS is set to true or is not set in the .env
} else {
fputcsv($handle, $formatter->escapeRecord($values));
}
}
});
+12 -7
View File
@@ -211,14 +211,19 @@ class ProfileController extends Controller
*/
public function printInventory(): View
{
$show_users = User::where('id', auth()->user()->id)->get();
$userId = auth()->id();
return view('users/print')
->with('assets', auth()->user()->assets())
->with('licenses', auth()->user()->licenses()->get())
->with('accessories', auth()->user()->accessories()->get())
->with('consumables', auth()->user()->consumables()->get())
->with('users', $show_users)
$show_user = User::withInventoryRelations($userId)->first();
$indirectItemsCount =
$show_user->assets->flatMap->assignedAssets->count()
+ $show_user->assets->flatMap->components->count()
+ $show_user->assets->flatMap->licenses->count()
+ $show_user->assets->flatMap->assignedAccessories->count();
return view('users.print')
->with('users', [$show_user])
->with('indirectItemsCount', $indirectItemsCount)
->with('settings', Setting::getSettings());
}
+39 -3
View File
@@ -56,6 +56,31 @@ class ReportsController extends Controller
parent::__construct();
}
public function index(): View
{
$this->authorize('reports.view');
$settings = Setting::getSettings();
$audit_alert_count = Asset::DueOrOverdueForAudit($settings)->count();
$checkin_alert_count = Asset::DueOrOverdueForCheckin($settings)->count();
// CheckoutAcceptance has no company_id column; scope through the checkoutable
// relationship so each type's CompanyableTrait global scope is applied.
$pending_acceptance_count = CheckoutAcceptance::pending()
->whereHasMorph('checkoutable', [Asset::class, LicenseSeat::class, Accessory::class, Component::class, Consumable::class])
->count();
$licenses_low_count = License::withCount(['freeSeats as free_seats_count'])
->get()
->filter(fn ($l) => $l->free_seats_count <= 0)
->count();
return view('reports/index', compact(
'audit_alert_count',
'checkin_alert_count',
'pending_acceptance_count',
'licenses_low_count',
));
}
/**
* Returns a view that displays the accessories report.
*
@@ -252,6 +277,7 @@ class ReportsController extends Controller
$response = new StreamedResponse(function () {
Log::debug('Starting streamed response');
Log::debug('CSV escaping is set to: '.config('app.escape_formulas'));
// Open output stream
$handle = fopen('php://output', 'w');
@@ -287,6 +313,8 @@ class ReportsController extends Controller
Log::debug('Walking results: '.$executionTime);
$count = 0;
$formatter = new EscapeFormula('`');
foreach ($actionlogs as $actionlog) {
$count++;
$target_name = '';
@@ -317,7 +345,15 @@ class ReportsController extends Controller
$actionlog->action_source,
$actionlog->log_meta,
];
fputcsv($handle, $row);
// CSV_ESCAPE_FORMULAS is set to false in the .env
if (config('app.escape_formulas') === false) {
fputcsv($handle, $row);
// CSV_ESCAPE_FORMULAS is set to true or is not set in the .env
} else {
fputcsv($handle, $formatter->escapeRecord($row));
}
}
});
@@ -852,7 +888,7 @@ class ReportsController extends Controller
}
if ($request->filled('purchase_date')) {
$row[] = ($asset->purchase_date) ? $asset->purchase_date : '';
$row[] = ($asset->purchase_date) ? Carbon::parse($asset->purchase_date)->format('Y-m-d') : '';
}
if ($request->filled('purchase_cost')) {
@@ -860,7 +896,7 @@ class ReportsController extends Controller
}
if ($request->filled('eol')) {
$row[] = ($asset->asset_eol_date != '') ? $asset->asset_eol_date : '';
$row[] = ($asset->asset_eol_date != '') ? Carbon::parse($asset->asset_eol_date)->format('Y-m-d') : '';
}
if ($request->filled('warranty')) {
+15 -21
View File
@@ -26,6 +26,7 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Password;
use League\Csv\EscapeFormula;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
@@ -572,6 +573,8 @@ class UsersController extends Controller
fputcsv($handle, $headers);
$formatter = new EscapeFormula('`');
foreach ($users as $user) {
$user_groups = '';
@@ -614,7 +617,14 @@ class UsersController extends Controller
$user->created_at,
];
fputcsv($handle, $values);
// CSV_ESCAPE_FORMULAS is set to false in the .env
if (config('app.escape_formulas') === false) {
fputcsv($handle, $values);
// CSV_ESCAPE_FORMULAS is set to true or is not set in the .env
} else {
fputcsv($handle, $formatter->escapeRecord($values));
}
}
});
@@ -639,32 +649,16 @@ class UsersController extends Controller
{
$this->authorize('view', User::class);
$user = User::where('id', $id)
->with([
'assets.log' => fn ($query) => $query->withTrashed()->where('target_type', User::class)->where('target_id', $id)->where('action_type', 'accepted'),
'assets.assignedAssets.log' => fn ($query) => $query->withTrashed()->where('target_type', User::class)->where('target_id', $id)->where('action_type', 'accepted'),
'assets.assignedAssets.defaultLoc',
'assets.assignedAssets.location',
'assets.assignedAssets.model.category',
'assets.defaultLoc',
'assets.location',
'assets.model.category',
'accessories.log' => fn ($query) => $query->withTrashed()->where('target_type', User::class)->where('target_id', $id)->where('action_type', 'accepted'),
'accessories.category',
'accessories.manufacturer',
'consumables.log' => fn ($query) => $query->withTrashed()->where('target_type', User::class)->where('target_id', $id)->where('action_type', 'accepted'),
'consumables.category',
'consumables.manufacturer',
'licenses.category',
])
->withTrashed()
->first();
$user = User::withInventoryRelations($id)->first();
$indirectItemsCount = $user?->assets?->flatMap->assignedAssets->count() + $user?->assets?->flatMap->components->count() + $user?->assets?->flatMap->licenses->count() + $user?->assets?->flatMap->assignedAccessories->count();
if ($user) {
$this->authorize('view', $user);
return view('users.print')
->with('users', [$user])
->with('indirectItemsCount', $indirectItemsCount)
->with('settings', Setting::getSettings());
}
@@ -75,7 +75,10 @@ class LicensesTransformer
'checkin' => Gate::allows('checkin', License::class),
'clone' => Gate::allows('create', License::class),
'update' => Gate::allows('update', License::class),
'delete' => (Gate::allows('delete', License::class) && ($license->free_seats_count == $license->seats)) ? true : false,
'delete' => $license->isDeletable(),
'bulk_selectable' => [
'delete' => $license->isDeletable(),
],
];
$array += $permissions_array;
+2 -2
View File
@@ -640,11 +640,11 @@ class License extends Depreciable
/**
* This is really dumb - needs to be refactored, since we have ~3 diff methods that do almost the same thing
*
* @author A. Gianotto <snipe@snipe.net>
* @return int
*
* @since [v2.0]
*
* @return Relation
* @author A. Gianotto <snipe@snipe.net>
*/
public function numRemaining()
{
+9 -4
View File
@@ -196,7 +196,12 @@ class SnipeSCIMConfig
public function doWrite($operation, $subop, $value, Model &$object, Path $path = null, $removeIfNotSet = false)
{
if ($value) {
$object->email = $value[0]['value'];
try {
$object->email = $value[0]['value'];
} catch (\Exception $e) {
\Log::debug($e);
throw new SCIMException("Unknown email object: '" . print_r($value, true) . "'", 422);
}
} else {
$object->email = null;
}
@@ -302,11 +307,11 @@ class SnipeSCIMConfig
// addresses[type eq "work"]
$matches = null;
if (!preg_match('/^.+\[type eq "([a-zA-Z]+)"](?:\.([a-zA-Z]+))?$/', (string)$path, $matches)) {
throw new SCIMException("Unknown path type '$path'")->setCode(422);
throw new SCIMException("Unknown path type '$path'", 422);
}
$type = $matches[1];
if ($type != 'work') {
throw new SCIMException("Unknown object type '$type'")->setCode(422);
throw new SCIMException("Unknown object type '$type'", 422);
}
$attribute = array_key_exists(2, $matches) ? $matches[2] : null;
if (array_key_exists($attribute, self::$addressmap)) {
@@ -315,7 +320,7 @@ class SnipeSCIMConfig
}
throw new SCIMException("Could not handle path for update $path")->setCode(422);
throw new SCIMException("Could not handle path for update $path", 422);
}
}
+118 -39
View File
@@ -219,6 +219,7 @@ trait Searchable
* - "is:null" → operator = is_null, value = "" (reserved token)
* - "is:not_null" → operator = is_not_null, value = "" (reserved token)
* - "is:flarb" → operator = exact, value = "flarb" (exact equality)
* - "is_not:flarb"→ operator = exact_not, value = "flarb" (exact inequality)
*
* `is:null` and `is:not_null` are checked before the generic `is:` prefix so they always
* resolve to their dedicated null-check operators regardless of casing.
@@ -249,6 +250,12 @@ trait Searchable
return ['value' => $exactValue, 'negate' => false, 'operator' => 'exact'];
}
if (str_starts_with($lower, 'is_not:')) {
$exactNotValue = ltrim(substr($raw, 7));
return ['value' => $exactNotValue, 'negate' => true, 'operator' => 'exact_not'];
}
if (str_starts_with($raw, '!')) {
return ['value' => substr($raw, 1), 'negate' => true, 'operator' => 'not_like'];
}
@@ -296,10 +303,12 @@ trait Searchable
$table = $this->getTable();
$whereMethod = $boolean === 'or' ? 'orWhere' : 'where';
$likeOperator = $negate ? 'NOT LIKE' : 'LIKE';
$isExactOperator = in_array($operator, ['exact', 'exact_not'], true);
$exactComparisonOperator = $operator === 'exact_not' ? '!=' : '=';
if (in_array($filterKey, $searchableAttributes, true)) {
if ($operator === 'exact') {
$query->{$whereMethod}($table.'.'.$filterKey, '=', $value);
if ($isExactOperator) {
$query->{$whereMethod}($table.'.'.$filterKey, $exactComparisonOperator, $value);
} else {
$query->{$whereMethod}($table.'.'.$filterKey, $likeOperator, '%'.$value.'%');
}
@@ -317,13 +326,13 @@ trait Searchable
$virtualColumns[$filterKey]
);
if ($operator === 'exact') {
if ($isExactOperator) {
// Exact match on the full CONCAT'd value, e.g. "John Smith" matches only
// users whose first_name + ' ' + last_name equals exactly "John Smith".
$concatSql = $this->buildMultipleColumnSearch($qualifiedColumns);
// buildMultipleColumnSearch intentionally returns a fragment ending in "LIKE ?";
// for exact matches we rewrite only the operator and keep the same SQL scaffold.
$concatSql = str_replace(' LIKE ?', ' = ?', $concatSql);
$concatSql = str_replace(' LIKE ?', $operator === 'exact_not' ? ' <> ?' : ' = ?', $concatSql);
$rawMethod = $boolean === 'or' ? 'orWhereRaw' : 'whereRaw';
$query->{$rawMethod}($concatSql, [$value]);
} else {
@@ -341,7 +350,7 @@ trait Searchable
}
if (in_array($filterKey, $searchableCounts, true)) {
return $this->applyCountAliasFilter($query, $filterKey, $value, $boolean, $negate);
return $this->applyCountAliasFilter($query, $filterKey, $value, $boolean, $negate, $isExactOperator);
}
// Check if this is a custom field (only for Assets - for *now*).
@@ -351,8 +360,8 @@ trait Searchable
$dbColumn = $this->resolveCustomFieldDbColumn($filterKey);
if ($dbColumn !== null) {
if ($operator === 'exact') {
$query->{$whereMethod}($table.'.'.$dbColumn, '=', $value);
if ($isExactOperator) {
$query->{$whereMethod}($table.'.'.$dbColumn, $exactComparisonOperator, $value);
} else {
$query->{$whereMethod}($table.'.'.$dbColumn, $likeOperator, '%'.$value.'%');
}
@@ -368,7 +377,7 @@ trait Searchable
}
if ($this->isAssignedToRelationKey($resolvedRelationKey)) {
return $this->applyAssignedToRelationFilter($query, $resolvedRelationKey, $value, $boolean, $negate);
return $this->applyAssignedToRelationFilter($query, $resolvedRelationKey, $value, $boolean, $negate, $operator);
}
$relationColumns = $this->getStructuredFilterRelationColumns(
@@ -380,27 +389,29 @@ trait Searchable
// For negated relation filters (e.g. location: !dam), include rows with
// no related record as well as rows with related records that do not match.
// This aligns advanced-search behavior with user expectation for "not X".
if ($operator !== 'exact' && $likeOperator === 'NOT LIKE') {
if ($operator === 'not_like' || $operator === 'exact_not') {
$compoundMethod = $boolean === 'or' ? 'orWhere' : 'where';
$query->{$compoundMethod}(function (Builder $compoundQuery) use ($resolvedRelationKey, $relationColumns, $value): void {
$query->{$compoundMethod}(function (Builder $compoundQuery) use ($resolvedRelationKey, $relationColumns, $value, $operator): void {
// Critical behavior: "not X" on relations should include records with no relation.
// Example: location=!dam should include users without a location.
$compoundQuery->doesntHave($resolvedRelationKey)
->orWhereHas($resolvedRelationKey, function (Builder $relationQuery) use ($resolvedRelationKey, $relationColumns, $value): void {
->orWhereHas($resolvedRelationKey, function (Builder $relationQuery) use ($resolvedRelationKey, $relationColumns, $value, $operator): void {
$relationTable = $this->getRelationTable($resolvedRelationKey);
$firstConditionAdded = false;
$relationComparisonOperator = $operator === 'exact_not' ? '!=' : 'NOT LIKE';
$relationComparisonValue = $operator === 'exact_not' ? $value : '%'.$value.'%';
foreach ($relationColumns as $relationColumn) {
if (! $firstConditionAdded) {
$relationQuery->where($relationTable.'.'.$relationColumn, 'NOT LIKE', '%'.$value.'%');
$relationQuery->where($relationTable.'.'.$relationColumn, $relationComparisonOperator, $relationComparisonValue);
$firstConditionAdded = true;
continue;
}
// For negation we AND the NOT LIKE conditions so all columns must not match.
$relationQuery->where($relationTable.'.'.$relationColumn, 'NOT LIKE', '%'.$value.'%');
$relationQuery->where($relationTable.'.'.$relationColumn, $relationComparisonOperator, $relationComparisonValue);
}
if (($resolvedRelationKey === 'adminuser') || ($resolvedRelationKey === 'user')) {
@@ -410,7 +421,11 @@ trait Searchable
'users.display_name',
]);
$relationQuery->whereRaw(str_replace('LIKE', 'NOT LIKE', $concatSql), ["%{$value}%"]);
if ($operator === 'exact_not') {
$relationQuery->whereRaw(str_replace(' LIKE ?', ' <> ?', $concatSql), [$value]);
} else {
$relationQuery->whereRaw(str_replace('LIKE', 'NOT LIKE', $concatSql), ["%{$value}%"]);
}
}
});
});
@@ -428,6 +443,8 @@ trait Searchable
if (! $firstConditionAdded) {
if ($operator === 'exact') {
$relationQuery->where($relationTable.'.'.$relationColumn, '=', $value);
} elseif ($operator === 'exact_not') {
$relationQuery->where($relationTable.'.'.$relationColumn, '!=', $value);
} else {
$relationQuery->where($relationTable.'.'.$relationColumn, $likeOperator, '%'.$value.'%');
}
@@ -440,6 +457,9 @@ trait Searchable
// For exact matches across multiple columns, OR them — any column matching
// the exact value is sufficient (e.g. name OR slug).
$relationQuery->orWhere($relationTable.'.'.$relationColumn, '=', $value);
} elseif ($operator === 'exact_not') {
// For exact exclusions we AND the conditions so no column can equal the value.
$relationQuery->where($relationTable.'.'.$relationColumn, '!=', $value);
} elseif ($likeOperator === 'NOT LIKE') {
// For negation we AND the NOT LIKE conditions so all columns must not match.
$relationQuery->where($relationTable.'.'.$relationColumn, $likeOperator, '%'.$value.'%');
@@ -459,6 +479,9 @@ trait Searchable
if ($operator === 'exact') {
$concatSql = str_replace(' LIKE ?', ' = ?', $concatSql);
$relationQuery->orWhereRaw($concatSql, [$value]);
} elseif ($operator === 'exact_not') {
$concatSql = str_replace(' LIKE ?', ' <> ?', $concatSql);
$relationQuery->whereRaw($concatSql, [$value]);
} elseif ($likeOperator === 'NOT LIKE') {
$relationQuery->whereRaw(str_replace('LIKE', 'NOT LIKE', $concatSql), ["%{$value}%"]);
} else {
@@ -524,7 +547,7 @@ trait Searchable
* (Records with no assignee are excluded; they do not satisfy "has an assignee
* where column NOT LIKE '%value%'".)
*/
private function applyAssignedToRelationFilter(Builder $query, string $relationKey, string $filterValue, string $boolean = 'and', bool $negate = false): Builder
private function applyAssignedToRelationFilter(Builder $query, string $relationKey, string $filterValue, string $boolean = 'and', bool $negate = false, string $operator = 'like'): Builder
{
$relationName = $this->resolveAssignedToRelationName();
@@ -533,12 +556,14 @@ trait Searchable
}
$likeOperator = $negate ? 'NOT LIKE' : 'LIKE';
$isExactOperator = in_array($operator, ['exact', 'exact_not'], true);
$exactComparisonOperator = $operator === 'exact_not' ? '!=' : '=';
$relationMethod = $boolean === 'or' ? 'orWhereHasMorph' : 'whereHasMorph';
return $query->{$relationMethod}(
$relationName,
[User::class, Asset::class, Location::class],
function (Builder $assigneeQuery, string $assigneeType) use ($filterValue, $likeOperator, $negate) {
function (Builder $assigneeQuery, string $assigneeType) use ($filterValue, $likeOperator, $negate, $operator, $isExactOperator, $exactComparisonOperator) {
$columns = $this->getAssigneeColumnsByType($assigneeType);
if (empty($columns)) {
@@ -550,7 +575,11 @@ trait Searchable
foreach ($columns as $column) {
if (! $firstConditionAdded) {
$assigneeQuery->where($table.'.'.$column, $likeOperator, '%'.$filterValue.'%');
if ($isExactOperator) {
$assigneeQuery->where($table.'.'.$column, $exactComparisonOperator, $filterValue);
} else {
$assigneeQuery->where($table.'.'.$column, $likeOperator, '%'.$filterValue.'%');
}
$firstConditionAdded = true;
continue;
@@ -558,17 +587,29 @@ trait Searchable
// For negation, AND the conditions (all columns must not match).
// For normal LIKE, OR them (any column matching is sufficient).
$negate
? $assigneeQuery->where($table.'.'.$column, $likeOperator, '%'.$filterValue.'%')
: $assigneeQuery->orWhere($table.'.'.$column, $likeOperator, '%'.$filterValue.'%');
if ($operator === 'exact') {
$assigneeQuery->orWhere($table.'.'.$column, '=', $filterValue);
} elseif ($operator === 'exact_not') {
$assigneeQuery->where($table.'.'.$column, '!=', $filterValue);
} else {
$negate
? $assigneeQuery->where($table.'.'.$column, $likeOperator, '%'.$filterValue.'%')
: $assigneeQuery->orWhere($table.'.'.$column, $likeOperator, '%'.$filterValue.'%');
}
}
if ($assigneeType === User::class) {
$concatSql = $this->buildMultipleColumnSearch(['users.first_name', 'users.last_name']);
$negate
? $assigneeQuery->whereRaw(str_replace('LIKE', 'NOT LIKE', $concatSql), ["%{$filterValue}%"])
: $assigneeQuery->orWhereRaw($concatSql, ["%{$filterValue}%"]);
if ($operator === 'exact') {
$assigneeQuery->orWhereRaw(str_replace(' LIKE ?', ' = ?', $concatSql), [$filterValue]);
} elseif ($operator === 'exact_not') {
$assigneeQuery->whereRaw(str_replace(' LIKE ?', ' <> ?', $concatSql), [$filterValue]);
} else {
$negate
? $assigneeQuery->whereRaw(str_replace('LIKE', 'NOT LIKE', $concatSql), ["%{$filterValue}%"])
: $assigneeQuery->orWhereRaw($concatSql, ["%{$filterValue}%"]);
}
}
}
);
@@ -613,7 +654,7 @@ trait Searchable
/**
* Apply filtering on computed count aliases (for example withCount aliases).
*/
private function applyCountAliasFilter(Builder $query, string $countAlias, string $filterValue, string $boolean = 'and', bool $negate = false): Builder
private function applyCountAliasFilter(Builder $query, string $countAlias, string $filterValue, string $boolean = 'and', bool $negate = false, bool $exact = false): Builder
{
$havingMethod = $boolean === 'or' ? 'orHaving' : 'having';
@@ -623,6 +664,12 @@ trait Searchable
return $query->{$havingMethod}($countAlias, $operator, (int) $filterValue);
}
if ($exact) {
$operator = $negate ? '!=' : '=';
return $query->{$havingMethod}($countAlias, $operator, $filterValue);
}
$likeOperator = $negate ? 'NOT LIKE' : 'LIKE';
return $query->{$havingMethod}($countAlias, $likeOperator, '%'.$filterValue.'%');
@@ -653,14 +700,21 @@ trait Searchable
$dbColumn = $this->resolveCustomFieldDbColumn($filterKey);
if ($dbColumn !== null) {
$method = match (true) {
$isNull && $boolean === 'or' => 'orWhereNull',
$isNull => 'whereNull',
$boolean === 'or' => 'orWhereNotNull',
default => 'whereNotNull',
};
$column = $table.'.'.$dbColumn;
$query->{$method}($table.'.'.$dbColumn);
$method = $boolean === 'or' ? 'orWhere' : 'where';
$query->{$method}(function (Builder $subQuery) use ($column, $isNull): void {
if ($isNull) {
$subQuery->whereNull($column)
->orWhere($column, '=', '');
return;
}
$subQuery->whereNotNull($column)
->where($column, '!=', '');
});
return $query;
}
@@ -668,14 +722,20 @@ trait Searchable
// Direct attribute column.
if (in_array($filterKey, $searchableAttributes, true)) {
$method = match (true) {
$isNull && $boolean === 'or' => 'orWhereNull',
$isNull => 'whereNull',
$boolean === 'or' => 'orWhereNotNull',
default => 'whereNotNull',
};
$column = $table.'.'.$filterKey;
$method = $boolean === 'or' ? 'orWhere' : 'where';
$query->{$method}($table.'.'.$filterKey);
$query->{$method}(function (Builder $subQuery) use ($column, $isNull): void {
if ($isNull) {
$subQuery->whereNull($column)
->orWhere($column, '=', '');
return;
}
$subQuery->whereNotNull($column)
->where($column, '!=', '');
});
return $query;
}
@@ -710,7 +770,26 @@ trait Searchable
$searchableRelations = $this->getSearchableRelations();
$resolvedRelationKey = $this->resolveSearchableRelationKey($filterKey, $searchableRelations);
if ($resolvedRelationKey !== null && ! $this->isAssignedToRelationKey($resolvedRelationKey)) {
if ($resolvedRelationKey !== null && $this->isAssignedToRelationKey($resolvedRelationKey)) {
$method = $boolean === 'or' ? 'orWhere' : 'where';
// Polymorphic assignment is present only when both columns are set; null matches either side missing.
if ($isNull) {
$query->{$method}(function (Builder $assigneeNullQuery) use ($table): void {
$assigneeNullQuery->whereNull($table.'.assigned_to')
->orWhereNull($table.'.assigned_type');
});
} else {
$query->{$method}(function (Builder $assigneeNotNullQuery) use ($table): void {
$assigneeNotNullQuery->whereNotNull($table.'.assigned_to')
->whereNotNull($table.'.assigned_type');
});
}
return $query;
}
if ($resolvedRelationKey !== null) {
if ($isNull) {
$method = $boolean === 'or' ? 'orDoesntHave' : 'doesntHave';
$query->{$method}($resolvedRelationKey);
+45 -1
View File
@@ -725,6 +725,10 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
{
return $this->belongsToMany(License::class, 'license_seats', 'assigned_to', 'license_id')->withPivot('id', 'created_at', 'updated_at');
}
public function directLicenses()
{
return $this->belongsToMany(\App\Models\License::class, 'license_seats', 'assigned_to', 'license_id')->withPivot('id', 'created_at', 'updated_at')->wherePivotNull('asset_id')->withTrashed();
}
/**
* Establishes the user -> reportTemplates relationship
@@ -1389,7 +1393,47 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
->orwhereRaw('CONCAT(users.first_name," ",users.last_name) LIKE \''.$search.'%\'');
}
public function scopeWithInventoryRelations($query, int $id)
{
return $query->where('id', $id)
->with([
'assets.log' => fn ($query) => $query->withTrashed()
->where('target_type', User::class)
->where('target_id', $id)
->where('action_type', 'accepted'),
'assets.defaultLoc',
'assets.location',
'assets.model.category',
'assets.assignedAssets.log' => fn ($query) => $query->withTrashed()
->where('target_type', User::class)
->where('target_id', $id)
->where('action_type', 'accepted'),
'assets.assignedAssets.assignedTo',
'assets.assignedAssets.defaultLoc',
'assets.assignedAssets.location',
'assets.assignedAssets.model.category',
'assets.components.category',
'assets.licenses',
'assets.licenses.category',
'assets.assignedAccessories',
'assets.assignedAccessories.accessory.category',
'accessories.log' => fn ($query) => $query->withTrashed()
->where('target_type', User::class)
->where('target_id', $id)
->where('action_type', 'accepted'),
'accessories.category',
'accessories.manufacturer',
'consumables.log' => fn ($query) => $query->withTrashed()
->where('target_type', User::class)
->where('target_id', $id)
->where('action_type', 'accepted'),
'consumables.category',
'consumables.manufacturer',
'directLicenses.category',
'licenses.category',
])
->withTrashed();
}
/**
* Get all direct and indirect subordinates for this user.
*
+8 -1
View File
@@ -16,6 +16,13 @@ class LicensePresenter extends Presenter
{
$layout = [
[
'field' => 'checkbox',
'checkbox' => true,
'formatter' => 'checkboxEnabledFormatter',
'titleTooltip' => trans('general.select_all_none'),
'printIgnore' => true,
'class' => 'hidden-print',
], [
'field' => 'id',
'searchable' => false,
'sortable' => true,
@@ -115,7 +122,7 @@ class LicensePresenter extends Presenter
'searchable' => false,
'sortable' => false,
'switchable' => true,
'title' => '% ' . trans('general.remaining'),
'title' => '% '.trans('general.remaining'),
'visible' => true,
'formatter' => 'progressBarFormatter',
], [
+1 -1
View File
@@ -7,5 +7,5 @@ return [
'prerelease_version' => '',
'hash_version' => 'g5014b1c459',
'full_hash' => 'v8.5.0-pre-207-g5014b1c459',
'branch' => 'develop',
'branch' => 'master',
];
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1645
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+6 -24718
View File
File diff suppressed because one or more lines are too long
+1 -414
View File
File diff suppressed because one or more lines are too long
+1 -135
View File
@@ -1,135 +1 @@
#signature-pad {
padding-top: 250px;
margin: auto;
}
.m-signature-pad {
position: relative;
font-size: 10px;
width: 100%;
height: 300px;
border: 1px solid #e8e8e8;
background-color: #fff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.27), 0 0 40px rgba(0, 0, 0, 0.08) inset;
border-radius: 4px;
}
.m-signature-pad:before, .m-signature-pad:after {
position: absolute;
z-index: -1;
content: "";
width: 40%;
height: 10px;
left: 20px;
bottom: 10px;
background: transparent;
-webkit-transform: skew(-3deg) rotate(-3deg);
-moz-transform: skew(-3deg) rotate(-3deg);
-ms-transform: skew(-3deg) rotate(-3deg);
-o-transform: skew(-3deg) rotate(-3deg);
transform: skew(-3deg) rotate(-3deg);
box-shadow: 0 8px 12px rgba(0, 0, 0, 0.4);
}
.m-signature-pad:after {
left: auto;
right: 20px;
-webkit-transform: skew(3deg) rotate(3deg);
-moz-transform: skew(3deg) rotate(3deg);
-ms-transform: skew(3deg) rotate(3deg);
-o-transform: skew(3deg) rotate(3deg);
transform: skew(3deg) rotate(3deg);
}
.m-signature-pad--body {
position: absolute;
top: 20px;
bottom: 60px;
border: 1px solid #f4f4f4;
background-color: white;
}
.m-signature-pad--body
canvas {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
border-radius: 4px;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.02) inset;
}
.m-signature-pad--footer {
position: absolute;
left: 20px;
right: 20px;
bottom: 20px;
height: 40px;
}
.m-signature-pad--footer
.description {
color: #C3C3C3;
text-align: center;
font-size: 1.2em;
margin-top: 1.8em;
}
.m-signature-pad--footer
.button {
position: absolute;
bottom: 0;
}
.m-signature-pad--footer
.button.clear {
left: 0;
}
.m-signature-pad--footer
.button.save {
right: 0;
}
@media screen and (max-width: 1024px) {
.m-signature-pad {
top: 0;
left: 0;
right: 0;
bottom: 0;
width: auto;
height: auto;
min-width: 250px;
min-height: 140px;
margin: 5%;
}
}
@media screen and (min-device-width: 768px) and (max-device-width: 1024px) {
.m-signature-pad {
margin: 10%;
}
}
@media screen and (max-height: 320px) {
.m-signature-pad--body {
left: 0;
right: 0;
top: 0;
bottom: 32px;
}
.m-signature-pad--footer {
left: 20px;
right: 20px;
bottom: 4px;
height: 28px;
}
.m-signature-pad--footer
.description {
font-size: 1em;
margin-top: 1em;
}
}
#signature-pad{padding-top:250px;margin:auto}.m-signature-pad{position:relative;font-size:10px;width:100%;height:300px;border:1px solid #e8e8e8;background-color:#fff;box-shadow:0 1px 4px rgba(0,0,0,.27),0 0 40px rgba(0,0,0,.08) inset;border-radius:4px}.m-signature-pad:after,.m-signature-pad:before{position:absolute;z-index:-1;content:"";width:40%;height:10px;left:20px;bottom:10px;background:0 0;-webkit-transform:skew(-3deg) rotate(-3deg);-moz-transform:skew(-3deg) rotate(-3deg);-ms-transform:skew(-3deg) rotate(-3deg);-o-transform:skew(-3deg) rotate(-3deg);transform:skew(-3deg) rotate(-3deg);box-shadow:0 8px 12px rgba(0,0,0,.4)}.m-signature-pad:after{left:auto;right:20px;-webkit-transform:skew(3deg) rotate(3deg);-moz-transform:skew(3deg) rotate(3deg);-ms-transform:skew(3deg) rotate(3deg);-o-transform:skew(3deg) rotate(3deg);transform:skew(3deg) rotate(3deg)}.m-signature-pad--body{position:absolute;top:20px;bottom:60px;border:1px solid #f4f4f4;background-color:#fff}.m-signature-pad--body canvas{position:absolute;left:0;top:0;width:100%;height:100%;border-radius:4px;box-shadow:0 0 5px rgba(0,0,0,.02) inset}.m-signature-pad--footer{position:absolute;left:20px;right:20px;bottom:20px;height:40px}.m-signature-pad--footer .description{color:#c3c3c3;text-align:center;font-size:1.2em;margin-top:1.8em}.m-signature-pad--footer .button{position:absolute;bottom:0}.m-signature-pad--footer .button.clear{left:0}.m-signature-pad--footer .button.save{right:0}@media screen and (max-width:1024px){.m-signature-pad{top:0;left:0;right:0;bottom:0;width:auto;height:auto;min-width:250px;min-height:140px;margin:5%}}@media screen and (min-device-width:768px) and (max-device-width:1024px){.m-signature-pad{margin:10%}}@media screen and (max-height:320px){.m-signature-pad--body{left:0;right:0;top:0;bottom:32px}.m-signature-pad--footer{left:20px;right:20px;bottom:4px;height:28px}.m-signature-pad--footer .description{font-size:1em;margin-top:1em}}
+2 -53211
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -38849
View File
File diff suppressed because one or more lines are too long
+5 -5
View File
@@ -1,9 +1,9 @@
{
"/js/dist/all.js": "/js/dist/all.js?id=4f5a712e780c903c0d996539233b4f78",
"/css/build/overrides.css": "/css/build/overrides.css?id=c173dd71d56c1089bf560a849586d93e",
"/css/build/app.css": "/css/build/app.css?id=63ef76491d01db361ad53cf1c8c7114f",
"/css/build/AdminLTE.css": "/css/build/AdminLTE.css?id=ee0ed88465dd878588ed044eefb67723",
"/css/dist/all.css": "/css/dist/all.css?id=57e6bf27bcfad47e58a82b9842a7d5bd",
"/js/dist/all.js": "/js/dist/all.js?id=6ed2062a051f86b7fed3b1f894749219",
"/css/build/overrides.css": "/css/build/overrides.css?id=9bfab28a94932d45568ad50f3c6c5e2c",
"/css/build/app.css": "/css/build/app.css?id=4b2abd7fa3560ada549e9d08bd836aa8",
"/css/build/AdminLTE.css": "/css/build/AdminLTE.css?id=bdf169bc2141f453390614c138cdce95",
"/css/dist/all.css": "/css/dist/all.css?id=f5f404325dedd1abd00dc781664c0034",
"/css/dist/signature-pad.css": "/css/dist/signature-pad.css?id=6a89d3cd901305e66ced1cf5f13147f7",
"/css/dist/signature-pad.min.css": "/css/dist/signature-pad.min.css?id=6a89d3cd901305e66ced1cf5f13147f7",
"/js/select2/i18n/af.js": "/js/select2/i18n/af.js?id=4f6fcd73488ce79fae1b7a90aceaecde",
@@ -37,6 +37,9 @@ return [
'confirm' => 'Are you sure you wish to delete this license?',
'error' => 'There was an issue deleting the license. Please try again.',
'success' => 'The license was deleted successfully.',
'bulk_success' => 'The selected licenses were deleted successfully.',
'partial_success' => 'License deleted successfully. See additional information below. | :count licenses were deleted successfully. See additional information below.',
'bulk_checkout_warning' => ':license_name has seats that are currently checked out and cannot be deleted. Please check in all seats before deleting.',
],
'checkout' => [
@@ -438,7 +438,7 @@ return [
'timezone' => 'Timezone',
'test_mail' => 'Test Mail',
'profile_edit' => 'Edit Profile',
'profile_edit_help' => 'Allow users to edit their own profiles.',
'profile_edit_help' => 'Allow users to edit their own profiles. (This does not include accessibility settings like light/dark mode, audio feedback, or link colors.)',
'default_avatar' => 'Custom Default Avatar',
'default_avatar_help' => 'This image will be displayed as a profile if a user does not have a profile photo.',
'restore_default_avatar' => 'Restore <a href=":default_avatar" data-toggle="lightbox" data-type="image">original system default avatar</a>',
+16 -1
View File
@@ -196,6 +196,7 @@ return [
'license' => 'License',
'license_report' => 'License Report',
'licenses_available' => 'Licenses available',
'licenses_with_no_seats' => 'Licenses with No Available Seats',
'licenses' => 'Licenses',
'list_all' => 'List All',
'loading' => 'Loading... please wait...',
@@ -485,8 +486,22 @@ return [
'set_to_null' => 'Delete values for this selection|Delete values for all :selection_count selections ',
'set_users_field_to_null' => 'Delete :field values for this user|Delete :field values for all :user_count users ',
'na_no_purchase_date' => 'N/A - No purchase date provided',
'assets_by_category' => 'Assets by Category',
'assets_by_status' => 'Assets by Status',
'assets_by_status_type' => 'Assets by Status Type',
'activity_overview' => 'Activity Overview',
'checkouts_checkins' => 'Checkouts & Check-ins',
'assets_newly_added' => 'Assets Added',
'checkouts' => 'Checkouts',
'checkins' => 'Check-ins',
'assets' => 'Assets',
'vs_prior_period' => 'vs. prior period',
'time_range' => 'Select Date Range',
'last_n_days' => 'Last :days Days',
'custom_range' => 'Custom Range',
'download_chart' => 'Download Chart as PNG',
'fullscreen' => 'Fullscreen',
'pie_chart_type' => 'Dashboard Pie Chart Type',
'hello_name' => 'Hello, :name!',
'unaccepted_profile_warning' => 'You have one item requiring acceptance. Click here to accept or decline it | You have :count items requiring acceptance. Click here to accept or decline them',
@@ -609,7 +624,7 @@ return [
'search_operator' => 'Search operator',
'and' => 'and',
'action_source' => 'Action Source',
'search_tip' => 'Searches return a partial match by default. For more specific results, you can use <code>not:value</code> to exclude, <code>is:value</code> for an exact match, <code>is:null</code> for empty values, and <code>is:not_null</code> for non-empty. (In these examples, <code>value</code> is the text you are searching for.)',
'search_tip' => 'Searches return a partial match by default. For more specific results, you can use <code>not:value</code> to fuzzy-exclude, <code>is:value</code> for an exact match, <code>is_not:value</code> for an exact exclusion, <code>is:null</code> for empty values, and <code>is:not_null</code> for non-empty. (In these examples, <code>value</code> is the text you are searching for.)',
'or' => 'or',
'url' => 'URL',
'phone' => 'Phone',
+1
View File
@@ -48,6 +48,7 @@ return [
'asset_tag' => 'Asset Tag',
'assets_warrantee_alert' => 'There is :count asset with an expiring warranty or that are reaching their end of life in the next :threshold days.|There are :count assets with expiring warranties or that are reaching their end of life in the next :threshold days.',
'assigned_to' => 'Assigned To',
'assigned_to_assets' => 'Assignments to Assets',
'eol' => 'EOL',
'best_regards' => 'Best regards,',
'canceled' => 'Canceled',
+1 -2
View File
@@ -143,13 +143,12 @@
</div>
</a>
</div><!-- ./col -->
</div>
</div>
@if ($counts['grand_total'] == 0)
<div class="row">
<div class="col-md-12">
<div class="box box-default">
<div class="box-header with-border">
+18 -5
View File
@@ -1352,8 +1352,9 @@
<!-- User Account: style can be found in dropdown.less -->
@if (auth()->check())
@auth
<li class="dropdown user user-menu">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
@if (auth()->user()->present()->gravatar())
<img src="{{ Auth::user()->present()->gravatar() }}" class="user-image"
@@ -1367,8 +1368,11 @@
<strong class="caret"></strong>
</span>
</a>
<ul class="dropdown-menu">
<!-- User image -->
<!-- User assets -->
@can('self.profile')
<li {!! (request()->is('account/view-assets') ? ' class="active"' : '') !!}>
<a href="{{ route('view-assets') }}">
@@ -1376,6 +1380,7 @@
{{ trans('general.viewassets') }}
</a>
</li>
@endcan
@can('viewRequestable', \App\Models\Asset::class)
@@ -1386,6 +1391,7 @@
</a></li>
@endcan
@can('self.profile')
<li {!! (request()->is('account/accept') ? ' class="active"' : '') !!}>
<a href="{{ route('account.accept') }}">
<x-icon type="checkmark" class="fa-fw" />
@@ -1394,7 +1400,7 @@
</li>
@endcan
<li {!! (request()->is('account/password') ? ' class="active"' : '') !!}>
<li {!! (request()->is('account/profile') ? ' class="active"' : '') !!}>
<a href="{{ route('profile') }}">
<x-icon type="user" class="fa-fw" />
{{ trans('general.editprofile') }}
@@ -1403,7 +1409,7 @@
@can('self.profile')
@if (Auth::user()->ldap_import!='1')
<li {!! (request()->is('account/profile') ? ' class="active"' : '') !!}>
<li {!! (request()->is('account/password') ? ' class="active"' : '') !!}>
<a href="{{ route('account.password.index') }}">
<x-icon type="password" class="fa-fw"/>
{{ trans('general.changepassword') }}
@@ -1426,6 +1432,7 @@
</a>
</li>
@endcan
<li class="divider"></li>
<li>
<a href="{{ route('logout.get') }}"
@@ -1442,7 +1449,7 @@
</li>
</ul>
</li>
@endif
@endauth
@can('superadmin')
@@ -1821,6 +1828,7 @@
@can('reports.view')
<li class="treeview{{ (request()->is('reports*') ? ' active' : '') }}">
<a href="#" class="dropdown-toggle">
<x-icon type="reports" class="fa-fw" />
<span>{{ trans('general.reports') }}</span>
@@ -1828,6 +1836,11 @@
</a>
<ul class="treeview-menu">
<li {{!! (request()->is('reports') ? ' class="active"' : '') !!}}>
<a href="{{ route('reports.index') }}">
{{ trans('general.list_all') }}
</a>
</li>
<li {{!! (request()->is('reports/activity') ? ' class="active"' : '') !!}}>
<a href="{{ route('reports.activity') }}">
{{ trans('general.activity_report') }}
+12
View File
@@ -12,6 +12,18 @@
<x-container>
<x-box>
<x-slot:bulkactions>
<x-table.bulk-actions
name='licenses'
action_route="{{ route('licenses.bulk.delete') }}"
model_name="license"
>
@can('delete', App\Models\License::class)
<option value="delete">{{ trans('general.delete') }}</option>
@endcan
</x-table.bulk-actions>
</x-slot:bulkactions>
<x-table.licenses
fixed_right_number="2"
fixed_number="1"
@@ -187,11 +187,11 @@
var colMap = {};
this.columns.forEach(c => colMap[c.field] = c.title);
var op = this.getAdvancedSearchOperator();
var html = '<span class="label label-warning" style="margin-right:6px;display:inline-block;margin-bottom:6px;">' +
var html = '<span class="label label-warning" style="font-size: 11px; margin-right:6px;display:inline-block;margin-bottom:6px;">' +
advancedSearchOperatorLabel + ': ' + (op === 'or' ? advancedSearchOrText : advancedSearchAndText) + '</span>';
Object.keys(filters).forEach(f => {
html += '<span class="label label-primary" style="margin-right:6px;display:inline-block;margin-bottom:6px;"><b>' +
html += '<span class="label label-primary" style="font-size: 11px; margin-right:6px;display:inline-block;margin-bottom:6px;"><b>' +
(colMap[f] || f).replace(/<[^>]*>/g, '') + ':</b> ' + escapeAdvancedSearchValue(filters[f]) +
' <a href="javascript:void(0)" class="snipe-advanced-search-tag-remove" data-field="' + f +
'" style="color:#fff;margin-left:6px;text-decoration:none;">&times;</a></span>';
@@ -368,6 +368,18 @@
_this.applyAdvancedSearch();
});
// Let Enter keypresses reuse the same submit path so keyboard users can apply filters quickly.
this.$toolbarModal.find('.toolbar-model-form')
.off('keydown.snipeAdvancedSearch')
.on('keydown.snipeAdvancedSearch', ':input', function (event) {
if (event.key !== 'Enter' || $(event.target).is('textarea')) {
return;
}
event.preventDefault();
$(this).closest('form').trigger('submit');
});
this.initAdvancedSearchFooter();
};
+687 -74
View File
@@ -2,89 +2,702 @@
{{-- Page title --}}
@section('title')
{{ trans('general.depreciation_report') }}
@parent
{{ trans('general.reports') }}
@parent
@stop
{{-- Page content --}}
@section('content')
<div class="page-header">
<div class="pull-right">
<a href="{{ route('reports/export') }}" class="btn btn-flat gray pull-right"><i class="fas fa-download icon-white" aria-hidden="true"></i>
{{ trans('admin/hardware/table.dl_csv') }}</a>
{{-- Row: Report Links --}}
<div class="row" style="padding-bottom: 10px;">
<div class="col-md-3 col-sm-6">
<span href="{{ route('reports.activity') }}" class="btn btn-theme btn-block" style="margin-bottom: 10px; white-space: normal;">
<x-icon type="reports"/> {{ trans('general.activity_report') }}
</span>
</div>
<h2>{{ trans('general.depreciation_report') }}</h2>
</div>
<div class="row">
<div class="col-md-3 col-sm-6">
<a href="{{ url('reports/custom') }}" class="btn btn-theme btn-block" style="margin-bottom: 10px; white-space: normal;">
<x-icon type="reports"/> {{ trans('general.custom_report') }}
</a>
</div>
<table id="example">
<thead>
<tr role="row">
<th class="col-sm-1">{{ trans('admin/hardware/table.asset_tag') }}</th>
<th class="col-sm-1">{{ trans('admin/hardware/table.title') }}</th>
@if ($snipeSettings->display_asset_name)
<th class="col-sm-1">{{ trans('general.name') }}</th>
@endif
<th class="col-sm-1">{{ trans('admin/hardware/table.serial') }}</th>
<th class="col-sm-1">{{ trans('admin/hardware/table.checkoutto') }}</th>
<th class="col-sm-1">{{ trans('admin/hardware/table.location') }}</th>
<th class="col-sm-1">{{ trans('admin/hardware/table.purchase_date') }}</th>
<th class="col-sm-1">{{ trans('admin/hardware/table.eol') }}</th>
<th class="col-sm-1">{{ trans('admin/hardware/table.purchase_cost') }}</th>
<th class="col-sm-1">{{ trans('admin/hardware/table.book_value') }}</th>
<th class="col-sm-1">{{ trans('admin/hardware/table.diff') }}</th>
</tr>
</thead>
<tbody>
@foreach ($assets as $asset)
<tr>
<td>{{ $asset->asset_tag }}</td>
<td>{{ $asset->model->name }}</td>
@if ($snipeSettings->display_asset_name)
<td>{{ $asset->name }}</td>
@endif
<td>{{ $asset->serial }}</td>
<td>
@if ($asset->assigned_to != '')
{!! $asset->assignedTo->present->nameUrl() !!}
@endif
</td>
<td>
@if (($asset->checkedOutToUser()) && ($asset->assignedTo->assetLoc))
{{ $asset->assignedTo->assetLoc->city }}, {{ $asset->assignedTo->assetLoc->state}}
@endif
</td>
<td>{{ $asset->purchase_date }}</td>
<div class="col-md-3 col-sm-6">
<a href="{{ route('reports.audit') }}" class="btn btn-theme btn-block" style="margin-bottom: 10px; white-space: normal;">
<x-icon type="audit"/> {{ trans('general.audit_report') }}
</a>
</div>
<td>
@if ($asset->model->eol) {{ $asset->present()->eol_date() }}
@endif
</td>
<div class="col-md-3 col-sm-6">
<a href="{{ url('reports/depreciation') }}" class="btn btn-theme btn-block" style="margin-bottom: 10px; white-space: normal;">
<x-icon type="reports"/> {{ trans('general.depreciation_report') }}
</a>
</div>
@if ($asset->purchase_cost > 0)
<td class="align-right">
{{ $snipeSettings->default_currency }}
{{ Helper::formatCurrencyOutput($asset->purchase_cost) }}
</td>
<td class="align-right">
{{ $snipeSettings->default_currency }}
{{ number_format($asset->depreciate()) }}
</td>
<td class="align-right">
{{ $snipeSettings->default_currency }}
-{{ number_format(($asset->purchase_cost - $asset->depreciate())) }}
</td>
@else {{-- purchase_cost > 0 --}}
<td></td>
<td></td>
<td></td>
@endif
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="col-md-3 col-sm-6">
<a href="{{ url('reports/licenses') }}" class="btn btn-theme btn-block" style="margin-bottom: 10px; white-space: normal;">
<x-icon type="licenses"/> {{ trans('general.license_report') }}
</a>
</div>
<div class="col-md-3 col-sm-6">
<a href="{{ route('ui.reports.maintenances') }}" class="btn btn-theme btn-block" style="margin-bottom: 10px; white-space: normal;">
<x-icon type="maintenances"/> {{ trans('general.asset_maintenance_report') }}
</a>
</div>
<div class="col-md-3 col-sm-6">
<a href="{{ url('reports/unaccepted_assets') }}" class="btn btn-theme btn-block" style="margin-bottom: 10px; white-space: normal;">
<x-icon type="assets"/> {{ trans('general.unaccepted_asset_report') }}
</a>
</div>
<div class="col-md-3 col-sm-6">
<a href="{{ url('reports/accessories') }}" class="btn btn-theme btn-block" style="margin-bottom: 10px; white-space: normal;">
<x-icon type="accessories"/> {{ trans('general.accessory_report') }}
</a>
</div>
</div>
{{-- Date Range Control + Stat Alert Cards --}}
<div class="row">
<div class="col-md-12">
<div class="well well-sm" style="margin-bottom:15px;">
<form class="form-inline" style="margin-bottom: 10px; border-bottom: var(--box-header-bottom-border)">
<label style="font-weight:bold; white-space:nowrap;">{{ trans('general.time_range') }}:</label>
<select id="chartTimeRange" class="form-control input-sm" style="width:auto;">
<option value="7">{{ trans('general.last_n_days', ['days' => 7]) }}</option>
<option value="14">{{ trans('general.last_n_days', ['days' => 14]) }}</option>
<option value="30" selected>{{ trans('general.last_n_days', ['days' => 30]) }}</option>
<option value="60">{{ trans('general.last_n_days', ['days' => 60]) }}</option>
<option value="90">{{ trans('general.last_n_days', ['days' => 90]) }}</option>
<option value="180">{{ trans('general.last_n_days', ['days' => 180]) }}</option>
<option value="365">{{ trans('general.last_n_days', ['days' => 365]) }}</option>
<option value="custom">{{ trans('general.custom_range') }}</option>
</select>
<div id="customRangePicker" class="input-daterange input-group" style="display:none; width:auto;">
<input type="text" id="chartStartDate" class="form-control input-sm" placeholder="{{ trans('general.select_date') }}" style="width:110px;" autocomplete="off">
<span class="input-group-addon"></span>
<input type="text" id="chartEndDate" class="form-control input-sm" placeholder="{{ trans('general.select_date') }}" style="width:110px;" autocomplete="off">
</div>
</form>
<div class="row" style="margin-bottom:-15px;">
<div class="col-lg-3 col-sm-6">
<a href="{{ route('reports.audit') }}">
<div class="info-box {{ $audit_alert_count > 0 ? 'bg-red' : 'bg-green' }}">
<span class="info-box-icon" aria-hidden="true">
<x-icon type="audit"/>
</span>
<div class="info-box-content">
<span class="info-box-text">{{ trans('general.audit_due') }} / {{ trans('general.audit_overdue') }}</span>
<span class="info-box-number">{{ number_format($audit_alert_count) }}
<span class="info-box-more" id="progress-audit-label">&nbsp;</span>
</span>
<div class="progress" style="height:6px;">
<div class="progress-bar" id="progress-audit" data-count="{{ $audit_alert_count }}" style="width: 0%"></div>
</div>
</div>
</div>
</a>
</div>
<div class="col-lg-3 col-sm-6">
<a href="{{ url('reports/custom') }}">
<div class="info-box {{ $checkin_alert_count > 0 ? 'bg-red' : 'bg-green' }}">
<span class="info-box-icon " aria-hidden="true">
<x-icon type="assets"/>
</span>
<div class="info-box-content">
<span class="info-box-text">{{ trans('general.checkin_due') }} / {{ trans('general.checkin_overdue') }}</span>
<span class="info-box-number">{{ number_format($checkin_alert_count) }}
<span class="info-box-more" id="progress-checkin-label">&nbsp;</span>
</span>
<div class="progress" style="height:6px;">
<div class="progress-bar" id="progress-checkin" data-count="{{ $checkin_alert_count }}" style="width: 0%"></div>
</div>
</div>
</div>
</a>
</div>
<div class="col-lg-3 col-sm-6">
<a href="{{ route('reports/unaccepted_assets') }}">
<div class="info-box {{ $pending_acceptance_count > 0 ? 'bg-yellow' : 'bg-green' }}">
<span class="info-box-icon" aria-hidden="true">
<x-icon type="assets"/>
</span>
<div class="info-box-content">
<span class="info-box-text">{{ trans('general.unaccepted_asset_report') }}</span>
<span class="info-box-number">{{ number_format($pending_acceptance_count) }}
<span class="info-box-more" id="progress-acceptance-label">&nbsp;</span>
</span>
<div class="progress" style="height:6px;">
<div class="progress-bar" id="progress-acceptance" data-count="{{ $pending_acceptance_count }}" style="width: 0%"></div>
</div>
</div>
</div>
</a>
</div>
<div class="col-lg-3 col-sm-6">
<a href="{{ url('reports/licenses') }}">
<div class="info-box {{ $licenses_low_count > 0 ? 'bg-red' : 'bg-green' }}">
<span class="info-box-icon" aria-hidden="true">
<x-icon type="licenses"/>
</span>
<div class="info-box-content">
<span class="info-box-text">{{ trans('general.licenses_with_no_seats') }}</span>
<span class="info-box-number">
{{ number_format($licenses_low_count) }}
<span class="info-box-more" id="progress-licenses-label">&nbsp;</span>
</span>
<div class="progress" style="height:6px;">
<div class="progress-bar" id="progress-licenses" data-count="{{ $licenses_low_count }}" style="width: 0%"></div>
</div>
</div>
</div>
</a>
</div>
</div>
</div>
</div>
</div>
{{-- Row 1: Users + Assets --}}
<div class="row">
<div class="col-md-6">
<div class="box box-default">
<div class="box-header with-border">
<h2 class="box-title"><x-icon type="users" /> {{ trans('general.users') }}</h2>
<div class="box-tools pull-right">
<button type="button" class="btn btn-box-tool chart-dl-btn" data-target="chart-users" title="{{ trans('general.download_chart') }}">
<i class="fa fa-download fa-fw"></i></button>
<button type="button" class="btn btn-box-tool chart-fs-btn" data-target="chart-users" title="{{ trans('general.fullscreen') }}">
<i class="fa fa-expand fa-fw"></i></button>
<button type="button" class="btn btn-box-tool" data-widget="collapse" aria-hidden="true"><x-icon type="minus" /></button>
</div>
</div>
<div class="box-body">
<div class="chart-scroll">
<div class="chart-inner" id="wrap-chart-users" style="position:relative; height:184px; min-width:100%;">
<canvas id="chart-users"></canvas>
</div>
</div>
<p class="chart-prev-note" id="note-chart-users"></p>
</div>
</div>
</div>
<div class="col-md-6">
<div class="box box-default">
<div class="box-header with-border">
<h2 class="box-title"><x-icon type="assets" /> {{ trans('general.assets') }}</h2>
<div class="box-tools pull-right">
<button type="button" class="btn btn-box-tool chart-dl-btn" data-target="chart-assets" title="{{ trans('general.download_chart') }}">
<i class="fa fa-download fa-fw"></i></button>
<button type="button" class="btn btn-box-tool chart-fs-btn" data-target="chart-assets" title="{{ trans('general.fullscreen') }}">
<i class="fa fa-expand fa-fw"></i></button>
<button type="button" class="btn btn-box-tool" data-widget="collapse" aria-hidden="true"><x-icon type="minus" /></button>
</div>
</div>
<div class="box-body">
<div class="chart-scroll">
<div class="chart-inner" id="wrap-chart-assets" style="position:relative; height:184px; min-width:100%;">
<canvas id="chart-assets"></canvas>
</div>
</div>
<p class="chart-prev-note" id="note-chart-assets"></p>
</div>
</div>
</div>
</div>
{{-- Row 2: Components + Consumables --}}
<div class="row">
<div class="col-md-6">
<div class="box box-default">
<div class="box-header with-border">
<h2 class="box-title"><x-icon type="components" /> {{ trans('general.components') }}</h2>
<div class="box-tools pull-right">
<button type="button" class="btn btn-box-tool chart-dl-btn" data-target="chart-components" title="{{ trans('general.download_chart') }}">
<i class="fa fa-download fa-fw"></i></button>
<button type="button" class="btn btn-box-tool chart-fs-btn" data-target="chart-components" title="{{ trans('general.fullscreen') }}">
<i class="fa fa-expand fa-fw"></i></button>
<button type="button" class="btn btn-box-tool" data-widget="collapse" aria-hidden="true"><x-icon type="minus" /></button>
</div>
</div>
<div class="box-body">
<div class="chart-scroll">
<div class="chart-inner" id="wrap-chart-components" style="position:relative; height:184px; min-width:100%;">
<canvas id="chart-components"></canvas>
</div>
</div>
<p class="chart-prev-note" id="note-chart-components"></p>
</div>
</div>
</div>
<div class="col-md-6">
<div class="box box-default">
<div class="box-header with-border">
<h2 class="box-title"><x-icon type="consumables" /> {{ trans('general.consumables') }}</h2>
<div class="box-tools pull-right">
<button type="button" class="btn btn-box-tool chart-dl-btn" data-target="chart-consumables" title="{{ trans('general.download_chart') }}">
<i class="fa fa-download fa-fw"></i></button>
<button type="button" class="btn btn-box-tool chart-fs-btn" data-target="chart-consumables" title="{{ trans('general.fullscreen') }}">
<i class="fa fa-expand fa-fw"></i></button>
<button type="button" class="btn btn-box-tool" data-widget="collapse" aria-hidden="true"><x-icon type="minus" /></button>
</div>
</div>
<div class="box-body">
<div class="chart-scroll">
<div class="chart-inner" id="wrap-chart-consumables" style="position:relative; height:184px; min-width:100%;">
<canvas id="chart-consumables"></canvas>
</div>
</div>
<p class="chart-prev-note" id="note-chart-consumables"></p>
</div>
</div>
</div>
</div>
{{-- Row 3: Licenses + Accessories --}}
<div class="row">
<div class="col-md-6">
<div class="box box-default">
<div class="box-header with-border">
<h2 class="box-title"><x-icon type="licenses" /> {{ trans('general.licenses') }}</h2>
<div class="box-tools pull-right">
<button type="button" class="btn btn-box-tool chart-dl-btn" data-target="chart-licenses" title="{{ trans('general.download_chart') }}">
<i class="fa fa-download fa-fw"></i></button>
<button type="button" class="btn btn-box-tool chart-fs-btn" data-target="chart-licenses" title="{{ trans('general.fullscreen') }}">
<i class="fa fa-expand fa-fw"></i></button>
<button type="button" class="btn btn-box-tool" data-widget="collapse" aria-hidden="true"><x-icon type="minus" /></button>
</div>
</div>
<div class="box-body">
<div class="chart-scroll">
<div class="chart-inner" id="wrap-chart-licenses" style="position:relative; height:184px; min-width:100%;">
<canvas id="chart-licenses"></canvas>
</div>
</div>
<p class="chart-prev-note" id="note-chart-licenses"></p>
</div>
</div>
</div>
<div class="col-md-6">
<div class="box box-default">
<div class="box-header with-border">
<h2 class="box-title"><x-icon type="accessories" /> {{ trans('general.accessories') }}</h2>
<div class="box-tools pull-right">
<button type="button" class="btn btn-box-tool chart-dl-btn" data-target="chart-accessories" title="{{ trans('general.download_chart') }}">
<i class="fa fa-download fa-fw"></i></button>
<button type="button" class="btn btn-box-tool chart-fs-btn" data-target="chart-accessories" title="{{ trans('general.fullscreen') }}">
<i class="fa fa-expand fa-fw"></i></button>
<button type="button" class="btn btn-box-tool" data-widget="collapse" aria-hidden="true"><x-icon type="minus" /></button>
</div>
</div>
<div class="box-body">
<div class="chart-scroll">
<div class="chart-inner" id="wrap-chart-accessories" style="position:relative; height:184px; min-width:100%;">
<canvas id="chart-accessories"></canvas>
</div>
</div>
<p class="chart-prev-note" id="note-chart-accessories"></p>
</div>
</div>
</div>
</div>
@stop
@push('css')
<style>
.info-box .info-box-text { text-transform: none; }
.info-box-more {
display: inline;
font-size: 70%;
font-weight: normal;
filter: brightness(95%);
}
/*[data-theme="dark"] .info-box { background: var(--box-bg); color: #d2d6de; }*/
/*[data-theme="dark"] .info-box .info-box-number,*/
/*[data-theme="dark"] .info-box .info-box-text,*/
/*[data-theme="dark"] .info-box .info-box-more { color: #d2d6de; }*/
.chart-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.chart-prev-note { font-size: 11px; font-style: italic; text-align: center; color: #999; margin: 4px 0 0; }
/* Fullscreen: expand the chart-inner div to fill the screen */
.chart-inner:fullscreen { height: 100vh !important; width: 100vw !important; }
.chart-inner:-webkit-full-screen { height: 100vh !important; width: 100vw !important; }
.chart-inner:-moz-full-screen { height: 100vh !important; width: 100vw !important; }
</style>
@endpush
@push('js')
<script src="{{ url(mix('js/dist/Chart.min.js')) }}"></script>
<script nonce="{{ csrf_token() }}">
var charts = {};
var lastParams = { days: 30 };
function isDark() {
return document.documentElement.getAttribute('data-theme') === 'dark';
}
function applyChartTheme() {
Chart.defaults.global.defaultFontColor = isDark() ? '#cccccc' : '#666666';
}
// Fill canvas with the box-body background so lines are visible in dark mode.
Chart.pluginService.register({
beforeDraw: function (chart) {
var el = chart.canvas.parentElement;
while (el && !(el.classList && el.classList.contains('box-body'))) {
el = el.parentElement;
}
var bg = el ? window.getComputedStyle(el).backgroundColor : null;
if (!bg || bg === 'rgba(0, 0, 0, 0)' || bg === 'transparent') return;
var ctx = chart.chart.ctx;
ctx.save();
ctx.fillStyle = bg;
ctx.fillRect(0, 0, chart.chart.width, chart.chart.height);
ctx.restore();
},
});
function getLineOptions() {
var dark = isDark();
var fontColor = dark ? '#cccccc' : '#666666';
var gridColor = dark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)';
return {
responsive: true,
maintainAspectRatio: false,
legend: {
position: 'bottom',
labels: {
boxWidth: 12,
fontColor: fontColor,
// Hide the previous-period (dashed) series from the legend —
// they're visible on the chart but would double the legend entries.
filter: function (item, data) {
return !data.datasets[item.datasetIndex].isPrev;
},
},
},
scales: {
xAxes: [{ gridLines: { display: false }, ticks: { maxTicksLimit: 10, fontColor: fontColor } }],
yAxes: [{ gridLines: { color: gridColor }, ticks: { beginAtZero: true, suggestedMin: 0, precision: 0, fontColor: fontColor } }]
}
};
}
function ds(label, data, color, isPrev) {
return {
label: label,
data: data,
borderColor: color,
backgroundColor: color,
borderWidth: isPrev ? 1.5 : 2,
borderDash: isPrev ? [5, 4] : [],
pointRadius: isPrev ? 0 : 3,
pointHoverRadius: isPrev ? 3 : 5,
fill: false,
tension: 0.3,
isPrev: isPrev,
};
}
// series = [{ label, current, previous, color }, ...]
function makeChartMulti(id, labels, series, prevPeriod) {
if (charts[id]) { charts[id].destroy(); }
var wrap = document.getElementById('wrap-' + id);
if (wrap) wrap.style.width = labels.length > 60 ? (labels.length * 8) + 'px' : '';
var datasets = [];
series.forEach(function (s) {
datasets.push(ds(s.label, s.current, s.color, false));
datasets.push(ds(s.label + ' (' + prevPeriod + ')', s.previous, hexToRgba(s.color, 0.5), true));
});
charts[id] = new Chart(document.getElementById(id), {
type: 'line',
data: {labels: labels, datasets: datasets},
options: getLineOptions()
});
var note = document.getElementById('note-' + id);
if (note) note.textContent = '- - - - = ' + prevPeriod;
}
function hexToRgba(hex, alpha) {
var r = parseInt(hex.slice(1,3), 16);
var g = parseInt(hex.slice(3,5), 16);
var b = parseInt(hex.slice(5,7), 16);
return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')';
}
function arrSum(arr) {
return arr.reduce(function(a, b) { return a + b; }, 0);
}
function trendPct(cur, prev) {
var c = arrSum(cur), p = arrSum(prev);
return (c + p) === 0 ? 0 : Math.round(c / (c + p) * 100);
}
function setInfoBar(id, pct, prevLabel) {
var bar = document.getElementById('progress-' + id);
var count = bar ? parseInt(bar.getAttribute('data-count'), 10) : -1;
var show = (count === 0) ? 0 : pct;
$('#progress-' + id).css('width', show + '%');
$('#progress-' + id + '-label').text('- ' + show + '% {!! trans('general.vs_prior_period') !!} (' + prevLabel + ')');
}
var palette = {
light: {
checkout: '#3c8dbc', checkin: '#00a65a', asset: '#f39c12',
maintenance: '#dd4b39', audit: '#605ca8', component: '#39cccc',
consumable: '#ff851b', license: '#d81b60', accessory: '#00c0ef',
},
dark: {
checkout: '#60b0e0', checkin: '#2ecc71', asset: '#f5b942',
maintenance: '#e74c3c', audit: '#9b97e0', component: '#4de8e8',
consumable: '#ffa03a', license: '#f06292', accessory: '#29d8ff',
},
};
function c(key) {
return isDark() ? palette.dark[key] : palette.light[key];
}
function loadCharts(params) {
lastParams = params;
applyChartTheme();
$.ajax({
type: 'GET',
url: '{{ route('api.reports.activity.chart') }}',
data: params,
headers: { "X-Requested-With": 'XMLHttpRequest', "X-CSRF-TOKEN": $('meta[name="csrf-token"]').attr('content') },
dataType: 'json',
success: function(d) {
var p = d.prev_label;
// Info-box progress bars
setInfoBar('audit', trendPct(d.asset_checkouts, d.prev_asset_checkouts), p);
setInfoBar('checkin', trendPct(d.asset_checkins, d.prev_asset_checkins), p);
setInfoBar('acceptance', trendPct(d.asset_checkouts, d.prev_asset_checkouts), p);
setInfoBar('licenses', trendPct(d.license_checkouts, d.prev_license_checkouts), p);
makeChartMulti('chart-users', d.labels, [
{
label: '{!! trans('general.created_plain') !!}',
current: d.new_users,
previous: d.prev_new_users,
color: c('checkin'),
},
{
label: '{!! trans('general.deleted_users') !!}',
current: d.deleted_users,
previous: d.prev_deleted_users,
color: c('maintenance'),
},
], p);
makeChartMulti('chart-assets', d.labels, [
{
label: '{!! trans('general.checkouts') !!}',
current: d.asset_checkouts,
previous: d.prev_asset_checkouts,
color: c('checkout'),
},
{
label: '{!! trans('general.checkins') !!}',
current: d.asset_checkins,
previous: d.prev_asset_checkins,
color: c('checkin'),
},
{
label: '{!! trans('general.created_plain') !!}',
current: d.new_assets,
previous: d.prev_new_assets,
color: c('asset'),
},
{
label: '{!! trans('general.maintenances') !!}',
current: d.new_maintenances,
previous: d.prev_new_maintenances,
color: c('maintenance'),
},
{
label: '{!! trans('general.audits') !!}',
current: d.new_audits,
previous: d.prev_new_audits,
color: c('audit'),
},
], p);
makeChartMulti('chart-components', d.labels, [
{
label: '{!! trans('general.checkouts') !!}',
current: d.component_checkouts,
previous: d.prev_component_checkouts,
color: c('checkout'),
},
{
label: '{!! trans('general.checkins') !!}',
current: d.component_checkins,
previous: d.prev_component_checkins,
color: c('checkin'),
},
{
label: '{!! trans('general.created_plain') !!}',
current: d.new_components,
previous: d.prev_new_components,
color: c('component'),
},
], p);
makeChartMulti('chart-consumables', d.labels, [
{
label: '{!! trans('general.checkouts') !!}',
current: d.consumable_checkouts,
previous: d.prev_consumable_checkouts,
color: c('checkout'),
},
{
label: '{!! trans('general.checkins') !!}',
current: d.consumable_checkins,
previous: d.prev_consumable_checkins,
color: c('checkin'),
},
{
label: '{!! trans('general.created_plain') !!}',
current: d.new_consumables,
previous: d.prev_new_consumables,
color: c('consumable'),
},
], p);
makeChartMulti('chart-licenses', d.labels, [
{
label: '{!! trans('general.checkouts') !!}',
current: d.license_checkouts,
previous: d.prev_license_checkouts,
color: c('checkout'),
},
{
label: '{!! trans('general.checkins') !!}',
current: d.license_checkins,
previous: d.prev_license_checkins,
color: c('checkin'),
},
{
label: '{!! trans('general.created_plain') !!}',
current: d.new_licenses,
previous: d.prev_new_licenses,
color: c('license'),
},
], p);
makeChartMulti('chart-accessories', d.labels, [
{
label: '{!! trans('general.checkouts') !!}',
current: d.accessory_checkouts,
previous: d.prev_accessory_checkouts,
color: c('checkout'),
},
{
label: '{!! trans('general.checkins') !!}',
current: d.accessory_checkins,
previous: d.prev_accessory_checkins,
color: c('checkin'),
},
{
label: '{!! trans('general.created_plain') !!}',
current: d.new_accessories,
previous: d.prev_new_accessories,
color: c('accessory'),
},
], p);
}
});
}
$('#chartTimeRange').on('change', function() {
if ($(this).val() === 'custom') {
$('#customRangePicker').css('display', 'flex');
} else {
$('#customRangePicker').hide();
loadCharts({ days: $(this).val() });
}
});
$('#customRangePicker').datepicker({
clearBtn: true,
todayHighlight: true,
endDate: '0d',
format: 'yyyy-mm-dd',
keepEmptyValues: true,
});
$('#customRangePicker').on('changeDate', function() {
var start = $('#chartStartDate').val();
var end = $('#chartEndDate').val();
if (start && end && start <= end) {
loadCharts({ start_date: start, end_date: end });
}
});
$(document).on('click', '.chart-dl-btn', function() {
var id = $(this).data('target');
var chart = charts[id];
if (!chart) return;
var a = document.createElement('a');
a.href = chart.toBase64Image();
a.download = id + '.png';
a.click();
});
$(document).on('click', '.chart-fs-btn', function() {
var wrap = document.getElementById('wrap-' + $(this).data('target'));
if (!wrap) return;
var req = wrap.requestFullscreen || wrap.webkitRequestFullscreen || wrap.mozRequestFullScreen;
if (req) req.call(wrap);
});
document.addEventListener('fullscreenchange', function() {
if (!document.fullscreenElement) {
$.each(charts, function(id, chart) { if (chart) chart.resize(); });
}
});
document.addEventListener('webkitfullscreenchange', function() {
if (!document.webkitFullscreenElement) {
$.each(charts, function(id, chart) { if (chart) chart.resize(); });
}
});
loadCharts({ days: 30 });
new MutationObserver(function () {
loadCharts(lastParams);
}).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
</script>
@endpush
+89 -34
View File
@@ -168,39 +168,6 @@
@endif
</td>
</tr>
@if ($settings->show_assigned_assets)
@php
$assignedCounter = 1;
@endphp
@foreach ($asset->assignedAssets as $asset)
<tr>
<td>{{ $counter }}.{{ $assignedCounter }}</td>
<td>
@if ($asset->getImageUrl())
<img src="{{ $asset->getImageUrl() }}" class="thumbnail" style="max-height: 50px;">
@endif
</td>
<td>{{ $asset->asset_tag }}</td>
<td>{{ $asset->name }}</td>
<td>{{ (($asset->model) && ($asset->model->category)) ? $asset->model->category->name : trans('general.invalid_category') }}</td>
<td>{{ ($asset->model) ? $asset->model->name : trans('general.invalid_model') }}</td>
<td>{{ ($asset->defaultLoc) ? $asset->defaultLoc->name : '' }}</td>
<td>{{ ($asset->location) ? $asset->location->name : '' }}</td>
<td>{{ $asset->serial }}</td>
<td>
{{ Helper::getFormattedDateObject($asset->last_checkout, 'datetime', false) }}
</td>
<td>
@if ($asset->getLatestSignedAcceptance($show_user))
<img style="width:auto;height:100px;" src="{{ asset('/') }}display-sig/{{ $asset->getLatestSignedAcceptance($show_user)->accept_signature }}">
@endif
</td>
</tr>
@php
$assignedCounter++
@endphp
@endforeach
@endif
@php
$counter++
@endphp
@@ -240,7 +207,7 @@
$lcounter = 1;
@endphp
@foreach ($show_user->licenses as $license)
@foreach ($show_user->directLicenses as $license)
@php
if (($license->category) && ($license->category->getEula())) $eulas[] = $license->category->getEula()
@endphp
@@ -398,7 +365,95 @@
@endforeach
</table>
@endif
@if($indirectItemsCount > 0 && $settings->show_assigned_assets)
<div id="indirect-assignments-toolbar">
<h4>{{ $indirectItemsCount.' '.trans('mail.assigned_to_assets') }}</h4>
</div>
<table
class="snipe-table table table-striped inventory"
id="indirect-assignments"
data-pagination="false"
data-toolbar="#indirect-assignments-toolbar"
data-id-table="indirect-assignments"
data-search="false"
data-side-pagination="client"
data-sortable="true"
data-sort-order="desc"
data-sort-name="name"
data-show-columns="true"
data-cookie-id-table="indirect-assignments">
<thead>
@php
$indirectAssignmentsCounter = 1;
@endphp
<tr>
<th style="width: 20px;" data-sortable="false" data-switchable="false">#</th>
<th style="width: 40%;" data-sortable="true" data-switchable="false">{{ trans('mail.assigned_to') }}</th>
<th style="width: 50%;" data-sortable="true">{{ trans('general.category') }}</th>
<th style="width: 10%;" data-sortable="true">{{ trans('mail.item') }}</th>
<th style="width: 10%;" data-sortable="true">{{ trans('general.quantity') }}</th>
</tr>
</thead>
@foreach ($show_user->assets as $asset)
@foreach ($asset->assignedAssets as $indirectAsset)
<tr>
<td>{{ $indirectAssignmentsCounter }}</td>
<td>{{ $asset->display_name ?? ''}}</td>
<td>{{ (($indirectAsset->model) && ($indirectAsset->model->category)) ? $indirectAsset->model->category->name : trans('general.invalid_category') }}</td>
<td>{{ $indirectAsset->display_name ?? '' }}</td>
<td>1</td>
</tr>
@php
$indirectAssignmentsCounter++
@endphp
@endforeach
@foreach ($asset->licenses as $indirectLicense)
@if($indirectLicense)
<tr>
<td>{{$indirectAssignmentsCounter}}</td>
<td>{{ $asset->display_name ?? ''}}</td>
<td>{{ $indirectLicense->category?->name ?? '' }}</td>
<td>{{ $indirectLicense->name ?? '' }}</td>
<td>1</td>
</tr>
@endif
@php
$indirectAssignmentsCounter ++
@endphp
@endforeach
@foreach ($asset->components as $component)
@if($component)
<tr>
<td>{{$indirectAssignmentsCounter}}</td>
<td>{{ $asset->display_name ?? ''}}</td>
<td>{{ $component->category?->name ?? '' }}</td>
<td>{{ $component->name ?? '' }}</td>
<td>{{ $component->pivot->assigned_qty }}</td>
</tr>
@endif
@php
$indirectAssignmentsCounter ++
@endphp
@endforeach
@foreach ($asset->assignedAccessories as $indirectAccessory)
@if($indirectAccessory)
<tr>
<td>{{$indirectAssignmentsCounter}}</td>
<td>{{ $asset->display_name ?? '' }}</td>
<td>{{ $indirectAccessory->accessory->category?->name ?? '' }}</td>
<td>{{ $indirectAccessory->accessory->name ?? '' }}</td>
<td>1</td>
</tr>
@endif
@php
$indirectAssignmentsCounter ++
@endphp
@endforeach
@endforeach
</table>
@endif
@php
if (!empty($eulas)) $eulas = array_unique($eulas);
@endphp
+12 -5
View File
@@ -30,12 +30,12 @@ Route::group(['prefix' => 'v1', 'middleware' => ['api', 'api-throttle:api']], fu
$client = Client::firstOrCreate(
['redirect' => 'com.grokability.snipeitmobile://home'],
[
'name' => 'Snipe-IT Mobile App',
'user_id' => null,
'secret' => '',
'name' => 'Snipe-IT Mobile App',
'user_id' => null,
'secret' => '',
'personal_access_client' => false,
'password_client' => false,
'revoked' => false,
'password_client' => false,
'revoked' => false,
]);
return response()->json([
@@ -1325,6 +1325,13 @@ Route::group(['prefix' => 'v1', 'middleware' => ['api', 'api-throttle:api']], fu
'index',
]
)->name('api.activity.index');
Route::get('activity/chart',
[
Api\ReportsController::class,
'activityChart',
]
)->name('api.reports.activity.chart');
}); // end reports api routes
/**
+19 -1
View File
@@ -457,15 +457,22 @@ Route::group(['middleware' => ['auth']], function () {
Route::group(['prefix' => 'reports', 'middleware' => ['auth']], function () {
Route::get('/', [ReportsController::class, 'index'])
->name('reports.index')
->breadcrumbs(fn (Trail $trail) => $trail->parent('home')
->push(trans('general.reports'), route('reports.index')));
Route::get('audit', [ReportsController::class, 'audit'])
->name('reports.audit')
->breadcrumbs(fn (Trail $trail) => $trail->parent('home')
->push(trans('general.reports'), route('reports.index'))
->push(trans('general.audit_report'), route('reports.audit')));
Route::get(
'depreciation', [ReportsController::class, 'getDeprecationReport'])
->name('reports/depreciation')
->breadcrumbs(fn (Trail $trail) => $trail->parent('home')
->push(trans('general.reports'), route('reports.index'))
->push(trans('general.depreciation_report'), route('reports/depreciation')));
// Is this still used??
@@ -473,30 +480,38 @@ Route::group(['prefix' => 'reports', 'middleware' => ['auth']], function () {
'export/depreciation', [ReportsController::class, 'exportDeprecationReport'])
->name('reports/export/depreciation')
->breadcrumbs(fn (Trail $trail) => $trail->parent('home')
->push(trans('general.reports'), route('reports.index'))
->push(trans('general.depreciation_report'), route('reports.audit')));
Route::get(
'maintenances', [ReportsController::class, 'getMaintenancesReport'])
->name('ui.reports.maintenances')
->breadcrumbs(fn (Trail $trail) => $trail->parent('home')
->push(trans('general.reports'), route('reports.index'))
->push(trans('general.asset_maintenance_report'), route('ui.reports.maintenances')));
// Is this still used?
Route::get('export/maintenances', [ReportsController::class, 'exportMaintenancesReport'])
->name('reports/export/maintenances')
->breadcrumbs(fn (Trail $trail) => $trail->parent('home')
->push(trans('general.reports'), route('reports.index'))
->push(trans('general.asset_maintenance_report'), route('reports/export/maintenances')));
Route::get('licenses', [ReportsController::class, 'getLicenseReport'])
->name('reports/licenses')
->breadcrumbs(fn (Trail $trail) => $trail->parent('home')
->push(trans('general.reports'), route('reports.index'))
->push(trans('general.license_report'), route('reports/licenses')));
// @TODO this should be a GET?
Route::get('export/licenses', [ReportsController::class, 'exportLicenseReport'])
->name('reports/export/licenses');
Route::get('accessories', [ReportsController::class, 'getAccessoryReport'])
->name('reports/accessories');
->name('reports/accessories')
->breadcrumbs(fn (Trail $trail) => $trail->parent('home')
->push(trans('general.reports'), route('reports.index'))
->push(trans('general.accessory_report'), route('reports/accessories')));
Route::get('export/accessories', [ReportsController::class, 'exportAccessoryReport'])
->name('reports/export/accessories');
@@ -504,6 +519,7 @@ Route::group(['prefix' => 'reports', 'middleware' => ['auth']], function () {
Route::get('custom', [ReportsController::class, 'getCustomReport'])
->name('reports/custom')
->breadcrumbs(fn (Trail $trail) => $trail->parent('home')
->push(trans('general.reports'), route('reports.index'))
->push(trans('general.custom_report'), route('reports/custom')));
Route::post('custom', [ReportsController::class, 'postCustom'])
@@ -539,6 +555,7 @@ Route::group(['prefix' => 'reports', 'middleware' => ['auth']], function () {
'activity', [ReportsController::class, 'getActivityReport'])
->name('reports.activity')
->breadcrumbs(fn (Trail $trail) => $trail->parent('home')
->push(trans('general.reports'), route('reports.index'))
->push(trans('general.activity_report'), route('reports.activity')));
Route::post('activity', [ReportsController::class, 'postActivityReport'])
@@ -547,6 +564,7 @@ Route::group(['prefix' => 'reports', 'middleware' => ['auth']], function () {
Route::get('unaccepted_assets/{deleted?}', [ReportsController::class, 'getAssetAcceptanceReport'])
->name('reports/unaccepted_assets')
->breadcrumbs(fn (Trail $trail) => $trail->parent('home')
->push(trans('general.reports'), route('reports.index'))
->push(trans('general.unaccepted_asset_report'), route('reports/unaccepted_assets')));
Route::post('unaccepted_assets/sent_reminder', [ReportsController::class, 'sentAssetAcceptanceReminder'])
+7 -7
View File
@@ -1,9 +1,9 @@
<?php
use App\Http\Controllers\Licenses;
use Illuminate\Support\Facades\Route;
use App\Models\License;
use App\Models\LicenseSeat;
use Illuminate\Support\Facades\Route;
use Tabuna\Breadcrumbs\Trail;
// Licenses
@@ -12,20 +12,18 @@ Route::group(['prefix' => 'licenses', 'middleware' => ['auth']], function () {
Route::get('{license}/checkout/{seatId?}', [Licenses\LicenseCheckoutController::class, 'create'])
->name('licenses.checkout')
->breadcrumbs(fn (Trail $trail, License $license) =>
$trail->parent('licenses.show', $license)
->breadcrumbs(fn (Trail $trail, License $license) => $trail->parent('licenses.show', $license)
->push(trans('general.checkout'), route('licenses.checkout', $license))
);
Route::post(
'{licenseId}/checkout/{seatId?}',
[Licenses\LicenseCheckoutController::class, 'store']
); //name() would duplicate here, so we skip it.
); // name() would duplicate here, so we skip it.
Route::get('{licenseSeat}/checkin/{backto?}', [Licenses\LicenseCheckinController::class, 'create'])
->name('licenses.checkin')
->breadcrumbs(fn (Trail $trail, LicenseSeat $licenseSeat) =>
$trail->parent('licenses.show', $licenseSeat->license)
->breadcrumbs(fn (Trail $trail, LicenseSeat $licenseSeat) => $trail->parent('licenses.show', $licenseSeat->license)
->push(trans('general.checkin'), route('licenses.checkin', $licenseSeat))
);
@@ -47,9 +45,11 @@ Route::group(['prefix' => 'licenses', 'middleware' => ['auth']], function () {
'export',
[
Licenses\LicensesController::class,
'getExportLicensesCsv'
'getExportLicensesCsv',
]
)->name('licenses.export');
Route::post('bulk/delete', [Licenses\BulkLicensesController::class, 'destroy'])->name('licenses.bulk.delete');
});
Route::resource('licenses', Licenses\LicensesController::class, [
@@ -577,6 +577,43 @@ class StoreAssetTest extends TestCase
$this->assertHasTheseActionLogs($asset, ['create', 'checkout']);
}
public function test_store_rejects_cross_company_checkout_target_with_full_company_support_enabled()
{
$this->settings->enableMultipleFullCompanySupport();
[$companyA, $companyB] = Company::factory()->count(2)->create();
$actorInCompanyA = User::factory()->createAssets()->for($companyA)->create();
$targetUserInCompanyB = User::factory()->for($companyB)->create();
$model = AssetModel::factory()->create();
$status = Statuslabel::factory()->readyToDeploy()->create();
$assetTag = 'fmcs-store-rollback-asset';
$this->actingAsForApi($actorInCompanyA)
->postJson(route('api.assets.store'), [
'asset_tag' => $assetTag,
'model_id' => $model->id,
'status_id' => $status->id,
'company_id' => $companyA->id,
'assigned_user' => $targetUserInCompanyB->id,
])
->assertOk()
->assertStatusMessageIs('error')
->assertMessagesAre(trans('general.error_user_company'));
$this->assertDatabaseMissing('assets', [
'asset_tag' => $assetTag,
]);
$this->assertDatabaseMissing('action_logs', [
'action_type' => 'checkout',
'target_type' => User::class,
'target_id' => $targetUserInCompanyB->id,
'item_type' => Asset::class,
]);
}
public static function checkoutTargets()
{
yield 'Users' => [
@@ -422,6 +422,40 @@ class UpdateAssetTest extends TestCase
$this->assertEquals($asset->assigned_type, 'App\Models\User');
}
public function test_update_rejects_cross_company_checkout_target_with_full_company_support_enabled()
{
$this->settings->enableMultipleFullCompanySupport();
[$companyA, $companyB] = Company::factory()->count(2)->create();
$asset = Asset::factory()->for($companyA)->create(['name' => 'Original Name']);
$actorInCompanyA = User::factory()->editAssets()->for($companyA)->create();
$targetUserInCompanyB = User::factory()->for($companyB)->create();
$this->actingAsForApi($actorInCompanyA)
->patchJson(route('api.assets.update', $asset->id), [
'name' => 'Name That Should Roll Back',
'assigned_user' => $targetUserInCompanyB->id,
])
->assertOk()
->assertStatusMessageIs('error')
->assertMessagesAre(trans('general.error_user_company'));
$asset->refresh();
$this->assertEquals('Original Name', $asset->name);
$this->assertNull($asset->assigned_to);
$this->assertNull($asset->assigned_type);
$this->assertDatabaseMissing('action_logs', [
'action_type' => 'checkout',
'target_type' => User::class,
'target_id' => $targetUserInCompanyB->id,
'item_type' => Asset::class,
'item_id' => $asset->id,
]);
}
public function test_checkout_to_user_with_assigned_to_and_assigned_type()
{
$asset = Asset::factory()->create();
@@ -5,6 +5,7 @@ namespace Tests\Feature\Checkouts\Api;
use App\Mail\CheckoutAccessoryMail;
use App\Models\Accessory;
use App\Models\Actionlog;
use App\Models\Company;
use App\Models\User;
use Illuminate\Support\Facades\Mail;
use Tests\Concerns\TestsPermissionsRequirement;
@@ -189,4 +190,42 @@ class AccessoryCheckoutTest extends TestCase implements TestsPermissionsRequirem
$this->assertHasTheseActionLogs($accessory, ['create', 'checkout']);
}
public function test_superuser_cannot_checkout_accessory_to_a_target_in_another_company_when_full_company_support_is_enabled()
{
$this->settings->enableMultipleFullCompanySupport();
[$companyA, $companyB] = Company::factory()->count(2)->create();
$superuser = User::factory()->superuser()->create(['company_id' => null]);
$accessoryInCompanyA = Accessory::factory()->for($companyA)->create(['qty' => 1]);
$userInCompanyB = User::factory()->for($companyB)->create();
$this->actingAsForApi($superuser)
->postJson(route('api.accessories.checkout', $accessoryInCompanyA), [
'assigned_user' => $userInCompanyB->id,
'checkout_to_type' => 'user',
'checkout_qty' => 1,
])
->assertOk()
->assertStatusMessageIs('error')
->assertMessagesAre(trans('general.error_user_company'));
$this->assertDatabaseMissing('accessories_checkout', [
'accessory_id' => $accessoryInCompanyA->id,
'assigned_to' => $userInCompanyB->id,
'assigned_type' => User::class,
]);
$this->assertDatabaseMissing('action_logs', [
'created_by' => $superuser->id,
'action_type' => 'checkout',
'target_type' => User::class,
'target_id' => $userInCompanyB->id,
'item_type' => Accessory::class,
'item_id' => $accessoryInCompanyA->id,
]);
$this->assertEquals(1, $accessoryInCompanyA->fresh()->numRemaining());
}
}
@@ -4,6 +4,7 @@ namespace Tests\Feature\Checkouts\Api;
use App\Events\CheckoutableCheckedOut;
use App\Models\Asset;
use App\Models\Company;
use App\Models\Location;
use App\Models\Statuslabel;
use App\Models\User;
@@ -95,7 +96,72 @@ class AssetCheckoutTest extends TestCase
public function test_cannot_checkout_across_companies_when_full_company_support_enabled()
{
$this->markTestIncomplete('This is not implemented');
$this->settings->enableMultipleFullCompanySupport();
[$companyA, $companyB] = Company::factory()->count(2)->create();
$actorInCompanyA = User::factory()->checkoutAssets()->for($companyA)->create();
$assetInCompanyA = Asset::factory()->for($companyA)->create();
$userInCompanyB = User::factory()->for($companyB)->create();
$this->actingAsForApi($actorInCompanyA)
->postJson(route('api.asset.checkout', $assetInCompanyA), [
'checkout_to_type' => 'user',
'assigned_user' => $userInCompanyB->id,
])
->assertOk()
->assertStatusMessageIs('error')
->assertMessagesAre(trans('general.error_user_company'));
$assetInCompanyA->refresh();
$this->assertNull($assetInCompanyA->assigned_to);
$this->assertNull($assetInCompanyA->assigned_type);
$this->assertEquals(0, $assetInCompanyA->checkout_counter);
$this->assertDatabaseMissing('action_logs', [
'created_by' => $actorInCompanyA->id,
'action_type' => 'checkout',
'target_type' => User::class,
'target_id' => $userInCompanyB->id,
'item_type' => Asset::class,
'item_id' => $assetInCompanyA->id,
]);
}
public function test_checkout_by_tag_cannot_checkout_across_companies_when_full_company_support_enabled()
{
$this->settings->enableMultipleFullCompanySupport();
[$companyA, $companyB] = Company::factory()->count(2)->create();
$actorInCompanyA = User::factory()->checkoutAssets()->for($companyA)->create();
$assetInCompanyA = Asset::factory()->for($companyA)->create();
$userInCompanyB = User::factory()->for($companyB)->create();
$this->actingAsForApi($actorInCompanyA)
->postJson(route('api.assets.checkout.bytag', $assetInCompanyA->asset_tag), [
'checkout_to_type' => 'user',
'assigned_user' => $userInCompanyB->id,
])
->assertOk()
->assertStatusMessageIs('error')
->assertMessagesAre(trans('general.error_user_company'));
$assetInCompanyA->refresh();
$this->assertNull($assetInCompanyA->assigned_to);
$this->assertNull($assetInCompanyA->assigned_type);
$this->assertEquals(0, $assetInCompanyA->checkout_counter);
$this->assertDatabaseMissing('action_logs', [
'created_by' => $actorInCompanyA->id,
'action_type' => 'checkout',
'target_type' => User::class,
'target_id' => $userInCompanyB->id,
'item_type' => Asset::class,
'item_id' => $assetInCompanyA->id,
]);
}
/**
@@ -126,6 +126,7 @@ class ComponentCheckoutTest extends TestCase implements TestsFullMultipleCompani
public function test_adheres_to_full_multiple_companies_support_scoping()
{
[$companyA, $companyB] = Company::factory()->count(2)->create();
$userForCompanyA = User::factory()->for($companyA)->create();
@@ -139,4 +140,76 @@ class ComponentCheckoutTest extends TestCase implements TestsFullMultipleCompani
])
->assertForbidden();
}
public function test_cannot_checkout_component_to_an_asset_in_another_company_when_full_company_support_is_enabled()
{
$this->settings->enableMultipleFullCompanySupport();
[$companyA, $companyB] = Company::factory()->count(2)->create();
$userInCompanyA = User::factory()->checkoutComponents()->for($companyA)->create();
$componentInCompanyA = Component::factory()->for($companyA)->create(['qty' => 1]);
$assetInCompanyB = Asset::factory()->for($companyB)->create();
$this->actingAsForApi($userInCompanyA)
->postJson(route('api.components.checkout', $componentInCompanyA->id), [
'assigned_to' => $assetInCompanyB->id,
'assigned_qty' => 1,
])
->assertOk()
->assertStatusMessageIs('error')
->assertMessagesAre(trans('general.error_user_company'));
$this->assertDatabaseMissing('components_assets', [
'component_id' => $componentInCompanyA->id,
'asset_id' => $assetInCompanyB->id,
]);
$this->assertDatabaseMissing('action_logs', [
'created_by' => $userInCompanyA->id,
'action_type' => 'checkout',
'target_type' => Asset::class,
'target_id' => $assetInCompanyB->id,
'item_type' => Component::class,
'item_id' => $componentInCompanyA->id,
]);
$this->assertEquals(1, $componentInCompanyA->fresh()->numRemaining());
}
public function test_superuser_cannot_checkout_component_to_an_asset_in_another_company_when_full_company_support_is_enabled()
{
$this->settings->enableMultipleFullCompanySupport();
[$companyA, $companyB] = Company::factory()->count(2)->create();
$superuser = User::factory()->superuser()->create(['company_id' => null]);
$componentInCompanyA = Component::factory()->for($companyA)->create(['qty' => 1]);
$assetInCompanyB = Asset::factory()->for($companyB)->create();
$this->actingAsForApi($superuser)
->postJson(route('api.components.checkout', $componentInCompanyA->id), [
'assigned_to' => $assetInCompanyB->id,
'assigned_qty' => 1,
])
->assertOk()
->assertStatusMessageIs('error')
->assertMessagesAre(trans('general.error_user_company'));
$this->assertDatabaseMissing('components_assets', [
'component_id' => $componentInCompanyA->id,
'asset_id' => $assetInCompanyB->id,
]);
$this->assertDatabaseMissing('action_logs', [
'created_by' => $superuser->id,
'action_type' => 'checkout',
'target_type' => Asset::class,
'target_id' => $assetInCompanyB->id,
'item_type' => Component::class,
'item_id' => $componentInCompanyA->id,
]);
$this->assertEquals(1, $componentInCompanyA->fresh()->numRemaining());
}
}
@@ -4,6 +4,7 @@ namespace Tests\Feature\Checkouts\Api;
use App\Mail\CheckoutConsumableMail;
use App\Models\Actionlog;
use App\Models\Company;
use App\Models\Consumable;
use App\Models\User;
use Illuminate\Support\Facades\Mail;
@@ -115,4 +116,40 @@ class ConsumableCheckoutTest extends TestCase
'Log entry either does not exist or there are more than expected'
);
}
public function test_superuser_cannot_checkout_consumable_to_a_user_in_another_company_when_full_company_support_is_enabled()
{
$this->settings->enableMultipleFullCompanySupport();
[$companyA, $companyB] = Company::factory()->count(2)->create();
$superuser = User::factory()->superuser()->create(['company_id' => null]);
$consumableInCompanyA = Consumable::factory()->for($companyA)->create(['qty' => 1]);
$userInCompanyB = User::factory()->for($companyB)->create();
$this->actingAsForApi($superuser)
->postJson(route('api.consumables.checkout', $consumableInCompanyA), [
'assigned_to' => $userInCompanyB->id,
'checkout_qty' => 1,
])
->assertOk()
->assertStatusMessageIs('error')
->assertMessagesAre(trans('general.error_user_company'));
$this->assertDatabaseMissing('consumables_users', [
'consumable_id' => $consumableInCompanyA->id,
'assigned_to' => $userInCompanyB->id,
]);
$this->assertDatabaseMissing('action_logs', [
'created_by' => $superuser->id,
'action_type' => 'checkout',
'target_type' => User::class,
'target_id' => $userInCompanyB->id,
'item_type' => Consumable::class,
'item_id' => $consumableInCompanyA->id,
]);
$this->assertEquals(1, $consumableInCompanyA->fresh()->numRemaining());
}
}
@@ -3,6 +3,7 @@
namespace Tests\Feature\LicenseSeats\Api;
use App\Models\Asset;
use App\Models\Company;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\User;
@@ -444,6 +445,46 @@ class LicenseSeatUpdateTest extends TestCase
]);
}
public function test_superuser_cannot_assign_a_license_seat_to_a_target_in_another_company_when_full_company_support_is_enabled()
{
$this->settings->enableMultipleFullCompanySupport();
[$companyA, $companyB] = Company::factory()->count(2)->create();
$superuser = User::factory()->superuser()->create(['company_id' => null]);
$licenseInCompanyA = License::factory()->for($companyA)->create();
$seatForCompanyA = LicenseSeat::factory()->create([
'license_id' => $licenseInCompanyA->id,
'assigned_to' => null,
'asset_id' => null,
'notes' => null,
]);
$userInCompanyB = User::factory()->for($companyB)->create();
$this->actingAsForApi($superuser)
->patchJson($this->route($seatForCompanyA), [
'assigned_to' => $userInCompanyB->id,
'notes' => 'cross-company assignment attempt',
])
->assertStatus(200)
->assertStatusMessageIs('error')
->assertMessagesAre(trans('general.error_user_company'));
$seatForCompanyA->refresh();
$this->assertNull($seatForCompanyA->assigned_to);
$this->assertNull($seatForCompanyA->asset_id);
$this->assertDatabaseMissing('action_logs', [
'created_by' => $superuser->id,
'action_type' => 'checkout',
'target_type' => User::class,
'target_id' => $userInCompanyB->id,
'item_type' => License::class,
'item_id' => $licenseInCompanyA->id,
'note' => 'cross-company assignment attempt',
]);
}
private function route(LicenseSeat $licenseSeat)
{
return route('api.licenses.seats.update', [$licenseSeat->license->id, $licenseSeat->id]);
@@ -4,6 +4,7 @@ namespace Tests\Feature\Licenses\Api;
use App\Models\Company;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\User;
use Tests\Concerns\TestsFullMultipleCompaniesSupport;
use Tests\Concerns\TestsPermissionsRequirement;
@@ -87,4 +88,36 @@ class DeleteLicensesTest extends TestCase implements TestsFullMultipleCompaniesS
$this->assertTrue($license->fresh()->licenseseats->isEmpty());
}
public function test_all_seats_for_deleted_license_are_soft_deleted()
{
$license = License::factory()->create(['seats' => 5]);
$this->actingAsForApi(User::factory()->deleteLicenses()->create())
->deleteJson(route('api.licenses.destroy', $license))
->assertStatusMessageIs('success');
$this->assertEquals(5, LicenseSeat::onlyTrashed()->where('license_id', $license->id)->count());
}
public function test_deleting_license_does_not_affect_seats_of_other_licenses()
{
$licenseToDelete = License::factory()->create(['seats' => 3]);
$otherLicense = License::factory()->create(['seats' => 2]);
$assignedUser = User::factory()->create();
$otherLicense->freeSeat()->update(['assigned_to' => $assignedUser->id]);
$this->actingAsForApi(User::factory()->deleteLicenses()->create())
->deleteJson(route('api.licenses.destroy', $licenseToDelete))
->assertStatusMessageIs('success');
$this->assertSoftDeleted($licenseToDelete);
$this->assertNotSoftDeleted($otherLicense);
$this->assertEquals(
$assignedUser->id,
LicenseSeat::where('license_id', $otherLicense->id)->whereNotNull('assigned_to')->value('assigned_to'),
'Seat on another license had its assignment incorrectly cleared'
);
}
}
@@ -0,0 +1,105 @@
<?php
namespace Tests\Feature\Licenses\Ui;
use App\Models\Company;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\User;
use Tests\Concerns\TestsPermissionsRequirement;
use Tests\TestCase;
class BulkDeleteLicensesTest extends TestCase implements TestsPermissionsRequirement
{
public function test_requires_permission()
{
$this->actingAs(User::factory()->create())
->post(route('licenses.bulk.delete'), [
'ids' => [1, 2, 3],
])
->assertForbidden();
}
public function test_licenses_without_checked_out_seats_can_be_bulk_deleted()
{
$license1 = License::factory()->create(['seats' => 5]);
$license2 = License::factory()->create(['seats' => 5]);
$this->actingAs(User::factory()->deleteLicenses()->create())
->post(route('licenses.bulk.delete'), [
'ids' => [$license1->id, $license2->id],
])
->assertRedirect(route('licenses.index'))
->assertSessionHas('success', trans('admin/licenses/message.delete.bulk_success'));
$this->assertSoftDeleted($license1);
$this->assertSoftDeleted($license2);
}
public function test_licenses_with_checked_out_seats_cannot_be_bulk_deleted()
{
$license = License::factory()->create(['seats' => 5]);
LicenseSeat::factory()->assignedToUser()->create(['license_id' => $license->id]);
$this->actingAs(User::factory()->deleteLicenses()->create())
->post(route('licenses.bulk.delete'), [
'ids' => [$license->id],
])
->assertRedirect(route('licenses.index'))
->assertSessionMissing('success');
$this->assertModelExists($license);
$this->assertNotSoftDeleted($license);
}
public function test_seats_are_cleaned_up_when_license_is_bulk_deleted()
{
$license = License::factory()->create(['seats' => 3]);
$this->actingAs(User::factory()->deleteLicenses()->create())
->post(route('licenses.bulk.delete'), [
'ids' => [$license->id],
])
->assertRedirect(route('licenses.index'));
$this->assertSoftDeleted($license);
$this->assertEquals(0, LicenseSeat::where('license_id', $license->id)->whereNotNull('assigned_to')->orWhere('license_id', $license->id)->whereNotNull('asset_id')->count());
}
public function test_fmcs_prevents_deleting_license_from_other_company()
{
[$myCompany, $otherCompany] = Company::factory()->count(2)->create();
$actor = User::factory()->deleteLicenses()->create(['company_id' => $myCompany->id]);
$otherLicense = License::factory()->create(['company_id' => $otherCompany->id, 'seats' => 1]);
$this->settings->enableMultipleFullCompanySupport();
$this->actingAs($actor)
->post(route('licenses.bulk.delete'), [
'ids' => [$otherLicense->id],
])
->assertRedirect(route('licenses.index'))
->assertSessionMissing('success');
$this->assertModelExists($otherLicense);
$this->assertNotSoftDeleted($otherLicense);
}
public function test_partial_success_when_some_licenses_have_checked_out_seats()
{
$cleanLicense = License::factory()->create(['seats' => 5]);
$checkedOutLicense = License::factory()->create(['seats' => 5]);
LicenseSeat::factory()->assignedToUser()->create(['license_id' => $checkedOutLicense->id]);
$this->actingAs(User::factory()->deleteLicenses()->create())
->post(route('licenses.bulk.delete'), [
'ids' => [$cleanLicense->id, $checkedOutLicense->id],
])
->assertRedirect(route('licenses.index'))
->assertSessionHas('success');
$this->assertSoftDeleted($cleanLicense);
$this->assertNotSoftDeleted($checkedOutLicense);
}
}
@@ -789,6 +789,32 @@ class SearchableTraitTest extends TestCase
$this->assertNotContains((int) $withoutNotes->id, $returnedIds2);
}
/**
* Blank string values should be treated like empty content for direct string fields.
*/
public function test_is_not_null_filter_excludes_blank_string_direct_attributes()
{
$populated = Asset::factory()->create([
'name' => 'Named Asset '.now()->timestamp,
'order_number' => 'PO-12345',
]);
$blank = Asset::factory()->create([
'name' => '',
'order_number' => '',
]);
$superuser = User::factory()->viewAssets()->create();
$response = $this->actingAsForApi($superuser)
->getJson(route('api.assets.index', ['filter' => json_encode(['order_number' => 'is:not_null'])]))
->assertOk();
$returnedIds = collect($response->json('rows'))->pluck('id')->map(fn ($id) => (int) $id)->all();
$this->assertContains((int) $populated->id, $returnedIds);
$this->assertNotContains((int) $blank->id, $returnedIds);
}
/**
* "is:not_null" on the User virtual "name" column should match users where
* at least one constituent column (first_name, last_name) is not null.
@@ -855,6 +881,41 @@ class SearchableTraitTest extends TestCase
$this->assertNotContains((int) $withoutSupplier->id, $returnedIds2);
}
/**
* Regression: `assigned_to` is a polymorphic searchable relation key.
* `is:null` should return unassigned assets; `is:not_null` should return assigned assets.
*/
public function test_is_null_filter_on_polymorphic_assigned_to_relation_key()
{
/** @var User $assignee */
$assignee = User::factory()->create();
$assignedAsset = Asset::factory()->assignedToUser($assignee)->create();
$unassignedAsset = Asset::factory()->create([
'assigned_to' => null,
'assigned_type' => null,
]);
$superuser = User::factory()->viewAssets()->create();
$response = $this->actingAsForApi($superuser)
->getJson(route('api.assets.index', ['filter' => json_encode(['assigned_to' => 'is:null'])]))
->assertOk();
$returnedNullIds = collect($response->json('rows'))->pluck('id')->map(fn ($id) => (int) $id)->all();
$this->assertContains((int) $unassignedAsset->id, $returnedNullIds);
$this->assertNotContains((int) $assignedAsset->id, $returnedNullIds);
$response2 = $this->actingAsForApi($superuser)
->getJson(route('api.assets.index', ['filter' => json_encode(['assigned_to' => 'is:not_null'])]))
->assertOk();
$returnedNotNullIds = collect($response2->json('rows'))->pluck('id')->map(fn ($id) => (int) $id)->all();
$this->assertContains((int) $assignedAsset->id, $returnedNotNullIds);
$this->assertNotContains((int) $unassignedAsset->id, $returnedNotNullIds);
}
/**
* Test custom field partial match via filter.
*/
@@ -899,6 +960,70 @@ class SearchableTraitTest extends TestCase
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
}
/**
* Regression: "is_not:" should perform an exact exclusion (not fuzzy).
*/
public function test_exact_exclusion_filter_with_is_not_prefix_on_attribute()
{
Asset::factory()->create(['name' => 'Dell', 'asset_tag' => 'ISNOT-ATTR-001']);
Asset::factory()->create(['name' => 'Dell XPS 13', 'asset_tag' => 'ISNOT-ATTR-002']);
Asset::factory()->create(['name' => 'HP Pavilion', 'asset_tag' => 'ISNOT-ATTR-003']);
$this->actingAsForApi(User::factory()->viewAssets()->create())
->getJson(route('api.assets.index', [
'filter' => json_encode(['name' => 'is_not:Dell']),
]))
->assertOk()
->assertJson(fn (AssertableJson $json) => $json->has('rows', 2)->etc());
}
/**
* Regression: "is_not:" on relations should exclude only exact relation values.
*/
public function test_exact_exclusion_filter_with_is_not_prefix_on_relation()
{
$apple = Manufacturer::factory()->create(['name' => 'Apple']);
$appleInc = Manufacturer::factory()->create(['name' => 'Apple Inc']);
$dell = Manufacturer::factory()->create(['name' => 'Dell']);
$appleModel = AssetModel::factory()->create(['manufacturer_id' => $apple->id]);
$appleIncModel = AssetModel::factory()->create(['manufacturer_id' => $appleInc->id]);
$dellModel = AssetModel::factory()->create(['manufacturer_id' => $dell->id]);
Asset::factory()->create(['model_id' => $appleModel->id, 'asset_tag' => 'ISNOT-REL-001']);
Asset::factory()->create(['model_id' => $appleIncModel->id, 'asset_tag' => 'ISNOT-REL-002']);
Asset::factory()->create(['model_id' => $dellModel->id, 'asset_tag' => 'ISNOT-REL-003']);
$this->actingAsForApi(User::factory()->viewAssets()->create())
->getJson(route('api.assets.index', [
'filter' => json_encode(['manufacturer' => 'is_not:Apple']),
]))
->assertOk()
->assertJson(fn (AssertableJson $json) => $json->has('rows', 2)->etc());
}
/**
* Regression: "is_not:" should perform exact exclusion on custom fields.
*/
public function test_exact_exclusion_filter_with_is_not_prefix_on_custom_field()
{
$field = CustomField::factory()->cpu()->create();
$dbColumn = $field->db_column_name();
Asset::factory()->create([$dbColumn => 'Intel', 'asset_tag' => 'ISNOT-CF-001']);
Asset::factory()->create([$dbColumn => 'Intel Core i9', 'asset_tag' => 'ISNOT-CF-002']);
Asset::factory()->create([$dbColumn => 'AMD Ryzen 7', 'asset_tag' => 'ISNOT-CF-003']);
Asset::flushCustomFieldFilterMap();
$this->actingAsForApi(User::factory()->viewAssets()->create())
->getJson(route('api.assets.index', [
'filter' => json_encode([$dbColumn => 'is_not:Intel']),
]))
->assertOk()
->assertJson(fn (AssertableJson $json) => $json->has('rows', 2)->etc());
}
/**
* Test negation filter using "!" prefix on a direct attribute.
* filter={"name":"!Dell"} should return all assets whose name does NOT contain "Dell".