Compare commits
94 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a614f986f0 | |||
| f398a59d26 | |||
| c8bd104268 | |||
| bdc8fc8d4a | |||
| 41be127489 | |||
| fdfae9593d | |||
| 6a21eb53c9 | |||
| efde2b4672 | |||
| daaa26cbf4 | |||
| 3e7441562c | |||
| 7b53fa5245 | |||
| d35d46f5b4 | |||
| 4c1bb7e0ac | |||
| 762ea9b4db | |||
| b16970a61e | |||
| dadb9bd81e | |||
| 13dc7de660 | |||
| 003ea36e18 | |||
| f4bd2a68c9 | |||
| be4e75d4f7 | |||
| 538c21ce1e | |||
| 626cd6cb2e | |||
| 2a56f6573d | |||
| 6ee2dc1cd6 | |||
| 3fcde8bd16 | |||
| e2ff7a7bc7 | |||
| c7efd16517 | |||
| f2907f04d9 | |||
| 7d98c267d5 | |||
| 5bc6330c13 | |||
| 1706ed597d | |||
| 6e9ba28ef7 | |||
| 554d1a44de | |||
| c0a8f4c1a4 | |||
| 08be9aac6d | |||
| a51b17fb53 | |||
| 66d5618d60 | |||
| e16c2384fd | |||
| b3323f08a0 | |||
| 7e63c2ef92 | |||
| 7f65b6d598 | |||
| 8fb8f0a4d2 | |||
| 637dbc8d2a | |||
| 978990fdff | |||
| 52a058e511 | |||
| 64bea202c5 | |||
| 37f60993ca | |||
| 32717c67c7 | |||
| 3681e3f025 | |||
| 1d0f055349 | |||
| fb3024ca9c | |||
| 005c0ea9f6 | |||
| 7c3f1f3a84 | |||
| 900e5209d9 | |||
| 4fbf416d16 | |||
| 7b7d2c87fb | |||
| 6debb3a65d | |||
| 315ba49a1d | |||
| ff57855038 | |||
| da6e837578 | |||
| a2d8f89162 | |||
| e36d65e695 | |||
| 34abf14cbe | |||
| dda7a4f22f | |||
| 283a885196 | |||
| d44aa3f16e | |||
| afb37981bf | |||
| 2b6518427a | |||
| 185e0073b3 | |||
| d0794ba71c | |||
| 1b42e2e138 | |||
| b4efabe82e | |||
| 9b37e95b58 | |||
| a92d8eeaab | |||
| e8dbb12ccc | |||
| 8a2cd19ea6 | |||
| afdf86ad0d | |||
| a5dae3f222 | |||
| 97765c08b1 | |||
| 6ad92556a1 | |||
| e2465ca2a7 | |||
| f5644928a8 | |||
| 8747ff32dd | |||
| 4ddd2f1cf8 | |||
| 11c8fd4d4c | |||
| ab04f3de93 | |||
| 4c16796256 | |||
| 516771d948 | |||
| e25ea465c5 | |||
| 30ac3d1a26 | |||
| e47c772230 | |||
| 706b623d95 | |||
| a908a76f53 | |||
| a2ec707f79 |
@@ -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 industry’s 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
|
||||
|
||||
@@ -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')));
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
@@ -7,5 +7,5 @@ return [
|
||||
'prerelease_version' => '',
|
||||
'hash_version' => 'g5014b1c459',
|
||||
'full_hash' => 'v8.5.0-pre-207-g5014b1c459',
|
||||
'branch' => 'develop',
|
||||
'branch' => 'master',
|
||||
];
|
||||
|
||||
+1
-5413
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1645
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1273
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+6
-24718
File diff suppressed because one or more lines are too long
Vendored
+1
-414
File diff suppressed because one or more lines are too long
+1
-135
@@ -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}}
|
||||
|
||||
Vendored
+2
-53211
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-38849
File diff suppressed because one or more lines are too long
@@ -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>',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,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;">×</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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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"> </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"> </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"> </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"> </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
|
||||
|
||||
@@ -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
@@ -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
@@ -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'])
|
||||
|
||||
@@ -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".
|
||||
|
||||
Reference in New Issue
Block a user