diff --git a/app/Http/Controllers/ReportTemplatesController.php b/app/Http/Controllers/ReportTemplatesController.php new file mode 100644 index 0000000000..2be1da99e0 --- /dev/null +++ b/app/Http/Controllers/ReportTemplatesController.php @@ -0,0 +1,80 @@ +authorize('reports.view'); + + // Ignore "options" rules since data does not come in under that key... + $validated = $request->validate(Arr::except((new ReportTemplate)->getRules(), 'options')); + + $report = $request->user()->reportTemplates()->create([ + 'name' => $validated['name'], + 'options' => $request->except(['_token', 'name']), + ]); + + session()->flash('success', trans('admin/reports/message.create.success')); + + return redirect()->route('report-templates.show', $report->id); + } + + public function show(ReportTemplate $reportTemplate) + { + $this->authorize('reports.view'); + + $customfields = CustomField::get(); + $report_templates = ReportTemplate::orderBy('name')->get(); + + return view('reports/custom', [ + 'customfields' => $customfields, + 'report_templates' => $report_templates, + 'template' => $reportTemplate, + ]); + } + + public function edit(ReportTemplate $reportTemplate) + { + $this->authorize('reports.view'); + + return view('reports/custom', [ + 'customfields' => CustomField::get(), + 'template' => $reportTemplate, + ]); + } + + public function update(Request $request, ReportTemplate $reportTemplate): RedirectResponse + { + $this->authorize('reports.view'); + + // Ignore "options" rules since data does not come in under that key... + $validated = $request->validate(Arr::except((new ReportTemplate)->getRules(), 'options')); + + $reportTemplate->update([ + 'name' => $validated['name'], + 'options' => $request->except(['_token', 'name']), + ]); + + session()->flash('success', trans('admin/reports/message.update.success')); + + return redirect()->route('report-templates.show', $reportTemplate->id); + } + + public function destroy(ReportTemplate $reportTemplate): RedirectResponse + { + $this->authorize('reports.view'); + + $reportTemplate->delete(); + + return redirect()->route('reports/custom') + ->with('success', trans('admin/reports/message.delete.success')); + } +} diff --git a/app/Http/Controllers/ReportsController.php b/app/Http/Controllers/ReportsController.php index 03f7ec1c3f..b57078e4d1 100644 --- a/app/Http/Controllers/ReportsController.php +++ b/app/Http/Controllers/ReportsController.php @@ -14,6 +14,7 @@ use App\Models\CheckoutAcceptance; use App\Models\CustomField; use App\Models\Depreciation; use App\Models\License; +use App\Models\ReportTemplate; use App\Models\Setting; use App\Notifications\CheckoutAssetNotification; use Carbon\Carbon; @@ -394,12 +395,27 @@ class ReportsController extends Controller * @see ReportsController::postCustomReport() method that generates the CSV * @since [v1.0] */ - public function getCustomReport() : View + public function getCustomReport(Request $request) : View { $this->authorize('reports.view'); $customfields = CustomField::get(); + $report_templates = ReportTemplate::orderBy('name')->get(); - return view('reports/custom')->with('customfields', $customfields); + // The view needs a template to render correctly, even if it is empty... + $template = new ReportTemplate; + + // Set the report's input values in the cases we were redirected back + // with validation errors so the report is populated as expected. + if ($request->old()) { + $template->name = $request->old('name'); + $template->options = $request->old(); + } + + return view('reports/custom', [ + 'customfields' => $customfields, + 'report_templates' => $report_templates, + 'template' => $template, + ]); } /** diff --git a/app/Models/ReportTemplate.php b/app/Models/ReportTemplate.php new file mode 100644 index 0000000000..06f0370a00 --- /dev/null +++ b/app/Models/ReportTemplate.php @@ -0,0 +1,241 @@ + 'array', + ]; + + protected $fillable = [ + 'created_by', + 'name', + 'options', + ]; + + protected $rules = [ + 'name' => [ + 'required', + 'string', + ], + 'options' => [ + 'required', + 'array', + ], + ]; + + protected static function booted() + { + // Scope to current user + static::addGlobalScope('current_user', function (Builder $builder) { + if (auth()->check()) { + $builder->where('created_by', auth()->id()); + } + }); + + static::created(function (ReportTemplate $reportTemplate) { + $logAction = new Actionlog([ + 'item_type' => ReportTemplate::class, + 'item_id' => $reportTemplate->id, + 'created_by' => auth()->id(), + ]); + + $logAction->logaction('create'); + }); + + static::updated(function (ReportTemplate $reportTemplate) { + $changed = []; + + foreach ($reportTemplate->getDirty() as $key => $value) { + $changed[$key] = [ + 'old' => $reportTemplate->getOriginal($key), + 'new' => $reportTemplate->getAttribute($key), + ]; + } + + $logAction = new Actionlog(); + $logAction->item_type = ReportTemplate::class; + $logAction->item_id = $reportTemplate->id; + $logAction->created_by = auth()->id(); + $logAction->log_meta = json_encode($changed); + $logAction->logaction('update'); + }); + + static::deleted(function (ReportTemplate $reportTemplate) { + $logAction = new Actionlog([ + 'item_type' => ReportTemplate::class, + 'item_id' => $reportTemplate->id, + 'created_by' => auth()->id(), + ]); + + $logAction->logaction('delete'); + }); + } + + /** + * Establishes the report template -> creator relationship. + * + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * Get the value of a checkbox field for the given field name. + * + * @param string $fieldName + * @param string $fallbackValue The value to return if the report template is not saved yet. + * + */ + public function checkmarkValue(string $fieldName, string $fallbackValue = '1'): string + { + // Assuming we're using the null object pattern, and an empty model + // was passed to the view when showing the default report page, + // return the fallback value so that checkboxes are checked by default. + if (is_null($this->id)) { + return $fallbackValue; + } + + // If the model does exist then return the value of the field + // or return 0 so the checkbox is unchecked. + // Falling back to 0 here is because checkboxes are not sent + // in the request when unchecked so they are not + // actually saved in the model's options. + return $this->options[$fieldName] ?? '0'; + } + + /** + * Get the value of a radio field for the given field name. + * + * @param string $fieldName + * @param string $value The value to check against. + * @param bool $isDefault Whether the radio input being checked is the default. + * + */ + public function radioValue(string $fieldName, string $value, bool $isDefault = false): bool + { + $fieldExists = array_has($this->options, $fieldName); + + // If the field doesn't exist but the radio input + // being checked is the default then return true. + if (!$fieldExists && $isDefault) { + return true; + } + + // If the field exists and matches what we're checking then return true. + if ($fieldExists && $this->options[$fieldName] === $value) { + return true; + } + + // Otherwise return false. + return false; + } + + /** + * Get the value of a select field for the given field name. + * + * @param string $fieldName + * @param string|null $model The Eloquent model to check against. + * + * @return mixed|null + * + */ + public function selectValue(string $fieldName, string $model = null) + { + // If the field does not exist then return null. + if (!isset($this->options[$fieldName])) { + return null; + } + + $value = $this->options[$fieldName]; + + // If the value was stored as an array, most likely + // due to a previously being a multi-select, + // then return the first value. + if (is_array($value)) { + $value = $value[0]; + } + + // If a model is provided then we should ensure we only return + // the value if the model still exists. + // Note: It is possible $value is an id that no longer exists and this will return null. + if ($model) { + $foundModel = $model::find($value); + + return $foundModel ? $foundModel->id : null; + } + + return $value; + } + + /** + * Get the values of a multi-select field for the given field name. + * + * @param string $fieldName + * @param string|null $model The Eloquent model to check against. + * + * @return iterable + * + */ + public function selectValues(string $fieldName, string $model = null): iterable + { + // If the field does not exist then return an empty array. + if (!isset($this->options[$fieldName])) { + return []; + } + + // If a model is provided then we should ensure we only return + // the ids of models that exist and are not deleted. + if ($model) { + return $model::findMany($this->options[$fieldName])->pluck('id'); + } + + // Wrap the value in an array if needed. This is to ensure + // values previously stored as a single value, + // most likely from a single select, are returned as an array. + if (!is_array($this->options[$fieldName])) { + return [$this->options[$fieldName]]; + } + + return $this->options[$fieldName]; + } + + /** + * Get the value of a text field for the given field name. + * + * @param string $fieldName + * @param string|null $fallbackValue + * + * @return string + */ + public function textValue(string $fieldName, string|null $fallbackValue = ''): string + { + // Assuming we're using the null object pattern, + // return the default value if the object is not saved yet. + if (is_null($this->id)) { + return (string) $fallbackValue; + } + + // Return the field's value if it exists + // and return the default value if not. + return $this->options[$fieldName] ?? ''; + } + + public function getDisplayNameAttribute() + { + return $this->name; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 24e68d1fe2..e48b8bf074 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -13,6 +13,7 @@ use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract; use Illuminate\Contracts\Translation\HasLocalePreference; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\Access\Authorizable; use Illuminate\Notifications\Notifiable; @@ -361,6 +362,15 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo return $this->belongsToMany(\App\Models\License::class, 'license_seats', 'assigned_to', 'license_id')->withPivot('id', 'created_at', 'updated_at'); } + /** + * Establishes the user -> reportTemplates relationship + * + */ + public function reportTemplates(): HasMany + { + return $this->hasMany(ReportTemplate::class, 'created_by'); + } + /** * Establishes a count of all items assigned * diff --git a/database/factories/ReportTemplateFactory.php b/database/factories/ReportTemplateFactory.php new file mode 100644 index 0000000000..7044af6342 --- /dev/null +++ b/database/factories/ReportTemplateFactory.php @@ -0,0 +1,25 @@ + $this->faker->word(), + 'options' => [ + 'id' => '1', + ], + 'created_by' => User::factory(), + ]; + } +} diff --git a/database/migrations/2023_08_23_232739_create_report_templates_table.php b/database/migrations/2023_08_23_232739_create_report_templates_table.php new file mode 100644 index 0000000000..d9438f2cc8 --- /dev/null +++ b/database/migrations/2023_08_23_232739_create_report_templates_table.php @@ -0,0 +1,36 @@ +id(); + $table->integer('created_by')->nullable(); + $table->string('name'); + $table->json('options'); + $table->softDeletes(); + $table->timestamps(); + $table->index('created_by'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('report_templates'); + } +} diff --git a/resources/lang/en-US/admin/reports/general.php b/resources/lang/en-US/admin/reports/general.php index 9b682f8ecd..ea22b07dfe 100644 --- a/resources/lang/en-US/admin/reports/general.php +++ b/resources/lang/en-US/admin/reports/general.php @@ -13,5 +13,10 @@ return [ 'user_state' => 'User State', 'user_country' => 'User Country', 'user_zip' => 'User Zip' - ] -]; \ No newline at end of file + ], + 'open_saved_template' => 'Open Saved Template', + 'save_template' => 'Save Template', + 'select_a_template' => 'Select a Template', + 'template_name' => 'Template Name', + 'update_template' => 'Update Template', +]; diff --git a/resources/lang/en-US/admin/reports/message.php b/resources/lang/en-US/admin/reports/message.php index d4c8f8198f..2800bbdf0b 100644 --- a/resources/lang/en-US/admin/reports/message.php +++ b/resources/lang/en-US/admin/reports/message.php @@ -1,5 +1,16 @@ 'You must select at least ONE option.' -); +return [ + 'about_templates' => 'About Saved Templates', + 'saving_templates_description' => 'Select your options, then enter the name of your template in the box above and click the \'Save Template\' button. Use the dropdown to select a previously saved template.', + 'create' => [ + 'success' => 'Template saved successfully', + ], + 'update' => [ + 'success' => 'Template updated successfully', + ], + 'delete' => [ + 'success' => 'Template deleted', + 'no_delete_permission' => 'Template does not exist or you do not have permission to delete it.', + ], +]; diff --git a/resources/lang/en-US/general.php b/resources/lang/en-US/general.php index 5af43d6aed..20c1cf8aea 100644 --- a/resources/lang/en-US/general.php +++ b/resources/lang/en-US/general.php @@ -302,6 +302,7 @@ return [ 'username_format' => 'Username Format', 'username' => 'Username', 'update' => 'Update', + 'updating_item' => 'Updating :item', 'upload_filetypes_help' => 'Allowed filetypes are png, gif, jpg, jpeg, doc, docx, pdf, xls, xlsx, txt, lic, xml, zip, rtf and rar. Max upload size allowed is :size.', 'uploaded' => 'Uploaded', 'user' => 'User', diff --git a/resources/views/partials/forms/edit/category-select.blade.php b/resources/views/partials/forms/edit/category-select.blade.php index f610f68470..f4b2ace30b 100644 --- a/resources/views/partials/forms/edit/category-select.blade.php +++ b/resources/views/partials/forms/edit/category-select.blade.php @@ -5,6 +5,18 @@