Compare commits
205 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da86e919d9 | |||
| 45d6a491cb | |||
| 3dc90f89f6 | |||
| e2bea57146 | |||
| 43a32071f1 | |||
| e3a9872d28 | |||
| 75f86cd669 | |||
| 73f72cbbb0 | |||
| 2033f25386 | |||
| 8d0a6af2aa | |||
| a698ba3082 | |||
| b57d286b15 | |||
| 3cd5e86527 | |||
| bccba46332 | |||
| 70357ada3d | |||
| 043ad713e7 | |||
| bb178b0a5c | |||
| 288bded7d9 | |||
| d12ad3d538 | |||
| 905d498ecd | |||
| 802067f398 | |||
| b40e227ad3 | |||
| b89504e1c3 | |||
| c8ae09cd43 | |||
| 8ebddd95ff | |||
| c14880dfca | |||
| a27c551f64 | |||
| e71453cb5d | |||
| bb19add3b6 | |||
| 9bd6396a15 | |||
| b060327219 | |||
| 8b383df13f | |||
| 6a0ec69451 | |||
| 66bf6275b8 | |||
| 6fc2ff7252 | |||
| 0f6367bb17 | |||
| e3190c3922 | |||
| 53628d6ae3 | |||
| d03f68ae34 | |||
| 87bc834885 | |||
| 9f89dffaae | |||
| ab1a5c0241 | |||
| 758c1cabc5 | |||
| 3dd5358e73 | |||
| 8cc4ad27c9 | |||
| cd1f6b8e73 | |||
| 07e70cf7a9 | |||
| a9d1069705 | |||
| 10703263a8 | |||
| b0aa21bee7 | |||
| 82fa1d7a26 | |||
| be446e97d7 | |||
| c44f3319e3 | |||
| 678d1c1428 | |||
| 535d7c0ff6 | |||
| e430e4e6e2 | |||
| df92076e15 | |||
| e2ba35ee80 | |||
| f4cac96358 | |||
| 5257c2ce84 | |||
| b378cf31f4 | |||
| 0f184840df | |||
| 3df21df85b | |||
| 0d870d540d | |||
| 144772cfbe | |||
| 80c8aa41dc | |||
| 5658cd6dd4 | |||
| 374f426f0c | |||
| 2af0c237a9 | |||
| dafd72af59 | |||
| cbc6dc94a5 | |||
| f74e7510c5 | |||
| d87cd7cbb9 | |||
| 9a8cbd6e00 | |||
| abc4363e83 | |||
| df0ee6020a | |||
| 53599544af | |||
| b5ec9e080d | |||
| 8f98c8a862 | |||
| 0959d87534 | |||
| 1252681d55 | |||
| 9bc4efa5ff | |||
| 5656e4f5b7 | |||
| a966198a75 | |||
| 4ff214ac47 | |||
| 5169d174ad | |||
| 9c849c337f | |||
| d0685464f6 | |||
| 10b5a8ef21 | |||
| f0a9a49753 | |||
| 1afde946d2 | |||
| e8ba1feddc | |||
| 18e9b5c5bf | |||
| f186dc20f6 | |||
| 80a722d465 | |||
| 765487f62e | |||
| 1d186fffaa | |||
| 6295b7726e | |||
| e7c45644b9 | |||
| 356a0d4c12 | |||
| 00d4d6c7a8 | |||
| 371d44b2a7 | |||
| 79732a9151 | |||
| a6e55fb462 | |||
| d032a51a3d | |||
| 9c2495af29 | |||
| d7bc6c45f6 | |||
| 4382e01f57 | |||
| bab5294399 | |||
| a161fa8519 | |||
| 5e5bd7a17d | |||
| 285717ab12 | |||
| 81d91da0b8 | |||
| b017e9382f | |||
| eb5334e865 | |||
| 01b1c3923d | |||
| 780fb76af8 | |||
| ab90fc16e0 | |||
| 990c50c5b9 | |||
| 2e91b3dc9a | |||
| 211bd02786 | |||
| e8d000a17a | |||
| 8fc373abfc | |||
| a473ca737e | |||
| ff6fc68981 | |||
| f133a67550 | |||
| b0a6cdc29f | |||
| edcb429366 | |||
| ba5a674526 | |||
| e84496f8b1 | |||
| 5f9212383a | |||
| a5493f11bc | |||
| ce434b3d04 | |||
| ade07b411b | |||
| 3868e469c0 | |||
| ea939acbd3 | |||
| 522544c131 | |||
| 445fb6f253 | |||
| 7bf8fd5eeb | |||
| c758fb4c83 | |||
| 4145f64399 | |||
| 4120ab6fe6 | |||
| 0170fb7711 | |||
| 42df2f6c31 | |||
| 9b522b69ff | |||
| 135db70b0f | |||
| 048e97f9a9 | |||
| 18d8f257ee | |||
| ec67195014 | |||
| 0d745ad10f | |||
| 89ce71b350 | |||
| 63c1f7922f | |||
| 5809ac7997 | |||
| 92b6e46249 | |||
| 46c11d8599 | |||
| 7651365ff6 | |||
| 35caa0e68d | |||
| e0a7fe443d | |||
| 4d0282ca0a | |||
| bf5013e527 | |||
| cbd961e922 | |||
| 7abe1bed50 | |||
| 4d06e81768 | |||
| 4edf40acaf | |||
| c02a6c105a | |||
| cfa8069953 | |||
| b3be2baf40 | |||
| 069912d051 | |||
| 86245ad4ae | |||
| c8bafdad79 | |||
| c94fce2367 | |||
| 653b1327cb | |||
| 849b217300 | |||
| 371f096e54 | |||
| 72a11113e7 | |||
| b0635f24db | |||
| 96088c416e | |||
| c8f3e833e5 | |||
| 5307a44fab | |||
| 2d6eb5d80a | |||
| 90e2c105cd | |||
| 875b0bbdec | |||
| be1f1bd1c5 | |||
| c9be696c84 | |||
| 187f160b21 | |||
| 8908b67b3d | |||
| 4373f761c7 | |||
| 8e9bd5dbb1 | |||
| 751541a54d | |||
| 3972799e56 | |||
| db2afd0dc7 | |||
| 460daf71b6 | |||
| 3074bae47c | |||
| 0f80950a91 | |||
| 2620b60048 | |||
| 81b1cdc6e9 | |||
| 0304933c53 | |||
| f0d84f5350 | |||
| 1ad562f8b9 | |||
| a5cea247f1 | |||
| 571bc39495 | |||
| 8ea78fae21 | |||
| ed6b3c04ab | |||
| a4ca0a592f | |||
| 90c8689596 |
@@ -37,6 +37,7 @@ MYSQL_ROOT_PASSWORD=changeme1234
|
||||
DB_PREFIX=null
|
||||
DB_DUMP_PATH='/usr/bin'
|
||||
DB_DUMP_SKIP_SSL=true
|
||||
DB_DUMP_SINGLE_TRANSACTION=false
|
||||
DB_CHARSET=utf8mb4
|
||||
DB_COLLATION=utf8mb4_unicode_ci
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ DB_PASSWORD=null
|
||||
DB_PREFIX=null
|
||||
DB_DUMP_PATH='/usr/bin'
|
||||
DB_DUMP_SKIP_SSL=false
|
||||
DB_DUMP_SINGLE_TRANSACTION=false
|
||||
DB_CHARSET=utf8mb4
|
||||
DB_COLLATION=utf8mb4_unicode_ci
|
||||
DB_SANITIZE_BY_DEFAULT=false
|
||||
|
||||
@@ -0,0 +1,989 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Events\CheckoutableCheckedIn;
|
||||
use App\Mail\BulkDeleteReportMail;
|
||||
use App\Models\Accessory;
|
||||
use App\Models\AccessoryCheckout;
|
||||
use App\Models\Actionlog;
|
||||
use App\Models\Asset;
|
||||
use App\Models\CheckoutAcceptance;
|
||||
use App\Models\Company;
|
||||
use App\Models\Component;
|
||||
use App\Models\Consumable;
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseSeat;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\Console\Helper\ProgressBar;
|
||||
|
||||
use function Laravel\Prompts\confirm;
|
||||
use function Laravel\Prompts\error;
|
||||
use function Laravel\Prompts\info;
|
||||
use function Laravel\Prompts\multisearch;
|
||||
use function Laravel\Prompts\multiselect;
|
||||
use function Laravel\Prompts\search;
|
||||
use function Laravel\Prompts\select;
|
||||
use function Laravel\Prompts\warning;
|
||||
|
||||
class BulkDelete extends Command
|
||||
{
|
||||
protected $signature = 'snipeit:checkin-delete-items';
|
||||
|
||||
protected $description = 'Interactively check in and/or delete items by company and type';
|
||||
|
||||
private const CHECKIN_NOTE = 'Checked in via bulk CLI operation';
|
||||
|
||||
private array $reportLines = [];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
// Step 1: Dry run?
|
||||
$dryRun = confirm(
|
||||
label: 'Is this a dry run?',
|
||||
default: true,
|
||||
yes: 'Yes — preview only, no changes will be made',
|
||||
no: 'No — LIVE RUN, changes WILL be made',
|
||||
);
|
||||
|
||||
// Step 2: Who are you?
|
||||
$adminId = search(
|
||||
label: 'Who are you? Search by username, first or last name.',
|
||||
placeholder: 'Type to search users...',
|
||||
options: function (string $value): array {
|
||||
if (strlen($value) < 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return User::where('activated', 1)
|
||||
->whereNull('deleted_at')
|
||||
->onlySuperAdmins()
|
||||
->where(function ($query) use ($value) {
|
||||
$query->where('username', 'like', "%{$value}%")
|
||||
->orWhere('first_name', 'like', "%{$value}%")
|
||||
->orWhere('last_name', 'like', "%{$value}%")
|
||||
->orWhereRaw("CONCAT(first_name, ' ', last_name) LIKE ?", ["%{$value}%"]);
|
||||
})
|
||||
->get()
|
||||
->mapWithKeys(fn (User $u) => [$u->id => "{$u->first_name} {$u->last_name} ({$u->username})"])
|
||||
->toArray();
|
||||
},
|
||||
validate: fn (mixed $value) => ! $value ? 'A valid active user is required.' : null,
|
||||
);
|
||||
|
||||
/** @var User $admin */
|
||||
$admin = User::findOrFail((int) $adminId);
|
||||
|
||||
// Step 3: Which companies?
|
||||
if (! Company::exists()) {
|
||||
error('No companies found. Please create at least one company before using this command.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$selectedCompanyKeys = multisearch(
|
||||
label: 'Which companies would you like to check in and delete items for?',
|
||||
placeholder: 'Type to search companies...',
|
||||
options: function (string $value): array {
|
||||
$results = [];
|
||||
|
||||
if ($value === '' || str_contains('(no company / unassigned)', strtolower($value))) {
|
||||
$results['__null__'] = '(No Company / Unassigned)';
|
||||
}
|
||||
|
||||
$query = Company::orderBy('name');
|
||||
if ($value !== '') {
|
||||
$query->where('name', 'like', "%{$value}%");
|
||||
}
|
||||
|
||||
$query->get()->each(function (Company $c) use (&$results) {
|
||||
$results[$c->id] = "{$c->name} (ID: {$c->id})";
|
||||
});
|
||||
|
||||
return $results;
|
||||
},
|
||||
scroll: 10,
|
||||
required: 'Please select at least one company.',
|
||||
hint: 'If you\'re searching on several differently named companies, use the up-arrow to go back to the search box to search again. ',
|
||||
);
|
||||
|
||||
$includeNullCompany = in_array('__null__', $selectedCompanyKeys);
|
||||
$selectedCompanyIds = array_values(array_filter(
|
||||
$selectedCompanyKeys,
|
||||
fn ($k) => $k !== '__null__'
|
||||
));
|
||||
|
||||
$companyNamesById = Company::whereIn('id', $selectedCompanyIds)->pluck('name', 'id')->toArray();
|
||||
$selectedCompanyNames = array_map(
|
||||
fn ($id) => $id === '__null__' ? '(No Company)' : ($companyNamesById[$id] ?? "(ID: {$id})"),
|
||||
$selectedCompanyKeys
|
||||
);
|
||||
|
||||
// Step 4: Which item types?
|
||||
$rawTypeSelection = multiselect(
|
||||
label: 'What item types would you like to check in and delete?',
|
||||
options: [
|
||||
'all' => 'All Items (assets, licenses, accessories, components, consumables, users)',
|
||||
'assets' => 'Assets',
|
||||
'licenses' => 'Licenses',
|
||||
'accessories' => 'Accessories',
|
||||
'components' => 'Components',
|
||||
'consumables' => 'Consumables',
|
||||
'users' => 'Users',
|
||||
],
|
||||
required: 'Please select at least one item type.',
|
||||
hint: 'Select "All Items" to process every supported type.',
|
||||
);
|
||||
|
||||
$allSubTypes = ['assets', 'licenses', 'accessories', 'components', 'consumables', 'users'];
|
||||
$selectedTypes = in_array('all', $rawTypeSelection)
|
||||
? $allSubTypes
|
||||
: array_values(array_intersect($allSubTypes, $rawTypeSelection));
|
||||
|
||||
// Compute and display counts now so the user can see what will be affected
|
||||
$counts = $this->getCounts($selectedTypes, $selectedCompanyIds, $includeNullCompany);
|
||||
|
||||
$skipAdminUser = false;
|
||||
|
||||
$this->line('');
|
||||
$this->line(' Items that would be affected:');
|
||||
foreach ($counts as $type => $count) {
|
||||
$this->line(sprintf(' %-14s %d', ucfirst($type).':', $count));
|
||||
}
|
||||
|
||||
if (in_array('users', $selectedTypes)) {
|
||||
$userInScope = $this->buildUserQuery($selectedCompanyIds, $includeNullCompany)
|
||||
->where('users.id', $admin->id)
|
||||
->exists();
|
||||
|
||||
if ($userInScope) {
|
||||
$skipAdminUser = true;
|
||||
$counts['users'] = max(0, ($counts['users'] ?? 0) - 1);
|
||||
warning(" Your user ({$admin->username}) is within the selected scope and will be skipped during user deletion.");
|
||||
}
|
||||
}
|
||||
|
||||
$this->line('');
|
||||
|
||||
// Step 5: Hard delete, soft delete, or no delete?
|
||||
$deleteType = select(
|
||||
label: 'How should items be deleted?',
|
||||
options: [
|
||||
'soft' => 'Soft delete — items moved to trash (recoverable)',
|
||||
'hard' => 'Hard delete — permanently removed (cannot be recovered)',
|
||||
'none' => 'No delete — check in only, items remain in inventory',
|
||||
],
|
||||
default: 'soft',
|
||||
);
|
||||
|
||||
// Step 6: Send checkin notifications? (not applicable to users or consumables)
|
||||
$notifiableTypes = array_intersect($selectedTypes, ['assets', 'licenses', 'accessories', 'components']);
|
||||
$sendNotifications = false;
|
||||
|
||||
if (! empty($notifiableTypes)) {
|
||||
$sendNotifications = confirm(
|
||||
label: 'Should we send checkin notifications?',
|
||||
default: true,
|
||||
hint: 'Applies to: '.implode(', ', $notifiableTypes).'. Users and consumables are excluded.',
|
||||
);
|
||||
}
|
||||
|
||||
// Step 7: Clear related action_logs?
|
||||
$clearLogs = confirm(
|
||||
label: 'Should we clear related action logs?',
|
||||
default: false,
|
||||
hint: 'This removes all history for affected items, as if the data never existed.',
|
||||
);
|
||||
|
||||
// Step 8: Delete associated files?
|
||||
$deleteFiles = false;
|
||||
if ($deleteType !== 'none') {
|
||||
$deleteFiles = confirm(
|
||||
label: 'Should we also delete associated image and upload files?',
|
||||
default: $deleteType === 'hard',
|
||||
hint: 'Permanently removes images, avatars, signatures, EULAs, and action log uploads from disk.',
|
||||
);
|
||||
}
|
||||
|
||||
// Step 9: Delete the companies themselves?
|
||||
$deleteCompanyType = 'keep';
|
||||
if (! empty($selectedCompanyIds)) {
|
||||
$deleteCompanyType = select(
|
||||
label: 'Should the selected companies also be deleted?',
|
||||
options: [
|
||||
'keep' => 'Keep — do not delete the companies',
|
||||
'soft' => 'Soft delete — companies moved to trash (recoverable)',
|
||||
'hard' => 'Hard delete — permanently removed (cannot be recovered)',
|
||||
],
|
||||
default: 'keep',
|
||||
);
|
||||
}
|
||||
|
||||
// Step 10: Backup first?
|
||||
$doBackup = confirm(
|
||||
label: 'Should we run a backup before proceeding?',
|
||||
default: true,
|
||||
hint: 'Strongly recommended. Saved as backup-before-bulk-delete-cli-[datetime].zip',
|
||||
);
|
||||
|
||||
// Step 11: Summary + final confirmation
|
||||
$this->line('');
|
||||
$this->line(' ════════════════════════════════════════════════════');
|
||||
$this->line(' SUMMARY OF ACTIONS');
|
||||
$this->line(' ════════════════════════════════════════════════════');
|
||||
$this->line(" Admin user: {$admin->first_name} {$admin->last_name} ({$admin->username})");
|
||||
$this->line(' Companies: '.implode(', ', $selectedCompanyNames));
|
||||
$this->line(' Item types: '.implode(', ', $selectedTypes));
|
||||
$this->line(" Delete mode: {$deleteType}");
|
||||
$this->line(' Notifications: '.($sendNotifications ? 'Yes' : 'No'));
|
||||
$this->line(' Clear logs: '.($clearLogs ? 'Yes' : 'No'));
|
||||
$this->line(' Delete files: '.($deleteFiles ? 'Yes' : 'No'));
|
||||
$this->line(' Delete companies: '.($deleteCompanyType === 'keep' ? 'No' : ucfirst($deleteCompanyType).' delete'));
|
||||
$this->line(' Backup first: '.($doBackup ? 'Yes' : 'No'));
|
||||
$this->line(' Dry run: '.($dryRun ? 'Yes' : 'No'));
|
||||
$this->line('');
|
||||
$this->line(' Items to be processed:');
|
||||
foreach ($counts as $type => $count) {
|
||||
$this->line(sprintf(' %-14s %d', ucfirst($type).':', $count));
|
||||
}
|
||||
if ($skipAdminUser) {
|
||||
$this->line(' * Your user account will be skipped during user deletion.');
|
||||
}
|
||||
$this->line(' ════════════════════════════════════════════════════');
|
||||
$this->line('');
|
||||
|
||||
// Step 10.5: Email report?
|
||||
$sendEmailReport = false;
|
||||
if ($admin->email) {
|
||||
$sendEmailReport = confirm(
|
||||
label: "Send an email report to {$admin->email}?",
|
||||
default: false,
|
||||
hint: 'A summary of all '.($dryRun ? 'would-be ' : '').'actions will be emailed to you.',
|
||||
);
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
$confirmed = confirm(
|
||||
label: 'Are you sure you want to proceed? This cannot be undone.',
|
||||
default: false,
|
||||
);
|
||||
|
||||
if (! $confirmed) {
|
||||
info('Aborted. No changes were made.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Run backup if requested
|
||||
if ($doBackup && ! $dryRun) {
|
||||
$backupFilename = 'backup-before-bulk-delete-cli-'.now()->format('Y-m-d-H-i-s');
|
||||
info("Running backup ({$backupFilename}.zip)...");
|
||||
$result = $this->callSilently('snipeit:backup', ['--filename' => $backupFilename]);
|
||||
if ($result === 0) {
|
||||
info("Backup completed: {$backupFilename}.zip");
|
||||
} else {
|
||||
warning("Backup may have failed (exit code {$result}). Proceeding anyway.");
|
||||
}
|
||||
}
|
||||
|
||||
// Step 11: Execute with progress bar
|
||||
$totalItems = array_sum($counts);
|
||||
$bar = $this->output->createProgressBar($totalItems > 0 ? $totalItems : 1);
|
||||
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %message%');
|
||||
$bar->setMessage('Starting...');
|
||||
$bar->start();
|
||||
|
||||
foreach ($selectedTypes as $type) {
|
||||
match ($type) {
|
||||
'assets' => $this->processAssets($selectedCompanyIds, $includeNullCompany, $sendNotifications, $admin, $dryRun, $deleteType, $clearLogs, $deleteFiles, $bar),
|
||||
'licenses' => $this->processLicenses($selectedCompanyIds, $includeNullCompany, $sendNotifications, $admin, $dryRun, $deleteType, $clearLogs, $deleteFiles, $bar),
|
||||
'accessories' => $this->processAccessories($selectedCompanyIds, $includeNullCompany, $sendNotifications, $admin, $dryRun, $deleteType, $clearLogs, $deleteFiles, $bar),
|
||||
'components' => $this->processComponents($selectedCompanyIds, $includeNullCompany, $sendNotifications, $admin, $dryRun, $deleteType, $clearLogs, $deleteFiles, $bar),
|
||||
'consumables' => $this->processConsumables($selectedCompanyIds, $includeNullCompany, $dryRun, $deleteType, $clearLogs, $deleteFiles, $bar),
|
||||
'users' => $this->processUsers($selectedCompanyIds, $includeNullCompany, $admin, $skipAdminUser, $dryRun, $deleteType, $clearLogs, $deleteFiles, $bar),
|
||||
};
|
||||
}
|
||||
|
||||
$bar->setMessage('Done.');
|
||||
$bar->finish();
|
||||
$this->line('');
|
||||
$this->line('');
|
||||
|
||||
// Delete companies if requested
|
||||
if ($deleteCompanyType !== 'keep' && ! empty($selectedCompanyIds)) {
|
||||
$companies = Company::whereIn('id', $selectedCompanyIds)->get();
|
||||
foreach ($companies as $company) {
|
||||
if ($dryRun) {
|
||||
$this->line(" [dry-run] Would {$deleteCompanyType}-delete company {$company->name}");
|
||||
$this->reportLines[] = "Would {$deleteCompanyType}-delete company {$company->name}";
|
||||
} else {
|
||||
if ($deleteCompanyType === 'soft') {
|
||||
$company->delete();
|
||||
} else {
|
||||
$company->forceDelete();
|
||||
}
|
||||
// Remove any remaining pivot associations (e.g. the admin user who was
|
||||
// skipped during user processing but is still a member of this company)
|
||||
DB::table('company_user')->where('company_id', $company->id)->delete();
|
||||
$this->reportLines[] = ucfirst($deleteCompanyType)."-deleted company {$company->name}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
warning('Dry run complete — no changes were made.');
|
||||
} else {
|
||||
info('All actions completed successfully.');
|
||||
}
|
||||
|
||||
if ($sendEmailReport && $admin->email) {
|
||||
Mail::to($admin->email)->send(new BulkDeleteReportMail(
|
||||
admin: $admin,
|
||||
dryRun: $dryRun,
|
||||
companyNames: $selectedCompanyNames,
|
||||
selectedTypes: $selectedTypes,
|
||||
deleteType: $deleteType,
|
||||
reportLines: $this->reportLines,
|
||||
runAt: now(),
|
||||
));
|
||||
info("Report sent to {$admin->email}.");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function getCounts(array $types, array $companyIds, bool $includeNull): array
|
||||
{
|
||||
$counts = [];
|
||||
|
||||
if (in_array('assets', $types)) {
|
||||
$counts['assets'] = $this->buildCompanyQuery(Asset::query(), $companyIds, $includeNull)->count();
|
||||
}
|
||||
if (in_array('licenses', $types)) {
|
||||
$counts['licenses'] = $this->buildCompanyQuery(License::query(), $companyIds, $includeNull)->count();
|
||||
}
|
||||
if (in_array('accessories', $types)) {
|
||||
$counts['accessories'] = $this->buildCompanyQuery(Accessory::query(), $companyIds, $includeNull)->count();
|
||||
}
|
||||
if (in_array('components', $types)) {
|
||||
$counts['components'] = $this->buildCompanyQuery(Component::query(), $companyIds, $includeNull)->count();
|
||||
}
|
||||
if (in_array('consumables', $types)) {
|
||||
$counts['consumables'] = $this->buildCompanyQuery(Consumable::query(), $companyIds, $includeNull)->count();
|
||||
}
|
||||
if (in_array('users', $types)) {
|
||||
$counts['users'] = $this->buildUserQuery($companyIds, $includeNull)->count();
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
private function buildCompanyQuery(Builder $query, array $companyIds, bool $includeNull): Builder
|
||||
{
|
||||
return $query->where(function (Builder $q) use ($companyIds, $includeNull) {
|
||||
if (! empty($companyIds)) {
|
||||
$q->whereIn('company_id', $companyIds);
|
||||
}
|
||||
if ($includeNull) {
|
||||
$method = ! empty($companyIds) ? 'orWhereNull' : 'whereNull';
|
||||
$q->{$method}('company_id');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function buildUserQuery(array $companyIds, bool $includeNull): Builder
|
||||
{
|
||||
return User::query()
|
||||
->where('activated', 1)
|
||||
->where(function (Builder $q) use ($companyIds, $includeNull) {
|
||||
if (! empty($companyIds)) {
|
||||
$q->whereIn('company_id', $companyIds);
|
||||
}
|
||||
if ($includeNull) {
|
||||
$method = ! empty($companyIds) ? 'orWhereNull' : 'whereNull';
|
||||
$q->{$method}('company_id');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function processAssets(
|
||||
array $companyIds,
|
||||
bool $includeNull,
|
||||
bool $sendNotifications,
|
||||
User $admin,
|
||||
bool $dryRun,
|
||||
string $deleteType,
|
||||
bool $clearLogs,
|
||||
bool $deleteFiles,
|
||||
ProgressBar $bar,
|
||||
): void {
|
||||
$assets = $this->buildCompanyQuery(Asset::query(), $companyIds, $includeNull)->get();
|
||||
|
||||
foreach ($assets as $asset) {
|
||||
$bar->setMessage("Assets: {$asset->asset_tag}");
|
||||
|
||||
if ($asset->assignedTo) {
|
||||
if ($dryRun) {
|
||||
$this->line(" [dry-run] Would check in asset {$asset->asset_tag} from {$asset->assignedTo->name}");
|
||||
$this->reportLines[] = "Would check in asset {$asset->asset_tag} (assigned to {$asset->assignedTo->name})";
|
||||
} else {
|
||||
$target = $asset->assignedTo;
|
||||
$checkinAt = now()->format('Y-m-d H:i:s');
|
||||
$originalValues = $asset->getRawOriginal();
|
||||
|
||||
if ($sendNotifications) {
|
||||
event(new CheckoutableCheckedIn($asset, $target, $admin, self::CHECKIN_NOTE, $checkinAt, $originalValues));
|
||||
DB::table('assets')->where('id', $asset->id)->update(['assigned_to' => null, 'assigned_type' => null]);
|
||||
} else {
|
||||
DB::table('assets')->where('id', $asset->id)->update(['assigned_to' => null, 'assigned_type' => null]);
|
||||
$asset->logCheckin($target, self::CHECKIN_NOTE, $checkinAt, $originalValues);
|
||||
}
|
||||
|
||||
$this->reportLines[] = "Checked in asset {$asset->asset_tag} from {$target->name}";
|
||||
$asset->licenseseats()->update(['assigned_to' => null]);
|
||||
|
||||
CheckoutAcceptance::where('checkoutable_type', Asset::class)
|
||||
->where('checkoutable_id', $asset->id)
|
||||
->whereNull('accepted_at')
|
||||
->whereNull('declined_at')
|
||||
->forceDelete();
|
||||
}
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
// Collect action log file paths before logs may be cleared
|
||||
$actionLogPaths = $deleteFiles
|
||||
? $asset->assetlog()->whereNotNull('filename')->get()
|
||||
->map(fn (Actionlog $log) => $log->uploads_file_path())
|
||||
->filter()
|
||||
->values()
|
||||
->toArray()
|
||||
: [];
|
||||
|
||||
// Delete checkout acceptance files, then hard-remove all acceptances
|
||||
if ($deleteFiles) {
|
||||
CheckoutAcceptance::where('checkoutable_type', Asset::class)
|
||||
->where('checkoutable_id', $asset->id)
|
||||
->get()
|
||||
->each(fn (CheckoutAcceptance $ca) => $this->deleteAcceptanceFiles($ca));
|
||||
}
|
||||
CheckoutAcceptance::where('checkoutable_type', Asset::class)
|
||||
->where('checkoutable_id', $asset->id)
|
||||
->forceDelete();
|
||||
|
||||
// Hard-delete-only cleanup: maintenance records, accessory checkouts to this
|
||||
// asset, and any other assets that were assigned to this one
|
||||
$maintenanceImages = [];
|
||||
if ($deleteType === 'hard') {
|
||||
if ($deleteFiles) {
|
||||
$maintenanceImages = $asset->maintenances()
|
||||
->whereNotNull('image')
|
||||
->pluck('image')
|
||||
->toArray();
|
||||
}
|
||||
$asset->maintenances()->forceDelete();
|
||||
AccessoryCheckout::where('assigned_to', $asset->id)
|
||||
->where('assigned_type', Asset::class)
|
||||
->delete();
|
||||
DB::table('assets')
|
||||
->where('assigned_to', $asset->id)
|
||||
->where('assigned_type', Asset::class)
|
||||
->update(['assigned_to' => null, 'assigned_type' => null]);
|
||||
}
|
||||
|
||||
match ($deleteType) {
|
||||
'soft' => $asset->delete(),
|
||||
'hard' => $asset->forceDelete(),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($deleteType !== 'none') {
|
||||
$this->reportLines[] = ucfirst($deleteType)."-deleted asset {$asset->asset_tag}";
|
||||
}
|
||||
|
||||
if ($clearLogs) {
|
||||
$asset->assetlog()->forceDelete();
|
||||
}
|
||||
|
||||
if ($deleteFiles) {
|
||||
if ($asset->image) {
|
||||
$this->deleteStorageFile('public', app('assets_upload_path').$asset->image);
|
||||
}
|
||||
foreach ($maintenanceImages as $img) {
|
||||
$this->deleteStorageFile('public', app('maintenances_upload_path').$img);
|
||||
}
|
||||
foreach ($actionLogPaths as $path) {
|
||||
$this->deleteStorageFile('local', $path);
|
||||
}
|
||||
}
|
||||
} elseif ($deleteType !== 'none') {
|
||||
$this->line(" [dry-run] Would {$deleteType}-delete asset {$asset->asset_tag}");
|
||||
$this->reportLines[] = "Would {$deleteType}-delete asset {$asset->asset_tag}";
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
}
|
||||
|
||||
private function processLicenses(
|
||||
array $companyIds,
|
||||
bool $includeNull,
|
||||
bool $sendNotifications,
|
||||
User $admin,
|
||||
bool $dryRun,
|
||||
string $deleteType,
|
||||
bool $clearLogs,
|
||||
bool $deleteFiles,
|
||||
ProgressBar $bar,
|
||||
): void {
|
||||
$licenses = $this->buildCompanyQuery(License::query(), $companyIds, $includeNull)->get();
|
||||
|
||||
foreach ($licenses as $license) {
|
||||
$bar->setMessage("Licenses: {$license->name}");
|
||||
|
||||
$seats = LicenseSeat::where('license_id', $license->id)
|
||||
->where(fn ($q) => $q->whereNotNull('assigned_to')->orWhereNotNull('asset_id'))
|
||||
->get();
|
||||
|
||||
foreach ($seats as $seat) {
|
||||
$target = $seat->assigned_to ? $seat->user : $seat->asset;
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" [dry-run] Would check in license seat for {$license->name} from ".($target?->name ?? $target?->asset_tag ?? 'unknown'));
|
||||
$this->reportLines[] = "Would check in license seat for {$license->name} from ".($target?->name ?? $target?->asset_tag ?? 'unknown');
|
||||
} else {
|
||||
$seat->assigned_to = null;
|
||||
$seat->asset_id = null;
|
||||
$seat->save();
|
||||
|
||||
$this->reportLines[] = "Checked in license seat for {$license->name} from ".($target?->name ?? $target?->asset_tag ?? 'unknown');
|
||||
|
||||
if ($target) {
|
||||
if ($sendNotifications) {
|
||||
event(new CheckoutableCheckedIn($seat, $target, $admin, self::CHECKIN_NOTE));
|
||||
} else {
|
||||
$seat->logCheckin($target, self::CHECKIN_NOTE);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
// Collect action log file paths before logs may be cleared
|
||||
$actionLogPaths = $deleteFiles
|
||||
? $license->assetlog()->whereNotNull('filename')->get()
|
||||
->map(fn (Actionlog $log) => $log->uploads_file_path())
|
||||
->filter()
|
||||
->values()
|
||||
->toArray()
|
||||
: [];
|
||||
|
||||
if ($deleteType === 'soft') {
|
||||
$license->licenseseats()->delete();
|
||||
$license->delete();
|
||||
$this->reportLines[] = "Soft-deleted license {$license->name}";
|
||||
} elseif ($deleteType === 'hard') {
|
||||
$seatIds = $license->licenseseats()->pluck('id');
|
||||
if ($deleteFiles) {
|
||||
CheckoutAcceptance::where('checkoutable_type', LicenseSeat::class)
|
||||
->whereIn('checkoutable_id', $seatIds)
|
||||
->get()
|
||||
->each(fn (CheckoutAcceptance $ca) => $this->deleteAcceptanceFiles($ca));
|
||||
}
|
||||
CheckoutAcceptance::where('checkoutable_type', LicenseSeat::class)
|
||||
->whereIn('checkoutable_id', $seatIds)
|
||||
->forceDelete();
|
||||
$license->licenseseats()->forceDelete();
|
||||
DB::table('kits_licenses')->where('license_id', $license->id)->delete();
|
||||
$license->forceDelete();
|
||||
$this->reportLines[] = "Hard-deleted license {$license->name}";
|
||||
}
|
||||
|
||||
if ($clearLogs) {
|
||||
$license->assetlog()->forceDelete();
|
||||
}
|
||||
|
||||
if ($deleteFiles) {
|
||||
foreach ($actionLogPaths as $path) {
|
||||
$this->deleteStorageFile('local', $path);
|
||||
}
|
||||
}
|
||||
} elseif ($deleteType !== 'none') {
|
||||
$this->line(" [dry-run] Would {$deleteType}-delete license {$license->name}");
|
||||
$this->reportLines[] = "Would {$deleteType}-delete license {$license->name}";
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
}
|
||||
|
||||
private function processAccessories(
|
||||
array $companyIds,
|
||||
bool $includeNull,
|
||||
bool $sendNotifications,
|
||||
User $admin,
|
||||
bool $dryRun,
|
||||
string $deleteType,
|
||||
bool $clearLogs,
|
||||
bool $deleteFiles,
|
||||
ProgressBar $bar,
|
||||
): void {
|
||||
$accessories = $this->buildCompanyQuery(Accessory::query(), $companyIds, $includeNull)->get();
|
||||
|
||||
foreach ($accessories as $accessory) {
|
||||
$bar->setMessage("Accessories: {$accessory->name}");
|
||||
|
||||
$checkouts = AccessoryCheckout::where('accessory_id', $accessory->id)->get();
|
||||
|
||||
foreach ($checkouts as $checkout) {
|
||||
$target = $checkout->assignedTo;
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" [dry-run] Would check in accessory {$accessory->name} from ".($target?->name ?? 'unknown'));
|
||||
$this->reportLines[] = "Would check in accessory {$accessory->name} from ".($target?->name ?? 'unknown');
|
||||
} else {
|
||||
$checkinAt = now()->format('Y-m-d H:i:s');
|
||||
$checkout->delete();
|
||||
|
||||
$this->reportLines[] = "Checked in accessory {$accessory->name} from ".($target?->name ?? 'unknown');
|
||||
|
||||
if ($target) {
|
||||
if ($sendNotifications) {
|
||||
event(new CheckoutableCheckedIn($accessory, $target, $admin, self::CHECKIN_NOTE, $checkinAt));
|
||||
} else {
|
||||
$accessory->logCheckin($target, self::CHECKIN_NOTE, $checkinAt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
// Collect action log file paths before logs may be cleared
|
||||
$actionLogPaths = $deleteFiles
|
||||
? $accessory->assetlog()->whereNotNull('filename')->get()
|
||||
->map(fn (Actionlog $log) => $log->uploads_file_path())
|
||||
->filter()
|
||||
->values()
|
||||
->toArray()
|
||||
: [];
|
||||
|
||||
if ($clearLogs) {
|
||||
$accessory->assetlog()->forceDelete();
|
||||
}
|
||||
|
||||
if ($deleteType === 'hard') {
|
||||
DB::table('kits_accessories')->where('accessory_id', $accessory->id)->delete();
|
||||
}
|
||||
|
||||
match ($deleteType) {
|
||||
'soft' => $accessory->delete(),
|
||||
'hard' => $accessory->forceDelete(),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($deleteType !== 'none') {
|
||||
$this->reportLines[] = ucfirst($deleteType)."-deleted accessory {$accessory->name}";
|
||||
}
|
||||
|
||||
if ($deleteFiles) {
|
||||
if ($accessory->image) {
|
||||
$this->deleteStorageFile('public', app('accessories_upload_path').$accessory->image);
|
||||
}
|
||||
foreach ($actionLogPaths as $path) {
|
||||
$this->deleteStorageFile('local', $path);
|
||||
}
|
||||
}
|
||||
} elseif ($deleteType !== 'none') {
|
||||
$this->line(" [dry-run] Would {$deleteType}-delete accessory {$accessory->name}");
|
||||
$this->reportLines[] = "Would {$deleteType}-delete accessory {$accessory->name}";
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
}
|
||||
|
||||
private function processComponents(
|
||||
array $companyIds,
|
||||
bool $includeNull,
|
||||
bool $sendNotifications,
|
||||
User $admin,
|
||||
bool $dryRun,
|
||||
string $deleteType,
|
||||
bool $clearLogs,
|
||||
bool $deleteFiles,
|
||||
ProgressBar $bar,
|
||||
): void {
|
||||
$components = $this->buildCompanyQuery(Component::query(), $companyIds, $includeNull)->get();
|
||||
|
||||
foreach ($components as $component) {
|
||||
$bar->setMessage("Components: {$component->name}");
|
||||
|
||||
$assignments = DB::table('components_assets')
|
||||
->where('component_id', $component->id)
|
||||
->get();
|
||||
|
||||
foreach ($assignments as $assignment) {
|
||||
$asset = Asset::find($assignment->asset_id);
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" [dry-run] Would check in component {$component->name} from asset ".($asset?->asset_tag ?? 'unknown'));
|
||||
$this->reportLines[] = "Would check in component {$component->name} from asset ".($asset?->asset_tag ?? 'unknown');
|
||||
} else {
|
||||
$checkinAt = now()->format('Y-m-d H:i:s');
|
||||
DB::table('components_assets')->where('id', $assignment->id)->delete();
|
||||
|
||||
$this->reportLines[] = "Checked in component {$component->name} from asset ".($asset?->asset_tag ?? 'unknown');
|
||||
|
||||
if ($asset) {
|
||||
if ($sendNotifications) {
|
||||
event(new CheckoutableCheckedIn($component, $asset, $admin, self::CHECKIN_NOTE, $checkinAt));
|
||||
} else {
|
||||
$component->logCheckin($asset, self::CHECKIN_NOTE, $checkinAt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
// Collect action log file paths before logs may be cleared
|
||||
$actionLogPaths = $deleteFiles
|
||||
? $component->assetlog()->whereNotNull('filename')->get()
|
||||
->map(fn (Actionlog $log) => $log->uploads_file_path())
|
||||
->filter()
|
||||
->values()
|
||||
->toArray()
|
||||
: [];
|
||||
|
||||
if ($clearLogs) {
|
||||
$component->assetlog()->forceDelete();
|
||||
}
|
||||
|
||||
match ($deleteType) {
|
||||
'soft' => $component->delete(),
|
||||
'hard' => $component->forceDelete(),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($deleteType !== 'none') {
|
||||
$this->reportLines[] = ucfirst($deleteType)."-deleted component {$component->name}";
|
||||
}
|
||||
|
||||
if ($deleteFiles) {
|
||||
if ($component->image) {
|
||||
$this->deleteStorageFile('public', app('components_upload_path').$component->image);
|
||||
}
|
||||
foreach ($actionLogPaths as $path) {
|
||||
$this->deleteStorageFile('local', $path);
|
||||
}
|
||||
}
|
||||
} elseif ($deleteType !== 'none') {
|
||||
$this->line(" [dry-run] Would {$deleteType}-delete component {$component->name}");
|
||||
$this->reportLines[] = "Would {$deleteType}-delete component {$component->name}";
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
}
|
||||
|
||||
private function processConsumables(
|
||||
array $companyIds,
|
||||
bool $includeNull,
|
||||
bool $dryRun,
|
||||
string $deleteType,
|
||||
bool $clearLogs,
|
||||
bool $deleteFiles,
|
||||
ProgressBar $bar,
|
||||
): void {
|
||||
$consumables = $this->buildCompanyQuery(Consumable::query(), $companyIds, $includeNull)->get();
|
||||
|
||||
foreach ($consumables as $consumable) {
|
||||
$bar->setMessage("Consumables: {$consumable->name}");
|
||||
|
||||
if (! $dryRun) {
|
||||
// Collect action log file paths before logs may be cleared
|
||||
$actionLogPaths = $deleteFiles
|
||||
? $consumable->assetlog()->whereNotNull('filename')->get()
|
||||
->map(fn (Actionlog $log) => $log->uploads_file_path())
|
||||
->filter()
|
||||
->values()
|
||||
->toArray()
|
||||
: [];
|
||||
|
||||
if ($clearLogs) {
|
||||
$consumable->assetlog()->forceDelete();
|
||||
}
|
||||
|
||||
if ($deleteType === 'hard') {
|
||||
DB::table('kits_consumables')->where('consumable_id', $consumable->id)->delete();
|
||||
}
|
||||
|
||||
match ($deleteType) {
|
||||
'soft' => $consumable->delete(),
|
||||
'hard' => $consumable->forceDelete(),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($deleteType !== 'none') {
|
||||
$this->reportLines[] = ucfirst($deleteType)."-deleted consumable {$consumable->name}";
|
||||
}
|
||||
|
||||
if ($deleteFiles) {
|
||||
if ($consumable->image) {
|
||||
$this->deleteStorageFile('public', app('consumables_upload_path').$consumable->image);
|
||||
}
|
||||
foreach ($actionLogPaths as $path) {
|
||||
$this->deleteStorageFile('local', $path);
|
||||
}
|
||||
}
|
||||
} elseif ($deleteType !== 'none') {
|
||||
$this->line(" [dry-run] Would {$deleteType}-delete consumable {$consumable->name}");
|
||||
$this->reportLines[] = "Would {$deleteType}-delete consumable {$consumable->name}";
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
}
|
||||
|
||||
private function processUsers(
|
||||
array $companyIds,
|
||||
bool $includeNull,
|
||||
User $admin,
|
||||
bool $skipAdminUser,
|
||||
bool $dryRun,
|
||||
string $deleteType,
|
||||
bool $clearLogs,
|
||||
bool $deleteFiles,
|
||||
ProgressBar $bar,
|
||||
): void {
|
||||
$users = $this->buildUserQuery($companyIds, $includeNull)->get();
|
||||
|
||||
foreach ($users as $user) {
|
||||
if ($skipAdminUser && $user->id === $admin->id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$bar->setMessage("Users: {$user->username}");
|
||||
|
||||
// If real companies were selected, check whether this user also belongs to
|
||||
// companies outside the selected scope. If so, only remove the selected-company
|
||||
// associations and skip full deletion to avoid orphaning them from their other companies.
|
||||
if (! empty($companyIds)) {
|
||||
$allUserCompanyIds = array_unique(array_filter(array_merge(
|
||||
$user->companies()->pluck('companies.id')->toArray(),
|
||||
$user->company_id ? [$user->company_id] : [],
|
||||
)));
|
||||
$outsideCompanyIds = array_values(array_diff($allUserCompanyIds, $companyIds));
|
||||
|
||||
if (! empty($outsideCompanyIds)) {
|
||||
$outsideNames = Company::whereIn('id', $outsideCompanyIds)->pluck('name')->implode(', ');
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" [dry-run] Would partially disassociate user {$user->username} (also belongs to: {$outsideNames})");
|
||||
$this->reportLines[] = "Would partially disassociate user {$user->username} — also belongs to: {$outsideNames}";
|
||||
} else {
|
||||
$user->companies()->detach($companyIds);
|
||||
warning(" Skipped full deletion of {$user->username}: they also belong to {$outsideNames}. Removed selected company associations only.");
|
||||
$this->reportLines[] = "Partially disassociated user {$user->username} — also belongs to: {$outsideNames}. Full deletion skipped.";
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
// Collect file paths and acceptance records before deleting pivot data
|
||||
$acceptancesToDelete = $deleteFiles
|
||||
? CheckoutAcceptance::where('assigned_to_id', $user->id)->get()
|
||||
: collect();
|
||||
|
||||
$actionLogPaths = $deleteFiles
|
||||
? Actionlog::where('item_type', User::class)
|
||||
->where('item_id', $user->id)
|
||||
->where('action_type', 'uploaded')
|
||||
->whereNotNull('filename')
|
||||
->get()
|
||||
->map(fn (Actionlog $log) => $log->uploads_file_path())
|
||||
->filter()
|
||||
->values()
|
||||
->toArray()
|
||||
: [];
|
||||
|
||||
// Clear pivot/assignment data that will orphan on deletion
|
||||
LicenseSeat::where('assigned_to', $user->id)->update(['assigned_to' => null]);
|
||||
AccessoryCheckout::where('assigned_to', $user->id)
|
||||
->where('assigned_type', User::class)
|
||||
->delete();
|
||||
DB::table('consumables_users')->where('assigned_to', $user->id)->delete();
|
||||
CheckoutAcceptance::where('assigned_to_id', $user->id)->forceDelete();
|
||||
if ($deleteType === 'hard') {
|
||||
DB::table('company_user')->where('user_id', $user->id)->delete();
|
||||
}
|
||||
|
||||
if ($clearLogs) {
|
||||
$user->userlog()->forceDelete();
|
||||
}
|
||||
|
||||
match ($deleteType) {
|
||||
'soft' => $user->delete(),
|
||||
'hard' => $user->forceDelete(),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($deleteType !== 'none') {
|
||||
$this->reportLines[] = ucfirst($deleteType)."-deleted user {$user->username}";
|
||||
}
|
||||
|
||||
if ($deleteFiles) {
|
||||
if ($user->avatar) {
|
||||
$this->deleteStorageFile('public', app('users_upload_path').$user->avatar);
|
||||
}
|
||||
$acceptancesToDelete->each(fn (CheckoutAcceptance $ca) => $this->deleteAcceptanceFiles($ca));
|
||||
foreach ($actionLogPaths as $path) {
|
||||
$this->deleteStorageFile('local', $path);
|
||||
}
|
||||
}
|
||||
} elseif ($deleteType !== 'none') {
|
||||
$this->line(" [dry-run] Would {$deleteType}-delete user {$user->username}");
|
||||
$this->reportLines[] = "Would {$deleteType}-delete user {$user->username}";
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteStorageFile(string $disk, ?string $path): void
|
||||
{
|
||||
if (! $path) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$storage = $disk === 'public'
|
||||
? Storage::disk('public')
|
||||
: Storage::disk(config('filesystems.default'));
|
||||
if ($storage->exists($path)) {
|
||||
$storage->delete($path);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::warning("Could not delete file {$path}: ".$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteAcceptanceFiles(CheckoutAcceptance $acceptance): void
|
||||
{
|
||||
if ($acceptance->signature_filename) {
|
||||
$this->deleteStorageFile('local', 'private_uploads/signatures/'.$acceptance->signature_filename);
|
||||
}
|
||||
if ($acceptance->stored_eula_file) {
|
||||
$this->deleteStorageFile('local', 'private_uploads/eula-pdfs/'.$acceptance->stored_eula_file);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ use Illuminate\Validation\ValidationException;
|
||||
use Intervention\Image\Exception\NotSupportedException;
|
||||
use JsonException;
|
||||
use League\OAuth2\Server\Exception\OAuthServerException;
|
||||
use Livewire\Exceptions\ComponentNotFoundException;
|
||||
use Livewire\Exceptions\PublicPropertyNotFoundException;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Throwable;
|
||||
@@ -43,6 +44,7 @@ class Handler extends ExceptionHandler
|
||||
SCIMException::class, // these generally don't need to be reported
|
||||
InvalidFormatException::class,
|
||||
PublicPropertyNotFoundException::class,
|
||||
ComponentNotFoundException::class,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -78,6 +80,12 @@ class Handler extends ExceptionHandler
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
// A request named a Livewire component that doesn't exist in this app (e.g. bots probing
|
||||
// for Filament endpoints). Return 404 so it doesn't surface as a 500.
|
||||
if ($e instanceof ComponentNotFoundException) {
|
||||
return response()->json(['message' => 'Component not found.'], 404);
|
||||
}
|
||||
|
||||
// CSRF token mismatch error
|
||||
if ($e instanceof TokenMismatchException) {
|
||||
return redirect()->back()->with('error', trans('general.token_expired'));
|
||||
|
||||
+87
-9
@@ -14,6 +14,7 @@ use App\Models\License;
|
||||
use App\Models\Location;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Statuslabel;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@@ -663,7 +664,7 @@ class Helper
|
||||
*/
|
||||
public static function depreciationList()
|
||||
{
|
||||
$depreciation_list = ['' => 'Do Not Depreciate'] + Depreciation::orderBy('name', 'asc')
|
||||
$depreciation_list = ['' => trans('admin/licenses/form.no_depreciation')] + Depreciation::orderBy('name', 'asc')
|
||||
->pluck('name', 'id')->toArray();
|
||||
|
||||
return $depreciation_list;
|
||||
@@ -1268,6 +1269,7 @@ class Helper
|
||||
$allowedExtensionMap = [
|
||||
// Images
|
||||
'jpg' => 'far fa-image',
|
||||
'jfif' => 'far fa-image',
|
||||
'jpeg' => 'far fa-image',
|
||||
'gif' => 'far fa-image',
|
||||
'png' => 'far fa-image',
|
||||
@@ -1596,7 +1598,17 @@ class Helper
|
||||
$checkout_to_type = session('checkout_to_type') ?? null;
|
||||
$checkedInFrom = session('checkedInFrom');
|
||||
$other_redirect = session('other_redirect');
|
||||
$backUrl = session()->pull('url.intended', 'home');
|
||||
$backUrl = str_replace(["\r", "\n"], '', session()->pull('url.intended', 'home'));
|
||||
|
||||
// Reject any stored back-URL that points off-site. redirect()->intended() performs
|
||||
// no host validation, and url.intended can be written from the SAML RelayState POST
|
||||
// parameter (SamlController), which an attacker-controlled IdP could set to an
|
||||
// off-site URL.
|
||||
$backHost = parse_url($backUrl, PHP_URL_HOST);
|
||||
$appHost = parse_url(config('app.url'), PHP_URL_HOST);
|
||||
if ($backHost && $backHost !== $appHost) {
|
||||
$backUrl = route('home');
|
||||
}
|
||||
|
||||
// return to previous page
|
||||
if ($redirect_option == 'back') {
|
||||
@@ -1689,6 +1701,8 @@ class Helper
|
||||
return [];
|
||||
}
|
||||
|
||||
$floater = (bool) Setting::getSettings()->null_company_is_floater;
|
||||
|
||||
foreach ($locations as $location) {
|
||||
// in case of an update of a single location, use the newly requested company_id
|
||||
if ($new_company_id) {
|
||||
@@ -1723,26 +1737,51 @@ class Helper
|
||||
foreach ($keywords as $keyword) {
|
||||
if ($relation == 'many') {
|
||||
$items = $location->{$keyword}->all();
|
||||
// assignedAccessories returns AccessoryCheckout records (no company_id);
|
||||
// resolve each to its parent Accessory so the comparison is valid.
|
||||
if ($keyword === 'assignedAccessories') {
|
||||
$items = collect($items)->map(fn ($checkout) => $checkout->accessory)->filter()->values()->all();
|
||||
}
|
||||
} else {
|
||||
$items = collect([])->push($location->$keyword);
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
foreach ($items as $item) {
|
||||
if (! $item) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($item && $item->company_id != $location_company) {
|
||||
// Users belong to companies via the many-to-many pivot (company_user).
|
||||
// canReceiveFromCompany() returns true only when the user's pivot
|
||||
// contains the location's company, so !canReceiveFromCompany() is
|
||||
// the correct mismatch signal.
|
||||
if ($item instanceof User) {
|
||||
$isMismatch = ! $item->canReceiveFromCompany((int) $location_company);
|
||||
} elseif ($item->company_id == $location_company) {
|
||||
$isMismatch = false;
|
||||
} elseif (is_null($item->company_id) || is_null($location_company)) {
|
||||
$isMismatch = ! $floater;
|
||||
} else {
|
||||
$isMismatch = true;
|
||||
}
|
||||
|
||||
if ($isMismatch) {
|
||||
if ($item instanceof User) {
|
||||
$itemCompanyIds = $item->companies->pluck('id')->implode(', ');
|
||||
$itemCompanyNames = $item->companies->pluck('name')->implode(', ');
|
||||
} else {
|
||||
$itemCompanyIds = $item->company_id ?? null;
|
||||
$itemCompanyNames = $item->company->name ?? null;
|
||||
}
|
||||
|
||||
$mismatched[] = [
|
||||
class_basename(get_class($item)),
|
||||
$item->id,
|
||||
$item->name ?? $item->asset_tag ?? $item->serial ?? $item->username,
|
||||
$item->assigned_type ? str_replace('App\\Models\\', '', $item->assigned_type) : null,
|
||||
$item->company_id ?? null,
|
||||
$item->company->name ?? null,
|
||||
// $item->defaultLoc->id ?? null,
|
||||
// $item->defaultLoc->name ?? null,
|
||||
// $item->defaultLoc->company->id ?? null,
|
||||
// $item->defaultLoc->company->name ?? null,
|
||||
$itemCompanyIds,
|
||||
$itemCompanyNames,
|
||||
$item->location->name ?? null,
|
||||
$item->location->company->name ?? null,
|
||||
$location_company ?? null,
|
||||
@@ -1856,4 +1895,43 @@ class Helper
|
||||
return 'App\\Models\\'.ucwords($model);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a markdown-textarea value as HTML.
|
||||
*
|
||||
* Soft line breaks (single newlines) are rendered as <br> so that line
|
||||
* breaks typed in the textarea are preserved in the output.
|
||||
*
|
||||
* When $inline is true, block-level elements are suppressed and hard
|
||||
* breaks are pre-processed manually — used for the encrypted reveal span
|
||||
* where block HTML cannot be placed inside a font-size-toggled <span>.
|
||||
*/
|
||||
public static function renderMarkdown(?string $text, bool $inline = false): string
|
||||
{
|
||||
if (empty($text)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($inline) {
|
||||
// Convert newlines to CommonMark hard breaks for inline rendering
|
||||
$text = preg_replace('/(?<! {2})\n/', " \n", $text);
|
||||
|
||||
return Str::inlineMarkdown($text, ['html_input' => 'escape', 'allow_unsafe_links' => false]);
|
||||
}
|
||||
|
||||
$html = trim(Str::markdown($text, [
|
||||
'html_input' => 'escape',
|
||||
'allow_unsafe_links' => false,
|
||||
'renderer' => ['soft_break' => "<br>\n"],
|
||||
]));
|
||||
|
||||
// If the entire output is a single <p> block, unwrap it so the content
|
||||
// renders inline-ish without the <p> adding unwanted top spacing in the
|
||||
// compact detail-view layout.
|
||||
if (str_starts_with($html, '<p>') && str_ends_with($html, '</p>') && substr_count($html, '<p>') === 1) {
|
||||
return substr($html, 3, -4);
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,20 @@ class AccessoryCheckoutController extends Controller
|
||||
$target = $this->determineCheckoutTarget();
|
||||
session()->put(['checkout_to_type' => $target]);
|
||||
|
||||
if (! $accessory->canCheckoutTo($target)) {
|
||||
$targetType = match (class_basename($target)) {
|
||||
'User' => trans('general.user'),
|
||||
'Location' => trans('general.location'),
|
||||
default => trans('general.asset'),
|
||||
};
|
||||
|
||||
return redirect()->back()->with('error', trans('general.error_checkout_company_mismatch', [
|
||||
'item' => trans('general.accessory').' "'.$accessory->name.'"',
|
||||
'item_company' => $accessory->company?->name ?? trans('general.unassigned'),
|
||||
'target' => $targetType.' "'.($target->name ?? $target->username ?? $target->id).'"',
|
||||
]));
|
||||
}
|
||||
|
||||
$accessory->checkout_qty = $request->input('checkout_qty', 1);
|
||||
|
||||
for ($i = 0; $i < $accessory->checkout_qty; $i++) {
|
||||
|
||||
@@ -107,7 +107,7 @@ class AccessoriesController extends Controller
|
||||
}
|
||||
|
||||
// Make sure the offset and limit are actually integers and do not exceed system limits
|
||||
$offset = ($request->input('offset') > $accessories->count()) ? $accessories->count() : abs($request->input('offset'));
|
||||
$offset = ($request->input('offset') > $accessories->count()) ? $accessories->count() : app('api_offset_value');
|
||||
$limit = app('api_limit_value');
|
||||
|
||||
$order = $request->input('order') === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
@@ -133,7 +133,8 @@ class AssetModelsController extends Controller
|
||||
}
|
||||
|
||||
// Make sure the offset and limit are actually integers and do not exceed system limits
|
||||
$offset = ($request->input('offset') > $assetmodels->count()) ? $assetmodels->count() : abs($request->input('offset'));
|
||||
$total = $assetmodels->count();
|
||||
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
|
||||
$limit = app('api_limit_value');
|
||||
|
||||
$order = $request->input('order') === 'asc' ? 'asc' : 'desc';
|
||||
@@ -157,7 +158,6 @@ class AssetModelsController extends Controller
|
||||
break;
|
||||
}
|
||||
|
||||
$total = $assetmodels->count();
|
||||
$assetmodels = $assetmodels->skip($offset)->take($limit)->get();
|
||||
|
||||
return (new AssetModelsTransformer)->transformAssetModels($assetmodels, $total);
|
||||
|
||||
@@ -609,6 +609,11 @@ class AssetsController extends Controller
|
||||
])->with('model', 'status', 'assignedTo')
|
||||
->NotArchived();
|
||||
|
||||
// When FMCS is enabled, automatically scope to companies the acting user belongs to.
|
||||
// scopeCompanyables is a no-op for superusers and when FMCS is disabled.
|
||||
$assets = Company::scopeCompanyables($assets);
|
||||
|
||||
// Allow further narrowing to a specific company passed via data-company-id on the select.
|
||||
if ((Setting::getSettings()->full_multiple_companies_support == '1') && $request->filled('companyId')) {
|
||||
$companyIds = array_values(array_filter(array_map('intval', explode(',', $request->input('companyId')))));
|
||||
if (! empty($companyIds)) {
|
||||
@@ -616,6 +621,10 @@ class AssetsController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->filled('excludeId')) {
|
||||
$assets->where('assets.id', '!=', (int) $request->input('excludeId'));
|
||||
}
|
||||
|
||||
if ($request->filled('statusType') && $request->input('statusType') === 'RTD') {
|
||||
$assets = $assets->RTD();
|
||||
}
|
||||
@@ -904,11 +913,7 @@ class AssetsController extends Controller
|
||||
|
||||
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)
|
||||
) {
|
||||
if (! $asset->canCheckoutTo($target)) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
|
||||
}
|
||||
|
||||
@@ -1062,13 +1067,8 @@ class AssetsController extends Controller
|
||||
}
|
||||
|
||||
// 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')));
|
||||
if ($mismatch = $this->checkoutCompanyMismatchResponse($asset, $target)) {
|
||||
return $mismatch;
|
||||
}
|
||||
|
||||
$checkout_at = request('checkout_at', date('Y-m-d H:i:s'));
|
||||
@@ -1126,7 +1126,9 @@ class AssetsController extends Controller
|
||||
$asset->assignedTo()->disassociate($asset);
|
||||
$asset->accepted = null;
|
||||
|
||||
if ($request->has('name')) {
|
||||
if ($request->input('clear_name') == '1') {
|
||||
$asset->name = null;
|
||||
} elseif ($request->has('name')) {
|
||||
$asset->name = $request->input('name');
|
||||
}
|
||||
|
||||
@@ -1269,6 +1271,10 @@ class AssetsController extends Controller
|
||||
|
||||
$asset->last_audit_date = date('Y-m-d H:i:s');
|
||||
|
||||
if ($request->input('clear_name') == '1') {
|
||||
$asset->name = null;
|
||||
}
|
||||
|
||||
// Set up the payload for re-display in the API response
|
||||
$payload = [
|
||||
'id' => $asset->id,
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Http\Requests\ImageUploadRequest;
|
||||
use App\Http\Transformers\CompaniesTransformer;
|
||||
use App\Http\Transformers\SelectlistTransformer;
|
||||
use App\Models\Company;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@@ -206,6 +207,16 @@ class CompaniesController extends Controller
|
||||
'companies.tag_color',
|
||||
]);
|
||||
|
||||
// When FMCS is enabled and the user is not a superuser, restrict the list to
|
||||
// companies they belong to (primary company_id + pivot companies). This lets
|
||||
// non-superusers select a company from their own set when creating assets, etc.
|
||||
if (Setting::getSettings()->full_multiple_companies_support == '1' && ! auth()->user()->isSuperUser()) {
|
||||
$userCompanyIds = auth()->user()->allCompanies()->pluck('id');
|
||||
if ($userCompanyIds->isNotEmpty()) {
|
||||
$companies->whereIn('companies.id', $userCompanyIds);
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$companies = $companies->where('companies.name', 'LIKE', '%'.$request->input('search').'%');
|
||||
}
|
||||
|
||||
@@ -67,7 +67,18 @@ class LocationsController extends Controller
|
||||
'notes',
|
||||
];
|
||||
|
||||
$locations = Location::with('parent', 'manager', 'children')->select([
|
||||
$locations = Location::with([
|
||||
'parent',
|
||||
'children',
|
||||
'manager' => fn ($q) => $q->withCount([
|
||||
'assets as assets_count',
|
||||
'accessories as accessories_count',
|
||||
'licenses as licenses_count',
|
||||
'consumables as consumables_count',
|
||||
'managesUsers as manages_users_count',
|
||||
'managedLocations as manages_locations_count',
|
||||
]),
|
||||
])->select([
|
||||
'locations.id',
|
||||
'locations.name',
|
||||
'locations.address',
|
||||
@@ -103,7 +114,9 @@ class LocationsController extends Controller
|
||||
->withCount('components as components_count')
|
||||
->with('adminuser');
|
||||
|
||||
// Only scope locations if the setting is enabled
|
||||
// scope_locations_fmcs is required for location-level company scoping (locations may not
|
||||
// have company_id assigned unless the compatibility check has been completed in Settings).
|
||||
// Without it, locations are visible to all authenticated users regardless of FMCS state.
|
||||
if (Setting::getSettings()->scope_locations_fmcs) {
|
||||
$locations = Company::scopeCompanyables($locations);
|
||||
}
|
||||
@@ -157,8 +170,6 @@ class LocationsController extends Controller
|
||||
$locations->where('tag_color', '=', $request->input('locations.tag_color'));
|
||||
}
|
||||
|
||||
// Make sure the offset and limit are actually integers and do not exceed system limits
|
||||
$offset = ($request->input('offset') > $locations->count()) ? $locations->count() : app('api_offset_value');
|
||||
$limit = app('api_limit_value');
|
||||
|
||||
$order = $request->input('order') === 'asc' ? 'asc' : 'desc';
|
||||
@@ -180,6 +191,7 @@ class LocationsController extends Controller
|
||||
}
|
||||
|
||||
$total = $locations->count();
|
||||
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
|
||||
$locations = $locations->skip($offset)->take($limit)->get();
|
||||
|
||||
return (new LocationsTransformer)->transformLocations($locations, $total);
|
||||
@@ -199,12 +211,19 @@ class LocationsController extends Controller
|
||||
$location->fill($request->all());
|
||||
$location = $request->handleImages($location);
|
||||
|
||||
// Only scope location if the setting is enabled
|
||||
if (Setting::getSettings()->scope_locations_fmcs) {
|
||||
$location->company_id = Company::getIdForCurrentUser($request->input('company_id'));
|
||||
// check if parent is set and has a different company
|
||||
if ($location->parent_id && Location::find($location->parent_id)->company_id != $location->company_id) {
|
||||
response()->json(Helper::formatStandardApiResponse('error', null, 'different company than parent'));
|
||||
}
|
||||
|
||||
// Parent company check applies whenever FMCS is on, independent of scope_locations_fmcs.
|
||||
if (Setting::getSettings()->full_multiple_companies_support) {
|
||||
$parent = $location->parent_id ? Location::find($location->parent_id) : null;
|
||||
if ($parent && $parent->company_id != $location->company_id) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_location_parent_company', [
|
||||
'parent' => $parent->name,
|
||||
'parent_company' => $parent->company?->name ?? trans('general.unassigned'),
|
||||
'location_company' => $location->company?->name ?? trans('general.unassigned'),
|
||||
])));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,7 +246,19 @@ class LocationsController extends Controller
|
||||
public function show($id): JsonResponse|array
|
||||
{
|
||||
$this->authorize('view', Location::class);
|
||||
$location = Location::with('parent', 'manager', 'children', 'company')
|
||||
$location = Location::with([
|
||||
'parent',
|
||||
'children',
|
||||
'company',
|
||||
'manager' => fn ($q) => $q->withCount([
|
||||
'assets as assets_count',
|
||||
'accessories as accessories_count',
|
||||
'licenses as licenses_count',
|
||||
'consumables as consumables_count',
|
||||
'managesUsers as manages_users_count',
|
||||
'managedLocations as manages_locations_count',
|
||||
]),
|
||||
])
|
||||
->select([
|
||||
'locations.id',
|
||||
'locations.name',
|
||||
@@ -279,18 +310,36 @@ class LocationsController extends Controller
|
||||
$location = $request->handleImages($location);
|
||||
|
||||
if ($request->filled('company_id')) {
|
||||
// Only scope location if the setting is enabled
|
||||
if (Setting::getSettings()->scope_locations_fmcs) {
|
||||
$location->company_id = Company::getIdForCurrentUser($request->input('company_id'));
|
||||
// check if there are related objects with different company
|
||||
if (Helper::test_locations_fmcs(false, $id, $location->company_id)) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, 'error scoped locations'));
|
||||
if ($mismatched = Helper::test_locations_fmcs(false, $id, $location->company_id)) {
|
||||
$first = $mismatched[0];
|
||||
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_location_scoped_items', [
|
||||
'item_type' => trans('general.'.strtolower($first[0])),
|
||||
'item_name' => $first[2],
|
||||
'item_company' => $first[5] ?? trans('general.unassigned'),
|
||||
])));
|
||||
}
|
||||
} else {
|
||||
$location->company_id = $request->input('company_id');
|
||||
}
|
||||
}
|
||||
|
||||
// Parent company check applies whenever FMCS is on, independent of scope_locations_fmcs.
|
||||
// Runs outside the company_id gate so a parent_id-only update is also validated.
|
||||
if (Setting::getSettings()->full_multiple_companies_support) {
|
||||
$parent = $location->parent_id ? Location::find($location->parent_id) : null;
|
||||
if ($parent && $parent->company_id != $location->company_id) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_location_parent_company', [
|
||||
'parent' => $parent->name,
|
||||
'parent_company' => $parent->company?->name ?? trans('general.unassigned'),
|
||||
'location_company' => $location->company?->name ?? trans('general.unassigned'),
|
||||
])));
|
||||
}
|
||||
}
|
||||
|
||||
if ($location->isValid()) {
|
||||
|
||||
$location->save();
|
||||
@@ -422,15 +471,6 @@ class LocationsController extends Controller
|
||||
'locations.tag_color',
|
||||
]);
|
||||
|
||||
// Only scope locations if the setting is enabled
|
||||
if (Setting::getSettings()->scope_locations_fmcs) {
|
||||
$locations = Company::scopeCompanyables($locations);
|
||||
}
|
||||
|
||||
if ((Setting::getSettings()->full_multiple_companies_support == '1') && $request->filled('companyId')) {
|
||||
$locations->where('locations.company_id', $request->input('companyId'));
|
||||
}
|
||||
|
||||
$page = 1;
|
||||
if ($request->filled('page')) {
|
||||
$page = $request->input('page');
|
||||
@@ -440,6 +480,10 @@ class LocationsController extends Controller
|
||||
$locations = $locations->where('locations.name', 'LIKE', '%'.$request->input('search').'%');
|
||||
}
|
||||
|
||||
if ($request->filled('excludeId')) {
|
||||
$locations->where('locations.id', '!=', (int) $request->input('excludeId'));
|
||||
}
|
||||
|
||||
$locations = $locations->orderBy('name', 'ASC')->get();
|
||||
|
||||
$locations_with_children = [];
|
||||
|
||||
@@ -30,7 +30,7 @@ class MaintenanceTypesController extends Controller
|
||||
$types->where('name', '=', $request->input('name'));
|
||||
}
|
||||
|
||||
$offset = ($request->input('offset') > $types->count()) ? $types->count() : abs($request->input('offset'));
|
||||
$offset = ($request->input('offset') > $types->count()) ? $types->count() : app('api_offset_value');
|
||||
$limit = app('api_limit_value');
|
||||
$order = $request->input('order') === 'asc' ? 'asc' : 'desc';
|
||||
$sort = in_array($request->input('sort'), ['id', 'name', 'created_at', 'updated_at']) ? $request->input('sort') : 'name';
|
||||
|
||||
@@ -102,7 +102,7 @@ class MaintenancesController extends Controller
|
||||
}
|
||||
|
||||
// Make sure the offset and limit are actually integers and do not exceed system limits
|
||||
$offset = ($request->input('offset') > $maintenances->count()) ? $maintenances->count() : abs($request->input('offset'));
|
||||
$offset = ($request->input('offset') > $maintenances->count()) ? $maintenances->count() : app('api_offset_value');
|
||||
$limit = app('api_limit_value');
|
||||
|
||||
$allowed_columns = [
|
||||
@@ -269,17 +269,33 @@ class MaintenancesController extends Controller
|
||||
|
||||
if ($maintenance = Maintenance::with('asset')->find($id)) {
|
||||
|
||||
// Can this user manage this asset?
|
||||
if (! Company::isCurrentUserHasAccess($maintenance->asset)) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.action_permission_denied', ['item_type' => trans('admin/maintenances/general.maintenance'), 'id' => $id, 'action' => trans('general.edit')])));
|
||||
}
|
||||
|
||||
// The asset this miantenance is attached to is not valid or has been deleted
|
||||
// The asset this maintenance is attached to is not valid or has been deleted
|
||||
if (! $maintenance->asset) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.item_not_found', ['item_type' => trans('general.asset'), 'id' => $id])));
|
||||
}
|
||||
|
||||
$maintenance->fill($request->all());
|
||||
// Can this user manage the existing asset?
|
||||
if (! Company::isCurrentUserHasAccess($maintenance->asset)) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.action_permission_denied', ['item_type' => trans('admin/maintenances/general.maintenance'), 'id' => $id, 'action' => trans('general.edit')])));
|
||||
}
|
||||
|
||||
// If the request changes asset_id, verify the new asset is accessible
|
||||
if ($request->filled('asset_id') && (int) $request->input('asset_id') !== $maintenance->asset_id) {
|
||||
$newAsset = Asset::find($request->input('asset_id'));
|
||||
|
||||
if (! $newAsset) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.item_not_found', ['item_type' => trans('general.asset'), 'id' => $request->input('asset_id')])));
|
||||
}
|
||||
|
||||
if (! Company::isCurrentUserHasAccess($newAsset)) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.action_permission_denied', ['item_type' => trans('general.asset'), 'id' => $request->input('asset_id'), 'action' => trans('general.edit')])), 403);
|
||||
}
|
||||
|
||||
$maintenance->fill($request->except('asset_id'));
|
||||
$maintenance->asset_id = $newAsset->id;
|
||||
} else {
|
||||
$maintenance->fill($request->except('asset_id'));
|
||||
}
|
||||
|
||||
if ($maintenance->save()) {
|
||||
return response()->json(Helper::formatStandardApiResponse('success', $maintenance, trans('admin/maintenances/message.edit.success')));
|
||||
|
||||
@@ -6,6 +6,9 @@ use App\Helpers\Helper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Transformers\PredefinedKitsTransformer;
|
||||
use App\Http\Transformers\SelectlistTransformer;
|
||||
use App\Models\Accessory;
|
||||
use App\Models\Consumable;
|
||||
use App\Models\License;
|
||||
use App\Models\PredefinedKit;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -183,6 +186,9 @@ class PredefinedKitsController extends Controller
|
||||
}
|
||||
|
||||
$license_id = $request->input('license');
|
||||
$license = License::findOrFail($license_id);
|
||||
$this->authorize('view', $license);
|
||||
|
||||
$relation = $kit->licenses();
|
||||
if ($relation->find($license_id)) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, ['license' => trans('admin/kits/general.license_error')]));
|
||||
@@ -329,6 +335,9 @@ class PredefinedKitsController extends Controller
|
||||
}
|
||||
|
||||
$consumable_id = $request->input('consumable');
|
||||
$consumable = Consumable::findOrFail($consumable_id);
|
||||
$this->authorize('view', $consumable);
|
||||
|
||||
$relation = $kit->consumables();
|
||||
if ($relation->find($consumable_id)) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, ['consumable' => trans('admin/kits/general.consumable_error')]));
|
||||
@@ -402,6 +411,9 @@ class PredefinedKitsController extends Controller
|
||||
}
|
||||
|
||||
$accessory_id = $request->input('accessory');
|
||||
$accessory = Accessory::findOrFail($accessory_id);
|
||||
$this->authorize('view', $accessory);
|
||||
|
||||
$relation = $kit->accessories();
|
||||
if ($relation->find($accessory_id)) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, ['accessory' => trans('admin/kits/general.accessory_error')]));
|
||||
|
||||
@@ -52,7 +52,7 @@ class UploadedFilesController extends Controller
|
||||
$uploads = self::$map_object_type[$object_type]::withTrashed()->find($id)->uploads()
|
||||
->with('adminuser');
|
||||
|
||||
$offset = ($request->input('offset') > $uploads->count()) ? $uploads->count() : abs($request->input('offset'));
|
||||
$offset = ($request->input('offset') > $uploads->count()) ? $uploads->count() : app('api_offset_value');
|
||||
$limit = app('api_limit_value');
|
||||
$order = $request->input('order') === 'asc' ? 'asc' : 'desc';
|
||||
$sort = in_array($request->input('sort'), $allowed_columns) ? $request->input('sort') : 'created_at';
|
||||
|
||||
@@ -396,13 +396,22 @@ class UsersController extends Controller
|
||||
]
|
||||
)->where('show_in_list', '=', '1');
|
||||
|
||||
// When FMCS is enabled, automatically scope to companies the acting user belongs to.
|
||||
// scopeCompanyables is a no-op for superusers and when FMCS is disabled.
|
||||
$users = Company::scopeCompanyables($users, 'company_id', 'users');
|
||||
|
||||
// Allow further narrowing to a specific company passed via data-company-ids on the select.
|
||||
if ((Setting::getSettings()->full_multiple_companies_support == '1') && $request->filled('companyId')) {
|
||||
$companyIds = array_values(array_filter(array_map('intval', explode(',', $request->input('companyId')))));
|
||||
if (! empty($companyIds)) {
|
||||
$users->whereHas('companies', fn ($q) => $q->whereIn('companies.id', $companyIds));
|
||||
$users = Company::scopeUsersByCompanyIds($users, $companyIds);
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->filled('excludeId')) {
|
||||
$users->where('users.id', '!=', (int) $request->input('excludeId'));
|
||||
}
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$users = $users->where(function ($query) use ($request) {
|
||||
$query->SimpleNameSearch($request->input('search'))
|
||||
@@ -617,12 +626,16 @@ class UsersController extends Controller
|
||||
$user->groups()->sync($request->input('groups'));
|
||||
}
|
||||
|
||||
// Sync company memberships when company_ids[] or company_id is provided
|
||||
if ($request->has('company_ids') || $request->filled('company_id')) {
|
||||
$companyIds = array_filter(
|
||||
(array) ($request->input('company_ids') ?? ($request->filled('company_id') ? [$request->input('company_id')] : []))
|
||||
);
|
||||
$user->syncCompaniesWithLogging(Company::getIdsForCurrentUser(array_map('intval', $companyIds)));
|
||||
// company_ids (new format) = full replacement sync.
|
||||
// Legacy company_id = add without removing other associations.
|
||||
if ($request->has('company_ids')) {
|
||||
$companyIds = array_filter(array_map('intval', (array) $request->input('company_ids')));
|
||||
$user->syncCompaniesWithLogging(Company::getIdsForCurrentUser($companyIds));
|
||||
} elseif ($request->filled('company_id')) {
|
||||
$filtered = Company::getIdsForCurrentUser([(int) $request->input('company_id')]);
|
||||
if (! empty($filtered)) {
|
||||
$user->companies()->syncWithoutDetaching($filtered);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json(Helper::formatStandardApiResponse('success', (new UsersTransformer)->transformUser($user), trans('admin/users/message.success.update')));
|
||||
|
||||
@@ -9,7 +9,6 @@ use App\Http\Requests\AssetCheckoutRequest;
|
||||
use App\Http\Traits\CheckInOutTrait;
|
||||
use App\Models\Asset;
|
||||
use App\Models\CheckoutAcceptance;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
@@ -119,13 +118,18 @@ class AssetCheckoutController extends Controller
|
||||
// Add any custom fields that should be included in the checkout
|
||||
$asset->customFieldsForCheckinCheckout('display_checkout');
|
||||
|
||||
$settings = Setting::getSettings();
|
||||
if (! $asset->canCheckoutTo($target)) {
|
||||
$targetType = match (class_basename($target)) {
|
||||
'User' => trans('general.user'),
|
||||
'Location' => trans('general.location'),
|
||||
default => trans('general.asset'),
|
||||
};
|
||||
|
||||
// We have to check whether $target->company_id is null here since locations don't have a company yet
|
||||
if (($settings->full_multiple_companies_support) && ((! is_null($target->company_id)) && (! is_null($asset->company_id)))) {
|
||||
if ($target->company_id != $asset->company_id) {
|
||||
return redirect()->route('hardware.checkout.create', $asset)->with('error', trans('general.error_user_company'));
|
||||
}
|
||||
return redirect()->route('hardware.checkout.create', $asset)->with('error', trans('general.error_checkout_company_mismatch', [
|
||||
'item' => trans('general.asset').' "'.$asset->display_name.'"',
|
||||
'item_company' => $asset->company?->name ?? trans('general.unassigned'),
|
||||
'target' => $targetType.' "'.($target->name ?? $target->username ?? $target->id).'"',
|
||||
]));
|
||||
}
|
||||
|
||||
session()->put([
|
||||
|
||||
@@ -358,7 +358,7 @@ class AssetsController extends Controller
|
||||
|
||||
$qr_code = (object) [
|
||||
'display' => $settings->qr_code == '1',
|
||||
'url' => route('qr_code/hardware', $asset),
|
||||
'url' => route('qr_code/common', ['object_type' => 'hardware', 'id' => $asset->id]),
|
||||
];
|
||||
|
||||
$total_maintenance_cost = $asset->maintenances?->sum('cost');
|
||||
@@ -443,7 +443,7 @@ class AssetsController extends Controller
|
||||
|
||||
if ($request->filled('image_delete')) {
|
||||
try {
|
||||
unlink(public_path().'/uploads/assets/'.$asset->image);
|
||||
unlink(public_path().'/uploads/assets/'.basename($asset->image));
|
||||
$asset->image = '';
|
||||
} catch (\Exception $e) {
|
||||
Log::info($e);
|
||||
@@ -549,7 +549,7 @@ class AssetsController extends Controller
|
||||
|
||||
if ($asset->image) {
|
||||
try {
|
||||
Storage::disk('public')->delete('assets'.'/'.$asset->image);
|
||||
Storage::disk('public')->delete('assets/'.basename($asset->image));
|
||||
} catch (\Exception $e) {
|
||||
Log::debug($e);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ use App\Models\CustomField;
|
||||
use App\Models\LicenseSeat;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Statuslabel;
|
||||
use App\Models\User;
|
||||
use App\View\Label;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\View\View;
|
||||
@@ -687,18 +688,25 @@ class BulkAssetsController extends Controller
|
||||
->with('error', trans('general.error_assets_already_checked_out'));
|
||||
}
|
||||
|
||||
// Prevent checking out assets across companies if FMCS enabled
|
||||
if (Setting::getSettings()->full_multiple_companies_support && $target->company_id) {
|
||||
$company_ids = $assets->pluck('company_id')->unique();
|
||||
// Prevent checking out assets across companies if FMCS enabled.
|
||||
if (Setting::getSettings()->full_multiple_companies_support) {
|
||||
$company_ids = $assets->pluck('company_id')->filter()->unique();
|
||||
|
||||
// if there is more than one unique company id or the singular company id does not match
|
||||
// then the checkout is invalid
|
||||
if ($company_ids->count() > 1 || $company_ids->first() != $target->company_id) {
|
||||
// re-add the asset ids so the assets select is re-populated
|
||||
$request->session()->flashInput(['selected_assets' => $asset_ids]);
|
||||
if ($company_ids->isNotEmpty()) {
|
||||
if ($company_ids->count() > 1) {
|
||||
// Selected assets span multiple companies; bulk checkout can't satisfy all of them.
|
||||
$mismatch = true;
|
||||
} else {
|
||||
// All assets share the same company; let the model enforce the checkout rules.
|
||||
$mismatch = ! $assets->first()->canCheckoutTo($target);
|
||||
}
|
||||
|
||||
return redirect(route('hardware.bulkcheckout.show'))
|
||||
->with('error', trans('general.error_user_company_multiple'));
|
||||
if ($mismatch) {
|
||||
$request->session()->flashInput(['selected_assets' => $asset_ids]);
|
||||
|
||||
return redirect(route('hardware.bulkcheckout.show'))
|
||||
->with('error', trans('general.error_user_company_multiple'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ class SamlController extends Controller
|
||||
{
|
||||
$auth = $this->saml->getAuth();
|
||||
$ssoUrl = $auth->login(session()->get('url.intended'), [], false, false, false, false);
|
||||
|
||||
return redirect()->away($ssoUrl);
|
||||
}
|
||||
|
||||
@@ -95,7 +96,7 @@ class SamlController extends Controller
|
||||
$saml = $this->saml;
|
||||
$auth = $saml->getAuth();
|
||||
$saml_exception = false;
|
||||
session()->put('url.intended', $request->post('RelayState'));
|
||||
session()->put('url.intended', str_replace(["\r", "\n"], '', $request->post('RelayState')));
|
||||
try {
|
||||
$auth->processResponse();
|
||||
} catch (\Exception $e) {
|
||||
|
||||
@@ -43,7 +43,8 @@ class ComponentCheckinController extends Controller
|
||||
}
|
||||
$this->authorize('checkin', $component);
|
||||
|
||||
return view('components/checkin', compact('component_assets', 'component', 'asset'));
|
||||
return view('components/checkin', compact('component_assets', 'component', 'asset'))
|
||||
->with('snipe_component', $component);
|
||||
}
|
||||
|
||||
return redirect()->route('components.index')->with('error', trans('admin/components/messages.not_found'));
|
||||
|
||||
@@ -7,7 +7,6 @@ use App\Helpers\Helper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Asset;
|
||||
use App\Models\Component;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@@ -46,7 +45,8 @@ class ComponentCheckoutController extends Controller
|
||||
}
|
||||
|
||||
// Return the checkout view
|
||||
return view('components/checkout', compact('component'));
|
||||
return view('components/checkout', compact('component'))
|
||||
->with('snipe_component', $component);
|
||||
}
|
||||
|
||||
// Invalid category
|
||||
@@ -103,8 +103,12 @@ class ComponentCheckoutController extends Controller
|
||||
// Check if the asset exists
|
||||
$asset = Asset::find($request->input('asset_id'));
|
||||
|
||||
if ((Setting::getSettings()->full_multiple_companies_support) && $component->company_id !== $asset->company_id) {
|
||||
return redirect()->route('components.checkout.show', $componentId)->with('error', trans('general.error_user_company'));
|
||||
if (! $component->canCheckoutTo($asset)) {
|
||||
return redirect()->route('components.checkout.show', $componentId)->with('error', trans('general.error_checkout_company_mismatch', [
|
||||
'item' => trans('general.component').' "'.$component->name.'"',
|
||||
'item_company' => $component->company?->name ?? trans('general.unassigned'),
|
||||
'target' => trans('general.asset').' "'.$asset->display_name.'"',
|
||||
]));
|
||||
}
|
||||
|
||||
$component->checkout_qty = $request->input('assigned_qty');
|
||||
|
||||
@@ -96,6 +96,14 @@ class ConsumableCheckoutController extends Controller
|
||||
return redirect()->route('consumables.checkout.show', $consumable)->with('error', trans('admin/consumables/message.checkout.user_does_not_exist'))->withInput();
|
||||
}
|
||||
|
||||
if (! $consumable->canCheckoutTo($user)) {
|
||||
return redirect()->back()->with('error', trans('general.error_checkout_company_mismatch', [
|
||||
'item' => trans('general.consumable').' "'.$consumable->name.'"',
|
||||
'item_company' => $consumable->company?->name ?? trans('general.unassigned'),
|
||||
'target' => trans('general.user').' "'.$user->username.'"',
|
||||
]));
|
||||
}
|
||||
|
||||
// Update the consumable data
|
||||
$consumable->assigned_to = e($request->input('assigned_to'));
|
||||
|
||||
|
||||
@@ -53,6 +53,8 @@ class CheckoutKitController extends Controller
|
||||
*/
|
||||
public function store(Request $request, $kit_id)
|
||||
{
|
||||
$this->authorize('checkout', Asset::class);
|
||||
|
||||
$user_id = e($request->input('user_id'));
|
||||
if (is_null($user = User::find($user_id))) {
|
||||
return redirect()->back()->with('error', trans('admin/users/message.user_not_found'));
|
||||
|
||||
@@ -36,7 +36,7 @@ class LicenseCheckinController extends Controller
|
||||
{
|
||||
// Check if the asset exists
|
||||
$license = License::find($licenseSeat->license_id);
|
||||
$this->authorize('checkout', $license);
|
||||
$this->authorize('checkin', $license);
|
||||
|
||||
return view('licenses/checkin', compact('licenseSeat'))->with('backto', $backTo);
|
||||
}
|
||||
@@ -70,7 +70,7 @@ class LicenseCheckinController extends Controller
|
||||
return redirect()->route('licenses.index')->with('error', trans('admin/licenses/message.checkin.error'));
|
||||
}
|
||||
|
||||
$this->authorize('checkout', $license);
|
||||
$this->authorize('checkin', $license);
|
||||
|
||||
// Declare the rules for the form validation
|
||||
$rules = [
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Models\Asset;
|
||||
use App\Models\CheckoutAcceptance;
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseSeat;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Contracts\View\View;
|
||||
@@ -95,6 +96,28 @@ class LicenseCheckoutController extends Controller
|
||||
return redirect()->route('licenses.index')->with('error', trans('admin/licenses/message.checkout.license_is_inactive'));
|
||||
}
|
||||
|
||||
if (Setting::getSettings()->full_multiple_companies_support == '1') {
|
||||
if ($request->filled('asset_id')) {
|
||||
$fmcsTarget = Asset::find($request->input('asset_id'));
|
||||
if ($fmcsTarget && ! $license->canCheckoutTo($fmcsTarget)) {
|
||||
return redirect()->route('licenses.index')->with('error', trans('general.error_checkout_company_mismatch', [
|
||||
'item' => trans('general.license').' "'.$license->name.'"',
|
||||
'item_company' => $license->company?->name ?? trans('general.unassigned'),
|
||||
'target' => trans('general.asset').' "'.$fmcsTarget->display_name.'"',
|
||||
]));
|
||||
}
|
||||
} elseif ($request->filled('assigned_to')) {
|
||||
$fmcsTarget = User::find($request->input('assigned_to'));
|
||||
if ($fmcsTarget && ! $license->canCheckoutTo($fmcsTarget)) {
|
||||
return redirect()->route('licenses.index')->with('error', trans('general.error_checkout_company_mismatch', [
|
||||
'item' => trans('general.license').' "'.$license->name.'"',
|
||||
'item_company' => $license->company?->name ?? trans('general.unassigned'),
|
||||
'target' => trans('general.user').' "'.$fmcsTarget->username.'"',
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$licenseSeat = null;
|
||||
$checkoutTarget = null;
|
||||
|
||||
@@ -240,14 +263,10 @@ class LicenseCheckoutController extends Controller
|
||||
|
||||
Log::debug('Checking out '.$licenseId.' via bulk');
|
||||
$license = License::findOrFail($licenseId);
|
||||
$this->authorize('checkin', $license);
|
||||
$avail_count = $license->getAvailSeatsCountAttribute();
|
||||
$this->authorize('checkout', $license);
|
||||
|
||||
$users = User::whereNull('deleted_at')->where('autoassign_licenses', '=', 1)->with('licenses')->get();
|
||||
Log::debug($avail_count.' will be assigned');
|
||||
|
||||
if ($users->count() > $avail_count) {
|
||||
Log::debug('You do not have enough free seats to complete this task, so we will check out as many as we can. ');
|
||||
if ($license->isInactive()) {
|
||||
return redirect()->back()->with('error', trans('admin/licenses/message.checkout.license_is_inactive'));
|
||||
}
|
||||
|
||||
// If the license is valid, check that there is an available seat
|
||||
@@ -255,6 +274,19 @@ class LicenseCheckoutController extends Controller
|
||||
return redirect()->back()->with('error', trans('admin/licenses/general.bulk.checkout_all.error_no_seats'));
|
||||
}
|
||||
|
||||
$avail_count = $license->getAvailSeatsCountAttribute();
|
||||
|
||||
$usersQuery = User::whereNull('deleted_at')->where('autoassign_licenses', '=', 1)->with('licenses');
|
||||
if (Setting::getSettings()->full_multiple_companies_support && $license->company_id) {
|
||||
$usersQuery->where('company_id', '=', $license->company_id);
|
||||
}
|
||||
$users = $usersQuery->get();
|
||||
Log::debug($avail_count.' will be assigned');
|
||||
|
||||
if ($users->count() > $avail_count) {
|
||||
Log::debug('You do not have enough free seats to complete this task, so we will check out as many as we can. ');
|
||||
}
|
||||
|
||||
$assigned_count = 0;
|
||||
|
||||
foreach ($users as $user) {
|
||||
|
||||
@@ -89,19 +89,24 @@ class LocationsController extends Controller
|
||||
$location->fax = request('fax');
|
||||
$location->tag_color = $request->input('tag_color');
|
||||
$location->notes = $request->input('notes');
|
||||
$location->company_id = Company::getIdForCurrentUser($request->input('company_id'));
|
||||
|
||||
// Only scope the location if the setting is enabled
|
||||
if (Setting::getSettings()->scope_locations_fmcs) {
|
||||
$location->company_id = Company::getIdForCurrentUser($request->input('company_id'));
|
||||
// check if parent is set and has a different company
|
||||
if ($location->parent_id && Location::find($location->parent_id)->company_id != $location->company_id) {
|
||||
return redirect()->back()->withInput()->withInput()->with('error', 'different company than parent');
|
||||
}
|
||||
} else {
|
||||
$location->company_id = $request->input('company_id');
|
||||
}
|
||||
|
||||
// Parent company check applies whenever FMCS is on, independent of scope_locations_fmcs.
|
||||
if (Setting::getSettings()->full_multiple_companies_support) {
|
||||
$parent = $location->parent_id ? Location::find($location->parent_id) : null;
|
||||
if ($parent && $parent->company_id != $location->company_id) {
|
||||
return redirect()->back()->withInput()->with('error', trans('general.error_location_parent_company', [
|
||||
'parent' => $parent->name,
|
||||
'parent_company' => $parent->company?->name ?? trans('general.unassigned'),
|
||||
'location_company' => $location->company?->name ?? trans('general.unassigned'),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->has('use_cloned_image')) {
|
||||
$cloned_model_img = Location::select('image')->find($request->input('clone_image_from_id'));
|
||||
if ($cloned_model_img) {
|
||||
@@ -171,17 +176,34 @@ class LocationsController extends Controller
|
||||
$location->tag_color = $request->input('tag_color');
|
||||
$location->notes = $request->input('notes');
|
||||
|
||||
// Only scope the location if the setting is enabled
|
||||
if (Setting::getSettings()->scope_locations_fmcs) {
|
||||
$location->company_id = Company::getIdForCurrentUser($request->input('company_id'));
|
||||
// check if there are related objects with different company
|
||||
if (Helper::test_locations_fmcs(false, $location->id, $location->company_id)) {
|
||||
return redirect()->back()->withInput()->withInput()->with('error', 'error scoped locations');
|
||||
if ($mismatched = Helper::test_locations_fmcs(false, $location->id, $location->company_id)) {
|
||||
$first = $mismatched[0];
|
||||
|
||||
return redirect()->back()->withInput()->with('error', trans('general.error_location_scoped_items', [
|
||||
'item_type' => trans('general.'.strtolower($first[0])),
|
||||
'item_name' => $first[2],
|
||||
'item_company' => $first[5] ?? trans('general.unassigned'),
|
||||
]));
|
||||
}
|
||||
} else {
|
||||
$location->company_id = $request->input('company_id');
|
||||
}
|
||||
|
||||
// Parent company check applies whenever FMCS is on, independent of scope_locations_fmcs.
|
||||
if (Setting::getSettings()->full_multiple_companies_support) {
|
||||
$parent = $location->parent_id ? Location::find($location->parent_id) : null;
|
||||
if ($parent && $parent->company_id != $location->company_id) {
|
||||
return redirect()->back()->withInput()->with('error', trans('general.error_location_parent_company', [
|
||||
'parent' => $parent->name,
|
||||
'parent_company' => $parent->company?->name ?? trans('general.unassigned'),
|
||||
'location_company' => $location->company?->name ?? trans('general.unassigned'),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
$location = $request->handleImages($location);
|
||||
|
||||
if ($location->save()) {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Helpers\Helper;
|
||||
use App\Models\Setting;
|
||||
use Com\Tecnick\Barcode\Barcode;
|
||||
use Illuminate\Http\Response;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class QrCodeController extends Controller
|
||||
{
|
||||
public static $map_show_route = [
|
||||
'accessories' => 'accessories.show',
|
||||
'assets' => 'hardware.show',
|
||||
'companies' => 'companies.show',
|
||||
'components' => 'components.show',
|
||||
'consumables' => 'consumables.show',
|
||||
'hardware' => 'hardware.show',
|
||||
'licenses' => 'licenses.show',
|
||||
'locations' => 'locations.show',
|
||||
'models' => 'models.show',
|
||||
'users' => 'users.show',
|
||||
];
|
||||
|
||||
public function show($object_type, $id): Response|BinaryFileResponse|string|bool
|
||||
{
|
||||
$settings = Setting::getSettings();
|
||||
|
||||
if ($settings->label2_2d_type === 'none') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! array_key_exists($object_type, self::$map_show_route)) {
|
||||
return $object_type.' is not a valid type.';
|
||||
}
|
||||
|
||||
$object = self::$map_object_type[$object_type]::withTrashed()->find($id);
|
||||
|
||||
if (! $object) {
|
||||
return 'That item is invalid';
|
||||
}
|
||||
|
||||
$this->authorize('view', $object);
|
||||
|
||||
$size = Helper::barcodeDimensions($settings->label2_2d_type);
|
||||
$qr_file = public_path().'/uploads/barcodes/qr-'.str_slug($object_type).'-'.str_slug($id).'.png';
|
||||
|
||||
if (file_exists($qr_file)) {
|
||||
return response()->file($qr_file, ['Content-type' => 'image/png']);
|
||||
}
|
||||
|
||||
$barcode = new Barcode;
|
||||
$barcode_obj = $barcode->getBarcodeObj(
|
||||
$settings->label2_2d_type,
|
||||
route(self::$map_show_route[$object_type], $id),
|
||||
$size['height'],
|
||||
$size['width'],
|
||||
'black',
|
||||
[-2, -2, -2, -2]
|
||||
);
|
||||
file_put_contents($qr_file, $barcode_obj->getPngData());
|
||||
|
||||
return response($barcode_obj->getPngData())->header('Content-type', 'image/png');
|
||||
}
|
||||
}
|
||||
@@ -36,8 +36,6 @@ use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use League\Csv\EscapeFormula;
|
||||
use League\Csv\Reader;
|
||||
use League\Csv\Writer;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/**
|
||||
@@ -105,36 +103,46 @@ class ReportsController extends Controller
|
||||
* @see ManufacturersController::getDatatable() method that generates the JSON response
|
||||
* @since [v1.0]
|
||||
*/
|
||||
public function exportAccessoryReport(): Response
|
||||
public function exportAccessoryReport(): StreamedResponse
|
||||
{
|
||||
$this->authorize('reports.view');
|
||||
$accessories = Accessory::orderBy('created_at', 'DESC')->get();
|
||||
|
||||
$rows = [];
|
||||
$header = [
|
||||
trans('admin/accessories/table.title'),
|
||||
trans('admin/accessories/general.accessory_category'),
|
||||
trans('admin/accessories/general.total'),
|
||||
trans('admin/accessories/general.remaining'),
|
||||
];
|
||||
$header = array_map('trim', $header);
|
||||
$rows[] = implode(', ', $header);
|
||||
$response = new StreamedResponse(function () {
|
||||
$handle = fopen('php://output', 'w');
|
||||
|
||||
// Row per accessory
|
||||
foreach ($accessories as $accessory) {
|
||||
$row = [];
|
||||
$row[] = e($accessory->accessory_name);
|
||||
$row[] = e($accessory->accessory_category);
|
||||
$row[] = e($accessory->total);
|
||||
$row[] = e($accessory->remaining);
|
||||
$header = [
|
||||
trans('admin/accessories/table.title'),
|
||||
trans('admin/accessories/general.accessory_category'),
|
||||
trans('admin/accessories/general.total'),
|
||||
trans('admin/accessories/general.remaining'),
|
||||
];
|
||||
fputcsv($handle, $header);
|
||||
|
||||
$rows[] = implode(',', $row);
|
||||
}
|
||||
$formatter = new EscapeFormula('`');
|
||||
|
||||
$csv = implode("\n", $rows);
|
||||
$response = response()->make($csv, 200);
|
||||
$response->header('Content-Type', 'text/csv');
|
||||
$response->header('Content-disposition', 'attachment;filename=report.csv');
|
||||
Accessory::with('category')->orderBy('created_at', 'DESC')
|
||||
->chunk(500, function ($accessories) use ($handle, $formatter) {
|
||||
foreach ($accessories as $accessory) {
|
||||
$row = [
|
||||
$accessory->name,
|
||||
$accessory->category?->name,
|
||||
$accessory->qty,
|
||||
$accessory->numRemaining(),
|
||||
];
|
||||
|
||||
if (config('app.escape_formulas') === false) {
|
||||
fputcsv($handle, $row);
|
||||
} else {
|
||||
fputcsv($handle, $formatter->escapeRecord($row));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
fclose($handle);
|
||||
}, 200, [
|
||||
'Content-Type' => 'text/csv',
|
||||
'Content-Disposition' => 'attachment; filename="accessories-report-'.date('Y-m-d-his').'.csv"',
|
||||
]);
|
||||
|
||||
return $response;
|
||||
}
|
||||
@@ -163,74 +171,80 @@ class ReportsController extends Controller
|
||||
*
|
||||
* @since [v1.0]
|
||||
*/
|
||||
public function exportDeprecationReport(): Response
|
||||
public function exportDeprecationReport(): StreamedResponse
|
||||
{
|
||||
$this->authorize('reports.view');
|
||||
// Grab all the assets
|
||||
$assets = Asset::with('model', 'assignedTo', 'status', 'defaultLoc', 'assetlog')
|
||||
->orderBy('created_at', 'DESC')->get();
|
||||
|
||||
$csv = Writer::createFromFileObject(new \SplTempFileObject);
|
||||
$csv->setOutputBOM(Reader::BOM_UTF16_BE);
|
||||
$response = new StreamedResponse(function () {
|
||||
$handle = fopen('php://output', 'w');
|
||||
$formatter = new EscapeFormula('`');
|
||||
|
||||
$rows = [];
|
||||
$header = [
|
||||
trans('admin/hardware/table.asset_tag'),
|
||||
trans('admin/hardware/table.title'),
|
||||
trans('admin/hardware/table.serial'),
|
||||
trans('admin/hardware/table.checkoutto'),
|
||||
trans('admin/hardware/table.location'),
|
||||
trans('admin/hardware/table.purchase_date'),
|
||||
trans('admin/hardware/table.purchase_cost'),
|
||||
trans('admin/hardware/table.book_value'),
|
||||
trans('admin/hardware/table.diff'),
|
||||
];
|
||||
fputcsv($handle, $header);
|
||||
|
||||
// Create the header row
|
||||
$header = [
|
||||
trans('admin/hardware/table.asset_tag'),
|
||||
trans('admin/hardware/table.title'),
|
||||
trans('admin/hardware/table.serial'),
|
||||
trans('admin/hardware/table.checkoutto'),
|
||||
trans('admin/hardware/table.location'),
|
||||
trans('admin/hardware/table.purchase_date'),
|
||||
trans('admin/hardware/table.purchase_cost'),
|
||||
trans('admin/hardware/table.book_value'),
|
||||
trans('admin/hardware/table.diff'),
|
||||
];
|
||||
Asset::with('model', 'assignedTo', 'status', 'defaultLoc', 'assetlog')
|
||||
->orderBy('created_at', 'DESC')
|
||||
->chunk(500, function ($assets) use ($handle, $formatter) {
|
||||
foreach ($assets as $asset) {
|
||||
$currency = $asset->location
|
||||
? $asset->location->currency
|
||||
: Setting::getSettings()->default_currency;
|
||||
|
||||
// we insert the CSV header
|
||||
$csv->insertOne($header);
|
||||
if ($target = $asset->assignedTo) {
|
||||
$assignedTo = $target->display_name;
|
||||
} else {
|
||||
$assignedTo = '';
|
||||
}
|
||||
|
||||
// Create a row per asset
|
||||
foreach ($assets as $asset) {
|
||||
$row = [];
|
||||
$row[] = e($asset->asset_tag);
|
||||
$row[] = e($asset->name);
|
||||
$row[] = e($asset->serial);
|
||||
if (($asset->assigned_to > 0) && ($location = $asset->location)) {
|
||||
if ($location->city) {
|
||||
$locationStr = $location->city.', '.$location->state;
|
||||
} elseif ($location->name) {
|
||||
$locationStr = $location->name;
|
||||
} else {
|
||||
$locationStr = '';
|
||||
}
|
||||
} else {
|
||||
$locationStr = '';
|
||||
}
|
||||
|
||||
if ($target = $asset->assignedTo) {
|
||||
$row[] = e($target->display_name);
|
||||
} else {
|
||||
$row[] = ''; // Empty string if unassigned
|
||||
}
|
||||
$row = [
|
||||
$asset->asset_tag,
|
||||
$asset->name,
|
||||
$asset->serial,
|
||||
$assignedTo,
|
||||
$locationStr,
|
||||
Helper::getFormattedDateObject($asset->purchase_date, 'date', false),
|
||||
$currency.Helper::formatCurrencyOutput($asset->purchase_cost),
|
||||
$currency.Helper::formatCurrencyOutput($asset->getDepreciatedValue()),
|
||||
$currency.Helper::formatCurrencyOutput($asset->purchase_cost - $asset->getDepreciatedValue()),
|
||||
];
|
||||
|
||||
if (($asset->assigned_to > 0) && ($location = $asset->location)) {
|
||||
if ($location->city) {
|
||||
$row[] = e($location->city).', '.e($location->state);
|
||||
} elseif ($location->name) {
|
||||
$row[] = e($location->name);
|
||||
} else {
|
||||
$row[] = '';
|
||||
}
|
||||
} else {
|
||||
$row[] = ''; // Empty string if location is not set
|
||||
}
|
||||
if (config('app.escape_formulas') === false) {
|
||||
fputcsv($handle, $row);
|
||||
} else {
|
||||
fputcsv($handle, $formatter->escapeRecord($row));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if ($asset->location) {
|
||||
$currency = e($asset->location->currency);
|
||||
} else {
|
||||
$currency = e(Setting::getSettings()->default_currency);
|
||||
}
|
||||
fclose($handle);
|
||||
}, 200, [
|
||||
'Content-Type' => 'text/csv',
|
||||
'Content-Disposition' => 'attachment; filename="depreciation-report-'.date('Y-m-d-his').'.csv"',
|
||||
]);
|
||||
|
||||
$row[] = Helper::getFormattedDateObject($asset->purchase_date, 'date', false);
|
||||
$row[] = $currency.Helper::formatCurrencyOutput($asset->purchase_cost);
|
||||
$row[] = $currency.Helper::formatCurrencyOutput($asset->getDepreciatedValue());
|
||||
$row[] = $currency.Helper::formatCurrencyOutput(($asset->purchase_cost - $asset->getDepreciatedValue()));
|
||||
$csv->insertOne($row);
|
||||
}
|
||||
|
||||
$csv->output('depreciation-report-'.date('Y-m-d').'.csv');
|
||||
exit;
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -395,45 +409,52 @@ class ReportsController extends Controller
|
||||
*
|
||||
* @since [v1.0]
|
||||
*/
|
||||
public function exportLicenseReport(): Response
|
||||
public function exportLicenseReport(): StreamedResponse
|
||||
{
|
||||
$this->authorize('reports.view');
|
||||
$licenses = License::orderBy('created_at', 'DESC')->get();
|
||||
|
||||
$rows = [];
|
||||
$header = [
|
||||
trans('admin/licenses/table.title'),
|
||||
trans('admin/licenses/table.serial'),
|
||||
trans('admin/licenses/form.seats'),
|
||||
trans('admin/licenses/form.remaining_seats'),
|
||||
trans('admin/licenses/form.expiration'),
|
||||
trans('general.purchase_date'),
|
||||
trans('general.depreciation'),
|
||||
trans('general.purchase_cost'),
|
||||
];
|
||||
$response = new StreamedResponse(function () {
|
||||
$handle = fopen('php://output', 'w');
|
||||
$formatter = new EscapeFormula('`');
|
||||
|
||||
$header = array_map('trim', $header);
|
||||
$rows[] = implode(', ', $header);
|
||||
$header = [
|
||||
trans('admin/licenses/table.title'),
|
||||
trans('admin/licenses/table.serial'),
|
||||
trans('admin/licenses/form.seats'),
|
||||
trans('admin/licenses/form.remaining_seats'),
|
||||
trans('admin/licenses/form.expiration'),
|
||||
trans('general.purchase_date'),
|
||||
trans('general.depreciation'),
|
||||
trans('general.purchase_cost'),
|
||||
];
|
||||
fputcsv($handle, $header);
|
||||
|
||||
// Row per license
|
||||
foreach ($licenses as $license) {
|
||||
$row = [];
|
||||
$row[] = e($license->name);
|
||||
$row[] = e($license->serial);
|
||||
$row[] = e($license->seats);
|
||||
$row[] = $license->remaincount();
|
||||
$row[] = $license->expiration_date;
|
||||
$row[] = $license->purchase_date;
|
||||
$row[] = ($license->depreciation != '') ? e($license->depreciation->name) : '';
|
||||
$row[] = '"'.Helper::formatCurrencyOutput($license->purchase_cost).'"';
|
||||
License::orderBy('created_at', 'DESC')->chunk(500, function ($licenses) use ($handle, $formatter) {
|
||||
foreach ($licenses as $license) {
|
||||
$row = [
|
||||
$license->name,
|
||||
$license->serial,
|
||||
$license->seats,
|
||||
$license->remaincount(),
|
||||
$license->expiration_date,
|
||||
$license->purchase_date,
|
||||
($license->depreciation != '') ? $license->depreciation->name : '',
|
||||
Helper::formatCurrencyOutput($license->purchase_cost),
|
||||
];
|
||||
|
||||
$rows[] = implode(',', $row);
|
||||
}
|
||||
if (config('app.escape_formulas') === false) {
|
||||
fputcsv($handle, $row);
|
||||
} else {
|
||||
fputcsv($handle, $formatter->escapeRecord($row));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$csv = implode("\n", $rows);
|
||||
$response = response()->make($csv, 200);
|
||||
$response->header('Content-Type', 'text/csv');
|
||||
$response->header('Content-disposition', 'attachment;filename=report.csv');
|
||||
fclose($handle);
|
||||
}, 200, [
|
||||
'Content-Type' => 'text/csv',
|
||||
'Content-Disposition' => 'attachment; filename="licenses-report-'.date('Y-m-d-his').'.csv"',
|
||||
]);
|
||||
|
||||
return $response;
|
||||
}
|
||||
@@ -778,12 +799,11 @@ class ReportsController extends Controller
|
||||
$checkout_start = Carbon::parse($request->input('checkout_date_start'))->startOfDay();
|
||||
$checkout_end = Carbon::parse($request->input('checkout_date_end', now()))->endOfDay();
|
||||
|
||||
$actionlogassets = Actionlog::where('action_type', '=', 'checkout')
|
||||
->where('item_type', 'LIKE', '%Asset%')
|
||||
->whereBetween('action_date', [$checkout_start, $checkout_end])
|
||||
->pluck('item_id');
|
||||
$actionlogassets = Actionlog::select('id')->where('action_type', '=', 'checkout')
|
||||
->where('item_type', '=', Asset::class)
|
||||
->whereBetween('action_date', [$checkout_start, $checkout_end]); // we are *not* doing ->get()...
|
||||
|
||||
$assets->whereIn('assets.id', $actionlogassets);
|
||||
$assets->whereIn('id', $actionlogassets); // ...because this _should_ act as a 'subquery'
|
||||
}
|
||||
|
||||
if (($request->filled('checkin_date_start'))) {
|
||||
@@ -1172,56 +1192,60 @@ class ReportsController extends Controller
|
||||
*
|
||||
* @version v1.0
|
||||
*/
|
||||
public function exportMaintenancesReport(): Response
|
||||
public function exportMaintenancesReport(): StreamedResponse
|
||||
{
|
||||
$this->authorize('reports.view');
|
||||
// Grab all the improvements
|
||||
$Maintenances = Maintenance::with('asset', 'supplier')
|
||||
->orderBy('created_at', 'DESC')
|
||||
->get();
|
||||
|
||||
$rows = [];
|
||||
$response = new StreamedResponse(function () {
|
||||
$handle = fopen('php://output', 'w');
|
||||
$formatter = new EscapeFormula('`');
|
||||
|
||||
$header = [
|
||||
trans('admin/hardware/table.asset_tag'),
|
||||
trans('admin/maintenances/table.asset_name'),
|
||||
trans('general.supplier'),
|
||||
trans('admin/maintenances/form.asset_maintenance_type'),
|
||||
trans('admin/maintenances/form.title'),
|
||||
trans('admin/maintenances/form.start_date'),
|
||||
trans('admin/maintenances/form.completion_date'),
|
||||
trans('admin/maintenances/form.asset_maintenance_time'),
|
||||
trans('admin/maintenances/form.cost'),
|
||||
];
|
||||
$header = [
|
||||
trans('admin/hardware/table.asset_tag'),
|
||||
trans('admin/maintenances/table.asset_name'),
|
||||
trans('general.supplier'),
|
||||
trans('admin/maintenances/form.asset_maintenance_type'),
|
||||
trans('admin/maintenances/form.title'),
|
||||
trans('admin/maintenances/form.start_date'),
|
||||
trans('admin/maintenances/form.completion_date'),
|
||||
trans('admin/maintenances/form.asset_maintenance_time'),
|
||||
trans('admin/maintenances/form.cost'),
|
||||
];
|
||||
fputcsv($handle, $header);
|
||||
|
||||
$header = array_map('trim', $header);
|
||||
$rows[] = implode(',', $header);
|
||||
Maintenance::with('asset', 'supplier')
|
||||
->orderBy('created_at', 'DESC')
|
||||
->chunk(500, function ($maintenances) use ($handle, $formatter) {
|
||||
foreach ($maintenances as $maintenance) {
|
||||
$improvementTime = is_null($maintenance->asset_maintenance_time)
|
||||
? (int) Carbon::now()->diffInDays(Carbon::parse($maintenance->start_date), true)
|
||||
: (int) $maintenance->asset_maintenance_time;
|
||||
|
||||
foreach ($Maintenances as $maintenance) {
|
||||
$row = [];
|
||||
$row[] = str_replace(',', '', e($maintenance->asset->asset_tag));
|
||||
$row[] = str_replace(',', '', e($maintenance->asset->name));
|
||||
$row[] = str_replace(',', '', e($maintenance->supplier->name));
|
||||
$row[] = e($maintenance->improvement_type);
|
||||
$row[] = e($maintenance->name);
|
||||
$row[] = e($maintenance->start_date);
|
||||
$row[] = e($maintenance->completion_date);
|
||||
if (is_null($maintenance->asset_maintenance_time)) {
|
||||
$improvementTime = (int) Carbon::now()
|
||||
->diffInDays(Carbon::parse($maintenance->start_date), true);
|
||||
} else {
|
||||
$improvementTime = (int) $maintenance->asset_maintenance_time;
|
||||
}
|
||||
$row[] = $improvementTime;
|
||||
$row[] = trans('general.currency').Helper::formatCurrencyOutput($maintenance->cost);
|
||||
$rows[] = implode(',', $row);
|
||||
}
|
||||
$row = [
|
||||
$maintenance->asset->asset_tag,
|
||||
$maintenance->asset->name,
|
||||
$maintenance->supplier->name,
|
||||
$maintenance->improvement_type,
|
||||
$maintenance->name,
|
||||
$maintenance->start_date,
|
||||
$maintenance->completion_date,
|
||||
$improvementTime,
|
||||
trans('general.currency').Helper::formatCurrencyOutput($maintenance->cost),
|
||||
];
|
||||
|
||||
// spit out a csv
|
||||
$csv = implode("\n", $rows);
|
||||
$response = response()->make($csv, 200);
|
||||
$response->header('Content-Type', 'text/csv');
|
||||
$response->header('Content-disposition', 'attachment;filename=report.csv');
|
||||
if (config('app.escape_formulas') === false) {
|
||||
fputcsv($handle, $row);
|
||||
} else {
|
||||
fputcsv($handle, $formatter->escapeRecord($row));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
fclose($handle);
|
||||
}, 200, [
|
||||
'Content-Type' => 'text/csv',
|
||||
'Content-Disposition' => 'attachment; filename="maintenances-report-'.date('Y-m-d-his').'.csv"',
|
||||
]);
|
||||
|
||||
return $response;
|
||||
}
|
||||
@@ -1300,6 +1324,11 @@ class ReportsController extends Controller
|
||||
// Redirect to the unaccepted items report page with error
|
||||
return redirect()->route('reports/unaccepted_assets')->with('error', trans('general.bad_data'));
|
||||
}
|
||||
|
||||
if (! $this->currentUserCanAccessAcceptance($acceptance)) {
|
||||
return redirect()->route('reports/unaccepted_assets')->with('error', trans('general.insufficient_permissions'));
|
||||
}
|
||||
|
||||
$item = $acceptance->checkoutable;
|
||||
$assignee = $acceptance->assignedTo ?? $item->assignedTo ?? null;
|
||||
$email = $assignee?->email;
|
||||
@@ -1334,6 +1363,33 @@ class ReportsController extends Controller
|
||||
return redirect()->route('reports/unaccepted_assets')->with('success', trans('admin/reports/general.reminder_sent'));
|
||||
}
|
||||
|
||||
private function currentUserCanAccessAcceptance(CheckoutAcceptance $acceptance): bool
|
||||
{
|
||||
if (Setting::getSettings()->full_multiple_companies_support != '1') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user->company_id || $user->isSuperUser()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Bypass Eloquent global scopes so cross-company items are still found
|
||||
$checkoutableType = $acceptance->checkoutable_type;
|
||||
$checkoutable = $checkoutableType::withoutGlobalScopes()->find($acceptance->checkoutable_id);
|
||||
|
||||
if ($checkoutable instanceof LicenseSeat) {
|
||||
$itemCompanyId = License::withoutGlobalScopes()
|
||||
->where('id', $checkoutable->license_id)
|
||||
->value('company_id');
|
||||
} else {
|
||||
$itemCompanyId = $checkoutable?->company_id;
|
||||
}
|
||||
|
||||
return $itemCompanyId === null || (int) $itemCompanyId === (int) $user->company_id;
|
||||
}
|
||||
|
||||
private function getCheckoutMailType(CheckoutAcceptance $acceptance, $logItem): Mailable
|
||||
{
|
||||
$lookup = [
|
||||
@@ -1366,11 +1422,21 @@ class ReportsController extends Controller
|
||||
{
|
||||
$this->authorize('reports.view');
|
||||
|
||||
if (! $acceptance = CheckoutAcceptance::pending()->find($acceptanceId)) {
|
||||
$acceptance = CheckoutAcceptance::pending()
|
||||
->with(['checkoutable' => function (MorphTo $morphTo) {
|
||||
$morphTo->morphWith([LicenseSeat::class => ['license']]);
|
||||
}])
|
||||
->find($acceptanceId);
|
||||
|
||||
if (! $acceptance) {
|
||||
// Redirect to the unaccepted assets report page with error
|
||||
return redirect()->route('reports/unaccepted_assets')->with('error', trans('general.bad_data'));
|
||||
}
|
||||
|
||||
if (! $this->currentUserCanAccessAcceptance($acceptance)) {
|
||||
return redirect()->route('reports/unaccepted_assets')->with('error', trans('general.insufficient_permissions'));
|
||||
}
|
||||
|
||||
if ($acceptance->delete()) {
|
||||
return redirect()->route('reports/unaccepted_assets')->with('success', trans('admin/reports/general.acceptance_deleted'));
|
||||
} else {
|
||||
|
||||
@@ -93,10 +93,12 @@ class SettingsController extends Controller
|
||||
$old_locations_fmcs = $setting->scope_locations_fmcs;
|
||||
$setting->full_multiple_companies_support = $request->input('full_multiple_companies_support', '0');
|
||||
$setting->scope_locations_fmcs = $request->input('scope_locations_fmcs', '0');
|
||||
$setting->null_company_is_floater = $request->input('null_company_is_floater', '0');
|
||||
|
||||
// Backward compatibility for locations makes no sense without FullMultipleCompanySupport
|
||||
// These options make no sense without FullMultipleCompanySupport
|
||||
if (! $setting->full_multiple_companies_support) {
|
||||
$setting->scope_locations_fmcs = '0';
|
||||
$setting->null_company_is_floater = '0';
|
||||
}
|
||||
|
||||
// check for inconsistencies when activating scoped locations
|
||||
|
||||
@@ -101,11 +101,13 @@ class UploadedFilesController extends Controller
|
||||
}
|
||||
|
||||
if (request('inline') == 'true') {
|
||||
$headers = [
|
||||
'Content-Disposition' => 'inline',
|
||||
];
|
||||
$path = self::$map_storage_path[$object_type];
|
||||
|
||||
return Storage::download(self::$map_storage_path[$object_type].$log->filename, $log->filename, $headers);
|
||||
if (! StorageHelper::allowSafeInline($path.$log->filename)) {
|
||||
return StorageHelper::downloader($path.$log->filename);
|
||||
}
|
||||
|
||||
return Storage::download($path.$log->filename, $log->filename, ['Content-Disposition' => 'inline']);
|
||||
}
|
||||
|
||||
return StorageHelper::downloader(self::$map_storage_path[$object_type].$log->filename);
|
||||
|
||||
@@ -175,7 +175,15 @@ class BulkUsersController extends Controller
|
||||
->conditionallyAddItem('start_date')
|
||||
->conditionallyAddItem('end_date')
|
||||
->conditionallyAddItem('city')
|
||||
->conditionallyAddItem('autoassign_licenses');
|
||||
->conditionallyAddItem('autoassign_licenses')
|
||||
->conditionallyAddItem('phone')
|
||||
->conditionallyAddItem('jobtitle')
|
||||
->conditionallyAddItem('address')
|
||||
->conditionallyAddItem('state')
|
||||
->conditionallyAddItem('country')
|
||||
->conditionallyAddItem('zip')
|
||||
->conditionallyAddItem('website')
|
||||
->conditionallyAddItem('notes');
|
||||
|
||||
// If the manager_id is one of the users being updated, generate a warning.
|
||||
if (array_search($request->input('manager_id'), $user_raw_array)) {
|
||||
@@ -220,6 +228,46 @@ class BulkUsersController extends Controller
|
||||
$this->update_array['display_name'] = null;
|
||||
}
|
||||
|
||||
if ($request->input('null_city') == '1') {
|
||||
$this->update_array['city'] = null;
|
||||
}
|
||||
|
||||
if ($request->input('null_phone') == '1') {
|
||||
$this->update_array['phone'] = null;
|
||||
}
|
||||
|
||||
if ($request->input('null_jobtitle') == '1') {
|
||||
$this->update_array['jobtitle'] = null;
|
||||
}
|
||||
|
||||
if ($request->input('null_employee_num') == '1') {
|
||||
$this->update_array['employee_num'] = null;
|
||||
}
|
||||
|
||||
if ($request->input('null_address') == '1') {
|
||||
$this->update_array['address'] = null;
|
||||
}
|
||||
|
||||
if ($request->input('null_state') == '1') {
|
||||
$this->update_array['state'] = null;
|
||||
}
|
||||
|
||||
if ($request->input('null_country') == '1') {
|
||||
$this->update_array['country'] = null;
|
||||
}
|
||||
|
||||
if ($request->input('null_zip') == '1') {
|
||||
$this->update_array['zip'] = null;
|
||||
}
|
||||
|
||||
if ($request->input('null_website') == '1') {
|
||||
$this->update_array['website'] = null;
|
||||
}
|
||||
|
||||
if ($request->input('null_notes') == '1') {
|
||||
$this->update_array['notes'] = null;
|
||||
}
|
||||
|
||||
if (! $manager_conflict) {
|
||||
$this->conditionallyAddItem('manager_id');
|
||||
}
|
||||
@@ -245,7 +293,15 @@ class BulkUsersController extends Controller
|
||||
User::whereIn('id', $user_raw_array)->where('id', '!=', auth()->id())
|
||||
->update(['company_id' => $scalarCompanyId]);
|
||||
foreach ($users as $user) {
|
||||
$user->companies()->sync($allowedIds);
|
||||
if ($clearCompanies && ! auth()->user()->isSuperUser() && Company::isFullMultipleCompanySupportEnabled()) {
|
||||
// Non-superusers can only detach companies they belong to; sync([]) would
|
||||
// also wipe memberships for companies outside their scope.
|
||||
$user->companies()->detach(Company::getIdsForCurrentUser(
|
||||
$user->companies()->pluck('companies.id')->toArray()
|
||||
));
|
||||
} else {
|
||||
$user->companies()->sync($allowedIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,6 +316,11 @@ class BulkUsersController extends Controller
|
||||
if ($request->filled('ldap_import')) {
|
||||
$authFieldUpdate['ldap_import'] = $request->input('ldap_import');
|
||||
}
|
||||
if ($request->filled('email')) {
|
||||
$authFieldUpdate['email'] = $request->input('email');
|
||||
} elseif ($request->input('null_email') == '1') {
|
||||
$authFieldUpdate['email'] = null;
|
||||
}
|
||||
if (! empty($authFieldUpdate)) {
|
||||
$user->update($authFieldUpdate);
|
||||
}
|
||||
@@ -334,6 +395,31 @@ class BulkUsersController extends Controller
|
||||
return redirect()->route('users.index')->with('error', 'No status selected');
|
||||
}
|
||||
|
||||
// Enforce per-item checkin permissions before touching anything (catches FMCS company scoping).
|
||||
foreach ($assets as $asset) {
|
||||
if (auth()->user()->cannot('checkin', $asset)) {
|
||||
return redirect()->route('users.index')->with('error', trans('general.insufficient_permissions'));
|
||||
}
|
||||
}
|
||||
|
||||
$licenseModels = License::whereIn('id', $licenses->pluck('license_id')->unique())->get();
|
||||
foreach ($licenseModels as $license) {
|
||||
if (auth()->user()->cannot('checkin', $license)) {
|
||||
return redirect()->route('users.index')->with('error', trans('general.insufficient_permissions'));
|
||||
}
|
||||
}
|
||||
|
||||
$accessoryModels = Accessory::whereIn('id', $accessoryUserRows->pluck('accessory_id')->unique())->get();
|
||||
foreach ($accessoryModels as $accessory) {
|
||||
if (auth()->user()->cannot('checkin', $accessory)) {
|
||||
return redirect()->route('users.index')->with('error', trans('general.insufficient_permissions'));
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->input('delete_user') == '1' && $users->isNotEmpty() && auth()->user()->cannot('delete', User::class)) {
|
||||
return redirect()->route('users.index')->with('error', trans('general.insufficient_permissions'));
|
||||
}
|
||||
|
||||
$this->logItemCheckinAndDelete($assets, Asset::class);
|
||||
$this->logAccessoriesCheckin($accessoryUserRows);
|
||||
$this->logItemCheckinAndDelete($licenses, License::class);
|
||||
@@ -440,6 +526,10 @@ class BulkUsersController extends Controller
|
||||
$users_to_merge = User::whereIn('id', $user_ids_to_merge)->with('assets', 'manager', 'userlog', 'licenses', 'consumables', 'accessories', 'managedLocations', 'uploads', 'acceptances')->get();
|
||||
$admin = User::find(auth()->id());
|
||||
|
||||
if (! auth()->user()->can('canEditAuthFields', $merge_into_user) || ! auth()->user()->can('editableOnDemo')) {
|
||||
return redirect()->route('users.index')->with('error', trans('general.insufficient_permissions'));
|
||||
}
|
||||
|
||||
// Walk users
|
||||
foreach ($users_to_merge as $user_to_merge) {
|
||||
|
||||
|
||||
@@ -10,11 +10,14 @@ use App\Http\Requests\DeleteUserRequest;
|
||||
use App\Http\Requests\ImageUploadRequest;
|
||||
use App\Http\Requests\SaveUserRequest;
|
||||
use App\Mail\UnacceptedAssetReminderMail;
|
||||
use App\Models\Accessory;
|
||||
use App\Models\Actionlog;
|
||||
use App\Models\Asset;
|
||||
use App\Models\CheckoutAcceptance;
|
||||
use App\Models\Company;
|
||||
use App\Models\Consumable;
|
||||
use App\Models\Group;
|
||||
use App\Models\License;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Notifications\CurrentInventory;
|
||||
@@ -702,9 +705,17 @@ class UsersController extends Controller
|
||||
{
|
||||
$this->authorize('view', User::class);
|
||||
|
||||
$user = User::withInventoryRelations($id)->first();
|
||||
$actor = auth()->user();
|
||||
$canViewLicenses = $actor->can('view', License::class);
|
||||
$canViewAccessories = $actor->can('view', Accessory::class);
|
||||
$canViewConsumables = $actor->can('view', Consumable::class);
|
||||
|
||||
$indirectItemsCount = $user?->assets?->flatMap->assignedAssets->count() + $user?->assets?->flatMap->components->count() + $user?->assets?->flatMap->licenses->count() + $user?->assets?->flatMap->assignedAccessories->count();
|
||||
$user = User::withInventoryRelations($id, $canViewLicenses, $canViewAccessories, $canViewConsumables)->first();
|
||||
|
||||
$indirectItemsCount = $user?->assets?->flatMap->assignedAssets->count()
|
||||
+ $user?->assets?->flatMap->components->count()
|
||||
+ ($canViewLicenses ? $user?->assets?->flatMap->licenses->count() : 0)
|
||||
+ ($canViewAccessories ? $user?->assets?->flatMap->assignedAccessories->count() : 0);
|
||||
|
||||
if ($user) {
|
||||
$this->authorize('view', $user);
|
||||
|
||||
@@ -222,6 +222,10 @@ class ViewAssetsController extends Controller
|
||||
|
||||
return redirect()->back()->with('success')->with('success', trans('admin/hardware/message.requests.canceled'));
|
||||
} else {
|
||||
if ($fullItemType === Asset::class && is_null(Asset::RequestableAssets()->find($item->id))) {
|
||||
return redirect()->back()->with('error', trans('admin/hardware/message.requests.error'));
|
||||
}
|
||||
|
||||
$item->request();
|
||||
if (($settings->alert_email != '') && ($settings->alerts_enabled == '1') && (! config('app.lock_passwords'))) {
|
||||
$logaction->logaction('requested');
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Http;
|
||||
|
||||
use App\Http\Middleware\AssetCountForSidebar;
|
||||
use App\Http\Middleware\CheckColorSettings;
|
||||
use App\Http\Middleware\CheckForDebug;
|
||||
use App\Http\Middleware\CheckForSetup;
|
||||
@@ -75,7 +74,6 @@ class Kernel extends HttpKernel
|
||||
CheckUserIsActivated::class,
|
||||
CheckForTwoFactor::class,
|
||||
CreateFreshApiToken::class,
|
||||
AssetCountForSidebar::class,
|
||||
CheckColorSettings::class,
|
||||
AuthenticateSession::class,
|
||||
SubstituteBindings::class,
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Asset;
|
||||
use App\Models\Setting;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class AssetCountForSidebar
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Request $request
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle($request, Closure $next)
|
||||
{
|
||||
/**
|
||||
* This needs to be set for the /setup process, since the tables might not exist yet
|
||||
*/
|
||||
$total_assets = 0;
|
||||
$total_due_for_checkin = 0;
|
||||
$total_overdue_for_checkin = 0;
|
||||
$total_due_for_audit = 0;
|
||||
$total_overdue_for_audit = 0;
|
||||
|
||||
try {
|
||||
$settings = Setting::getSettings();
|
||||
view()->share('settings', $settings);
|
||||
} catch (\Exception $e) {
|
||||
Log::debug($e);
|
||||
}
|
||||
|
||||
try {
|
||||
$total_assets = Asset::AssetsForShow()->count();
|
||||
view()->share('total_assets', $total_assets);
|
||||
} catch (\Exception $e) {
|
||||
Log::debug($e);
|
||||
}
|
||||
|
||||
try {
|
||||
$total_rtd_sidebar = Asset::RTD()->count();
|
||||
view()->share('total_rtd_sidebar', $total_rtd_sidebar);
|
||||
} catch (\Exception $e) {
|
||||
Log::debug($e);
|
||||
}
|
||||
|
||||
try {
|
||||
$total_deployed_sidebar = Asset::Deployed()->count();
|
||||
view()->share('total_deployed_sidebar', $total_deployed_sidebar);
|
||||
} catch (\Exception $e) {
|
||||
Log::debug($e);
|
||||
}
|
||||
|
||||
try {
|
||||
$total_archived_sidebar = Asset::Archived()->count();
|
||||
view()->share('total_archived_sidebar', $total_archived_sidebar);
|
||||
} catch (\Exception $e) {
|
||||
Log::debug($e);
|
||||
}
|
||||
|
||||
try {
|
||||
$total_pending_sidebar = Asset::Pending()->count();
|
||||
view()->share('total_pending_sidebar', $total_pending_sidebar);
|
||||
} catch (\Exception $e) {
|
||||
Log::debug($e);
|
||||
}
|
||||
|
||||
try {
|
||||
$total_undeployable_sidebar = Asset::Undeployable()->count();
|
||||
view()->share('total_undeployable_sidebar', $total_undeployable_sidebar);
|
||||
} catch (\Exception $e) {
|
||||
Log::debug($e);
|
||||
}
|
||||
|
||||
try {
|
||||
$total_byod_sidebar = Asset::where('byod', '=', '1')->count();
|
||||
view()->share('total_byod_sidebar', $total_byod_sidebar);
|
||||
} catch (\Exception $e) {
|
||||
Log::debug($e);
|
||||
}
|
||||
|
||||
try {
|
||||
$total_due_for_audit = Asset::DueForAudit($settings)->count();
|
||||
view()->share('total_due_for_audit', $total_due_for_audit);
|
||||
} catch (\Exception $e) {
|
||||
Log::debug($e);
|
||||
}
|
||||
|
||||
try {
|
||||
$total_overdue_for_audit = Asset::OverdueForAudit()->count();
|
||||
view()->share('total_overdue_for_audit', $total_overdue_for_audit);
|
||||
} catch (\Exception $e) {
|
||||
Log::debug($e);
|
||||
}
|
||||
|
||||
try {
|
||||
$total_due_for_checkin = Asset::DueForCheckin($settings)->count();
|
||||
view()->share('total_due_for_checkin', $total_due_for_checkin);
|
||||
} catch (\Exception $e) {
|
||||
Log::debug($e);
|
||||
}
|
||||
|
||||
try {
|
||||
$total_overdue_for_checkin = Asset::OverdueForCheckin()->count();
|
||||
view()->share('total_overdue_for_checkin', $total_overdue_for_checkin);
|
||||
} catch (\Exception $e) {
|
||||
Log::debug($e);
|
||||
}
|
||||
|
||||
view()->share('total_due_and_overdue_for_checkin', ($total_due_for_checkin + $total_overdue_for_checkin));
|
||||
view()->share('total_due_and_overdue_for_audit', ($total_due_for_audit + $total_overdue_for_audit));
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,20 @@
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\Accessory;
|
||||
use App\Models\Asset;
|
||||
use App\Models\AssetModel;
|
||||
use App\Models\Component;
|
||||
use App\Models\Consumable;
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseSeat;
|
||||
use App\Models\Location;
|
||||
use App\Models\Maintenance;
|
||||
use App\Models\User;
|
||||
use App\Rules\ValidJson;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class FilterRequest extends FormRequest
|
||||
{
|
||||
@@ -23,8 +34,37 @@ class FilterRequest extends FormRequest
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$allowedTypes = [
|
||||
'accessory',
|
||||
Accessory::class,
|
||||
'asset',
|
||||
Asset::class,
|
||||
'assetmodel',
|
||||
'assetModel',
|
||||
'AssetModel',
|
||||
AssetModel::class,
|
||||
'component',
|
||||
Component::class,
|
||||
'consumable',
|
||||
Consumable::class,
|
||||
'license',
|
||||
License::class,
|
||||
'licenseseat',
|
||||
'licenseSeat',
|
||||
'LicenseSeat',
|
||||
LicenseSeat::class,
|
||||
'location',
|
||||
Location::class,
|
||||
'maintenance',
|
||||
Maintenance::class,
|
||||
'user',
|
||||
User::class,
|
||||
];
|
||||
|
||||
return [
|
||||
'filter' => ['nullable', new ValidJson],
|
||||
'item_type' => ['nullable', Rule::in($allowedTypes)],
|
||||
'target_type' => ['nullable', Rule::in($allowedTypes)],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ class SaveUserRequest extends FormRequest
|
||||
'department_id' => 'nullable|integer|exists:departments,id',
|
||||
'manager_id' => 'nullable|integer|exists:users,id',
|
||||
'company_id' => ['nullable', 'integer', 'exists:companies,id'],
|
||||
'company_ids' => 'nullable|array',
|
||||
'company_ids.*' => 'integer|exists:companies,id',
|
||||
];
|
||||
|
||||
switch ($this->method()) {
|
||||
@@ -52,13 +54,13 @@ class SaveUserRequest extends FormRequest
|
||||
$rules['first_name'] = 'required|string|min:1';
|
||||
$rules['username'] = 'required_unless:ldap_import,1|string|min:1';
|
||||
$rules['password'] = Setting::passwordComplexityRulesSaving('update').'|confirmed';
|
||||
$rules['company_id'] = [new UserCannotSwitchCompaniesIfItemsAssigned];
|
||||
$rules['company_id'] = ['nullable', 'integer', 'exists:companies,id', new UserCannotSwitchCompaniesIfItemsAssigned];
|
||||
break;
|
||||
|
||||
// Save only what's passed
|
||||
case 'PATCH':
|
||||
$rules['password'] = Setting::passwordComplexityRulesSaving('update');
|
||||
$rules['company_id'] = [new UserCannotSwitchCompaniesIfItemsAssigned];
|
||||
$rules['company_id'] = ['nullable', 'integer', 'exists:companies,id', new UserCannotSwitchCompaniesIfItemsAssigned];
|
||||
break;
|
||||
|
||||
default:
|
||||
|
||||
@@ -26,6 +26,7 @@ class AccessoriesTransformer
|
||||
'id' => $accessory->id,
|
||||
'name' => e($accessory->name),
|
||||
'image' => ($accessory->image) ? Storage::disk('public')->url('accessories/'.e($accessory->image)) : null,
|
||||
'qr_code_url' => route('qr_code/common', ['object_type' => 'accessories', 'id' => $accessory->id]),
|
||||
'company' => ($accessory->company) ? [
|
||||
'id' => $accessory->company->id,
|
||||
'name' => e($accessory->company->name),
|
||||
|
||||
@@ -48,6 +48,7 @@ class AssetModelsTransformer
|
||||
'tag_color' => ($assetmodel->manufacturer->tag_color) ? e($assetmodel->manufacturer->tag_color) : null,
|
||||
] : null,
|
||||
'image' => ($assetmodel->image != '') ? Storage::disk('public')->url('models/'.e($assetmodel->image)) : null,
|
||||
'qr_code_url' => route('qr_code/common', ['object_type' => 'models', 'id' => $assetmodel->id]),
|
||||
'model_number' => ($assetmodel->model_number ? e($assetmodel->model_number) : null),
|
||||
'min_amt' => ($assetmodel->min_amt) ? (int) $assetmodel->min_amt : null,
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@ class AssetsTransformer
|
||||
'tag_color' => ($asset->defaultLoc->tag_color) ? e($asset->defaultLoc->tag_color) : null,
|
||||
] : null,
|
||||
'image' => ($asset->getImageUrl()) ? $asset->getImageUrl() : null,
|
||||
'qr_code_url' => route('qr_code/common', ['object_type' => 'hardware', 'id' => $asset->id]),
|
||||
'qr' => ($setting->qr_code == '1') ? Storage::disk('public')->url('barcodes/qr-'.str_slug($asset->asset_tag).'-'.str_slug($asset->id).'.png') : null,
|
||||
'alt_barcode' => ($setting->alt_barcode_enabled == '1') ? Storage::disk('public')->url('barcodes/'.str_slug($setting->alt_barcode).'-'.str_slug($asset->asset_tag).'.png') : null,
|
||||
'assigned_to' => $this->transformAssignedTo($asset),
|
||||
@@ -144,7 +145,7 @@ class AssetsTransformer
|
||||
|
||||
$fields_array[$field->name] = [
|
||||
'field' => e($field->db_column),
|
||||
'value' => e($value),
|
||||
'value' => ($field->element == 'markdown-textarea' && Gate::allows('assets.view.encrypted_custom_fields')) ? Helper::renderMarkdown($value) : e($value),
|
||||
'field_format' => $field->format,
|
||||
'element' => $field->element,
|
||||
];
|
||||
@@ -158,7 +159,7 @@ class AssetsTransformer
|
||||
|
||||
$fields_array[$field->name] = [
|
||||
'field' => e($field->db_column),
|
||||
'value' => e($value),
|
||||
'value' => ($field->element == 'markdown-textarea') ? Helper::renderMarkdown($value) : e($value),
|
||||
'field_format' => $field->format,
|
||||
'element' => $field->element,
|
||||
];
|
||||
@@ -274,7 +275,7 @@ class AssetsTransformer
|
||||
$value = Helper::getFormattedDateObject($value, 'date', false);
|
||||
}
|
||||
|
||||
$fields_array[$field->db_column] = e($value);
|
||||
$fields_array[$field->db_column] = ($field->element == 'markdown-textarea') ? Helper::renderMarkdown($value) : e($value);
|
||||
}
|
||||
|
||||
$array['custom_fields'] = $fields_array;
|
||||
|
||||
@@ -30,6 +30,7 @@ class CompaniesTransformer
|
||||
'fax' => ($company->fax != '') ? e($company->fax) : null,
|
||||
'email' => ($company->email != '') ? e($company->email) : null,
|
||||
'image' => ($company->image) ? Storage::disk('public')->url('companies/'.e($company->image)) : null,
|
||||
'qr_code_url' => route('qr_code/common', ['object_type' => 'companies', 'id' => $company->id]),
|
||||
'assets_count' => (int) $company->assets_count,
|
||||
'licenses_count' => (int) $company->licenses_count,
|
||||
'accessories_count' => (int) $company->accessories_count,
|
||||
|
||||
@@ -26,6 +26,7 @@ class ComponentsTransformer
|
||||
'id' => (int) $component->id,
|
||||
'name' => e($component->name),
|
||||
'image' => ($component->image) ? Storage::disk('public')->url('components/'.e($component->image)) : null,
|
||||
'qr_code_url' => route('qr_code/common', ['object_type' => 'components', 'id' => $component->id]),
|
||||
'serial' => ($component->serial) ? e($component->serial) : null,
|
||||
'location' => ($component->location) ? [
|
||||
'id' => (int) $component->location->id,
|
||||
|
||||
@@ -25,6 +25,7 @@ class ConsumablesTransformer
|
||||
'id' => (int) $consumable->id,
|
||||
'name' => e($consumable->name),
|
||||
'image' => ($consumable->getImageUrl()) ? ($consumable->getImageUrl()) : null,
|
||||
'qr_code_url' => route('qr_code/common', ['object_type' => 'consumables', 'id' => $consumable->id]),
|
||||
'category' => ($consumable->category) ? [
|
||||
'id' => $consumable->category->id,
|
||||
'name' => e($consumable->category->name),
|
||||
|
||||
@@ -24,6 +24,7 @@ class LicensesTransformer
|
||||
$array = [
|
||||
'id' => (int) $license->id,
|
||||
'name' => e($license->name),
|
||||
'qr_code_url' => route('qr_code/common', ['object_type' => 'licenses', 'id' => $license->id]),
|
||||
'company' => ($license->company) ? ['id' => (int) $license->company->id, 'name' => e($license->company->name)] : null,
|
||||
'manufacturer' => ($license->manufacturer) ? [
|
||||
'id' => (int) $license->manufacturer->id,
|
||||
|
||||
@@ -39,6 +39,7 @@ class LocationsTransformer
|
||||
'id' => (int) $location->id,
|
||||
'name' => e($location->name),
|
||||
'image' => ($location->image) ? Storage::disk('public')->url('locations/'.e($location->image)) : null,
|
||||
'qr_code_url' => route('qr_code/common', ['object_type' => 'locations', 'id' => $location->id]),
|
||||
'address' => ($location->address) ? e($location->address) : null,
|
||||
'address2' => ($location->address2) ? e($location->address2) : null,
|
||||
'city' => ($location->city) ? e($location->city) : null,
|
||||
|
||||
@@ -21,7 +21,6 @@ class UsersTransformer
|
||||
|
||||
public function transformUser(User $user)
|
||||
{
|
||||
|
||||
$role = null;
|
||||
if ($user->isSuperUser()) {
|
||||
$role = 'superadmin';
|
||||
@@ -31,6 +30,7 @@ class UsersTransformer
|
||||
$array = [
|
||||
'id' => (int) $user->id,
|
||||
'avatar' => e($user->present()->gravatar) ?? null,
|
||||
'qr_code_url' => route('qr_code/common', ['object_type' => 'users', 'id' => $user->id]),
|
||||
'name' => e($user->getFullNameAttribute()) ?? null,
|
||||
'first_name' => e($user->first_name) ?? null,
|
||||
'last_name' => e($user->last_name) ?? null,
|
||||
|
||||
@@ -111,7 +111,7 @@ class AssetImporter extends ItemImporter
|
||||
}
|
||||
|
||||
$this->item['notes'] = trim($this->findCsvMatch($row, 'asset_notes'));
|
||||
$this->item['image'] = trim($this->findCsvMatch($row, 'image'));
|
||||
$this->item['image'] = basename(trim($this->findCsvMatch($row, 'image')));
|
||||
$this->item['requestable'] = trim(($this->fetchHumanBoolean($this->findCsvMatch($row, 'requestable'))) == 1) ? '1' : 0;
|
||||
$asset->requestable = $this->item['requestable'];
|
||||
$this->item['warranty_months'] = intval(trim($this->findCsvMatch($row, 'warranty_months')));
|
||||
@@ -214,16 +214,25 @@ class AssetImporter extends ItemImporter
|
||||
// -- the class that needs to use it (command importer or GUI importer inside the project).
|
||||
if (isset($target) && ($target !== false)) {
|
||||
$asset = $asset->fresh();
|
||||
$targetType = get_class($target);
|
||||
$alreadyCheckedOutToTarget = ($asset->assigned_to == $target->id) && ($asset->assigned_type === $targetType);
|
||||
|
||||
// Skip duplicate checkout noise when update mode keeps the same assignment target.
|
||||
if (! $alreadyCheckedOutToTarget) {
|
||||
if (! is_null($asset->assigned_to)) {
|
||||
event(new CheckoutableCheckedIn($asset, $asset->assigned, auth()->user(), 'Checkin from CSV Importer', $checkin_date));
|
||||
if (! $asset->canCheckoutTo($target)) {
|
||||
$this->log(trans('general.error_checkout_company_mismatch', [
|
||||
'item' => trans('general.asset').' "'.$asset->display_name.'"',
|
||||
'item_company' => $asset->company?->name ?? trans('general.unassigned'),
|
||||
'target' => ($target->name ?? $target->username ?? $target->id),
|
||||
]));
|
||||
} else {
|
||||
$targetType = get_class($target);
|
||||
$alreadyCheckedOutToTarget = ($asset->assigned_to == $target->id) && ($asset->assigned_type === $targetType);
|
||||
|
||||
// Skip duplicate checkout noise when update mode keeps the same assignment target.
|
||||
if (! $alreadyCheckedOutToTarget) {
|
||||
if (! is_null($asset->assigned_to)) {
|
||||
event(new CheckoutableCheckedIn($asset, $asset->assigned, auth()->user(), 'Checkin from CSV Importer', $checkin_date));
|
||||
}
|
||||
|
||||
$asset->checkOut($target, $this->created_by, $checkout_date, null, 'Checkout from CSV Importer', $asset->name);
|
||||
}
|
||||
|
||||
$asset->checkOut($target, $this->created_by, $checkout_date, null, 'Checkout from CSV Importer', $asset->name);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,13 +59,21 @@ class ComponentImporter extends ItemImporter
|
||||
|
||||
// If we have an asset tag, checkout to that asset.
|
||||
if (isset($this->item['asset_tag']) && ($asset = Asset::where('asset_tag', $this->item['asset_tag'])->first())) {
|
||||
$component->assets()->attach($component->id, [
|
||||
'component_id' => $component->id,
|
||||
'created_by' => auth()->id(),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'assigned_qty' => 1, // Only assign the first one to the asset
|
||||
'asset_id' => $asset->id,
|
||||
]);
|
||||
if (! $component->canCheckoutTo($asset)) {
|
||||
$this->log(trans('general.error_checkout_company_mismatch', [
|
||||
'item' => trans('general.component').' "'.$component->name.'"',
|
||||
'item_company' => $component->company?->name ?? trans('general.unassigned'),
|
||||
'target' => trans('general.asset').' "'.$asset->display_name.'"',
|
||||
]));
|
||||
} else {
|
||||
$component->assets()->attach($component->id, [
|
||||
'component_id' => $component->id,
|
||||
'created_by' => auth()->id(),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'assigned_qty' => 1, // Only assign the first one to the asset
|
||||
'asset_id' => $asset->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
|
||||
@@ -82,6 +82,7 @@ class ItemImporter extends Importer
|
||||
$this->item['qty'] = $this->findCsvMatch($row, 'quantity');
|
||||
$this->item['requestable'] = $this->findCsvMatch($row, 'requestable');
|
||||
$this->item['created_by'] = auth()->id();
|
||||
$this->item['asset_tag'] = $this->findCsvMatch($row, 'asset_tag');
|
||||
$this->item['serial'] = $this->findCsvMatch($row, 'serial');
|
||||
$this->item['item_no'] = trim($this->findCsvMatch($row, 'item_no'));
|
||||
|
||||
|
||||
@@ -106,16 +106,32 @@ class LicenseImporter extends ItemImporter
|
||||
}
|
||||
|
||||
if ($checkout_target) {
|
||||
$targetLicense->assigned_to = $checkout_target->id;
|
||||
$targetLicense->created_by = auth()->id();
|
||||
if ($asset) {
|
||||
$targetLicense->asset_id = $asset->id;
|
||||
if (! $license->canCheckoutTo($checkout_target)) {
|
||||
$this->log(trans('general.error_checkout_company_mismatch', [
|
||||
'item' => trans('general.license').' "'.$license->name.'"',
|
||||
'item_company' => $license->company?->name ?? trans('general.unassigned'),
|
||||
'target' => ($checkout_target->name ?? $checkout_target->username ?? $checkout_target->id),
|
||||
]));
|
||||
} else {
|
||||
$targetLicense->assigned_to = $checkout_target->id;
|
||||
$targetLicense->created_by = auth()->id();
|
||||
if ($asset) {
|
||||
$targetLicense->asset_id = $asset->id;
|
||||
}
|
||||
$targetLicense->save();
|
||||
}
|
||||
$targetLicense->save();
|
||||
} elseif ($asset) {
|
||||
$targetLicense->created_by = auth()->id();
|
||||
$targetLicense->asset_id = $asset->id;
|
||||
$targetLicense->save();
|
||||
if (! $license->canCheckoutTo($asset)) {
|
||||
$this->log(trans('general.error_checkout_company_mismatch', [
|
||||
'item' => trans('general.license').' "'.$license->name.'"',
|
||||
'item_company' => $license->company?->name ?? trans('general.unassigned'),
|
||||
'target' => trans('general.asset').' "'.$asset->display_name.'"',
|
||||
]));
|
||||
} else {
|
||||
$targetLicense->created_by = auth()->id();
|
||||
$targetLicense->asset_id = $asset->id;
|
||||
$targetLicense->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ class UserImporter extends ItemImporter
|
||||
// Pull the records from the CSV to determine their values
|
||||
$this->item['id'] = trim($this->findCsvMatch($row, 'id'));
|
||||
$this->item['username'] = trim($this->findCsvMatch($row, 'username'));
|
||||
$this->item['display_name'] = trim($this->findCsvMatch($row, 'display_name'));
|
||||
$this->item['display_name'] = trim($this->findCsvMatch($row, 'display_name')) ?: null;
|
||||
$this->item['first_name'] = trim($this->findCsvMatch($row, 'first_name'));
|
||||
$this->item['last_name'] = trim($this->findCsvMatch($row, 'last_name'));
|
||||
$this->item['email'] = trim($this->findCsvMatch($row, 'email'));
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailables\Address;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class BulkDeleteReportMail extends BaseMailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly User $admin,
|
||||
public readonly bool $dryRun,
|
||||
public readonly array $companyNames,
|
||||
public readonly array $selectedTypes,
|
||||
public readonly string $deleteType,
|
||||
public readonly array $reportLines,
|
||||
public readonly Carbon $runAt,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$subject = $this->dryRun
|
||||
? '[Dry Run] Bulk Check-in/Delete Report'
|
||||
: 'Bulk Check-in/Delete Report';
|
||||
|
||||
return new Envelope(
|
||||
from: new Address(config('mail.from.address'), config('mail.from.name')),
|
||||
subject: $subject,
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
markdown: 'notifications.markdown.report-bulk-delete',
|
||||
with: [
|
||||
'admin' => $this->admin,
|
||||
'dryRun' => $this->dryRun,
|
||||
'companyNames' => $this->companyNames,
|
||||
'selectedTypes' => $this->selectedTypes,
|
||||
'deleteType' => $this->deleteType,
|
||||
'reportLines' => $this->reportLines,
|
||||
'runAt' => $this->runAt,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function attachments(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,7 @@ class Accessory extends SnipeModel
|
||||
'name' => 'required|max:255',
|
||||
'qty' => 'nullable|integer|min:0',
|
||||
'category_id' => 'required|integer|exists:categories,id',
|
||||
'company_id' => 'integer|nullable',
|
||||
'company_id' => 'integer|nullable|exists:companies,id',
|
||||
'location_id' => 'exists:locations,id|nullable|fmcs_location',
|
||||
'min_amt' => 'integer|min:0|nullable',
|
||||
'purchase_cost' => 'numeric|nullable|gte:0|max:99999999999999999.99',
|
||||
|
||||
+97
-46
@@ -8,7 +8,6 @@ use App\Helpers\Helper;
|
||||
use App\Http\Traits\UniqueUndeletedTrait;
|
||||
use App\Models\Traits\Acceptable;
|
||||
use App\Models\Traits\CompanyableTrait;
|
||||
use App\Models\Traits\HasCustomFields;
|
||||
use App\Models\Traits\HasUploads;
|
||||
use App\Models\Traits\Loggable;
|
||||
use App\Models\Traits\Requestable;
|
||||
@@ -21,6 +20,7 @@ use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Watson\Validating\ValidatingTrait;
|
||||
@@ -34,10 +34,9 @@ class Asset extends Depreciable
|
||||
{
|
||||
protected $presenter = AssetPresenter::class;
|
||||
|
||||
protected $with = ['model', 'adminuser', 'location', 'company'];
|
||||
// protected $with = ['model', 'adminuser', 'location', 'company'];
|
||||
|
||||
use CompanyableTrait;
|
||||
use HasCustomFields;
|
||||
use HasFactory;
|
||||
use HasUploads;
|
||||
use Loggable;
|
||||
@@ -253,9 +252,41 @@ class Asset extends Depreciable
|
||||
$this->attributes['expected_checkin'] = $value;
|
||||
}
|
||||
|
||||
protected function getCustomFieldset(): ?CustomFieldset
|
||||
public function customFieldValidationRules()
|
||||
{
|
||||
return $this->model?->fieldset ?? null;
|
||||
|
||||
$customFieldValidationRules = [];
|
||||
|
||||
if (($this->model) && ($this->model->fieldset)) {
|
||||
|
||||
foreach ($this->model->fieldset->fields as $field) {
|
||||
|
||||
// this just casts booleans that may come through as strings to an actual boolean type
|
||||
// adding !$field->field_encrypted because when the encrypted value comes through it
|
||||
// screws things up for the encrypted validation rules (and the encrypted string
|
||||
// is not a valid boolean type)
|
||||
if ($field->format == 'BOOLEAN' && ! $field->field_encrypted) {
|
||||
$this->{$field->db_column} = filter_var($this->{$field->db_column}, FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
}
|
||||
|
||||
$customFieldValidationRules += $this->model->fieldset->validation_rules();
|
||||
}
|
||||
|
||||
return $customFieldValidationRules;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* This handles the custom field validation for assets
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
public function save(array $params = [])
|
||||
{
|
||||
$this->rules += $this->customFieldValidationRules();
|
||||
|
||||
return parent::save($params);
|
||||
}
|
||||
|
||||
public function getDisplayNameAttribute()
|
||||
@@ -577,6 +608,40 @@ class Asset extends Depreciable
|
||||
return $this->rules;
|
||||
}
|
||||
|
||||
public function customFieldsForCheckinCheckout($checkin_checkout)
|
||||
{
|
||||
// Check to see if any of the custom fields were included on the form and if they have any values
|
||||
if (($this->model) && ($this->model->fieldset) && ($this->model->fieldset->fields)) {
|
||||
|
||||
foreach ($this->model->fieldset->fields as $field) {
|
||||
|
||||
if (($field->{$checkin_checkout} == 1) && (request()->has($field->db_column))) {
|
||||
|
||||
if ($field->field_encrypted == '1') {
|
||||
|
||||
if (Gate::allows('assets.view.encrypted_custom_fields')) {
|
||||
if (is_array(request()->input($field->db_column))) {
|
||||
$this->{$field->db_column} = Crypt::encrypt(implode(', ', request()->input($field->db_column)));
|
||||
} else {
|
||||
$this->{$field->db_column} = Crypt::encrypt(request()->input($field->db_column));
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
if (is_array(request()->input($field->db_column))) {
|
||||
$this->{$field->db_column} = implode(', ', request()->input($field->db_column));
|
||||
} else {
|
||||
$this->{$field->db_column} = request()->input($field->db_column);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public function manufacturer()
|
||||
{
|
||||
return $this->hasOneThrough(Manufacturer::class, AssetModel::class, 'id', 'id', 'model_id', 'manufacturer_id');
|
||||
@@ -1417,13 +1482,10 @@ class Asset extends Depreciable
|
||||
*/
|
||||
public function scopePending($query)
|
||||
{
|
||||
return $query->whereHas(
|
||||
'status', function ($query) {
|
||||
$query->where('deployable', '=', 0)
|
||||
->where('pending', '=', 1)
|
||||
->where('archived', '=', 0);
|
||||
}
|
||||
);
|
||||
// Pluck IDs then whereIn — do NOT replace with whereHas. whereHas generates a correlated EXISTS per row and causes severe slowdowns in withCount contexts.
|
||||
$ids = Statuslabel::where('deployable', 0)->where('pending', 1)->where('archived', 0)->whereNull('deleted_at')->pluck('id');
|
||||
|
||||
return $query->whereIn('assets.status_id', $ids->isEmpty() ? [0] : $ids);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1473,14 +1535,11 @@ class Asset extends Depreciable
|
||||
*/
|
||||
public function scopeRTD($query)
|
||||
{
|
||||
// Pluck IDs then whereIn — do NOT replace with whereHas. whereHas generates a correlated EXISTS per row and causes severe slowdowns in withCount contexts.
|
||||
$ids = Statuslabel::where('deployable', 1)->where('pending', 0)->where('archived', 0)->whereNull('deleted_at')->pluck('id');
|
||||
|
||||
return $query->whereNull('assets.assigned_to')
|
||||
->whereHas(
|
||||
'status', function ($query) {
|
||||
$query->where('deployable', '=', 1)
|
||||
->where('pending', '=', 0)
|
||||
->where('archived', '=', 0);
|
||||
}
|
||||
);
|
||||
->whereIn('assets.status_id', $ids->isEmpty() ? [0] : $ids);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1491,13 +1550,10 @@ class Asset extends Depreciable
|
||||
*/
|
||||
public function scopeUndeployable($query)
|
||||
{
|
||||
return $query->whereHas(
|
||||
'status', function ($query) {
|
||||
$query->where('deployable', '=', 0)
|
||||
->where('pending', '=', 0)
|
||||
->where('archived', '=', 0);
|
||||
}
|
||||
);
|
||||
// Pluck IDs then whereIn — do NOT replace with whereHas. whereHas generates a correlated EXISTS per row and causes severe slowdowns in withCount contexts.
|
||||
$ids = Statuslabel::where('deployable', 0)->where('pending', 0)->where('archived', 0)->whereNull('deleted_at')->pluck('id');
|
||||
|
||||
return $query->whereIn('assets.status_id', $ids->isEmpty() ? [0] : $ids);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1508,11 +1564,10 @@ class Asset extends Depreciable
|
||||
*/
|
||||
public function scopeNotArchived($query)
|
||||
{
|
||||
return $query->whereHas(
|
||||
'status', function ($query) {
|
||||
$query->where('archived', '=', 0);
|
||||
}
|
||||
);
|
||||
// Pluck IDs then whereIn — do NOT replace with whereHas. whereHas generates a correlated EXISTS per row and causes severe slowdowns in withCount contexts.
|
||||
$ids = Statuslabel::where('archived', 0)->whereNull('deleted_at')->pluck('id');
|
||||
|
||||
return $query->whereIn('assets.status_id', $ids->isEmpty() ? [0] : $ids);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1675,17 +1730,16 @@ class Asset extends Depreciable
|
||||
*/
|
||||
public function scopeAssetsForShow($query)
|
||||
{
|
||||
|
||||
// Pluck IDs then whereIn — do NOT replace with whereHas. whereHas generates a correlated EXISTS per row and causes severe slowdowns in withCount contexts.
|
||||
if (Setting::getSettings()->show_archived_in_list != 1) {
|
||||
return $query->whereHas(
|
||||
'status', function ($query) {
|
||||
$query->where('archived', '=', 0);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
return $query;
|
||||
$validStatusIds = Statuslabel::where('archived', 0)
|
||||
->whereNull('deleted_at')
|
||||
->pluck('id');
|
||||
|
||||
return $query->whereIn('assets.status_id', $validStatusIds->isEmpty() ? [0] : $validStatusIds);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1696,13 +1750,10 @@ class Asset extends Depreciable
|
||||
*/
|
||||
public function scopeArchived($query)
|
||||
{
|
||||
return $query->whereHas(
|
||||
'status', function ($query) {
|
||||
$query->where('deployable', '=', 0)
|
||||
->where('pending', '=', 0)
|
||||
->where('archived', '=', 1);
|
||||
}
|
||||
);
|
||||
// Pluck IDs then whereIn — do NOT replace with whereHas. whereHas generates a correlated EXISTS per row and causes severe slowdowns in withCount contexts.
|
||||
$ids = Statuslabel::where('deployable', 0)->where('pending', 0)->where('archived', 1)->whereNull('deleted_at')->pluck('id');
|
||||
|
||||
return $query->whereIn('assets.status_id', $ids->isEmpty() ? [0] : $ids);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+69
-5
@@ -15,6 +15,7 @@ use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Watson\Validating\ValidatingTrait;
|
||||
|
||||
/**
|
||||
@@ -155,11 +156,27 @@ final class Company extends SnipeModel
|
||||
if ($current_user->isSuperUser()) {
|
||||
return self::getIdFromInput($unescaped_input);
|
||||
} else {
|
||||
if ($current_user->company_id != null) {
|
||||
return $current_user->company_id;
|
||||
} else {
|
||||
return null;
|
||||
$userCompanyIds = self::getCurrentUserCompanyIds();
|
||||
$submittedId = (int) self::getIdFromInput($unescaped_input);
|
||||
|
||||
// Company membership is now determined entirely by the pivot (company_user table).
|
||||
// If the submitted value is a company the user actually belongs to, honour it.
|
||||
if ($submittedId && in_array($submittedId, $userCompanyIds)) {
|
||||
return $submittedId;
|
||||
}
|
||||
|
||||
// A user with pivot memberships who submits a company they don't belong to is
|
||||
// attempting cross-tenant assignment — reject outright rather than silently
|
||||
// overriding or storing null.
|
||||
if ($submittedId && ! empty($userCompanyIds)) {
|
||||
throw ValidationException::withMessages([
|
||||
'company_id' => [trans('validation.in', ['attribute' => 'company_id'])],
|
||||
]);
|
||||
}
|
||||
|
||||
// No company submitted (or user has no pivot memberships) — fall back to the
|
||||
// user's single company if unambiguous, otherwise null.
|
||||
return count($userCompanyIds) === 1 ? $userCompanyIds[0] : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -269,7 +286,7 @@ final class Company extends SnipeModel
|
||||
{
|
||||
return ! self::isFullMultipleCompanySupportEnabled()
|
||||
|| auth()->user()->isSuperUser()
|
||||
|| empty(self::getCurrentUserCompanyIds());
|
||||
|| ! empty(self::getCurrentUserCompanyIds());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -381,10 +398,17 @@ final class Company extends SnipeModel
|
||||
return $query->whereIn('companies.id', $companyIds);
|
||||
}
|
||||
|
||||
$floater = Setting::getSettings()->null_company_is_floater;
|
||||
|
||||
// Users are scoped by pivot membership (company_user), not by company_id column,
|
||||
// since a user may belong to multiple companies and company_id alone is insufficient.
|
||||
if ($query->getModel()->getTable() == 'users') {
|
||||
if (empty($companyIds)) {
|
||||
// Floater: actor has no company and is unrestricted — see everyone.
|
||||
if ($floater) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
// No pivot memberships: mirror old null-company behavior — show only users
|
||||
// who are also not in any company via the pivot.
|
||||
return $query->whereNotIn('users.id', function ($sub) {
|
||||
@@ -392,6 +416,15 @@ final class Company extends SnipeModel
|
||||
});
|
||||
}
|
||||
|
||||
// Floater: also include users with no company associations (they float). They all float down here, Georgie.).
|
||||
if ($floater) {
|
||||
return $query->where(function ($q) use ($companyIds) {
|
||||
$q->whereIn('users.id', function ($sub) use ($companyIds) {
|
||||
$sub->select('user_id')->from('company_user')->whereIn('company_id', $companyIds);
|
||||
})->orWhereDoesntHave('companies');
|
||||
});
|
||||
}
|
||||
|
||||
return $query->whereIn('users.id', function ($sub) use ($companyIds) {
|
||||
$sub->select('user_id')->from('company_user')->whereIn('company_id', $companyIds);
|
||||
});
|
||||
@@ -402,6 +435,11 @@ final class Company extends SnipeModel
|
||||
$table = ($table_name) ? $table_name.'.' : $query->getModel()->getTable().'.';
|
||||
|
||||
if (empty($companyIds)) {
|
||||
// Floater: actor has no company and is unrestricted — see everything.
|
||||
if ($floater) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->whereNull($table.$column);
|
||||
}
|
||||
|
||||
@@ -415,10 +453,36 @@ final class Company extends SnipeModel
|
||||
});
|
||||
}
|
||||
|
||||
// Floater: null-company items are visible to users from any company.
|
||||
if ($floater) {
|
||||
return $query->where(function ($q) use ($table, $column, $companyIds) {
|
||||
$q->whereIn($table.$column, $companyIds)
|
||||
->orWhereNull($table.$column);
|
||||
});
|
||||
}
|
||||
|
||||
return $query->whereIn($table.$column, $companyIds);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a users query to those belonging to the given company IDs, respecting floater mode.
|
||||
*
|
||||
* Extracted from controller-level inline logic so the same rule is enforced consistently
|
||||
* everywhere users are filtered by a specific set of company IDs (e.g. select2 dropdowns).
|
||||
*/
|
||||
public static function scopeUsersByCompanyIds($query, array $companyIds): mixed
|
||||
{
|
||||
if (Setting::getSettings()->null_company_is_floater) {
|
||||
return $query->where(function ($q) use ($companyIds) {
|
||||
$q->whereHas('companies', fn ($q2) => $q2->whereIn('companies.id', $companyIds))
|
||||
->orWhereDoesntHave('companies');
|
||||
});
|
||||
}
|
||||
|
||||
return $query->whereHas('companies', fn ($q) => $q->whereIn('companies.id', $companyIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* I legit do not know what this method does, but we can't remove it (yet).
|
||||
*
|
||||
|
||||
@@ -48,7 +48,7 @@ class Consumable extends SnipeModel
|
||||
'name' => 'required|max:255',
|
||||
'qty' => 'required|integer|min:0|max:99999',
|
||||
'category_id' => 'required|integer',
|
||||
'company_id' => 'integer|nullable',
|
||||
'company_id' => 'integer|nullable|exists:companies,id',
|
||||
'location_id' => 'exists:locations,id|nullable|fmcs_location',
|
||||
'min_amt' => 'integer|min:0|max:99999|nullable',
|
||||
'purchase_cost' => 'numeric|nullable|gte:0|max:99999999999999999.99',
|
||||
|
||||
@@ -51,7 +51,7 @@ class CustomField extends Model
|
||||
*/
|
||||
protected $rules = [
|
||||
'name' => 'required|unique:custom_fields',
|
||||
'element' => 'required|in:text,listbox,textarea,checkbox,radio',
|
||||
'element' => 'required|in:text,listbox,textarea,markdown-textarea,checkbox,radio',
|
||||
'field_encrypted' => 'nullable|boolean',
|
||||
'auto_add_to_fieldsets' => 'boolean',
|
||||
'show_in_listview' => 'boolean',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Models\Labels;
|
||||
|
||||
use App\Models\Asset;
|
||||
use App\Models\User;
|
||||
|
||||
class FieldOption
|
||||
{
|
||||
@@ -27,14 +28,19 @@ class FieldOption
|
||||
// assignedTo directly on the asset is a special case where
|
||||
// we want to avoid returning the property directly
|
||||
// and instead return the entity's presented name.
|
||||
if ($dataPath[0] === 'assignedTo') {
|
||||
if ($asset->relationLoaded('assignedTo')) {
|
||||
// If the "assignedTo" relationship was eager loaded then the way to get the
|
||||
// relationship changes from $asset->assignedTo to $asset->assigned.
|
||||
return $asset->assigned ? $asset->assigned->display_name : null;
|
||||
}
|
||||
if (in_array($dataPath[0], ['assignedTo', 'displayName'])) {
|
||||
$assigned = $asset->relationLoaded('assignedTo') ? $asset->assigned : $asset->assignedTo;
|
||||
|
||||
return $asset->assignedTo ? $asset->assignedTo->display_name : null;
|
||||
if (!$assigned) {
|
||||
return null;
|
||||
}
|
||||
if ($dataPath[0] === 'displayName') {
|
||||
return $assigned->getRawOriginal('display_name') ?? $assigned->display_name;
|
||||
}
|
||||
if ($assigned instanceof User) {
|
||||
return $assigned->full_name;
|
||||
}
|
||||
return $assigned->name ?? $assigned->display_name ?? null;
|
||||
}
|
||||
|
||||
// Handle Laravel's stupid Carbon datetime casting
|
||||
|
||||
@@ -59,7 +59,7 @@ class License extends Depreciable
|
||||
'license_name' => 'string|nullable|max:100',
|
||||
'notes' => 'string|nullable',
|
||||
'category_id' => 'required|exists:categories,id',
|
||||
'company_id' => 'integer|nullable',
|
||||
'company_id' => 'integer|nullable|exists:companies,id',
|
||||
'purchase_cost' => 'numeric|nullable|gte:0|max:99999999999999999.99',
|
||||
'purchase_date' => 'date_format:Y-m-d|nullable|max:10|required_with:depreciation_id',
|
||||
'expiration_date' => 'date_format:Y-m-d|nullable|max:10',
|
||||
|
||||
@@ -170,14 +170,13 @@ class Location extends SnipeModel
|
||||
*/
|
||||
public function assets()
|
||||
{
|
||||
// Pluck IDs then whereIn — do NOT replace with whereHas. whereHas generates a correlated EXISTS per row and causes severe slowdowns in withCount contexts.
|
||||
$ids = Statuslabel::where(function ($q) {
|
||||
$q->where('deployable', 1)->orWhere('pending', 1)->orWhere('archived', 0);
|
||||
})->whereNull('deleted_at')->pluck('id');
|
||||
|
||||
return $this->hasMany(Asset::class, 'location_id')
|
||||
->whereHas(
|
||||
'status', function ($query) {
|
||||
$query->where('status_labels.deployable', '=', 1)
|
||||
->orWhere('status_labels.pending', '=', 1)
|
||||
->orWhere('status_labels.archived', '=', 0);
|
||||
}
|
||||
);
|
||||
->whereIn('assets.status_id', $ids->isEmpty() ? [0] : $ids);
|
||||
}
|
||||
|
||||
public function countAllTheThings()
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class SCIMUser extends User
|
||||
{
|
||||
protected $table = 'users';
|
||||
@@ -21,4 +23,9 @@ class SCIMUser extends User
|
||||
return $this->belongsToMany(\App\Models\Group::class, 'users_groups', 'user_id', 'group_id');
|
||||
}
|
||||
|
||||
public function companies(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Company::class, 'company_user', 'user_id', 'company_id');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -216,6 +216,11 @@ class Setting extends Model
|
||||
return $custom_css;
|
||||
}
|
||||
|
||||
public function isQrEnabled(): bool
|
||||
{
|
||||
return $this->qr_code == '1' || $this->label2_2d_type !== 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts bytes into human readable file size.
|
||||
*
|
||||
|
||||
+185
-10
@@ -12,6 +12,7 @@ use ArieTimmerman\Laravel\SCIMServer\Attribute\Meta;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Attribute\MutableCollection;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Attribute\Schema as AttributeSchema;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Exceptions\SCIMException;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Parser\Parser;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Parser\Path;
|
||||
use ArieTimmerman\Laravel\SCIMServer\SCIM\Schema;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -31,11 +32,137 @@ function eloquent($name, $attribute = null): Attribute
|
||||
return new Eloquent($name, $attribute);
|
||||
}
|
||||
|
||||
class EloquentWithRemove extends Eloquent
|
||||
// Extends Complex to handle schema-qualified attribute keys in PATCH add/replace operations.
|
||||
// Azure Entra ID sends PATCH without a "path" field, putting the full URN as the value dict key
|
||||
// e.g. {"op":"add","value":{"urn:...grokability...:location":"Head Office"}}.
|
||||
// The upstream library's add() only searches the default (core) schema, silently dropping grokability attrs.
|
||||
class SnipeRootComplex extends Complex
|
||||
{
|
||||
public function remove($value, Model &$object, ?Path $path = null)
|
||||
private function findInSchema(string $schemaUrn, string $attrName): ?object
|
||||
{
|
||||
$object->{$this->attribute} = null;
|
||||
$schemaNode = $this->getSubNode($schemaUrn);
|
||||
|
||||
return ($schemaNode instanceof AttributeSchema) ? $schemaNode->getSubNode($attrName) : null;
|
||||
}
|
||||
|
||||
public function add($value, Model &$object)
|
||||
{
|
||||
$match = false;
|
||||
$this->dirty = true;
|
||||
|
||||
if ($this->mutability == 'readOnly') {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($value as $key => $v) {
|
||||
if (is_numeric($key)) {
|
||||
throw new SCIMException('Invalid key: '.$key.' for complex object '.$this->getFullKey());
|
||||
}
|
||||
|
||||
$path = Parser::parse($key);
|
||||
|
||||
if ($path->isNotEmpty()) {
|
||||
$attributeNames = $path->getAttributePathAttributes();
|
||||
$schema = $path->getAttributePath()?->path?->schema;
|
||||
$path = $path->shiftAttributePathAttributes();
|
||||
|
||||
$subNode = ($schema !== null) ? $this->findInSchema($schema, $attributeNames[0]) : null;
|
||||
if ($subNode === null) {
|
||||
$subNode = $this->getSubNode($attributeNames[0]);
|
||||
}
|
||||
|
||||
$match = true;
|
||||
|
||||
$newValue = $v;
|
||||
if ($path->isNotEmpty()) {
|
||||
$newValue = [implode('.', $path->getAttributePathAttributes()) => $v];
|
||||
}
|
||||
|
||||
if ($subNode !== null) {
|
||||
$subNode->add($newValue, $object);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! $match && $this->parent == null) {
|
||||
foreach ($this->subAttributes as $attribute) {
|
||||
if ($attribute instanceof AttributeSchema) {
|
||||
$attribute->add($value, $object);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function replace($value, Model &$object, ?Path $path = null, $removeIfNotSet = false)
|
||||
{
|
||||
$this->dirty = true;
|
||||
|
||||
if ($this->mutability == 'readOnly') {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($value as $key => $v) {
|
||||
if (is_numeric($key)) {
|
||||
throw new SCIMException('Invalid key: '.$key.' for complex object '.$this->getFullKey());
|
||||
}
|
||||
|
||||
$subNode = null;
|
||||
|
||||
if (strpos($key, ':') !== false) {
|
||||
$parsed = Parser::parse($key);
|
||||
$schemaUrn = $parsed->getAttributePath()?->path?->schema;
|
||||
$attrName = $parsed->getAttributePathAttributes()[0] ?? null;
|
||||
if ($schemaUrn !== null && $attrName !== null) {
|
||||
$subNode = $this->findInSchema($schemaUrn, $attrName);
|
||||
}
|
||||
if ($subNode === null) {
|
||||
$subNode = $this->getSubNode($key);
|
||||
}
|
||||
} else {
|
||||
$path = Parser::parse($key);
|
||||
if ($path->isNotEmpty()) {
|
||||
$attributeNames = $path->getAttributePathAttributes();
|
||||
$path = $path->shiftAttributePathAttributes();
|
||||
$subNode = $this->getSubNode($attributeNames[0] ?? $path->getAttributePath()?->path?->schema);
|
||||
}
|
||||
}
|
||||
|
||||
if ($subNode !== null) {
|
||||
$newValue = $v;
|
||||
if ($path !== null && $path->isNotEmpty()) {
|
||||
$newValue = [implode('.', $path->getAttributePathAttributes()) => $v];
|
||||
}
|
||||
$subNode->replace($newValue, $object, $path);
|
||||
}
|
||||
}
|
||||
|
||||
if ($subNode == null && $this->parent == null) {
|
||||
foreach ($this->subAttributes as $attribute) {
|
||||
if ($attribute instanceof AttributeSchema) {
|
||||
$attribute->replace($value, $object, $path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($removeIfNotSet) {
|
||||
foreach ($this->subAttributes as $attribute) {
|
||||
if (! $attribute->isDirty()) {
|
||||
$attribute->remove(null, $object);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Azure Entra ID sends op=replace with path=members and only the single user being provisioned,
|
||||
// not the full member list. Using sync() would wipe all other members on every user update.
|
||||
// Override replace() to use syncWithoutDetaching() so it behaves like add(); op=remove with a
|
||||
// filter path still handles explicit removals correctly.
|
||||
class SnipeMutableCollection extends MutableCollection
|
||||
{
|
||||
public function replace($value, Model &$object, ?Path $path = null)
|
||||
{
|
||||
$this->add($value, $object);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,8 +173,8 @@ class MappedTable extends Attribute
|
||||
private string $relationship_name,
|
||||
private string $relationship_class,
|
||||
private string $relationship_id_field,
|
||||
private string $relationship_field)
|
||||
{
|
||||
private string $relationship_field
|
||||
) {
|
||||
parent::__construct($this->scim_attribute_name);
|
||||
}
|
||||
|
||||
@@ -72,6 +199,50 @@ class MappedTable extends Attribute
|
||||
}
|
||||
}
|
||||
|
||||
// Company is stored only in the company_user pivot, not company_id. Read from the pivot
|
||||
// and sync it on write. For new users (not yet saved) defer the sync via a saved() callback.
|
||||
class SCIMCompanyAttribute extends MappedTable
|
||||
{
|
||||
protected function doRead(&$object, $attributes = [])
|
||||
{
|
||||
return $object->companies->first()?->name;
|
||||
}
|
||||
|
||||
private function applyCompany(?int $companyId, Model &$object): void
|
||||
{
|
||||
$ids = $companyId ? [$companyId] : [];
|
||||
|
||||
if ($object->exists) {
|
||||
$object->companies()->sync($ids);
|
||||
} else {
|
||||
$object->saved(fn () => $object->companies()->sync($ids));
|
||||
}
|
||||
}
|
||||
|
||||
public function add($value, Model &$object)
|
||||
{
|
||||
$this->applyCompany($value ? Company::firstOrCreate(['name' => $value])->id : null, $object);
|
||||
}
|
||||
|
||||
public function replace($value, Model &$object, $path = null, $removeIfNotSet = false)
|
||||
{
|
||||
$this->applyCompany($value ? Company::firstOrCreate(['name' => $value])->id : null, $object);
|
||||
}
|
||||
|
||||
public function patch($operation, $value, Model &$object, ?Path $path = null, $removeIfNotSet = false)
|
||||
{
|
||||
$this->applyCompany($value ? Company::firstOrCreate(['name' => $value])->id : null, $object);
|
||||
}
|
||||
}
|
||||
|
||||
class EloquentWithRemove extends Eloquent
|
||||
{
|
||||
public function remove($value, Model &$object, ?Path $path = null)
|
||||
{
|
||||
$object->{$this->attribute} = null;
|
||||
}
|
||||
}
|
||||
|
||||
class UpdatableComplex extends Complex
|
||||
{
|
||||
public function doWrite($operation, $subop, $value, Model &$object, ?Path $path = null, $removeIfNotSet = false)
|
||||
@@ -132,7 +303,7 @@ class SnipeSCIMConfig
|
||||
'withRelations' => [],
|
||||
'description' => 'User Account',
|
||||
|
||||
'map' => complex()->withSubAttributes(
|
||||
'map' => (new SnipeRootComplex)->withSubAttributes(
|
||||
new class('schemas', ['urn:ietf:params:scim:schemas:core:2.0:User', self::ENTERPRISE, self::GROKABILITY]) extends Constant
|
||||
{
|
||||
public function replace($value, &$object, $path = null)
|
||||
@@ -190,7 +361,11 @@ class SnipeSCIMConfig
|
||||
{
|
||||
if ($value) {
|
||||
try {
|
||||
$object->email = $value[0]['value'];
|
||||
if (is_string($value)) {
|
||||
$object->email = $value; // Weird MS-SCIM stuff :/
|
||||
} else {
|
||||
$object->email = $value[0]['value'];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
\Log::debug($e);
|
||||
throw new SCIMException("Unknown email object: '".print_r($value, true)."'", 422);
|
||||
@@ -299,7 +474,7 @@ class SnipeSCIMConfig
|
||||
$address['primary'] = true;
|
||||
}
|
||||
|
||||
return $address;
|
||||
return [$address];
|
||||
}
|
||||
|
||||
public function doWrite($operation, $subop, $value, Model &$object, ?Path $path = null, $removeIfNotSet = false)
|
||||
@@ -422,7 +597,7 @@ class SnipeSCIMConfig
|
||||
),
|
||||
(new AttributeSchema(self::GROKABILITY, false))->withSubAttributes(
|
||||
new MappedTable('location', 'location', Location::class, 'location_id', 'name'),
|
||||
new MappedTable('company', 'company', Company::class, 'company_id', 'name'),
|
||||
new SCIMCompanyAttribute('company', 'company', Company::class, 'company_id', 'name'),
|
||||
)
|
||||
),
|
||||
];
|
||||
@@ -471,7 +646,7 @@ class SnipeSCIMConfig
|
||||
$fail('The name has already been taken.');
|
||||
}
|
||||
}),
|
||||
(new MutableCollection('members'))->withSubAttributes(
|
||||
(new SnipeMutableCollection('members'))->withSubAttributes(
|
||||
eloquent('value', 'id')->ensure('required'),
|
||||
(new class('$ref') extends Eloquent
|
||||
{
|
||||
|
||||
@@ -30,7 +30,7 @@ class Supplier extends SnipeModel
|
||||
'fax' => 'min:7|max:35|nullable',
|
||||
'phone' => 'min:7|max:35|nullable',
|
||||
'contact' => 'max:100|nullable',
|
||||
'notes' => 'max:191|nullable', // Default string length is 191 characters..
|
||||
'notes' => 'max:16383|nullable', // text is 65535 but each character can take up to 4 bytes in utf8mb4
|
||||
'email' => 'email|max:150|nullable',
|
||||
'address' => 'max:250|nullable',
|
||||
'address2' => 'max:250|nullable',
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
namespace App\Models\Traits;
|
||||
|
||||
use App\Models\Company\Company;
|
||||
use App\Models\CompanyableScope;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
trait CompanyableTrait
|
||||
{
|
||||
@@ -18,13 +19,43 @@ trait CompanyableTrait
|
||||
*/
|
||||
public static function bootCompanyableTrait()
|
||||
{
|
||||
// In Version 7.0 and before locations weren't scoped by companies, so add a check for the backward compatibility setting
|
||||
if (__CLASS__ != 'App\Models\Location') {
|
||||
static::addGlobalScope(new CompanyableScope);
|
||||
} else {
|
||||
if (Setting::getSettings()?->scope_locations_fmcs == 1) {
|
||||
static::addGlobalScope(new CompanyableScope);
|
||||
}
|
||||
static::addGlobalScope(new CompanyableScope);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this item may be checked out to the given target under FMCS rules.
|
||||
*
|
||||
* Returns true when:
|
||||
* - FMCS is disabled, OR
|
||||
* - this item has no company (uncompanied items are unrestricted), OR
|
||||
* - target is a User whose company pivot includes this item's company, OR
|
||||
* - target has no company and null_company_is_floater is enabled, OR
|
||||
* - target's company_id exactly matches this item's company_id.
|
||||
*/
|
||||
public function canCheckoutTo(Model $target): bool
|
||||
{
|
||||
$settings = Setting::getSettings();
|
||||
|
||||
if (! $settings->full_multiple_companies_support) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! $this->company_id) {
|
||||
if (is_null($target->company_id)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (bool) $settings->null_company_is_floater;
|
||||
}
|
||||
|
||||
if ($target instanceof User) {
|
||||
return $target->canReceiveFromCompany((int) $this->company_id);
|
||||
}
|
||||
|
||||
if (is_null($target->company_id)) {
|
||||
return (bool) $settings->null_company_is_floater;
|
||||
}
|
||||
|
||||
return (int) $target->company_id === (int) $this->company_id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Traits;
|
||||
|
||||
use App\Models\CustomFieldset;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
trait HasCustomFields
|
||||
{
|
||||
/**
|
||||
* Return the CustomFieldset for this model instance.
|
||||
* Override in each model to supply the correct fieldset.
|
||||
*/
|
||||
protected function getCustomFieldset(): ?CustomFieldset
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function customFieldValidationRules(): array
|
||||
{
|
||||
$fieldset = $this->getCustomFieldset();
|
||||
|
||||
if (! $fieldset) {
|
||||
return [];
|
||||
}
|
||||
|
||||
foreach ($fieldset->fields as $field) {
|
||||
if ($field->format === 'BOOLEAN' && ! $field->field_encrypted) {
|
||||
$this->{$field->db_column} = filter_var($this->{$field->db_column}, FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
}
|
||||
|
||||
return $fieldset->validation_rules();
|
||||
}
|
||||
|
||||
public function save(array $params = [])
|
||||
{
|
||||
$this->rules += $this->customFieldValidationRules();
|
||||
|
||||
return parent::save($params);
|
||||
}
|
||||
|
||||
public function customFieldsForCheckinCheckout(string $checkin_checkout): void
|
||||
{
|
||||
$fieldset = $this->getCustomFieldset();
|
||||
|
||||
if (! $fieldset?->fields) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($fieldset->fields as $field) {
|
||||
if (($field->{$checkin_checkout} == 1) && request()->has($field->db_column)) {
|
||||
if ($field->field_encrypted == '1') {
|
||||
if (Gate::allows('assets.view.encrypted_custom_fields')) {
|
||||
$value = request()->input($field->db_column);
|
||||
$this->{$field->db_column} = Crypt::encrypt(is_array($value) ? implode(', ', $value) : $value);
|
||||
}
|
||||
} else {
|
||||
$value = request()->input($field->db_column);
|
||||
$this->{$field->db_column} = is_array($value) ? implode(', ', $value) : $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -449,7 +449,7 @@ trait Loggable
|
||||
|
||||
} catch (ServerException $e) {
|
||||
|
||||
Log::error('Teams webhook server error', [
|
||||
Log::warning('Teams webhook server error', [
|
||||
'endpoint' => $endpoint,
|
||||
'status' => $e->getResponse()?->getStatusCode(),
|
||||
'error' => $e->getMessage(),
|
||||
@@ -464,19 +464,28 @@ trait Loggable
|
||||
]);
|
||||
} catch (RequestException $e) {
|
||||
|
||||
Log::error('Teams webhook request failure', [
|
||||
Log::warning('Teams webhook request failure', [
|
||||
'endpoint' => $endpoint,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
Log::error('Teams webhook failed unexpectedly', [
|
||||
Log::warning('Teams webhook failed unexpectedly', [
|
||||
'endpoint' => $endpoint,
|
||||
'exception' => get_class($e),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
Setting::getSettings()->notify(new AuditNotification($params));
|
||||
try {
|
||||
Setting::getSettings()->notify(new AuditNotification($params));
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('Audit webhook notification failed', [
|
||||
'endpoint' => Setting::getSettings()->webhook_endpoint,
|
||||
'channel' => Setting::getSettings()->webhook_selected,
|
||||
'exception' => get_class($e),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $log;
|
||||
|
||||
+76
-25
@@ -323,7 +323,7 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
|
||||
protected function displayName(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn (mixed $value) => $value ?? $this->getFullNameAttribute(),
|
||||
get: fn (mixed $value) => ($value !== null && $value !== '') ? $value : $this->getFullNameAttribute(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -601,7 +601,6 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
|
||||
&& (($this->accessories_count ?? $this->accessories()->count()) === 0)
|
||||
&& (($this->licenses_count ?? $this->licenses()->count()) === 0)
|
||||
&& (($this->consumables_count ?? $this->consumables()->count()) === 0)
|
||||
&& (($this->accessories_count ?? $this->accessories()->count()) === 0)
|
||||
&& (($this->manages_users_count ?? $this->managesUsers()->count()) === 0)
|
||||
&& (($this->manages_locations_count ?? $this->managedLocations()->count()) === 0)
|
||||
&& ($this->deleted_at == '');
|
||||
@@ -626,6 +625,43 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
|
||||
return $this->belongsToMany(Company::class, 'company_user');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether an FMCS company check should allow this user to receive
|
||||
* an asset that belongs to the given company.
|
||||
*
|
||||
* - If the user has no company associations at all: returns true (no restriction).
|
||||
* - If the user has associations: returns true only when $companyId is among them.
|
||||
*/
|
||||
public function canReceiveFromCompany(int $companyId): bool
|
||||
{
|
||||
// Items with no company association are unrestricted — anyone can receive them.
|
||||
if (! $companyId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Query the pivot directly to avoid the Company model's FMCS global scope,
|
||||
// which would restrict results to the current actor's visible companies.
|
||||
$userCompanyIds = DB::table('company_user')
|
||||
->where('user_id', $this->id)
|
||||
->pluck('company_id');
|
||||
|
||||
if ($userCompanyIds->isEmpty()) {
|
||||
return (bool) Setting::getSettings()->null_company_is_floater;
|
||||
}
|
||||
|
||||
return $userCompanyIds->contains($companyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all companies this user belongs to — union of the primary company_id
|
||||
* column and the many-to-many pivot — as a deduplicated Collection.
|
||||
* Used to scope FMCS dropdowns to companies the user is allowed to work with.
|
||||
*/
|
||||
public function allCompanies(): Collection
|
||||
{
|
||||
return $this->companies->unique('id')->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync company pivot membership and log the change if the set of companies changed.
|
||||
*
|
||||
@@ -1486,28 +1522,38 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
|
||||
|
||||
}
|
||||
|
||||
public function scopeWithInventoryRelations($query, int $id)
|
||||
public function scopeWithInventoryRelations($query, int $id, bool $withLicenses = true, bool $withAccessories = true, bool $withConsumables = true)
|
||||
{
|
||||
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',
|
||||
$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',
|
||||
];
|
||||
|
||||
if ($withLicenses) {
|
||||
$with = array_merge($with, [
|
||||
'assets.licenses',
|
||||
'assets.licenses.category',
|
||||
'directLicenses.category',
|
||||
'licenses.category',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($withAccessories) {
|
||||
$with = array_merge($with, [
|
||||
'assets.assignedAccessories',
|
||||
'assets.assignedAccessories.accessory.category',
|
||||
'accessories.log' => fn ($query) => $query->withTrashed()
|
||||
@@ -1516,16 +1562,21 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
|
||||
->where('action_type', 'accepted'),
|
||||
'accessories.category',
|
||||
'accessories.manufacturer',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($withConsumables) {
|
||||
$with = array_merge($with, [
|
||||
'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();
|
||||
]);
|
||||
}
|
||||
|
||||
return $query->where('id', $id)->with($with)->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -139,23 +139,22 @@ class CheckinAssetNotification extends Notification
|
||||
$target = $this->target;
|
||||
$item = $this->item;
|
||||
$note = $this->note;
|
||||
|
||||
//
|
||||
return GoogleChatMessage::create()
|
||||
->to($this->settings->webhook_endpoint)
|
||||
->card(
|
||||
Card::create()
|
||||
->header(
|
||||
'<strong>'.trans('mail.Asset_Checkin_Notification', ['tag' => '']).'</strong>' ?: '',
|
||||
'<strong>' . trans('mail.Asset_Checkin_Notification', ['tag' => '']) . '</strong>' ?: '',
|
||||
htmlspecialchars_decode($item->display_name) ?: '',
|
||||
)
|
||||
->section(
|
||||
Section::create(
|
||||
KeyValue::create(
|
||||
trans('mail.checked_into') ?: '',
|
||||
($item->location) ? $item->location->name : '',
|
||||
trans('admin/hardware/form.status').': '.$item->status?->name,
|
||||
)
|
||||
->onClick(route('hardware.show', $item->id))
|
||||
($item->location) ? $item->location?->name : '',
|
||||
trans('admin/hardware/form.status') . ': ' . $item->status?->name
|
||||
)->onClick(route('hardware.show', $item->id))
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -14,12 +14,27 @@ class AccessoryObserver
|
||||
*/
|
||||
public function updated(Accessory $accessory)
|
||||
{
|
||||
$logAction = new Actionlog;
|
||||
$logAction->item_type = Accessory::class;
|
||||
$logAction->item_id = $accessory->id;
|
||||
$logAction->created_at = date('Y-m-d H:i:s');
|
||||
$logAction->created_by = auth()->id();
|
||||
$logAction->logaction('update');
|
||||
$changed = [];
|
||||
|
||||
foreach ($accessory->getRawOriginal() as $key => $value) {
|
||||
if ($key === 'updated_at') {
|
||||
continue;
|
||||
}
|
||||
if ($accessory->getRawOriginal()[$key] != $accessory->getAttributes()[$key]) {
|
||||
$changed[$key]['old'] = $accessory->getRawOriginal()[$key];
|
||||
$changed[$key]['new'] = $accessory->getAttributes()[$key];
|
||||
}
|
||||
}
|
||||
|
||||
if (count($changed) > 0) {
|
||||
$logAction = new Actionlog;
|
||||
$logAction->item_type = Accessory::class;
|
||||
$logAction->item_id = $accessory->id;
|
||||
$logAction->created_at = date('Y-m-d H:i:s');
|
||||
$logAction->created_by = auth()->id();
|
||||
$logAction->log_meta = json_encode($changed);
|
||||
$logAction->logaction('update');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,6 +15,8 @@ class ComponentObserver
|
||||
public function updated(Component $component)
|
||||
{
|
||||
|
||||
$changed = [];
|
||||
|
||||
foreach ($component->getRawOriginal() as $key => $value) {
|
||||
// Check and see if the value changed
|
||||
if ($component->getRawOriginal()[$key] != $component->getAttributes()[$key]) {
|
||||
|
||||
@@ -14,12 +14,27 @@ class LicenseObserver
|
||||
*/
|
||||
public function updated(License $license)
|
||||
{
|
||||
$logAction = new Actionlog;
|
||||
$logAction->item_type = License::class;
|
||||
$logAction->item_id = $license->id;
|
||||
$logAction->created_at = date('Y-m-d H:i:s');
|
||||
$logAction->created_by = auth()->id();
|
||||
$logAction->logaction('update');
|
||||
$changed = [];
|
||||
|
||||
foreach ($license->getRawOriginal() as $key => $value) {
|
||||
if ($key === 'updated_at') {
|
||||
continue;
|
||||
}
|
||||
if ($license->getRawOriginal()[$key] != $license->getAttributes()[$key]) {
|
||||
$changed[$key]['old'] = $license->getRawOriginal()[$key];
|
||||
$changed[$key]['new'] = $license->getAttributes()[$key];
|
||||
}
|
||||
}
|
||||
|
||||
if (count($changed) > 0) {
|
||||
$logAction = new Actionlog;
|
||||
$logAction->item_type = License::class;
|
||||
$logAction->item_id = $license->id;
|
||||
$logAction->created_at = date('Y-m-d H:i:s');
|
||||
$logAction->created_by = auth()->id();
|
||||
$logAction->log_meta = json_encode($changed);
|
||||
$logAction->logaction('update');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -75,13 +75,18 @@ class UserObserver
|
||||
foreach (array_unique(array_merge(array_keys($oldDecoded), array_keys($newDecoded))) as $permKey) {
|
||||
$oldPerm = $oldDecoded[$permKey] ?? null;
|
||||
$newPerm = $newDecoded[$permKey] ?? null;
|
||||
if ($oldPerm != $newPerm) {
|
||||
// null and "0" are both "inherit" — treat them as equivalent
|
||||
$normalizedOld = ($oldPerm === null || $oldPerm === '0' || $oldPerm === 0) ? null : $oldPerm;
|
||||
$normalizedNew = ($newPerm === null || $newPerm === '0' || $newPerm === 0) ? null : $newPerm;
|
||||
if ($normalizedOld !== $normalizedNew) {
|
||||
$diffOld[$permKey] = $oldPerm;
|
||||
$diffNew[$permKey] = $newPerm;
|
||||
}
|
||||
}
|
||||
$changed['permissions']['old'] = json_encode($diffOld);
|
||||
$changed['permissions']['new'] = json_encode($diffNew);
|
||||
if (! empty($diffOld) || ! empty($diffNew)) {
|
||||
$changed['permissions']['old'] = json_encode($diffOld);
|
||||
$changed['permissions']['new'] = json_encode($diffNew);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ class CategoryPresenter extends Presenter
|
||||
'searchable' => false,
|
||||
'sortable' => true,
|
||||
'class' => 'css-envelope',
|
||||
'title' => 'Send Email',
|
||||
'title' => trans('general.send_email'),
|
||||
'visible' => true,
|
||||
'formatter' => 'trueFalseFormatter',
|
||||
], [
|
||||
|
||||
@@ -23,11 +23,13 @@ use App\Observers\LocationObserver;
|
||||
use App\Observers\MaintenanceObserver;
|
||||
use App\Observers\SettingObserver;
|
||||
use App\Observers\UserObserver;
|
||||
use App\View\Composers\SidebarComposer;
|
||||
use Illuminate\Pagination\Paginator;
|
||||
use Illuminate\Routing\UrlGenerator;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Rollbar\Laravel\RollbarServiceProvider;
|
||||
|
||||
@@ -75,6 +77,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
|
||||
Paginator::useBootstrap();
|
||||
|
||||
View::composer('layouts.default', SidebarComposer::class);
|
||||
|
||||
Schema::defaultStringLength(191);
|
||||
Accessory::observe(AccessoryObserver::class);
|
||||
Asset::observe(AssetObserver::class);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\Accessory;
|
||||
use App\Models\AccessoryCheckout;
|
||||
use App\Models\Asset;
|
||||
use App\Models\AssetModel;
|
||||
use App\Models\Category;
|
||||
@@ -22,6 +23,7 @@ use App\Models\PredefinedKit;
|
||||
use App\Models\Statuslabel;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Tabuna\Breadcrumbs\Breadcrumbs;
|
||||
use Tabuna\Breadcrumbs\Trail;
|
||||
@@ -119,6 +121,24 @@ class BreadcrumbsServiceProvider extends ServiceProvider
|
||||
->push(trans('general.update'))
|
||||
);
|
||||
|
||||
Breadcrumbs::for('accessories.checkout.show', fn (Trail $trail, Accessory $accessory) => $trail->parent('accessories.show', $accessory)
|
||||
->push(trans('general.checkout'))
|
||||
);
|
||||
|
||||
Breadcrumbs::for('accessories.checkin.show', function (Trail $trail, int $accessoryID) {
|
||||
$checkout = AccessoryCheckout::find($accessoryID);
|
||||
$accessory = $checkout ? Accessory::find($checkout->accessory_id) : null;
|
||||
$trail->parent('accessories.index');
|
||||
if ($accessory) {
|
||||
$trail->push($accessory->name, route('accessories.show', $accessory));
|
||||
}
|
||||
$trail->push(trans('general.checkin'));
|
||||
});
|
||||
|
||||
Breadcrumbs::for('clone/accessories', fn (Trail $trail, Accessory $accessory) => $trail->parent('accessories.show', $accessory)
|
||||
->push(trans('general.clone'))
|
||||
);
|
||||
|
||||
/**
|
||||
* Categories Breadcrumbs
|
||||
*/
|
||||
@@ -184,6 +204,25 @@ class BreadcrumbsServiceProvider extends ServiceProvider
|
||||
->push(trans('general.clone'), route('components.create'))
|
||||
);
|
||||
|
||||
Breadcrumbs::for('components.checkout.show', function (Trail $trail, int $componentID) {
|
||||
$component = Component::find($componentID);
|
||||
$trail->parent('components.index');
|
||||
if ($component) {
|
||||
$trail->push($component->name, route('components.show', $component));
|
||||
}
|
||||
$trail->push(trans('general.checkout'));
|
||||
});
|
||||
|
||||
Breadcrumbs::for('components.checkin.show', function (Trail $trail, int $componentAssetId) {
|
||||
$componentAsset = DB::table('components_assets')->find($componentAssetId);
|
||||
$component = $componentAsset ? Component::find($componentAsset->component_id) : null;
|
||||
$trail->parent('components.index');
|
||||
if ($component) {
|
||||
$trail->push($component->name, route('components.show', $component));
|
||||
}
|
||||
$trail->push(trans('general.checkin'));
|
||||
});
|
||||
|
||||
/**
|
||||
* Consumables Breadcrumbs
|
||||
*/
|
||||
@@ -204,6 +243,19 @@ class BreadcrumbsServiceProvider extends ServiceProvider
|
||||
->push(trans('general.update'))
|
||||
);
|
||||
|
||||
Breadcrumbs::for('consumables.checkout.show', function (Trail $trail, $consumablesID) {
|
||||
$consumable = Consumable::find($consumablesID);
|
||||
$trail->parent('consumables.index');
|
||||
if ($consumable) {
|
||||
$trail->push($consumable->name, route('consumables.show', $consumable));
|
||||
}
|
||||
$trail->push(trans('general.checkout'));
|
||||
});
|
||||
|
||||
Breadcrumbs::for('consumables.clone.create', fn (Trail $trail, Consumable $consumable) => $trail->parent('consumables.show', $consumable)
|
||||
->push(trans('general.clone'))
|
||||
);
|
||||
|
||||
/**
|
||||
* Custom fields Breadcrumbs
|
||||
*/
|
||||
|
||||
@@ -30,6 +30,7 @@ class SettingsServiceProvider extends ServiceProvider
|
||||
// Share common setting variables with all views.
|
||||
view()->composer('*', function ($view) {
|
||||
$view->with('snipeSettings', Setting::getSettings());
|
||||
$view->with('settings', Setting::getSettings());
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -349,6 +349,20 @@ class ValidationServiceProvider extends ServiceProvider
|
||||
return in_array($value, $options);
|
||||
});
|
||||
|
||||
Validator::replacer('fmcs_location', function ($message, $attribute, $rule, $parameters, $validator) {
|
||||
$locationId = $validator->getData()[$attribute] ?? null;
|
||||
$location = $locationId ? Location::find($locationId) : null;
|
||||
|
||||
return str_replace(
|
||||
[':location', ':location_company'],
|
||||
[
|
||||
$location?->name ?? '?',
|
||||
$location?->company?->name ?? trans('general.unassigned'),
|
||||
],
|
||||
$message
|
||||
);
|
||||
});
|
||||
|
||||
// Validates that the company of the validated object matches the company of the location in case of scoped locations
|
||||
Validator::extend('fmcs_location', function ($attribute, $value, $parameters, $validator) {
|
||||
$settings = Setting::getSettings();
|
||||
|
||||
@@ -7,11 +7,11 @@ use App\Models\PredefinedKit;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Class incapsulates checkout logic for reuse in different controllers
|
||||
*
|
||||
* @author [D. Minaev.] [<dmitriy.minaev.v@gmail.com>]
|
||||
*/
|
||||
class PredefinedKitCheckoutService
|
||||
@@ -19,9 +19,9 @@ class PredefinedKitCheckoutService
|
||||
use AuthorizesRequests;
|
||||
|
||||
/**
|
||||
* @param Request $request, this function works with fields: checkout_at, expected_checkin, note
|
||||
* @param PredefinedKit $kit kit for checkout
|
||||
* @param User $user checkout target
|
||||
* @param Request $request, this function works with fields: checkout_at, expected_checkin, note
|
||||
* @param PredefinedKit $kit kit for checkout
|
||||
* @param User $user checkout target
|
||||
* @return array Empty array if all ok, else [string_error1, string_error2...]
|
||||
*/
|
||||
public function checkout(Request $request, PredefinedKit $kit, User $user)
|
||||
@@ -93,7 +93,7 @@ class PredefinedKitCheckoutService
|
||||
}
|
||||
}
|
||||
if ($quantity > 0) {
|
||||
$errors[] = trans('admin/kits/general.none_models', ['model'=> $model->name, 'qty' => $model->pivot->quantity]);
|
||||
$errors[] = trans('admin/kits/general.none_models', ['model' => $model->name, 'qty' => $model->pivot->quantity]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,9 +107,10 @@ class PredefinedKitCheckoutService
|
||||
->with('freeSeats')
|
||||
->get();
|
||||
foreach ($licenses as $license) {
|
||||
$this->authorize('checkout', $license);
|
||||
$quantity = $license->pivot->quantity;
|
||||
if ($quantity > count($license->freeSeats)) {
|
||||
$errors[] = trans('admin/kits/general.none_licenses', ['license'=> $license->name, 'qty' => $license->pivot->quantity]);
|
||||
$errors[] = trans('admin/kits/general.none_licenses', ['license' => $license->name, 'qty' => $license->pivot->quantity]);
|
||||
}
|
||||
for ($i = 0; $i < $quantity; $i++) {
|
||||
$seats_to_add[] = $license->freeSeats[$i];
|
||||
@@ -123,8 +124,9 @@ class PredefinedKitCheckoutService
|
||||
{
|
||||
$consumables = $kit->consumables()->with('users')->get();
|
||||
foreach ($consumables as $consumable) {
|
||||
$this->authorize('checkout', $consumable);
|
||||
if ($consumable->numRemaining() < $consumable->pivot->quantity) {
|
||||
$errors[] = trans('admin/kits/general.none_consumables', ['consumable'=> $consumable->name, 'qty' => $consumable->pivot->quantity]);
|
||||
$errors[] = trans('admin/kits/general.none_consumables', ['consumable' => $consumable->name, 'qty' => $consumable->pivot->quantity]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,8 +137,9 @@ class PredefinedKitCheckoutService
|
||||
{
|
||||
$accessories = $kit->accessories()->with('users')->get();
|
||||
foreach ($accessories as $accessory) {
|
||||
$this->authorize('checkout', $accessory);
|
||||
if ($accessory->numRemaining() < $accessory->pivot->quantity) {
|
||||
$errors[] = trans('admin/kits/general.none_accessory', ['accessory'=> $accessory->name, 'qty' => $accessory->pivot->quantity]);
|
||||
$errors[] = trans('admin/kits/general.none_accessory', ['accessory' => $accessory->name, 'qty' => $accessory->pivot->quantity]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,7 +178,7 @@ class PredefinedKitCheckoutService
|
||||
]);
|
||||
event(new CheckoutableCheckedOut($consumable, $user, $admin, $note));
|
||||
}
|
||||
//accessories
|
||||
// accessories
|
||||
foreach ($accessories_to_add as $accessory) {
|
||||
$accessory->assigned_to = $user->id;
|
||||
$accessory->users()->attach($accessory->id, [
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
// A View Composer is a callback Laravel runs right before a specific view renders.
|
||||
// It's registered in AppServiceProvider bound to 'layouts.default', so it only fires
|
||||
// when a full page is rendered — not on modal AJAX responses, select2 searches, or
|
||||
// API requests. This replaces the old AssetCountForSidebar middleware, which ran on
|
||||
// every web request regardless of what was returned.
|
||||
|
||||
namespace App\View\Composers;
|
||||
|
||||
use App\Models\Asset;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SidebarComposer
|
||||
{
|
||||
public function compose(View $view): void
|
||||
{
|
||||
// Guard against the setup wizard, where DB tables may not exist yet
|
||||
try {
|
||||
$settings = Setting::getSettings();
|
||||
} catch (\Exception $e) {
|
||||
Log::debug($e);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$due_for_checkin = Asset::DueForCheckin($settings)->count();
|
||||
$overdue_for_checkin = Asset::OverdueForCheckin()->count();
|
||||
$due_for_audit = Asset::DueForAudit($settings)->count();
|
||||
$overdue_for_audit = Asset::OverdueForAudit()->count();
|
||||
|
||||
$view->with([
|
||||
'total_assets' => Asset::AssetsForShow()->count(),
|
||||
'total_rtd_sidebar' => Asset::RTD()->count(),
|
||||
'total_deployed_sidebar' => Asset::Deployed()->count(),
|
||||
'total_archived_sidebar' => Asset::Archived()->count(),
|
||||
'total_pending_sidebar' => Asset::Pending()->count(),
|
||||
'total_undeployable_sidebar' => Asset::Undeployable()->count(),
|
||||
'total_byod_sidebar' => Asset::where('byod', 1)->count(),
|
||||
'total_due_for_audit' => $due_for_audit,
|
||||
'total_overdue_for_audit' => $overdue_for_audit,
|
||||
'total_due_for_checkin' => $due_for_checkin,
|
||||
'total_overdue_for_checkin' => $overdue_for_checkin,
|
||||
'total_due_and_overdue_for_checkin' => $due_for_checkin + $overdue_for_checkin,
|
||||
'total_due_and_overdue_for_audit' => $due_for_audit + $overdue_for_audit,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::debug($e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -11,12 +11,16 @@
|
||||
// This is used by the mysql dump options in spatie backup
|
||||
$dump_options = [
|
||||
'dump_binary_path' => env('DB_DUMP_PATH', '/usr/local/bin'), // only the path, so without 'mysqldump'
|
||||
'use_single_transaction' => false,
|
||||
'timeout' => 60 * 5, // 5 minute timeout
|
||||
// 'exclude_tables' => ['table1', 'table2'],
|
||||
// 'add_extra_option' => '--optionname=optionvalue',
|
||||
];
|
||||
|
||||
// Default to false (preserves original behavior for non-RDS installs).
|
||||
// Set DB_DUMP_SINGLE_TRANSACTION=true in .env for RDS or other environments
|
||||
// that require --single-transaction (e.g. no LOCK TABLES privilege).
|
||||
$dump_options['use_single_transaction'] = (env('DB_DUMP_SINGLE_TRANSACTION', 'false') === 'true');
|
||||
|
||||
// For modern versions of mysqldump, use --ssl-mode=DISABLED
|
||||
if (env('DB_DUMP_SKIP_SSL') == 'true') {
|
||||
// Correctly add the option as a string to the 'add_extra_option' key.
|
||||
|
||||
@@ -131,6 +131,7 @@ $config['allowed_upload_extensions_array'] = [
|
||||
'docx',
|
||||
'gif',
|
||||
'ico',
|
||||
'jfif',
|
||||
'jpeg',
|
||||
'jpg',
|
||||
'json',
|
||||
|
||||
+5
-5
@@ -1,11 +1,11 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'app_version' => 'v8.6.1',
|
||||
'full_app_version' => 'v8.6.1 - build 22962-g4edf40acaf',
|
||||
'build_version' => '22962',
|
||||
'app_version' => 'v8.6.2',
|
||||
'full_app_version' => 'v8.6.2 - build 23177-gc98eea1ce5',
|
||||
'build_version' => '23177',
|
||||
'prerelease_version' => '',
|
||||
'hash_version' => 'g4edf40acaf',
|
||||
'full_hash' => 'v8.6.1-149-g4edf40acaf',
|
||||
'hash_version' => 'gc98eea1ce5',
|
||||
'full_hash' => 'v8.6.2-212-gc98eea1ce5',
|
||||
'branch' => 'develop',
|
||||
];
|
||||
|
||||
@@ -154,4 +154,15 @@ class CustomFieldFactory extends Factory
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function testMarkdownTextarea()
|
||||
{
|
||||
return $this->state(function () {
|
||||
return [
|
||||
'name' => 'Notes',
|
||||
'help_text' => 'Additional notes about this asset. Markdown is supported.',
|
||||
'element' => 'markdown-textarea',
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,6 +430,11 @@ class UserFactory extends Factory
|
||||
return $this->appendPermission(['kits.delete' => '1']);
|
||||
}
|
||||
|
||||
public function editPredefinedKits()
|
||||
{
|
||||
return $this->appendPermission(['kits.edit' => '1']);
|
||||
}
|
||||
|
||||
public function viewPredefinedKits()
|
||||
{
|
||||
return $this->appendPermission(['kits.view' => '1']);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('settings', function (Blueprint $table) {
|
||||
$table->boolean('null_company_is_floater')->default(false)->after('scope_locations_fmcs');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('settings', function (Blueprint $table) {
|
||||
$table->dropColumn('null_company_is_floater');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -36,6 +36,7 @@ class CustomFieldSeeder extends Seeder
|
||||
CustomField::factory()->count(1)->testEncrypted()->create();
|
||||
CustomField::factory()->count(1)->testCheckbox()->create();
|
||||
CustomField::factory()->count(1)->testRadio()->create();
|
||||
CustomField::factory()->count(1)->testMarkdownTextarea()->create();
|
||||
|
||||
DB::table('custom_field_custom_fieldset')->insert([
|
||||
[
|
||||
@@ -109,6 +110,20 @@ class CustomFieldSeeder extends Seeder
|
||||
'required' => 0,
|
||||
],
|
||||
|
||||
[
|
||||
'custom_field_id' => '9',
|
||||
'custom_fieldset_id' => '1',
|
||||
'order' => 0,
|
||||
'required' => 0,
|
||||
],
|
||||
|
||||
[
|
||||
'custom_field_id' => '9',
|
||||
'custom_fieldset_id' => '2',
|
||||
'order' => 0,
|
||||
'required' => 0,
|
||||
],
|
||||
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ class DatabaseSeeder extends Seeder
|
||||
public function run()
|
||||
{
|
||||
Model::unguard();
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS=0');
|
||||
|
||||
// Only create default settings if they do not exist in the db.
|
||||
if (! Setting::first()) {
|
||||
@@ -51,6 +52,7 @@ class DatabaseSeeder extends Seeder
|
||||
Log::info($output);
|
||||
|
||||
Model::reguard();
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS=1');
|
||||
|
||||
DB::table('imports')->truncate();
|
||||
DB::table('requested_assets')->truncate();
|
||||
|
||||
+91
-47
@@ -721,6 +721,9 @@ body {
|
||||
.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading {
|
||||
z-index: 0 !important;
|
||||
}
|
||||
.bootstrap-table .fixed-table-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
@media print {
|
||||
@page {
|
||||
size: A4;
|
||||
@@ -1030,21 +1033,21 @@ h4 {
|
||||
|
||||
See https://github.com/grokability/snipe-it/issues/7989
|
||||
*/
|
||||
th.css-accessory > .th-inner,
|
||||
th.css-accessory-alt > .th-inner,
|
||||
th.css-barcode > .th-inner,
|
||||
th.css-component > .th-inner,
|
||||
th.css-consumable > .th-inner,
|
||||
th.css-envelope > .th-inner,
|
||||
th.css-house-flag > .th-inner,
|
||||
th.css-house-laptop > .th-inner,
|
||||
th.css-house-user > .th-inner,
|
||||
th.css-license > .th-inner,
|
||||
th.css-location > .th-inner,
|
||||
th.css-users > .th-inner,
|
||||
th.css-currency > .th-inner,
|
||||
th.css-child-locations > .th-inner,
|
||||
th.css-history > .th-inner {
|
||||
thead th.css-accessory > .th-inner,
|
||||
thead th.css-accessory-alt > .th-inner,
|
||||
thead th.css-barcode > .th-inner,
|
||||
thead th.css-component > .th-inner,
|
||||
thead th.css-consumable > .th-inner,
|
||||
thead th.css-envelope > .th-inner,
|
||||
thead th.css-house-flag > .th-inner,
|
||||
thead th.css-house-laptop > .th-inner,
|
||||
thead th.css-house-user > .th-inner,
|
||||
thead th.css-license > .th-inner,
|
||||
thead th.css-location > .th-inner,
|
||||
thead th.css-users > .th-inner,
|
||||
thead th.css-currency > .th-inner,
|
||||
thead th.css-child-locations > .th-inner,
|
||||
thead th.css-history > .th-inner {
|
||||
font-size: 0px;
|
||||
line-height: 0.75 !important;
|
||||
text-align: left;
|
||||
@@ -1052,22 +1055,22 @@ th.css-history > .th-inner {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
th.css-location > .th-inner::before,
|
||||
th.css-accessory > .th-inner::before,
|
||||
th.css-accessory-alt > .th-inner::before,
|
||||
th.css-barcode > .th-inner::before,
|
||||
th.css-component > .th-inner::before,
|
||||
th.css-consumable > .th-inner::before,
|
||||
th.css-envelope > .th-inner::before,
|
||||
th.css-house-flag > .th-inner::before,
|
||||
th.css-house-laptop > .th-inner::before,
|
||||
th.css-house-user > .th-inner::before,
|
||||
th.css-license > .th-inner::before,
|
||||
th.css-location > .th-inner::before,
|
||||
th.css-users > .th-inner::before,
|
||||
th.css-currency > .th-inner::before,
|
||||
th.css-child-locations > .th-inner::before,
|
||||
th.css-history > .th-inner::before {
|
||||
thead th.css-location > .th-inner::before,
|
||||
thead th.css-accessory > .th-inner::before,
|
||||
thead th.css-accessory-alt > .th-inner::before,
|
||||
thead th.css-barcode > .th-inner::before,
|
||||
thead th.css-component > .th-inner::before,
|
||||
thead th.css-consumable > .th-inner::before,
|
||||
thead th.css-envelope > .th-inner::before,
|
||||
thead th.css-house-flag > .th-inner::before,
|
||||
thead th.css-house-laptop > .th-inner::before,
|
||||
thead th.css-house-user > .th-inner::before,
|
||||
thead th.css-license > .th-inner::before,
|
||||
thead th.css-location > .th-inner::before,
|
||||
thead th.css-users > .th-inner::before,
|
||||
thead th.css-currency > .th-inner::before,
|
||||
thead th.css-child-locations > .th-inner::before,
|
||||
thead th.css-history > .th-inner::before {
|
||||
display: inline-block;
|
||||
font-size: 20px;
|
||||
font-family: "Font Awesome 5 Free";
|
||||
@@ -1077,90 +1080,90 @@ th.css-history > .th-inner::before {
|
||||
BEGIN ICON TABLE HEADERS
|
||||
Set the font-weight css property as 900 (For Solid), 400 (Regular or Brands), 300 (Light for pro icons).
|
||||
**/
|
||||
th.css-barcode > .th-inner::before {
|
||||
thead th.css-barcode > .th-inner::before {
|
||||
content: "\f02a";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-weight: 900;
|
||||
}
|
||||
th.css-license > .th-inner::before {
|
||||
thead th.css-license > .th-inner::before {
|
||||
content: "\f0c7";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-weight: 400;
|
||||
}
|
||||
th.css-consumable > .th-inner::before {
|
||||
thead th.css-consumable > .th-inner::before {
|
||||
content: "\f043";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-weight: 900;
|
||||
}
|
||||
th.css-envelope > .th-inner::before {
|
||||
thead th.css-envelope > .th-inner::before {
|
||||
content: "\f0e0";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-weight: 400;
|
||||
}
|
||||
th.css-accessory > .th-inner::before {
|
||||
thead th.css-accessory > .th-inner::before {
|
||||
content: "\f11c";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-weight: 400;
|
||||
}
|
||||
th.css-users > .th-inner::before {
|
||||
thead th.css-users > .th-inner::before {
|
||||
content: "\f0c0";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 15px;
|
||||
}
|
||||
th.css-location > .th-inner::before {
|
||||
thead th.css-location > .th-inner::before {
|
||||
content: "\f3c5";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
th.css-component > .th-inner::before {
|
||||
thead th.css-component > .th-inner::before {
|
||||
content: "\f0a0";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-weight: 500;
|
||||
}
|
||||
th.css-padlock > .th-inner::before {
|
||||
thead th.css-padlock > .th-inner::before {
|
||||
content: "\f023";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-weight: 800;
|
||||
padding-right: 3px;
|
||||
}
|
||||
th.css-house-user > .th-inner::before {
|
||||
thead th.css-house-user > .th-inner::before {
|
||||
content: "\e1b0";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
th.css-house-flag > .th-inner::before {
|
||||
thead th.css-house-flag > .th-inner::before {
|
||||
content: "\e50d";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
th.css-house-laptop > .th-inner::before {
|
||||
thead th.css-house-laptop > .th-inner::before {
|
||||
content: "\e066";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
th.css-accessory-alt > .th-inner::before {
|
||||
thead th.css-accessory-alt > .th-inner::before {
|
||||
content: "\f11c";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
th.css-child-locations > .th-inner::before {
|
||||
thead th.css-child-locations > .th-inner::before {
|
||||
content: "\f64f";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
th.css-currency > .th-inner::before {
|
||||
thead th.css-currency > .th-inner::before {
|
||||
content: "\24";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
th.css-history > .th-inner::before {
|
||||
thead th.css-history > .th-inner::before {
|
||||
content: "\f1da";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
@@ -1463,6 +1466,46 @@ caption.tableCaption {
|
||||
font-size: 18px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
.markdown-field-content h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin: 6px 0 4px;
|
||||
}
|
||||
.markdown-field-content h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 6px 0 4px;
|
||||
}
|
||||
.markdown-field-content h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin: 6px 0 4px;
|
||||
}
|
||||
.markdown-field-content h4 {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin: 4px 0 4px;
|
||||
}
|
||||
.markdown-field-content h5,
|
||||
.markdown-field-content h6 {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
margin: 4px 0 4px;
|
||||
}
|
||||
.markdown-field-content p {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
.markdown-field-content p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.markdown-field-content ul,
|
||||
.markdown-field-content ol {
|
||||
margin: 0 0 4px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.markdown-field-content > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.sidebar-toggle.btn {
|
||||
border-radius: 3px;
|
||||
box-shadow: none;
|
||||
@@ -1565,6 +1608,7 @@ Radio toggle styles for permission settings and check/uncheck all
|
||||
cursor: pointer;
|
||||
}
|
||||
.js-copy-link {
|
||||
float: left;
|
||||
color: grey;
|
||||
}
|
||||
.deleted {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -349,6 +349,9 @@ body {
|
||||
.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading {
|
||||
z-index: 0 !important;
|
||||
}
|
||||
.bootstrap-table .fixed-table-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
@media print {
|
||||
@page {
|
||||
size: A4;
|
||||
@@ -658,21 +661,21 @@ h4 {
|
||||
|
||||
See https://github.com/grokability/snipe-it/issues/7989
|
||||
*/
|
||||
th.css-accessory > .th-inner,
|
||||
th.css-accessory-alt > .th-inner,
|
||||
th.css-barcode > .th-inner,
|
||||
th.css-component > .th-inner,
|
||||
th.css-consumable > .th-inner,
|
||||
th.css-envelope > .th-inner,
|
||||
th.css-house-flag > .th-inner,
|
||||
th.css-house-laptop > .th-inner,
|
||||
th.css-house-user > .th-inner,
|
||||
th.css-license > .th-inner,
|
||||
th.css-location > .th-inner,
|
||||
th.css-users > .th-inner,
|
||||
th.css-currency > .th-inner,
|
||||
th.css-child-locations > .th-inner,
|
||||
th.css-history > .th-inner {
|
||||
thead th.css-accessory > .th-inner,
|
||||
thead th.css-accessory-alt > .th-inner,
|
||||
thead th.css-barcode > .th-inner,
|
||||
thead th.css-component > .th-inner,
|
||||
thead th.css-consumable > .th-inner,
|
||||
thead th.css-envelope > .th-inner,
|
||||
thead th.css-house-flag > .th-inner,
|
||||
thead th.css-house-laptop > .th-inner,
|
||||
thead th.css-house-user > .th-inner,
|
||||
thead th.css-license > .th-inner,
|
||||
thead th.css-location > .th-inner,
|
||||
thead th.css-users > .th-inner,
|
||||
thead th.css-currency > .th-inner,
|
||||
thead th.css-child-locations > .th-inner,
|
||||
thead th.css-history > .th-inner {
|
||||
font-size: 0px;
|
||||
line-height: 0.75 !important;
|
||||
text-align: left;
|
||||
@@ -680,22 +683,22 @@ th.css-history > .th-inner {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
th.css-location > .th-inner::before,
|
||||
th.css-accessory > .th-inner::before,
|
||||
th.css-accessory-alt > .th-inner::before,
|
||||
th.css-barcode > .th-inner::before,
|
||||
th.css-component > .th-inner::before,
|
||||
th.css-consumable > .th-inner::before,
|
||||
th.css-envelope > .th-inner::before,
|
||||
th.css-house-flag > .th-inner::before,
|
||||
th.css-house-laptop > .th-inner::before,
|
||||
th.css-house-user > .th-inner::before,
|
||||
th.css-license > .th-inner::before,
|
||||
th.css-location > .th-inner::before,
|
||||
th.css-users > .th-inner::before,
|
||||
th.css-currency > .th-inner::before,
|
||||
th.css-child-locations > .th-inner::before,
|
||||
th.css-history > .th-inner::before {
|
||||
thead th.css-location > .th-inner::before,
|
||||
thead th.css-accessory > .th-inner::before,
|
||||
thead th.css-accessory-alt > .th-inner::before,
|
||||
thead th.css-barcode > .th-inner::before,
|
||||
thead th.css-component > .th-inner::before,
|
||||
thead th.css-consumable > .th-inner::before,
|
||||
thead th.css-envelope > .th-inner::before,
|
||||
thead th.css-house-flag > .th-inner::before,
|
||||
thead th.css-house-laptop > .th-inner::before,
|
||||
thead th.css-house-user > .th-inner::before,
|
||||
thead th.css-license > .th-inner::before,
|
||||
thead th.css-location > .th-inner::before,
|
||||
thead th.css-users > .th-inner::before,
|
||||
thead th.css-currency > .th-inner::before,
|
||||
thead th.css-child-locations > .th-inner::before,
|
||||
thead th.css-history > .th-inner::before {
|
||||
display: inline-block;
|
||||
font-size: 20px;
|
||||
font-family: "Font Awesome 5 Free";
|
||||
@@ -705,90 +708,90 @@ th.css-history > .th-inner::before {
|
||||
BEGIN ICON TABLE HEADERS
|
||||
Set the font-weight css property as 900 (For Solid), 400 (Regular or Brands), 300 (Light for pro icons).
|
||||
**/
|
||||
th.css-barcode > .th-inner::before {
|
||||
thead th.css-barcode > .th-inner::before {
|
||||
content: "\f02a";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-weight: 900;
|
||||
}
|
||||
th.css-license > .th-inner::before {
|
||||
thead th.css-license > .th-inner::before {
|
||||
content: "\f0c7";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-weight: 400;
|
||||
}
|
||||
th.css-consumable > .th-inner::before {
|
||||
thead th.css-consumable > .th-inner::before {
|
||||
content: "\f043";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-weight: 900;
|
||||
}
|
||||
th.css-envelope > .th-inner::before {
|
||||
thead th.css-envelope > .th-inner::before {
|
||||
content: "\f0e0";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-weight: 400;
|
||||
}
|
||||
th.css-accessory > .th-inner::before {
|
||||
thead th.css-accessory > .th-inner::before {
|
||||
content: "\f11c";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-weight: 400;
|
||||
}
|
||||
th.css-users > .th-inner::before {
|
||||
thead th.css-users > .th-inner::before {
|
||||
content: "\f0c0";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 15px;
|
||||
}
|
||||
th.css-location > .th-inner::before {
|
||||
thead th.css-location > .th-inner::before {
|
||||
content: "\f3c5";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
th.css-component > .th-inner::before {
|
||||
thead th.css-component > .th-inner::before {
|
||||
content: "\f0a0";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-weight: 500;
|
||||
}
|
||||
th.css-padlock > .th-inner::before {
|
||||
thead th.css-padlock > .th-inner::before {
|
||||
content: "\f023";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-weight: 800;
|
||||
padding-right: 3px;
|
||||
}
|
||||
th.css-house-user > .th-inner::before {
|
||||
thead th.css-house-user > .th-inner::before {
|
||||
content: "\e1b0";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
th.css-house-flag > .th-inner::before {
|
||||
thead th.css-house-flag > .th-inner::before {
|
||||
content: "\e50d";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
th.css-house-laptop > .th-inner::before {
|
||||
thead th.css-house-laptop > .th-inner::before {
|
||||
content: "\e066";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
th.css-accessory-alt > .th-inner::before {
|
||||
thead th.css-accessory-alt > .th-inner::before {
|
||||
content: "\f11c";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
th.css-child-locations > .th-inner::before {
|
||||
thead th.css-child-locations > .th-inner::before {
|
||||
content: "\f64f";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
th.css-currency > .th-inner::before {
|
||||
thead th.css-currency > .th-inner::before {
|
||||
content: "\24";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
th.css-history > .th-inner::before {
|
||||
thead th.css-history > .th-inner::before {
|
||||
content: "\f1da";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
@@ -1091,6 +1094,46 @@ caption.tableCaption {
|
||||
font-size: 18px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
.markdown-field-content h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin: 6px 0 4px;
|
||||
}
|
||||
.markdown-field-content h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 6px 0 4px;
|
||||
}
|
||||
.markdown-field-content h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin: 6px 0 4px;
|
||||
}
|
||||
.markdown-field-content h4 {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin: 4px 0 4px;
|
||||
}
|
||||
.markdown-field-content h5,
|
||||
.markdown-field-content h6 {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
margin: 4px 0 4px;
|
||||
}
|
||||
.markdown-field-content p {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
.markdown-field-content p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.markdown-field-content ul,
|
||||
.markdown-field-content ol {
|
||||
margin: 0 0 4px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.markdown-field-content > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.sidebar-toggle.btn {
|
||||
border-radius: 3px;
|
||||
box-shadow: none;
|
||||
@@ -1193,6 +1236,7 @@ Radio toggle styles for permission settings and check/uncheck all
|
||||
cursor: pointer;
|
||||
}
|
||||
.js-copy-link {
|
||||
float: left;
|
||||
color: grey;
|
||||
}
|
||||
.deleted {
|
||||
|
||||
File diff suppressed because one or more lines are too long
Vendored
+182
-94
@@ -22057,6 +22057,9 @@ body {
|
||||
.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading {
|
||||
z-index: 0 !important;
|
||||
}
|
||||
.bootstrap-table .fixed-table-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
@media print {
|
||||
@page {
|
||||
size: A4;
|
||||
@@ -22366,21 +22369,21 @@ h4 {
|
||||
|
||||
See https://github.com/grokability/snipe-it/issues/7989
|
||||
*/
|
||||
th.css-accessory > .th-inner,
|
||||
th.css-accessory-alt > .th-inner,
|
||||
th.css-barcode > .th-inner,
|
||||
th.css-component > .th-inner,
|
||||
th.css-consumable > .th-inner,
|
||||
th.css-envelope > .th-inner,
|
||||
th.css-house-flag > .th-inner,
|
||||
th.css-house-laptop > .th-inner,
|
||||
th.css-house-user > .th-inner,
|
||||
th.css-license > .th-inner,
|
||||
th.css-location > .th-inner,
|
||||
th.css-users > .th-inner,
|
||||
th.css-currency > .th-inner,
|
||||
th.css-child-locations > .th-inner,
|
||||
th.css-history > .th-inner {
|
||||
thead th.css-accessory > .th-inner,
|
||||
thead th.css-accessory-alt > .th-inner,
|
||||
thead th.css-barcode > .th-inner,
|
||||
thead th.css-component > .th-inner,
|
||||
thead th.css-consumable > .th-inner,
|
||||
thead th.css-envelope > .th-inner,
|
||||
thead th.css-house-flag > .th-inner,
|
||||
thead th.css-house-laptop > .th-inner,
|
||||
thead th.css-house-user > .th-inner,
|
||||
thead th.css-license > .th-inner,
|
||||
thead th.css-location > .th-inner,
|
||||
thead th.css-users > .th-inner,
|
||||
thead th.css-currency > .th-inner,
|
||||
thead th.css-child-locations > .th-inner,
|
||||
thead th.css-history > .th-inner {
|
||||
font-size: 0px;
|
||||
line-height: 0.75 !important;
|
||||
text-align: left;
|
||||
@@ -22388,22 +22391,22 @@ th.css-history > .th-inner {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
th.css-location > .th-inner::before,
|
||||
th.css-accessory > .th-inner::before,
|
||||
th.css-accessory-alt > .th-inner::before,
|
||||
th.css-barcode > .th-inner::before,
|
||||
th.css-component > .th-inner::before,
|
||||
th.css-consumable > .th-inner::before,
|
||||
th.css-envelope > .th-inner::before,
|
||||
th.css-house-flag > .th-inner::before,
|
||||
th.css-house-laptop > .th-inner::before,
|
||||
th.css-house-user > .th-inner::before,
|
||||
th.css-license > .th-inner::before,
|
||||
th.css-location > .th-inner::before,
|
||||
th.css-users > .th-inner::before,
|
||||
th.css-currency > .th-inner::before,
|
||||
th.css-child-locations > .th-inner::before,
|
||||
th.css-history > .th-inner::before {
|
||||
thead th.css-location > .th-inner::before,
|
||||
thead th.css-accessory > .th-inner::before,
|
||||
thead th.css-accessory-alt > .th-inner::before,
|
||||
thead th.css-barcode > .th-inner::before,
|
||||
thead th.css-component > .th-inner::before,
|
||||
thead th.css-consumable > .th-inner::before,
|
||||
thead th.css-envelope > .th-inner::before,
|
||||
thead th.css-house-flag > .th-inner::before,
|
||||
thead th.css-house-laptop > .th-inner::before,
|
||||
thead th.css-house-user > .th-inner::before,
|
||||
thead th.css-license > .th-inner::before,
|
||||
thead th.css-location > .th-inner::before,
|
||||
thead th.css-users > .th-inner::before,
|
||||
thead th.css-currency > .th-inner::before,
|
||||
thead th.css-child-locations > .th-inner::before,
|
||||
thead th.css-history > .th-inner::before {
|
||||
display: inline-block;
|
||||
font-size: 20px;
|
||||
font-family: "Font Awesome 5 Free";
|
||||
@@ -22413,90 +22416,90 @@ th.css-history > .th-inner::before {
|
||||
BEGIN ICON TABLE HEADERS
|
||||
Set the font-weight css property as 900 (For Solid), 400 (Regular or Brands), 300 (Light for pro icons).
|
||||
**/
|
||||
th.css-barcode > .th-inner::before {
|
||||
thead th.css-barcode > .th-inner::before {
|
||||
content: "\f02a";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-weight: 900;
|
||||
}
|
||||
th.css-license > .th-inner::before {
|
||||
thead th.css-license > .th-inner::before {
|
||||
content: "\f0c7";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-weight: 400;
|
||||
}
|
||||
th.css-consumable > .th-inner::before {
|
||||
thead th.css-consumable > .th-inner::before {
|
||||
content: "\f043";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-weight: 900;
|
||||
}
|
||||
th.css-envelope > .th-inner::before {
|
||||
thead th.css-envelope > .th-inner::before {
|
||||
content: "\f0e0";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-weight: 400;
|
||||
}
|
||||
th.css-accessory > .th-inner::before {
|
||||
thead th.css-accessory > .th-inner::before {
|
||||
content: "\f11c";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-weight: 400;
|
||||
}
|
||||
th.css-users > .th-inner::before {
|
||||
thead th.css-users > .th-inner::before {
|
||||
content: "\f0c0";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 15px;
|
||||
}
|
||||
th.css-location > .th-inner::before {
|
||||
thead th.css-location > .th-inner::before {
|
||||
content: "\f3c5";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
th.css-component > .th-inner::before {
|
||||
thead th.css-component > .th-inner::before {
|
||||
content: "\f0a0";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-weight: 500;
|
||||
}
|
||||
th.css-padlock > .th-inner::before {
|
||||
thead th.css-padlock > .th-inner::before {
|
||||
content: "\f023";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-weight: 800;
|
||||
padding-right: 3px;
|
||||
}
|
||||
th.css-house-user > .th-inner::before {
|
||||
thead th.css-house-user > .th-inner::before {
|
||||
content: "\e1b0";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
th.css-house-flag > .th-inner::before {
|
||||
thead th.css-house-flag > .th-inner::before {
|
||||
content: "\e50d";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
th.css-house-laptop > .th-inner::before {
|
||||
thead th.css-house-laptop > .th-inner::before {
|
||||
content: "\e066";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
th.css-accessory-alt > .th-inner::before {
|
||||
thead th.css-accessory-alt > .th-inner::before {
|
||||
content: "\f11c";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
th.css-child-locations > .th-inner::before {
|
||||
thead th.css-child-locations > .th-inner::before {
|
||||
content: "\f64f";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
th.css-currency > .th-inner::before {
|
||||
thead th.css-currency > .th-inner::before {
|
||||
content: "\24";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
th.css-history > .th-inner::before {
|
||||
thead th.css-history > .th-inner::before {
|
||||
content: "\f1da";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
@@ -22799,6 +22802,46 @@ caption.tableCaption {
|
||||
font-size: 18px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
.markdown-field-content h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin: 6px 0 4px;
|
||||
}
|
||||
.markdown-field-content h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 6px 0 4px;
|
||||
}
|
||||
.markdown-field-content h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin: 6px 0 4px;
|
||||
}
|
||||
.markdown-field-content h4 {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin: 4px 0 4px;
|
||||
}
|
||||
.markdown-field-content h5,
|
||||
.markdown-field-content h6 {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
margin: 4px 0 4px;
|
||||
}
|
||||
.markdown-field-content p {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
.markdown-field-content p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.markdown-field-content ul,
|
||||
.markdown-field-content ol {
|
||||
margin: 0 0 4px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.markdown-field-content > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.sidebar-toggle.btn {
|
||||
border-radius: 3px;
|
||||
box-shadow: none;
|
||||
@@ -22901,6 +22944,7 @@ Radio toggle styles for permission settings and check/uncheck all
|
||||
cursor: pointer;
|
||||
}
|
||||
.js-copy-link {
|
||||
float: left;
|
||||
color: grey;
|
||||
}
|
||||
.deleted {
|
||||
@@ -23814,6 +23858,9 @@ body {
|
||||
.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading {
|
||||
z-index: 0 !important;
|
||||
}
|
||||
.bootstrap-table .fixed-table-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
@media print {
|
||||
@page {
|
||||
size: A4;
|
||||
@@ -24123,21 +24170,21 @@ h4 {
|
||||
|
||||
See https://github.com/grokability/snipe-it/issues/7989
|
||||
*/
|
||||
th.css-accessory > .th-inner,
|
||||
th.css-accessory-alt > .th-inner,
|
||||
th.css-barcode > .th-inner,
|
||||
th.css-component > .th-inner,
|
||||
th.css-consumable > .th-inner,
|
||||
th.css-envelope > .th-inner,
|
||||
th.css-house-flag > .th-inner,
|
||||
th.css-house-laptop > .th-inner,
|
||||
th.css-house-user > .th-inner,
|
||||
th.css-license > .th-inner,
|
||||
th.css-location > .th-inner,
|
||||
th.css-users > .th-inner,
|
||||
th.css-currency > .th-inner,
|
||||
th.css-child-locations > .th-inner,
|
||||
th.css-history > .th-inner {
|
||||
thead th.css-accessory > .th-inner,
|
||||
thead th.css-accessory-alt > .th-inner,
|
||||
thead th.css-barcode > .th-inner,
|
||||
thead th.css-component > .th-inner,
|
||||
thead th.css-consumable > .th-inner,
|
||||
thead th.css-envelope > .th-inner,
|
||||
thead th.css-house-flag > .th-inner,
|
||||
thead th.css-house-laptop > .th-inner,
|
||||
thead th.css-house-user > .th-inner,
|
||||
thead th.css-license > .th-inner,
|
||||
thead th.css-location > .th-inner,
|
||||
thead th.css-users > .th-inner,
|
||||
thead th.css-currency > .th-inner,
|
||||
thead th.css-child-locations > .th-inner,
|
||||
thead th.css-history > .th-inner {
|
||||
font-size: 0px;
|
||||
line-height: 0.75 !important;
|
||||
text-align: left;
|
||||
@@ -24145,22 +24192,22 @@ th.css-history > .th-inner {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
th.css-location > .th-inner::before,
|
||||
th.css-accessory > .th-inner::before,
|
||||
th.css-accessory-alt > .th-inner::before,
|
||||
th.css-barcode > .th-inner::before,
|
||||
th.css-component > .th-inner::before,
|
||||
th.css-consumable > .th-inner::before,
|
||||
th.css-envelope > .th-inner::before,
|
||||
th.css-house-flag > .th-inner::before,
|
||||
th.css-house-laptop > .th-inner::before,
|
||||
th.css-house-user > .th-inner::before,
|
||||
th.css-license > .th-inner::before,
|
||||
th.css-location > .th-inner::before,
|
||||
th.css-users > .th-inner::before,
|
||||
th.css-currency > .th-inner::before,
|
||||
th.css-child-locations > .th-inner::before,
|
||||
th.css-history > .th-inner::before {
|
||||
thead th.css-location > .th-inner::before,
|
||||
thead th.css-accessory > .th-inner::before,
|
||||
thead th.css-accessory-alt > .th-inner::before,
|
||||
thead th.css-barcode > .th-inner::before,
|
||||
thead th.css-component > .th-inner::before,
|
||||
thead th.css-consumable > .th-inner::before,
|
||||
thead th.css-envelope > .th-inner::before,
|
||||
thead th.css-house-flag > .th-inner::before,
|
||||
thead th.css-house-laptop > .th-inner::before,
|
||||
thead th.css-house-user > .th-inner::before,
|
||||
thead th.css-license > .th-inner::before,
|
||||
thead th.css-location > .th-inner::before,
|
||||
thead th.css-users > .th-inner::before,
|
||||
thead th.css-currency > .th-inner::before,
|
||||
thead th.css-child-locations > .th-inner::before,
|
||||
thead th.css-history > .th-inner::before {
|
||||
display: inline-block;
|
||||
font-size: 20px;
|
||||
font-family: "Font Awesome 5 Free";
|
||||
@@ -24170,90 +24217,90 @@ th.css-history > .th-inner::before {
|
||||
BEGIN ICON TABLE HEADERS
|
||||
Set the font-weight css property as 900 (For Solid), 400 (Regular or Brands), 300 (Light for pro icons).
|
||||
**/
|
||||
th.css-barcode > .th-inner::before {
|
||||
thead th.css-barcode > .th-inner::before {
|
||||
content: "\f02a";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-weight: 900;
|
||||
}
|
||||
th.css-license > .th-inner::before {
|
||||
thead th.css-license > .th-inner::before {
|
||||
content: "\f0c7";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-weight: 400;
|
||||
}
|
||||
th.css-consumable > .th-inner::before {
|
||||
thead th.css-consumable > .th-inner::before {
|
||||
content: "\f043";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-weight: 900;
|
||||
}
|
||||
th.css-envelope > .th-inner::before {
|
||||
thead th.css-envelope > .th-inner::before {
|
||||
content: "\f0e0";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-weight: 400;
|
||||
}
|
||||
th.css-accessory > .th-inner::before {
|
||||
thead th.css-accessory > .th-inner::before {
|
||||
content: "\f11c";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-weight: 400;
|
||||
}
|
||||
th.css-users > .th-inner::before {
|
||||
thead th.css-users > .th-inner::before {
|
||||
content: "\f0c0";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 15px;
|
||||
}
|
||||
th.css-location > .th-inner::before {
|
||||
thead th.css-location > .th-inner::before {
|
||||
content: "\f3c5";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
th.css-component > .th-inner::before {
|
||||
thead th.css-component > .th-inner::before {
|
||||
content: "\f0a0";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-weight: 500;
|
||||
}
|
||||
th.css-padlock > .th-inner::before {
|
||||
thead th.css-padlock > .th-inner::before {
|
||||
content: "\f023";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-weight: 800;
|
||||
padding-right: 3px;
|
||||
}
|
||||
th.css-house-user > .th-inner::before {
|
||||
thead th.css-house-user > .th-inner::before {
|
||||
content: "\e1b0";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
th.css-house-flag > .th-inner::before {
|
||||
thead th.css-house-flag > .th-inner::before {
|
||||
content: "\e50d";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
th.css-house-laptop > .th-inner::before {
|
||||
thead th.css-house-laptop > .th-inner::before {
|
||||
content: "\e066";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
th.css-accessory-alt > .th-inner::before {
|
||||
thead th.css-accessory-alt > .th-inner::before {
|
||||
content: "\f11c";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
th.css-child-locations > .th-inner::before {
|
||||
thead th.css-child-locations > .th-inner::before {
|
||||
content: "\f64f";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
th.css-currency > .th-inner::before {
|
||||
thead th.css-currency > .th-inner::before {
|
||||
content: "\24";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
th.css-history > .th-inner::before {
|
||||
thead th.css-history > .th-inner::before {
|
||||
content: "\f1da";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
@@ -24556,6 +24603,46 @@ caption.tableCaption {
|
||||
font-size: 18px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
.markdown-field-content h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin: 6px 0 4px;
|
||||
}
|
||||
.markdown-field-content h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 6px 0 4px;
|
||||
}
|
||||
.markdown-field-content h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin: 6px 0 4px;
|
||||
}
|
||||
.markdown-field-content h4 {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin: 4px 0 4px;
|
||||
}
|
||||
.markdown-field-content h5,
|
||||
.markdown-field-content h6 {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
margin: 4px 0 4px;
|
||||
}
|
||||
.markdown-field-content p {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
.markdown-field-content p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.markdown-field-content ul,
|
||||
.markdown-field-content ol {
|
||||
margin: 0 0 4px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.markdown-field-content > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.sidebar-toggle.btn {
|
||||
border-radius: 3px;
|
||||
box-shadow: none;
|
||||
@@ -24658,6 +24745,7 @@ Radio toggle styles for permission settings and check/uncheck all
|
||||
cursor: pointer;
|
||||
}
|
||||
.js-copy-link {
|
||||
float: left;
|
||||
color: grey;
|
||||
}
|
||||
.deleted {
|
||||
|
||||
Vendored
+11
-3
@@ -52318,7 +52318,8 @@ $(function () {
|
||||
search: params.term,
|
||||
page: params.page || 1,
|
||||
statusType: link.data("asset-status-type"),
|
||||
companyId: link.data("company-ids") || link.data("company-id")
|
||||
companyId: link.data("company-ids") || link.data("company-id"),
|
||||
excludeId: link.data("exclude-id")
|
||||
};
|
||||
return data;
|
||||
},
|
||||
@@ -52541,8 +52542,15 @@ $(function () {
|
||||
syncCheckoutToTypeUi(true);
|
||||
});
|
||||
|
||||
// Apply the current radio selection on initial render.
|
||||
syncCheckoutToTypeUi(false);
|
||||
// Apply the current radio selection on initial render, but only when the
|
||||
// selector row itself is already visible. On the asset create page the selector
|
||||
// starts hidden (display:none) and user_add() reveals it after a deployability
|
||||
// AJAX check — running here would prematurely show a panel before the radio
|
||||
// group is visible. On the standalone checkout page the selector is visible
|
||||
// from the start, so the sync runs normally there.
|
||||
if ($('#assignto_selector').is(':visible')) {
|
||||
syncCheckoutToTypeUi(false);
|
||||
}
|
||||
});
|
||||
|
||||
// ------------------------------------------------
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"/js/dist/all.js": "/js/dist/all.js?id=4619b48bfce17ad41fc5a2e9ee578988",
|
||||
"/css/build/overrides.css": "/css/build/overrides.css?id=c173dd71d56c1089bf560a849586d93e",
|
||||
"/css/build/app.css": "/css/build/app.css?id=63ef76491d01db361ad53cf1c8c7114f",
|
||||
"/js/dist/all.js": "/js/dist/all.js?id=b7b86fa704f44a30a4593132fd496fee",
|
||||
"/css/build/overrides.css": "/css/build/overrides.css?id=ce04095907063a4e5504e2a794e5112d",
|
||||
"/css/build/app.css": "/css/build/app.css?id=afa3130cba11c74d7f72a92b345e282c",
|
||||
"/css/build/AdminLTE.css": "/css/build/AdminLTE.css?id=ee0ed88465dd878588ed044eefb67723",
|
||||
"/css/dist/all.css": "/css/dist/all.css?id=57e6bf27bcfad47e58a82b9842a7d5bd",
|
||||
"/css/dist/all.css": "/css/dist/all.css?id=c2c4079a74ba152dc7242128be555c47",
|
||||
"/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",
|
||||
|
||||
@@ -211,6 +211,7 @@ $(function () {
|
||||
page: params.page || 1,
|
||||
statusType: link.data("asset-status-type"),
|
||||
companyId: link.data("company-ids") || link.data("company-id"),
|
||||
excludeId: link.data("exclude-id"),
|
||||
};
|
||||
return data;
|
||||
},
|
||||
@@ -468,8 +469,15 @@ $(function () {
|
||||
syncCheckoutToTypeUi(true);
|
||||
});
|
||||
|
||||
// Apply the current radio selection on initial render.
|
||||
syncCheckoutToTypeUi(false);
|
||||
// Apply the current radio selection on initial render, but only when the
|
||||
// selector row itself is already visible. On the asset create page the selector
|
||||
// starts hidden (display:none) and user_add() reveals it after a deployability
|
||||
// AJAX check — running here would prematurely show a panel before the radio
|
||||
// group is visible. On the standalone checkout page the selector is visible
|
||||
// from the start, so the sync runs normally there.
|
||||
if ($('#assignto_selector').is(':visible')) {
|
||||
syncCheckoutToTypeUi(false);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -340,6 +340,12 @@ body {
|
||||
z-index: 0 !important;
|
||||
}
|
||||
|
||||
// Clip the absolutely-positioned fixed-column overlays so they cannot
|
||||
// bleed past the container's bottom edge into the pagination area.
|
||||
.bootstrap-table .fixed-table-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media print {
|
||||
|
||||
@page {
|
||||
@@ -695,21 +701,21 @@ h4 {
|
||||
|
||||
See https://github.com/grokability/snipe-it/issues/7989
|
||||
*/
|
||||
th.css-accessory > .th-inner,
|
||||
th.css-accessory-alt > .th-inner,
|
||||
th.css-barcode > .th-inner,
|
||||
th.css-component > .th-inner,
|
||||
th.css-consumable > .th-inner,
|
||||
th.css-envelope > .th-inner,
|
||||
th.css-house-flag > .th-inner,
|
||||
th.css-house-laptop > .th-inner,
|
||||
th.css-house-user > .th-inner,
|
||||
th.css-license > .th-inner,
|
||||
th.css-location > .th-inner,
|
||||
th.css-users > .th-inner,
|
||||
th.css-currency > .th-inner,
|
||||
th.css-child-locations > .th-inner,
|
||||
th.css-history > .th-inner
|
||||
thead th.css-accessory > .th-inner,
|
||||
thead th.css-accessory-alt > .th-inner,
|
||||
thead th.css-barcode > .th-inner,
|
||||
thead th.css-component > .th-inner,
|
||||
thead th.css-consumable > .th-inner,
|
||||
thead th.css-envelope > .th-inner,
|
||||
thead th.css-house-flag > .th-inner,
|
||||
thead th.css-house-laptop > .th-inner,
|
||||
thead th.css-house-user > .th-inner,
|
||||
thead th.css-license > .th-inner,
|
||||
thead th.css-location > .th-inner,
|
||||
thead th.css-users > .th-inner,
|
||||
thead th.css-currency > .th-inner,
|
||||
thead th.css-child-locations > .th-inner,
|
||||
thead th.css-history > .th-inner
|
||||
{
|
||||
font-size: 0px;
|
||||
line-height: 0.75 !important;
|
||||
@@ -720,22 +726,22 @@ th.css-history > .th-inner
|
||||
}
|
||||
|
||||
|
||||
th.css-location > .th-inner::before,
|
||||
th.css-accessory > .th-inner::before,
|
||||
th.css-accessory-alt > .th-inner::before,
|
||||
th.css-barcode > .th-inner::before,
|
||||
th.css-component > .th-inner::before,
|
||||
th.css-consumable > .th-inner::before,
|
||||
th.css-envelope > .th-inner::before,
|
||||
th.css-house-flag > .th-inner::before,
|
||||
th.css-house-laptop > .th-inner::before,
|
||||
th.css-house-user > .th-inner::before,
|
||||
th.css-license > .th-inner::before,
|
||||
th.css-location > .th-inner::before,
|
||||
th.css-users > .th-inner::before,
|
||||
th.css-currency > .th-inner::before,
|
||||
th.css-child-locations > .th-inner::before,
|
||||
th.css-history > .th-inner::before
|
||||
thead th.css-location > .th-inner::before,
|
||||
thead th.css-accessory > .th-inner::before,
|
||||
thead th.css-accessory-alt > .th-inner::before,
|
||||
thead th.css-barcode > .th-inner::before,
|
||||
thead th.css-component > .th-inner::before,
|
||||
thead th.css-consumable > .th-inner::before,
|
||||
thead th.css-envelope > .th-inner::before,
|
||||
thead th.css-house-flag > .th-inner::before,
|
||||
thead th.css-house-laptop > .th-inner::before,
|
||||
thead th.css-house-user > .th-inner::before,
|
||||
thead th.css-license > .th-inner::before,
|
||||
thead th.css-location > .th-inner::before,
|
||||
thead th.css-users > .th-inner::before,
|
||||
thead th.css-currency > .th-inner::before,
|
||||
thead th.css-child-locations > .th-inner::before,
|
||||
thead th.css-history > .th-inner::before
|
||||
{
|
||||
display: inline-block;
|
||||
font-size: 20px;
|
||||
@@ -747,91 +753,91 @@ th.css-history > .th-inner::before
|
||||
BEGIN ICON TABLE HEADERS
|
||||
Set the font-weight css property as 900 (For Solid), 400 (Regular or Brands), 300 (Light for pro icons).
|
||||
**/
|
||||
th.css-barcode > .th-inner::before
|
||||
thead th.css-barcode > .th-inner::before
|
||||
{
|
||||
content: "\f02a"; font-family: "Font Awesome 5 Free"; font-weight: 900;
|
||||
}
|
||||
|
||||
th.css-license > .th-inner::before
|
||||
thead th.css-license > .th-inner::before
|
||||
{
|
||||
content: "\f0c7"; font-family: "Font Awesome 5 Free"; font-weight: 400;
|
||||
}
|
||||
|
||||
th.css-consumable > .th-inner::before
|
||||
thead th.css-consumable > .th-inner::before
|
||||
{
|
||||
content: "\f043"; font-family: "Font Awesome 5 Free"; font-weight: 900;
|
||||
}
|
||||
|
||||
th.css-envelope > .th-inner::before
|
||||
thead th.css-envelope > .th-inner::before
|
||||
{
|
||||
content: "\f0e0"; font-family: "Font Awesome 5 Free"; font-weight: 400;
|
||||
}
|
||||
|
||||
th.css-accessory > .th-inner::before
|
||||
thead th.css-accessory > .th-inner::before
|
||||
{
|
||||
content: "\f11c"; font-family: "Font Awesome 5 Free"; font-weight: 400;
|
||||
}
|
||||
|
||||
th.css-users > .th-inner::before {
|
||||
thead th.css-users > .th-inner::before {
|
||||
content: "\f0c0"; font-family: "Font Awesome 5 Free"; font-size: 15px;
|
||||
}
|
||||
|
||||
th.css-location > .th-inner::before {
|
||||
thead th.css-location > .th-inner::before {
|
||||
content: "\f3c5"; font-family: "Font Awesome 5 Free"; font-size: 19px; margin-bottom: 0px;
|
||||
}
|
||||
|
||||
th.css-component > .th-inner::before
|
||||
thead th.css-component > .th-inner::before
|
||||
{
|
||||
content: "\f0a0"; font-family: "Font Awesome 5 Free"; font-weight: 500;
|
||||
}
|
||||
|
||||
th.css-padlock > .th-inner::before
|
||||
thead th.css-padlock > .th-inner::before
|
||||
{
|
||||
content: "\f023"; font-family: "Font Awesome 5 Free";
|
||||
font-weight: 800;
|
||||
padding-right: 3px;
|
||||
}
|
||||
|
||||
th.css-house-user > .th-inner::before {
|
||||
thead th.css-house-user > .th-inner::before {
|
||||
content: "\e1b0";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
th.css-house-flag > .th-inner::before {
|
||||
thead th.css-house-flag > .th-inner::before {
|
||||
content: "\e50d";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
th.css-house-laptop > .th-inner::before {
|
||||
thead th.css-house-laptop > .th-inner::before {
|
||||
content: "\e066";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
th.css-accessory-alt > .th-inner::before {
|
||||
thead th.css-accessory-alt > .th-inner::before {
|
||||
content: "\f11c";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
th.css-child-locations > .th-inner::before {
|
||||
thead th.css-child-locations > .th-inner::before {
|
||||
content: "\f64f"; // change this to f51e for coins
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
th.css-currency > .th-inner::before {
|
||||
thead th.css-currency > .th-inner::before {
|
||||
content: "\24"; // change this to f51e for coins
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
th.css-history > .th-inner::before {
|
||||
thead th.css-history > .th-inner::before {
|
||||
content: "\f1da"; // change this to f51e for coins
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-size: 19px;
|
||||
@@ -1186,6 +1192,19 @@ caption.tableCaption {
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
|
||||
.markdown-field-content {
|
||||
h1 { font-size: 22px; font-weight: 700; margin: 6px 0 4px; }
|
||||
h2 { font-size: 20px; font-weight: 700; margin: 6px 0 4px; }
|
||||
h3 { font-size: 18px; font-weight: 700; margin: 6px 0 4px; }
|
||||
h4 { font-size: 16px; font-weight: 700; margin: 4px 0 4px; }
|
||||
h5, h6 { font-size: 14px; font-weight: 700; margin: 4px 0 4px; }
|
||||
p { margin: 0 0 4px; }
|
||||
p:last-child { margin-bottom: 0; }
|
||||
ul, ol { margin: 0 0 4px; padding-left: 20px; }
|
||||
> *:first-child { margin-top: 0; }
|
||||
}
|
||||
|
||||
// via https://github.com/grokability/snipe-it/issues/11754
|
||||
.sidebar-toggle.btn {
|
||||
border-radius: 3px;
|
||||
@@ -1307,6 +1326,7 @@ Radio toggle styles for permission settings and check/uncheck all
|
||||
}
|
||||
|
||||
.js-copy-link {
|
||||
float: left;
|
||||
color: grey;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ return [
|
||||
'manage' => 'crwdns6501:0crwdne6501:0',
|
||||
'field' => 'crwdns1487:0crwdne1487:0',
|
||||
'about_fieldsets_title' => 'crwdns1488:0crwdne1488:0',
|
||||
'about_fieldsets_text' => 'crwdns14799:0crwdne14799:0',
|
||||
'about_fieldsets_text' => 'crwdns14909:0crwdne14909:0',
|
||||
'custom_format' => 'crwdns6505:0crwdne6505:0',
|
||||
'encrypt_field' => 'crwdns1792:0crwdne1792:0',
|
||||
'encrypt_field_help' => 'crwdns1683:0crwdne1683:0',
|
||||
@@ -64,8 +64,10 @@ return [
|
||||
'text' => 'crwdns13260:0crwdne13260:0',
|
||||
'listbox' => 'crwdns13262:0crwdne13262:0',
|
||||
'textarea' => 'crwdns13264:0crwdne13264:0',
|
||||
'markdown-textarea' => 'crwdns14919:0crwdne14919:0',
|
||||
'checkbox' => 'crwdns13266:0crwdne13266:0',
|
||||
'radio' => 'crwdns13268:0crwdne13268:0',
|
||||
],
|
||||
'markdown_supported' => 'crwdns14921:0crwdne14921:0',
|
||||
'general_help_text' => 'crwdns13902:0crwdne13902:0',
|
||||
];
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user