Merge pull request #19039 from grokability/add-rp-to-maintenances

🖼️ Add custom maintenance types, responsible party and assigned to to maintenances
This commit is contained in:
snipe
2026-05-18 20:14:20 +01:00
committed by GitHub
53 changed files with 2313 additions and 225 deletions
+3
View File
@@ -31,6 +31,9 @@ enum ActionType: string
case DeleteSeats = 'delete seats';
case AddSeats = 'add seats';
// Maintenances
case MaintenanceComplete = 'completed';
// File Uploads
case Uploaded = 'uploaded';
case UploadDeleted = 'upload deleted';
@@ -0,0 +1,87 @@
<?php
namespace App\Http\Controllers\Api;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Transformers\MaintenanceTypesTransformer;
use App\Models\MaintenanceType;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class MaintenanceTypesController extends Controller
{
public function index(FilterRequest $request): JsonResponse|array
{
$this->authorize('view', MaintenanceType::class);
$types = MaintenanceType::select(['id', 'name', 'created_at', 'updated_at', 'deleted_at']);
if ($request->input('deleted') == 'true') {
$types->onlyTrashed();
}
if ($request->filled('search')) {
$types->where('name', 'LIKE', '%'.$request->input('search').'%');
}
if ($request->filled('name')) {
$types->where('name', '=', $request->input('name'));
}
$offset = ($request->input('offset') > $types->count()) ? $types->count() : abs($request->input('offset'));
$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';
$total = $types->count();
$types = $types->orderBy($sort, $order)->skip($offset)->take($limit)->get();
return (new MaintenanceTypesTransformer)->transformMaintenanceTypes($types, $total);
}
public function show(MaintenanceType $maintenanceType): JsonResponse|array
{
$this->authorize('view', $maintenanceType);
return (new MaintenanceTypesTransformer)->transformMaintenanceType($maintenanceType);
}
public function store(Request $request): JsonResponse
{
$this->authorize('create', MaintenanceType::class);
$type = new MaintenanceType;
$type->name = $request->input('name');
$type->created_by = auth()->id();
if ($type->save()) {
return response()->json(Helper::formatStandardApiResponse('success', (new MaintenanceTypesTransformer)->transformMaintenanceType($type), trans('admin/maintenance_types/message.create.success')));
}
return response()->json(Helper::formatStandardApiResponse('error', null, $type->getErrors()));
}
public function update(Request $request, MaintenanceType $maintenanceType): JsonResponse
{
$this->authorize('update', $maintenanceType);
$maintenanceType->name = $request->input('name');
if ($maintenanceType->save()) {
return response()->json(Helper::formatStandardApiResponse('success', (new MaintenanceTypesTransformer)->transformMaintenanceType($maintenanceType), trans('admin/maintenance_types/message.update.success')));
}
return response()->json(Helper::formatStandardApiResponse('error', null, $maintenanceType->getErrors()));
}
public function destroy(MaintenanceType $maintenanceType): JsonResponse
{
$this->authorize('delete', $maintenanceType);
$maintenanceType->delete();
return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/maintenance_types/message.delete.success')));
}
}
@@ -2,15 +2,19 @@
namespace App\Http\Controllers\Api;
use App\Enums\ActionType;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Transformers\ActionlogsTransformer;
use App\Http\Transformers\MaintenancesTransformer;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\Company;
use App\Models\Maintenance;
use App\Models\Setting;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -39,7 +43,7 @@ class MaintenancesController extends Controller
$maintenances = Maintenance::select('maintenances.*')
->whereHas('asset')
->with('asset', 'asset.model', 'asset.location', 'asset.defaultLoc', 'supplier', 'asset.company', 'asset.status', 'adminuser', 'asset.assignedTo');
->with('asset', 'asset.model', 'asset.location', 'asset.defaultLoc', 'supplier', 'asset.company', 'asset.status', 'adminuser', 'asset.assignedTo', 'maintenanceType', 'responsibleParty', 'completedByUser');
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
@@ -62,8 +66,39 @@ class MaintenancesController extends Controller
$maintenances->where('maintenances.url', '=', $request->input('url'));
}
if ($request->filled('asset_maintenance_type')) {
$maintenances->where('asset_maintenance_type', '=', $request->input('asset_maintenance_type'));
if ($request->filled('maintenance_type')) {
$maintenances->where('maintenance_type', '=', $request->input('maintenance_type'));
}
if ($request->filled('maintenance_type_id')) {
$maintenances->where('maintenance_type_id', '=', $request->input('maintenance_type_id'));
}
if ($request->filled('responsible_party_id')) {
$maintenances->where('responsible_party_id', '=', $request->input('responsible_party_id'));
}
if ($request->filled('completed')) {
if ($request->input('completed') === 'true') {
$maintenances->completed();
} else {
$maintenances->active();
}
}
if ($request->filled('upcoming_status')) {
$settings = Setting::getSettings();
switch ($request->input('upcoming_status')) {
case 'due':
$maintenances->dueForCompletion($settings);
break;
case 'overdue':
$maintenances->overdueForCompletion();
break;
case 'due-or-overdue':
$maintenances->dueOrOverdueForCompletion($settings);
break;
}
}
// Make sure the offset and limit are actually integers and do not exceed system limits
@@ -74,10 +109,10 @@ class MaintenancesController extends Controller
'id',
'name',
'asset_maintenance_time',
'asset_maintenance_type',
'cost',
'start_date',
'completion_date',
'completed_at',
'notes',
'asset_tag',
'asset_name',
@@ -89,6 +124,7 @@ class MaintenancesController extends Controller
'status_label',
'model',
'model_number',
'maintenance_type',
];
$order = $request->input('order') === 'asc' ? 'asc' : 'desc';
@@ -96,31 +132,37 @@ class MaintenancesController extends Controller
switch ($sort) {
case 'created_by':
$maintenances = $maintenances->OrderByCreatedBy($order);
$maintenances = $maintenances->orderByCreatedBy($order);
break;
case 'supplier':
$maintenances = $maintenances->OrderBySupplier($order);
$maintenances = $maintenances->orderBySupplier($order);
break;
case 'asset_tag':
$maintenances = $maintenances->OrderByTag($order);
$maintenances = $maintenances->orderByTag($order);
break;
case 'asset_name':
$maintenances = $maintenances->OrderByAssetName($order);
$maintenances = $maintenances->orderByAssetName($order);
break;
case 'model':
$maintenances = $maintenances->OrderByAssetModelName($order);
$maintenances = $maintenances->orderByAssetModelName($order);
break;
case 'model_number':
$maintenances = $maintenances->OrderByAssetModelNumber($order);
$maintenances = $maintenances->orderByAssetModelNumber($order);
break;
case 'serial':
$maintenances = $maintenances->OrderByAssetSerial($order);
$maintenances = $maintenances->orderByAssetSerial($order);
break;
case 'location':
$maintenances = $maintenances->OrderLocationName($order);
$maintenances = $maintenances->orderLocationName($order);
break;
case 'status_label':
$maintenances = $maintenances->OrderStatusName($order);
$maintenances = $maintenances->orderStatusName($order);
break;
case 'maintenance_type':
$maintenances = $maintenances->orderByMaintenanceType($order);
break;
case 'completed_at':
$maintenances = $maintenances->orderByCompletedAt($order);
break;
default:
$maintenances = $maintenances->orderBy($sort, $order);
@@ -153,19 +195,60 @@ class MaintenancesController extends Controller
{
$this->authorize('update', Asset::class);
// create a new model instance
$maintenance = new Maintenance;
$maintenance->fill($request->all());
$maintenance->created_by = auth()->id();
$maintenance = $request->handleImages($maintenance);
// Was the asset maintenance created?
if ($maintenance->save()) {
return response()->json(Helper::formatStandardApiResponse('success', $maintenance, trans('admin/maintenances/message.create.success')));
$isBulk = $request->has('asset_ids');
$assetIds = $isBulk
? array_values(array_filter((array) $request->input('asset_ids')))
: [$request->input('asset_id')];
$created = new EloquentCollection;
$errors = [];
foreach ($assetIds as $assetId) {
$asset = Asset::find($assetId);
if (! $asset) {
$errors[] = trans('general.item_not_found', ['item_type' => trans('general.asset'), 'id' => $assetId]);
continue;
}
if (! Company::isCurrentUserHasAccess($asset)) {
$errors[] = trans('general.action_permission_denied', ['item_type' => trans('general.asset'), 'id' => $assetId, 'action' => trans('general.create')]);
continue;
}
$maintenance = new Maintenance;
$maintenance->fill($request->except(['asset_id', 'asset_ids']));
$maintenance->asset_id = $assetId;
$maintenance->created_by = auth()->id();
$request->handleImages($maintenance);
if ($maintenance->save()) {
$created->push($maintenance->fresh());
} else {
$errors[] = $maintenance->getErrors();
}
}
return response()->json(Helper::formatStandardApiResponse('error', null, $maintenance->getErrors()));
if ($isBulk) {
if ($created->isEmpty()) {
return response()->json(Helper::formatStandardApiResponse('error', null, count($errors) === 1 ? $errors[0] : $errors));
}
return response()->json(Helper::formatStandardApiResponse(
'success',
(new MaintenancesTransformer)->transformMaintenances($created, $created->count()),
trans('admin/maintenances/message.create.success')
));
}
// Single asset_id path — backward compatible response shape
if ($created->isNotEmpty()) {
return response()->json(Helper::formatStandardApiResponse('success', $created->first(), trans('admin/maintenances/message.create.success')));
}
return response()->json(Helper::formatStandardApiResponse('error', null, ! empty($errors) ? $errors[0] : null));
}
/**
@@ -256,6 +339,35 @@ class MaintenancesController extends Controller
}
public function complete(Request $request, Maintenance $maintenance): JsonResponse
{
$this->authorize('update', Asset::class);
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' => $maintenance->id, 'action' => trans('admin/maintenances/form.mark_complete')])));
}
if ($maintenance->completed_at) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/maintenances/form.already_complete')));
}
$maintenance->completed_at = now();
$maintenance->completed_by = auth()->id();
$maintenance->asset_maintenance_time = (int) $maintenance->created_at->diffInDays(now(), true);
$maintenance->saveQuietly();
$logAction = new Actionlog;
$logAction->item_type = Maintenance::class;
$logAction->item_id = $maintenance->id;
$logAction->target_type = Asset::class;
$logAction->target_id = $maintenance->asset_id;
$logAction->created_by = auth()->id();
$logAction->note = $request->input('note');
$logAction->logaction(ActionType::MaintenanceComplete);
return response()->json(Helper::formatStandardApiResponse('success', (new MaintenancesTransformer)->transformMaintenance($maintenance->fresh()), trans('admin/maintenances/message.complete.success')));
}
public function history(Request $request, Maintenance $maintenance): JsonResponse|array
{
$this->authorize('history', $maintenance);
@@ -267,4 +379,50 @@ class MaintenancesController extends Controller
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
}
public function notesIndex(Maintenance $maintenance): JsonResponse
{
$this->authorize('journal', $maintenance);
$notes = Actionlog::with('user:id,username')
->where('item_type', Maintenance::class)
->where('item_id', $maintenance->id)
->where('action_type', 'note added')
->orderBy('created_at', 'desc')
->get(['id', 'created_at', 'note', 'created_by', 'item_id', 'item_type', 'action_type']);
$notesArray = $notes->map(fn ($note) => [
'id' => $note->id,
'created_at' => $note->created_at,
'note' => $note->note,
'created_by' => $note->created_by,
'username' => $note->user?->username,
'item_id' => $note->item_id,
'item_type' => $note->item_type,
'action_type' => $note->action_type,
]);
return response()->json(Helper::formatStandardApiResponse('success', ['notes' => $notesArray, 'maintenance_id' => $maintenance->id]));
}
public function notesStore(Request $request, Maintenance $maintenance): JsonResponse
{
$this->authorize('update', $maintenance);
if (! $request->filled('note')) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('validation.required', ['attribute' => 'note'])), 422);
}
$logaction = new Actionlog;
$logaction->item_type = Maintenance::class;
$logaction->created_by = auth()->id();
$logaction->item_id = $maintenance->id;
$logaction->note = $request->input('note');
if ($logaction->logaction('note added')) {
return response()->json(Helper::formatStandardApiResponse('success', ['note' => $logaction->note, 'item_id' => $maintenance->id], trans('general.note_added')));
}
return response()->json(Helper::formatStandardApiResponse('error', null, 'Something went wrong'), 500);
}
}
@@ -0,0 +1,72 @@
<?php
namespace App\Http\Controllers;
use App\Models\MaintenanceType;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class MaintenanceTypesController extends Controller
{
public function index(): View
{
$this->authorize('index', MaintenanceType::class);
return view('maintenance-types.index');
}
public function create(): View
{
$this->authorize('create', MaintenanceType::class);
return view('maintenance-types.edit')->with('item', new MaintenanceType);
}
public function store(Request $request): RedirectResponse
{
$this->authorize('create', MaintenanceType::class);
$type = new MaintenanceType;
$type->name = $request->input('name');
$type->created_by = auth()->id();
if ($type->save()) {
return redirect()->route('maintenance-types.index')
->with('success', trans('admin/maintenance_types/message.create.success'));
}
return redirect()->back()->withInput()->withErrors($type->getErrors());
}
public function edit(MaintenanceType $maintenanceType): View
{
$this->authorize('update', $maintenanceType);
return view('maintenance-types.edit')->with('item', $maintenanceType);
}
public function update(Request $request, MaintenanceType $maintenanceType): RedirectResponse
{
$this->authorize('update', $maintenanceType);
$maintenanceType->name = $request->input('name');
if ($maintenanceType->save()) {
return redirect()->route('maintenance-types.index')
->with('success', trans('admin/maintenance_types/message.update.success'));
}
return redirect()->back()->withInput()->withErrors($maintenanceType->getErrors());
}
public function destroy(MaintenanceType $maintenanceType): RedirectResponse
{
$this->authorize('delete', $maintenanceType);
$maintenanceType->delete();
return redirect()->route('maintenance-types.index')
->with('success', trans('admin/maintenance_types/message.delete.success'));
}
}
+44 -28
View File
@@ -2,11 +2,14 @@
namespace App\Http\Controllers;
use App\Enums\ActionType;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Requests\UploadFileRequest;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\Company;
use App\Models\Maintenance;
use Carbon\Carbon;
use App\Models\MaintenanceType;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -57,6 +60,7 @@ class MaintenancesController extends Controller
return view('maintenances/edit')
->with('maintenanceType', Maintenance::getImprovementOptions())
->with('maintenanceTypes', MaintenanceType::orderBy('name')->get())
->with('asset', $asset)
->with('item', new Maintenance);
}
@@ -82,6 +86,10 @@ class MaintenancesController extends Controller
// Loop through the selected assets
foreach ($assets as $asset) {
if (! Company::isCurrentUserHasAccess($asset)) {
continue;
}
$maintenance = new Maintenance;
$maintenance->supplier_id = $request->input('supplier_id');
$maintenance->is_warranty = $request->input('is_warranty');
@@ -92,20 +100,13 @@ class MaintenancesController extends Controller
// Save the asset maintenance data
$maintenance->asset_id = $asset->id;
$maintenance->asset_maintenance_type = $request->input('asset_maintenance_type');
$maintenance->maintenance_type_id = $request->input('maintenance_type_id');
$maintenance->name = $request->input('name');
$maintenance->start_date = $request->input('start_date');
$maintenance->completion_date = $request->input('completion_date');
$maintenance->responsible_party_id = $request->input('responsible_party_id') ?: auth()->id();
$maintenance->created_by = auth()->id();
if (($maintenance->completion_date !== null)
&& ($maintenance->start_date !== '')
&& ($maintenance->start_date !== '0000-00-00')
) {
$startDate = Carbon::parse($maintenance->start_date);
$completionDate = Carbon::parse($maintenance->completion_date);
$maintenance->asset_maintenance_time = (int) $completionDate->diffInDays($startDate, true);
}
$request->handleImages($maintenance);
// Was the asset maintenance created?
@@ -141,6 +142,7 @@ class MaintenancesController extends Controller
->with('selected_assets', $maintenance->asset->pluck('id')->toArray())
->with('asset_ids', request()->input('asset_ids', []))
->with('maintenanceType', Maintenance::getImprovementOptions())
->with('maintenanceTypes', MaintenanceType::orderBy('name')->get())
->with('item', $maintenance);
}
@@ -169,28 +171,12 @@ class MaintenancesController extends Controller
$maintenance->cost = $request->input('cost');
$maintenance->notes = $request->input('notes');
$maintenance->asset_maintenance_type = $request->input('asset_maintenance_type');
$maintenance->maintenance_type_id = $request->input('maintenance_type_id');
$maintenance->name = $request->input('name');
$maintenance->start_date = $request->input('start_date');
$maintenance->completion_date = $request->input('completion_date');
$maintenance->responsible_party_id = $request->input('responsible_party_id');
$maintenance->url = $request->input('url');
// Todo - put this in a getter/setter?
if (($maintenance->completion_date == null)) {
if (($maintenance->asset_maintenance_time !== 0)
|| (! is_null($maintenance->asset_maintenance_time))
) {
$maintenance->asset_maintenance_time = null;
}
}
if (($maintenance->completion_date !== null)
&& ($maintenance->start_date !== '')
&& ($maintenance->start_date !== '0000-00-00')
) {
$startDate = Carbon::parse($maintenance->start_date);
$completionDate = Carbon::parse($maintenance->completion_date);
$maintenance->asset_maintenance_time = (int) $completionDate->diffInDays($startDate, true);
}
$request->handleImages($maintenance);
if ($maintenance->save()) {
@@ -253,6 +239,36 @@ class MaintenancesController extends Controller
)->validate();
}
/**
* Mark a maintenance record as complete, logging who completed it and when.
*/
public function complete(Request $request, Maintenance $maintenance): RedirectResponse
{
$this->authorize('update', $maintenance->asset);
if ($maintenance->completed_at) {
return redirect()->back()
->with('warning', trans('admin/maintenances/form.already_complete'));
}
$maintenance->completed_at = now();
$maintenance->completed_by = auth()->id();
$maintenance->asset_maintenance_time = (int) $maintenance->created_at->diffInDays(now(), true);
$maintenance->saveQuietly();
$logAction = new Actionlog;
$logAction->item_type = Maintenance::class;
$logAction->item_id = $maintenance->id;
$logAction->target_type = Asset::class;
$logAction->target_id = $maintenance->asset_id;
$logAction->created_by = auth()->id();
$logAction->note = $request->input('note');
$logAction->logaction(ActionType::MaintenanceComplete);
return redirect()->back()
->with('success', trans('admin/maintenances/message.complete.success'));
}
/**
* Delete an asset maintenance
*
+1
View File
@@ -30,6 +30,7 @@ class ModalController extends Controller
'kit-consumable',
'kit-accessory',
'location',
'maintenance-type',
'manufacturer',
'model',
'statuslabel',
+14 -9
View File
@@ -4,13 +4,15 @@ namespace App\Http\Controllers;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\Maintenance;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
class NotesController extends Controller
{
public function store(Request $request)
public function store(Request $request): RedirectResponse
{
$this->authorize('update', Asset::class);
@@ -19,13 +21,19 @@ class NotesController extends Controller
'note' => 'required|string|max:50000',
'type' => [
'required',
Rule::in(['asset']),
Rule::in(['asset', 'maintenance']),
],
]);
$item = Asset::findOrFail($validated['id']);
$this->authorize('update', $item);
if ($validated['type'] === 'maintenance') {
$item = Maintenance::findOrFail($validated['id']);
$this->authorize('update', $item->asset);
$redirect = redirect()->route('maintenances.show', $validated['id']);
} else {
$item = Asset::findOrFail($validated['id']);
$this->authorize('update', $item);
$redirect = redirect()->route('hardware.show', $validated['id']);
}
$logaction = new Actionlog;
$logaction->item_id = $item->id;
@@ -34,9 +42,6 @@ class NotesController extends Controller
$logaction->created_by = Auth::id();
$logaction->logaction('note added');
return redirect()
->route('hardware.show', $validated['id'])
->withFragment('history')
->with('success', trans('general.note_added'));
return $redirect->withFragment('notes')->with('success', trans('general.note_added'));
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Http\Transformers;
use App\Helpers\Helper;
use App\Models\MaintenanceType;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Gate;
class MaintenanceTypesTransformer
{
public function transformMaintenanceTypes(Collection $types, int $total): array
{
$array = [];
foreach ($types as $type) {
$array[] = self::transformMaintenanceType($type);
}
return (new DatatablesTransformer)->transformDatatables($array, $total);
}
public function transformMaintenanceType(MaintenanceType $type): array
{
return [
'id' => (int) $type->id,
'name' => e($type->name),
'created_at' => Helper::getFormattedDateObject($type->created_at, 'datetime'),
'updated_at' => Helper::getFormattedDateObject($type->updated_at, 'datetime'),
'deleted_at' => Helper::getFormattedDateObject($type->deleted_at, 'datetime'),
'available_actions' => [
'update' => Gate::allows('update', $type),
'delete' => $type->isDeletable(),
'restore' => Gate::allows('delete', $type),
],
];
}
}
@@ -82,6 +82,22 @@ class MaintenancesTransformer
'id' => (int) $assetmaintenance->adminuser->id,
'name' => e($assetmaintenance->adminuser->display_name),
] : null,
'maintenance_type' => $assetmaintenance->maintenanceType
? e($assetmaintenance->maintenanceType->name)
: null,
'responsible_party' => ($assetmaintenance->responsibleParty) ? [
'id' => (int) $assetmaintenance->responsibleParty->id,
'name' => e($assetmaintenance->responsibleParty->display_name),
] : null,
'checked_out_to_at_creation' => $assetmaintenance->checked_out_to_id ? [
'id' => (int) $assetmaintenance->checked_out_to_id,
'type' => $assetmaintenance->checked_out_to_type,
] : null,
'completed_at' => Helper::getFormattedDateObject($assetmaintenance->completed_at, 'datetime'),
'completed_by' => ($assetmaintenance->completedByUser) ? [
'id' => (int) $assetmaintenance->completedByUser->id,
'name' => e($assetmaintenance->completedByUser->display_name),
] : null,
'created_at' => Helper::getFormattedDateObject($assetmaintenance->created_at, 'datetime'),
'updated_at' => Helper::getFormattedDateObject($assetmaintenance->updated_at, 'datetime'),
'is_warranty' => (bool) $assetmaintenance->is_warranty,
@@ -91,6 +107,7 @@ class MaintenancesTransformer
$permissions_array['available_actions'] = [
'update' => (Gate::allows('update', Asset::class) && ((($assetmaintenance->asset) && $assetmaintenance->asset->deleted_at == ''))) ? true : false,
'delete' => Gate::allows('delete', Asset::class),
'complete' => Gate::allows('update', Asset::class) && ! $assetmaintenance->completed_at,
];
$array += $permissions_array;
@@ -128,10 +145,23 @@ class MaintenancesTransformer
'supplier' => ($assetmaintenance->supplier) ? e($assetmaintenance->supplier?->name) : null,
'url' => ($assetmaintenance->url) ? e($assetmaintenance->url) : null,
'cost' => Helper::formatCurrencyOutput($assetmaintenance->cost),
'maintenance_type' => $assetmaintenance->maintenanceType
? e($assetmaintenance->maintenanceType->name)
: null,
'asset_maintenance_type' => e($assetmaintenance->asset_maintenance_type),
'start_date' => Helper::getFormattedDateObject($assetmaintenance->start_date, 'date'),
'asset_maintenance_time' => $assetmaintenance->asset_maintenance_time,
'completion_date' => Helper::getFormattedDateObject($assetmaintenance->completion_date, 'date'),
'responsible_party' => ($assetmaintenance->responsibleParty) ? [
'id' => (int) $assetmaintenance->responsibleParty->id,
'name' => e($assetmaintenance->responsibleParty->display_name),
] : null,
'checked_out_to_at_creation' => ($assetmaintenance->checkedOutTo) ? e($assetmaintenance->checkedOutTo->display_name) : null,
'completed_at' => Helper::getFormattedDateObject($assetmaintenance->completed_at, 'datetime'),
'completed_by' => ($assetmaintenance->completedByUser) ? [
'id' => (int) $assetmaintenance->completedByUser->id,
'name' => e($assetmaintenance->completedByUser->display_name),
] : null,
'created_by' => ($assetmaintenance->adminuser) ? e($assetmaintenance->adminuser->display_name) : null,
'created_at' => Helper::getFormattedDateObject($assetmaintenance->created_at, 'datetime'),
'updated_at' => Helper::getFormattedDateObject($assetmaintenance->updated_at, 'datetime'),
@@ -0,0 +1,117 @@
<?php
namespace App\Models\Builders;
use App\Models\Setting;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
class MaintenanceQueryBuilder extends Builder
{
public function active(): static
{
return $this->whereNull('maintenances.completed_at');
}
public function completed(): static
{
return $this->whereNotNull('maintenances.completed_at');
}
public function dueForCompletion(Setting $settings): static
{
$interval = (int) ($settings->audit_warning_days ?? 0);
$today = Carbon::now();
return $this->whereNotNull('maintenances.completion_date')
->whereNull('maintenances.completed_at')
->whereBetween('maintenances.completion_date', [
$today->format('Y-m-d'),
$today->copy()->addDays($interval)->format('Y-m-d'),
]);
}
public function overdueForCompletion(): static
{
return $this->whereNotNull('maintenances.completion_date')
->whereNull('maintenances.completed_at')
->where('maintenances.completion_date', '<', Carbon::now()->format('Y-m-d'));
}
public function dueOrOverdueForCompletion(Setting $settings): static
{
return $this->where(fn ($q) => $q->overdueForCompletion())
->orWhere(fn ($q) => $q->dueForCompletion($settings));
}
public function orderBySupplier(string $order): static
{
return $this->leftJoin('suppliers as suppliers_maintenances', 'maintenances.supplier_id', '=', 'suppliers_maintenances.id')
->orderBy('suppliers_maintenances.name', $order);
}
public function orderByTag(string $order): static
{
return $this->leftJoin('assets', 'maintenances.asset_id', '=', 'assets.id')
->orderBy('assets.asset_tag', $order);
}
public function orderByAssetName(string $order): static
{
return $this->leftJoin('assets', 'maintenances.asset_id', '=', 'assets.id')
->orderBy('assets.name', $order);
}
public function orderByAssetSerial(string $order): static
{
return $this->leftJoin('assets', 'maintenances.asset_id', '=', 'assets.id')
->orderBy('assets.serial', $order);
}
public function orderStatusName(string $order): static
{
return $this->join('assets as maintained_asset', 'maintenances.asset_id', '=', 'maintained_asset.id')
->leftJoin('status_labels as maintained_asset_status', 'maintained_asset_status.id', '=', 'maintained_asset.status_id')
->orderBy('maintained_asset_status.name', $order);
}
public function orderLocationName(string $order): static
{
return $this->join('assets as maintained_asset', 'maintenances.asset_id', '=', 'maintained_asset.id')
->leftJoin('locations as maintained_asset_location', 'maintained_asset_location.id', '=', 'maintained_asset.location_id')
->orderBy('maintained_asset_location.name', $order);
}
public function orderByCreatedBy(string $order): static
{
return $this->leftJoin('users as admin_sort', 'maintenances.created_by', '=', 'admin_sort.id')
->select('maintenances.*')
->orderBy('admin_sort.first_name', $order)
->orderBy('admin_sort.last_name', $order);
}
public function orderByAssetModelName(string $order): static
{
return $this->join('assets as maintained_asset', 'maintenances.asset_id', '=', 'maintained_asset.id')
->leftJoin('models as maintained_asset_model', 'maintained_asset_model.id', '=', 'maintained_asset.model_id')
->orderBy('maintained_asset_model.name', $order);
}
public function orderByAssetModelNumber(string $order): static
{
return $this->join('assets as maintained_asset', 'maintenances.asset_id', '=', 'maintained_asset.id')
->leftJoin('models as maintained_asset_model', 'maintained_asset_model.id', '=', 'maintained_asset.model_id')
->orderBy('maintained_asset_model.model_number', $order);
}
public function orderByMaintenanceType(string $order): static
{
return $this->leftJoin('maintenance_types as maintenance_type_sort', 'maintenances.maintenance_type_id', '=', 'maintenance_type_sort.id')
->orderBy('maintenance_type_sort.name', $order);
}
public function orderByCompletedAt(string $order): static
{
return $this->orderBy('maintenances.completed_at', $order);
}
}
+40 -108
View File
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Helpers\Helper;
use App\Models\Builders\MaintenanceQueryBuilder;
use App\Models\Traits\CompanyableChildTrait;
use App\Models\Traits\HasUploads;
use App\Models\Traits\Loggable;
@@ -12,7 +13,6 @@ use App\Presenters\Presentable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\Gate;
use Watson\Validating\ValidatingTrait;
@@ -39,7 +39,7 @@ class Maintenance extends SnipeModel implements ICompanyableChild
protected $rules = [
'asset_id' => 'required|integer',
'supplier_id' => 'nullable|integer',
'asset_maintenance_type' => 'required',
'maintenance_type_id' => 'required|integer|exists:maintenance_types,id',
'name' => 'required|max:100',
'is_warranty' => 'boolean',
'start_date' => 'required|date_format:Y-m-d',
@@ -47,6 +47,8 @@ class Maintenance extends SnipeModel implements ICompanyableChild
'notes' => 'string|nullable',
'cost' => 'numeric|nullable|gte:0|max:99999999999999999.99',
'url' => 'nullable|url|max:255',
'responsible_party_id' => 'nullable|integer|exists:users,id',
'completed_by' => 'nullable|integer|exists:users,id',
];
/**
@@ -59,6 +61,7 @@ class Maintenance extends SnipeModel implements ICompanyableChild
'asset_id',
'supplier_id',
'asset_maintenance_type',
'maintenance_type_id',
'is_warranty',
'start_date',
'completion_date',
@@ -66,6 +69,11 @@ class Maintenance extends SnipeModel implements ICompanyableChild
'notes',
'cost',
'url',
'checked_out_to_id',
'checked_out_to_type',
'responsible_party_id',
'completed_at',
'completed_by',
];
use Searchable;
@@ -79,7 +87,6 @@ class Maintenance extends SnipeModel implements ICompanyableChild
[
'name',
'notes',
'asset_maintenance_type',
'cost',
'start_date',
'completion_date',
@@ -97,6 +104,7 @@ class Maintenance extends SnipeModel implements ICompanyableChild
'asset.status' => ['name'],
'supplier' => ['name'],
'adminuser' => ['first_name', 'last_name', 'display_name'],
'maintenanceType' => ['name'],
];
public function getCompanyableParents()
@@ -204,116 +212,40 @@ class Maintenance extends SnipeModel implements ICompanyableChild
->withTrashed();
}
public function maintenanceType()
{
return $this->belongsTo(MaintenanceType::class, 'maintenance_type_id');
}
public function responsibleParty()
{
return $this->belongsTo(User::class, 'responsible_party_id')
->withTrashed();
}
public function completedByUser()
{
return $this->belongsTo(User::class, 'completed_by')
->withTrashed();
}
public function checkedOutTo()
{
return $this->morphTo('checked_out_to');
}
public function journal()
{
return $this->assetlog()->where('action_type', '=', 'note added');
}
public function getDisplayNameAttribute()
{
return $this->name;
}
/**
* -----------------------------------------------
* BEGIN QUERY SCOPES
* -----------------------------------------------
**/
/**
* Query builder scope to order on a supplier
*
* @param Builder $query Query builder instance
* @param string $order Order
* @return Builder Modified query builder
*/
public function scopeOrderBySupplier($query, $order)
public function newEloquentBuilder($query): MaintenanceQueryBuilder
{
return $query->leftJoin('suppliers as suppliers_maintenances', 'maintenances.supplier_id', '=', 'suppliers_maintenances.id')
->orderBy('suppliers_maintenances.name', $order);
}
/**
* Query builder scope to order on asset tag
*
* @param Builder $query Query builder instance
* @param string $order Order
* @return Builder Modified query builder
*/
public function scopeOrderByTag($query, $order)
{
return $query->leftJoin('assets', 'maintenances.asset_id', '=', 'assets.id')
->orderBy('assets.asset_tag', $order);
}
/**
* Query builder scope to order on asset tag
*
* @param Builder $query Query builder instance
* @param string $order Order
* @return Builder Modified query builder
*/
public function scopeOrderByAssetName($query, $order)
{
return $query->leftJoin('assets', 'maintenances.asset_id', '=', 'assets.id')
->orderBy('assets.name', $order);
}
/**
* Query builder scope to order on serial
*
* @param Builder $query Query builder instance
* @param string $order Order
* @return Builder Modified query builder
*/
public function scopeOrderByAssetSerial($query, $order)
{
return $query->leftJoin('assets', 'maintenances.asset_id', '=', 'assets.id')
->orderBy('assets.serial', $order);
}
/**
* Query builder scope to order on status label name
*
* @param Builder $query Query builder instance
* @param text $order Order
* @return Builder Modified query builder
*/
public function scopeOrderStatusName($query, $order)
{
return $query->join('assets as maintained_asset', 'maintenances.asset_id', '=', 'maintained_asset.id')
->leftjoin('status_labels as maintained_asset_status', 'maintained_asset_status.id', '=', 'maintained_asset.status_id')
->orderBy('maintained_asset_status.name', $order);
}
/**
* Query builder scope to order on status label name
*
* @param Builder $query Query builder instance
* @param text $order Order
* @return Builder Modified query builder
*/
public function scopeOrderLocationName($query, $order)
{
return $query->join('assets as maintained_asset', 'maintenances.asset_id', '=', 'maintained_asset.id')
->leftjoin('locations as maintained_asset_location', 'maintained_asset_location.id', '=', 'maintained_asset.location_id')
->orderBy('maintained_asset_location.name', $order);
}
/**
* Query builder scope to order on the user that created it
*/
public function scopeOrderByCreatedBy($query, $order)
{
return $query->leftJoin('users as admin_sort', 'maintenances.created_by', '=', 'admin_sort.id')->select('maintenances.*')->orderBy('admin_sort.first_name', $order)->orderBy('admin_sort.last_name', $order);
}
public function scopeOrderByAssetModelName($query, $order)
{
return $query->join('assets as maintained_asset', 'maintenances.asset_id', '=', 'maintained_asset.id')
->leftjoin('models as maintained_asset_model', 'maintained_asset_model.id', '=', 'maintained_asset.model_id')
->orderBy('maintained_asset_model.name', $order);
}
public function scopeOrderByAssetModelNumber($query, $order)
{
return $query->join('assets as maintained_asset', 'maintenances.asset_id', '=', 'maintained_asset.id')
->leftjoin('models as maintained_asset_model', 'maintained_asset_model.id', '=', 'maintained_asset.model_id')
->orderBy('maintained_asset_model.model_number', $order);
return new MaintenanceQueryBuilder($query);
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Models;
use App\Presenters\Presentable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Gate;
use Watson\Validating\ValidatingTrait;
class MaintenanceType extends SnipeModel
{
use HasFactory;
use Presentable;
use SoftDeletes;
use ValidatingTrait;
protected $table = 'maintenance_types';
protected $rules = [
'name' => 'required|max:100|unique:maintenance_types,name,NULL,id,deleted_at,NULL',
];
protected $injectUniqueIdentifier = true;
protected $fillable = ['name'];
public function isDeletable(): bool
{
return Gate::allows('delete', $this)
&& ($this->deleted_at == '');
}
public function maintenances()
{
return $this->hasMany(Maintenance::class, 'maintenance_type_id');
}
public function getDisplayNameAttribute(): string
{
return $this->name;
}
}
+26
View File
@@ -5,9 +5,23 @@ namespace App\Observers;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\Maintenance;
use App\Models\MaintenanceType;
class MaintenanceObserver
{
/**
* Capture the asset's current checkout state before the maintenance record is saved.
*/
public function creating(Maintenance $maintenance): void
{
if ($maintenance->asset_id && $asset = Asset::find($maintenance->asset_id)) {
$maintenance->checked_out_to_id = $asset->assigned_to;
$maintenance->checked_out_to_type = $asset->assigned_type;
}
$this->syncLegacyMaintenanceType($maintenance);
}
/**
* Listen to the User created event.
*
@@ -15,6 +29,8 @@ class MaintenanceObserver
*/
public function updating(Maintenance $maintenance)
{
$this->syncLegacyMaintenanceType($maintenance);
$changed = [];
foreach ($maintenance->getRawOriginal() as $key => $value) {
@@ -47,6 +63,16 @@ class MaintenanceObserver
$logAction->logaction('update');
}
private function syncLegacyMaintenanceType(Maintenance $maintenance): void
{
if ($maintenance->maintenance_type_id && ! $maintenance->asset_maintenance_type) {
$type = MaintenanceType::find($maintenance->maintenance_type_id);
if ($type) {
$maintenance->asset_maintenance_type = $type->name;
}
}
}
/**
* Listen to the Component created event when
* a new component is created.
+5
View File
@@ -94,4 +94,9 @@ final class MaintenancePolicy
|| Gate::allows('view', $maintenance)
|| $user->hasAccess('activity.view');
}
public function journal(User $user, Maintenance $maintenance): bool
{
return Gate::allows('view', $maintenance) || $user->hasAccess('activity.view');
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace App\Policies;
class MaintenanceTypePolicy extends SnipePermissionsPolicy
{
protected function columnName()
{
return 'maintenances';
}
}
+9
View File
@@ -461,6 +461,15 @@ class AssetPresenter extends Presenter
}
}
public function formattedNameLink()
{
if (auth()->user()->can('view', ['\App\Models\Asset', $this])) {
return '<a href="'.route('hardware.show', e($this->id)).'" class="'.(($this->deleted_at != '') ? 'deleted' : '').'">'.e($this->display_name).'</a>';
}
return '<span class="'.(($this->deleted_at != '') ? 'deleted' : '').'">'.e($this->display_name).'</span>';
}
public function modelUrl()
{
if ($this->model->model) {
@@ -0,0 +1,54 @@
<?php
namespace App\Presenters;
class MaintenanceTypePresenter extends Presenter
{
public static function dataTableLayout(): string
{
$layout = [
[
'field' => 'id',
'searchable' => false,
'sortable' => true,
'switchable' => true,
'title' => trans('general.id'),
'visible' => false,
], [
'field' => 'name',
'searchable' => true,
'sortable' => true,
'switchable' => false,
'title' => trans('general.name'),
'visible' => true,
], [
'field' => 'created_at',
'searchable' => false,
'sortable' => true,
'switchable' => true,
'title' => trans('general.created_at'),
'visible' => false,
'formatter' => 'dateDisplayFormatter',
], [
'field' => 'updated_at',
'searchable' => false,
'sortable' => true,
'switchable' => true,
'title' => trans('general.updated_at'),
'visible' => false,
'formatter' => 'dateDisplayFormatter',
], [
'field' => 'actions',
'searchable' => false,
'sortable' => false,
'switchable' => false,
'title' => trans('table.actions'),
'visible' => true,
'formatter' => 'maintenanceTypesActionsFormatter',
'printIgnore' => true,
],
];
return json_encode($layout);
}
}
+69 -3
View File
@@ -88,7 +88,7 @@ class MaintenancesPresenter extends Presenter
'switchable' => true,
'title' => trans('general.model_no'),
'visible' => true,
],[
], [
'field' => 'assigned_to',
'searchable' => true,
'sortable' => true,
@@ -111,10 +111,43 @@ class MaintenancesPresenter extends Presenter
'title' => trans('general.location'),
'formatter' => 'locationsLinkObjFormatter',
], [
'field' => 'asset_maintenance_type',
'field' => 'maintenance_type',
'searchable' => true,
'sortable' => true,
'switchable' => true,
'title' => trans('admin/maintenances/form.asset_maintenance_type'),
'visible' => true,
], [
'field' => 'responsible_party',
'searchable' => true,
'sortable' => false,
'switchable' => true,
'title' => trans('admin/maintenances/form.responsible_party'),
'visible' => false,
'formatter' => 'usersLinkObjFormatter',
], [
'field' => 'checked_out_to_at_creation',
'searchable' => false,
'sortable' => false,
'switchable' => true,
'title' => trans('admin/maintenances/form.checked_out_to_at_creation'),
'visible' => false,
], [
'field' => 'completed_at',
'searchable' => false,
'sortable' => true,
'switchable' => true,
'title' => trans('admin/maintenances/form.completed_at'),
'visible' => false,
'formatter' => 'dateDisplayFormatter',
], [
'field' => 'completed_by',
'searchable' => false,
'sortable' => false,
'switchable' => true,
'title' => trans('admin/maintenances/form.completed_by'),
'visible' => false,
'formatter' => 'usersLinkObjFormatter',
], [
'field' => 'start_date',
'searchable' => true,
@@ -274,10 +307,27 @@ class MaintenancesPresenter extends Presenter
'sortable' => true,
'title' => trans('general.location'),
], [
'field' => 'asset_maintenance_type',
'field' => 'maintenance_type',
'searchable' => true,
'sortable' => true,
'switchable' => true,
'title' => trans('admin/maintenances/form.asset_maintenance_type'),
'visible' => true,
], [
'field' => 'responsible_party',
'searchable' => true,
'sortable' => false,
'switchable' => true,
'title' => trans('admin/maintenances/form.responsible_party'),
'visible' => false,
'formatter' => 'usersLinkObjFormatter',
], [
'field' => 'checked_out_to_at_creation',
'searchable' => false,
'sortable' => false,
'switchable' => true,
'title' => trans('admin/maintenances/form.checked_out_to_at_creation'),
'visible' => false,
], [
'field' => 'start_date',
'searchable' => true,
@@ -295,6 +345,22 @@ class MaintenancesPresenter extends Presenter
'searchable' => true,
'sortable' => true,
'title' => trans('admin/maintenances/form.asset_maintenance_time'),
], [
'field' => 'completed_at',
'searchable' => false,
'sortable' => true,
'switchable' => true,
'title' => trans('admin/maintenances/form.completed_at'),
'visible' => false,
'formatter' => 'dateDisplayFormatter',
], [
'field' => 'completed_by',
'searchable' => false,
'sortable' => false,
'switchable' => true,
'title' => trans('admin/maintenances/form.completed_by'),
'visible' => false,
'formatter' => 'usersLinkObjFormatter',
], [
'field' => 'url',
'searchable' => true,
+3
View File
@@ -16,6 +16,7 @@ use App\Models\Depreciation;
use App\Models\License;
use App\Models\Location;
use App\Models\Maintenance;
use App\Models\MaintenanceType;
use App\Models\Manufacturer;
use App\Models\PredefinedKit;
use App\Models\Statuslabel;
@@ -35,6 +36,7 @@ use App\Policies\DepreciationPolicy;
use App\Policies\LicensePolicy;
use App\Policies\LocationPolicy;
use App\Policies\MaintenancePolicy;
use App\Policies\MaintenanceTypePolicy;
use App\Policies\ManufacturerPolicy;
use App\Policies\PredefinedKitPolicy;
use App\Policies\StatuslabelPolicy;
@@ -71,6 +73,7 @@ class AuthServiceProvider extends ServiceProvider
License::class => LicensePolicy::class,
Location::class => LocationPolicy::class,
Maintenance::class => MaintenancePolicy::class,
MaintenanceType::class => MaintenanceTypePolicy::class,
PredefinedKit::class => PredefinedKitPolicy::class,
Statuslabel::class => StatuslabelPolicy::class,
Supplier::class => SupplierPolicy::class,
+12 -3
View File
@@ -356,9 +356,18 @@ class BreadcrumbsServiceProvider extends ServiceProvider
/**
* Maintenances Breadcrumbs
*/
Breadcrumbs::for('maintenances.index', fn (Trail $trail) => $trail->parent('hardware.index', route('hardware.index'))
->push(trans('general.maintenances'), route('maintenances.index'))
);
Breadcrumbs::for('maintenances.index', function (Trail $trail) {
$trail->parent('hardware.index', route('hardware.index'))
->push(trans('general.maintenances'), route('maintenances.index'));
if (request()->input('upcoming_status') === 'due') {
$trail->push(trans('admin/maintenances/general.due'));
} elseif (request()->input('upcoming_status') === 'overdue') {
$trail->push(trans('admin/maintenances/general.overdue'));
} elseif (request()->input('completed') === 'true') {
$trail->push(trans('admin/maintenances/general.completed'));
}
});
Breadcrumbs::for('maintenances.create', fn (Trail $trail) => $trail->parent('maintenances.index', route('maintenances.index'))
->push(trans('general.create'), route('maintenances.create'))
+5 -1
View File
@@ -4,6 +4,7 @@ namespace Database\Factories;
use App\Models\Asset;
use App\Models\Maintenance;
use App\Models\MaintenanceType;
use App\Models\Supplier;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
@@ -24,10 +25,13 @@ class MaintenanceFactory extends Factory
*/
public function definition()
{
$maintenanceType = MaintenanceType::factory()->create();
return [
'asset_id' => Asset::factory()->laptopZenbook(),
'supplier_id' => Supplier::factory(),
'asset_maintenance_type' => $this->faker->randomElement(['maintenance', 'repair', 'upgrade']),
'maintenance_type_id' => $maintenanceType->id,
'asset_maintenance_type' => $maintenanceType->name,
'name' => $this->faker->sentence(3),
'start_date' => $this->faker->date(),
'is_warranty' => $this->faker->boolean(),
@@ -0,0 +1,18 @@
<?php
namespace Database\Factories;
use App\Models\MaintenanceType;
use Illuminate\Database\Eloquent\Factories\Factory;
class MaintenanceTypeFactory extends Factory
{
protected $model = MaintenanceType::class;
public function definition(): array
{
return [
'name' => $this->faker->unique()->words(2, true),
];
}
}
@@ -0,0 +1,79 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// Create maintenance_types lookup table
Schema::create('maintenance_types', function (Blueprint $table) {
$table->id();
$table->string('name', 100);
$table->unsignedBigInteger('created_by')->nullable();
$table->softDeletes();
$table->timestamps();
});
// Seed with the 8 built-in types
$now = now();
$types = [
'Maintenance',
'Repair',
'Upgrade',
'PAT Test',
'Calibration',
'Software Support',
'Hardware Support',
'Configuration Change',
];
foreach ($types as $name) {
DB::table('maintenance_types')->insert([
'name' => $name,
'created_at' => $now,
'updated_at' => $now,
]);
}
// Add new tracking columns and the maintenance_type FK to maintenances
Schema::table('maintenances', function (Blueprint $table) {
$table->unsignedBigInteger('maintenance_type_id')->nullable();
$table->unsignedBigInteger('checked_out_to_id')->nullable();
$table->string('checked_out_to_type')->nullable();
$table->unsignedBigInteger('responsible_party_id')->nullable();
$table->timestamp('completed_at')->nullable();
$table->unsignedBigInteger('completed_by')->nullable();
});
// Map existing asset_maintenance_type strings to the new FK.
// The stored values are the same strings we just seeded into maintenance_types.
$types = DB::table('maintenance_types')->pluck('id', 'name');
foreach ($types as $name => $id) {
DB::table('maintenances')
->whereNull('maintenance_type_id')
->where('asset_maintenance_type', $name)
->update(['maintenance_type_id' => $id]);
}
}
public function down(): void
{
Schema::table('maintenances', function (Blueprint $table) {
$table->dropColumn([
'maintenance_type_id',
'checked_out_to_id',
'checked_out_to_type',
'responsible_party_id',
'completed_at',
'completed_by',
]);
});
Schema::dropIfExists('maintenance_types');
}
};
@@ -0,0 +1,7 @@
<?php
return [
'maintenance_types' => 'Maintenance Types',
'create' => 'Create Maintenance Type',
'update' => 'Update Maintenance Type',
];
@@ -0,0 +1,22 @@
<?php
return [
'not_found' => 'Maintenance type not found.',
'create' => [
'error' => 'Maintenance type was not created, please try again.',
'success' => 'Maintenance type created successfully.',
],
'update' => [
'error' => 'Maintenance type was not updated, please try again.',
'success' => 'Maintenance type updated successfully.',
],
'delete' => [
'confirm' => 'Are you sure you wish to delete this maintenance type?',
'error' => 'There was an issue deleting this maintenance type. Please try again.',
'success' => 'The maintenance type was deleted successfully.',
],
'complete' => [
'success' => 'Maintenance marked as complete.',
'error' => 'There was an issue marking this maintenance as complete. Please try again.',
],
];
@@ -5,11 +5,18 @@ return [
'asset_maintenance_type' => 'Type',
'title' => 'Title',
'start_date' => 'Start Date',
'completion_date' => 'Completion Date',
'completion_date' => 'Expected Completion',
'cost' => 'Cost',
'is_warranty' => 'Warranty Improvement',
'asset_maintenance_time' => 'Duration',
'notes' => 'Notes',
'update' => 'Update Asset Maintenance',
'create' => 'Create Asset Maintenance',
'responsible_party' => 'Responsible Party',
'checked_out_to_at_creation' => 'Checked Out To',
'completed_at' => 'Completed At',
'completed_by' => 'Completed By',
'mark_complete' => 'Mark Complete',
'already_complete' => 'Already Completed',
'completion_notes' => 'Completion Notes',
];
@@ -14,4 +14,10 @@ return [
'hardware_support' => 'Hardware Support',
'configuration_change' => 'Configuration Change',
'pat_test' => 'PAT Test',
'checked_out_to_help' => 'The user, etc that the asset was checked out to at the time of maintenance creation. This is for historical reference and does not affect the current checkout status of the asset.',
'show_completed' => 'Show Completed',
'show_active' => 'Show Active',
'due' => 'Due',
'overdue' => 'Overdue',
'completed' => 'Completed',
];
@@ -18,4 +18,9 @@ return [
'asset_maintenance_incomplete' => 'Not Completed Yet',
'warranty' => 'Warranty',
'not_warranty' => 'Not Warranty',
'complete' => [
'confirm' => 'Are you sure you want to mark this maintenance as complete? This cannot be undone.',
'success' => 'Maintenance marked as complete.',
'error' => 'There was an issue marking this maintenance as complete. Please try again.',
],
];
+2 -1
View File
@@ -209,6 +209,7 @@ return [
'logout' => 'Logout',
'lookup_by_tag' => 'Lookup by Asset Tag',
'maintenances' => 'Maintenances',
'maintenance_complete' => 'Maintenance Complete',
'manage_api_keys' => 'Manage API keys',
'manufacturer' => 'Manufacturer',
'manufacturers' => 'Manufacturers',
@@ -237,7 +238,7 @@ return [
'note_added' => 'Note Added',
'options' => 'Options',
'preview' => 'Preview',
'add_note' => 'Add Note',
'add_note' => 'Add Journal Note',
'note_edited' => 'Note Edited',
'edit_note' => 'Edit Note',
'note_deleted' => 'Note Deleted',
+1 -1
View File
@@ -299,7 +299,7 @@
<x-well class="well-sm">
<div class="well-display">
<x-data-row icon_type="maintenances" label="Active Maintenances" align="right">
{{ $asset->maintenances->whereNull('completion_date')->count() }}
{{ $asset->maintenances()->active()->count() }}
</x-data-row>
<x-data-row icon_type="checkout" :label="trans('general.checkouts_count')" align="right">
@@ -0,0 +1,33 @@
@extends('layouts/default')
{{-- Page title --}}
@section('title')
@if ($item->id)
{{ trans('admin/maintenance_types/general.update') }}
@else
{{ trans('admin/maintenance_types/general.create') }}
@endif
@parent
@stop
@section('header_right')
<a href="{{ URL::previous() }}" class="btn btn-primary pull-right">
{{ trans('general.back') }}
</a>
@stop
{{-- Page content --}}
@section('content')
<x-container class="col-md-6 col-md-offset-3">
<x-form :$item route="{{ $item->id ? route('maintenance-types.update', $item->id) : route('maintenance-types.store') }}">
<x-box>
<x-form.row
:label="trans('general.name')"
:$item
name="name"
required="true"
/>
</x-box>
</x-form>
</x-container>
@stop
@@ -0,0 +1,28 @@
@extends('layouts/default')
{{-- Page title --}}
@section('title')
{{ trans('admin/maintenance_types/general.maintenance_types') }}
@parent
@stop
{{-- Page content --}}
@section('content')
<x-container>
<x-box>
<x-table
name="maintenancetype"
buttons="maintenanceTypeButtons"
fixed_right_number="1"
fixed_number="1"
api_url="{{ route('api.maintenance-types.index') }}"
:presenter="\App\Presenters\MaintenanceTypePresenter::dataTableLayout()"
export_filename="export-maintenance-types-{{ date('Y-m-d') }}"
/>
</x-box>
</x-container>
@stop
@section('moar_scripts')
@include ('partials.bootstrap-table')
@stop
+37 -6
View File
@@ -21,7 +21,7 @@
@section('content')
<div class="row">
<div class="col-md-9">
<div class="col-md-6 col-md-offset-3">
@if ($item->id)
<form class="form-horizontal" method="post" action="{{ route('maintenances.update', $item->id) }}" autocomplete="off" enctype="multipart/form-data">
{{ method_field('PUT') }}
@@ -106,7 +106,36 @@
@include ('partials.forms.edit.maintenance_type')
<!-- Start Date -->
<!-- Responsible Party -->
<div class="form-group {{ $errors->has('responsible_party_id') ? ' has-error' : '' }}">
<label for="responsible_party_id" class="col-md-3 control-label">
{{ trans('admin/maintenances/form.responsible_party') }}
</label>
<div class="col-md-7">
<select
class="js-data-ajax select2"
data-endpoint="users"
name="responsible_party_id"
id="responsible_party_id"
data-placeholder="{{ trans('general.select_user') }}"
aria-label="responsible_party_id"
style="width: 100%;"
>
@if ($item->responsibleParty)
<option value="{{ $item->responsibleParty->id }}" selected="selected">
{{ $item->responsibleParty->display_name }}
</option>
@elseif (! $item->id)
<option value="{{ auth()->id() }}" selected="selected">
{{ auth()->user()->display_name }}
</option>
@endif
</select>
{!! $errors->first('responsible_party_id', '<span class="alert-msg" aria-hidden="true"><i class="fas fa-times" aria-hidden="true"></i> :message</span>') !!}
</div>
</div>
<!-- Start Date -->
<div class="form-group {{ $errors->has('start_date') ? ' has-error' : '' }}">
<label for="start_date" class="col-md-3 control-label">
{{ trans('admin/maintenances/form.start_date') }}
@@ -157,16 +186,18 @@
<!-- Asset Maintenance Cost -->
<div class="form-group {{ $errors->has('cost') ? ' has-error' : '' }}">
<label for="cost" class="col-md-3 control-label">{{ trans('admin/maintenances/form.cost') }}</label>
<div class="col-md-3 text-right">
<div class="input-group">
<div class="col-md-9">
<div class="input-group col-md-5" style="padding-left: 0px;">
<input class="form-control" type="text" inputmode="decimal" pattern="[\d.,]+" name="cost" aria-label="cost" id="cost" value="{{ old('cost', \App\Helpers\Helper::formatCurrencyOutput($item->cost)) }}" maxlength="25" data-msg-pattern="{{ trans('general.purchase_cost_invalid') }}"/>
<span class="input-group-addon">
@if (($item->asset) && ($item->asset->location) && ($item->asset->location->currency!=''))
@if (($item->asset) && ($item->asset->location) && ($item->asset->location->currency != ''))
{{ $item->asset->location->currency }}
@else
{{ $snipeSettings->default_currency }}
@endif
</span>
<input class="form-control" type="text" inputmode="decimal" pattern="[\d.,]+" name="cost" aria-label="cost" id="cost" value="{{ old('cost', \App\Helpers\Helper::formatCurrencyOutput($item->cost)) }}" maxlength="25" data-msg-pattern="{{ trans('general.purchase_cost_invalid') }}"/>
</div>
<div class="col-md-9" style="padding-left: 0px;">
{!! $errors->first('cost', '<span class="alert-msg" aria-hidden="true"><i class="fas fa-times" aria-hidden="true"></i> :message</span>') !!}
<p class="help-block">{{ trans('general.purchase_cost_format_help', ['format' => $snipeSettings->digit_separator]) }}</p>
</div>
+53 -13
View File
@@ -16,7 +16,7 @@
name="maintenances"
fixed_right_number="1"
buttons="maintenanceButtons"
api_url="{{ route('api.maintenances.index') }}"
api_url="{{ route('api.maintenances.index') }}?completed={{ request()->input('completed', 'false') }}&upcoming_status={{ request()->input('upcoming_status', '') }}"
:presenter="\App\Presenters\MaintenancesPresenter::dataTableLayout()"
export_filename="export-maintenances-{{ date('Y-m-d') }}"
/>
@@ -27,24 +27,64 @@
@section('moar_scripts')
@include ('partials.bootstrap-table', ['exportFile' => 'maintenances-export', 'search' => true])
<div class="modal fade" id="completeMaintenanceModal" tabindex="-1" role="dialog" aria-labelledby="completeMaintenanceModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="{{ trans('button.close') }}"><span aria-hidden="true">&times;</span></button>
<h4 class="modal-title" id="completeMaintenanceModalLabel">{{ trans('admin/maintenances/form.mark_complete') }}</h4>
</div>
<form id="completeMaintenanceForm" method="POST" action="">
@csrf
<div class="modal-body">
<p>{{ trans('admin/maintenances/message.complete.confirm') }}</p>
<div class="form-group">
<label for="completionNote">{{ trans('admin/maintenances/form.completion_notes') }}</label>
<textarea class="form-control" id="completionNote" name="note" rows="3"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default pull-left" data-dismiss="modal">{{ trans('button.cancel') }}</button>
<button type="submit" class="btn btn-success pull-right">{{ trans('admin/maintenances/form.mark_complete') }}</button>
</div>
</form>
</div>
</div>
</div>
<script nonce="{{ csrf_token() }}">
function maintenanceActions(value, row) {
function maintenancesActionsFormatter(value, row) {
var actions = '<nobr>';
if ((row) && (row.available_actions.update === true)) {
actions += '<a href="{{ config('app.url') }}/hardware/maintenances/' + row.id + '/edit" class="btn btn-sm btn-warning" data-tooltip="true" title="Update"><i class="fas fa-pencil-alt"></i></a>&nbsp;';
}
actions += '</nobr>'
if ((row) && (row.available_actions.delete === true)) {
actions += '<a href="{{ config('app.url') }}/hardware/maintenances/' + row.id + '" '
+ ' class="btn btn-danger btn-sm delete-asset" data-tooltip="true" '
+ ' data-toggle="modal" '
+ ' data-content="{{ trans('general.sure_to_delete') }} ' + row.name + '?" '
+ ' data-title="{{ trans('general.delete') }}" onClick="return false;">'
+ '<i class="fas fa-trash"></i></a></nobr>';
if ((row.available_actions) && (row.available_actions.update === true)) {
actions += '<a href="{{ config('app.url') }}/maintenances/' + row.id + '/edit" class="actions btn btn-sm btn-warning hidden-print" data-tooltip="true" title="{{ trans('general.update') }}"><x-icon type="edit" class="fa-fw" /><span class="sr-only">{{ trans('general.update') }}</span></a>&nbsp;';
}
if ((row.available_actions) && (row.available_actions.complete === true)) {
actions += '<button type="button" class="actions btn btn-sm btn-success hidden-print complete-maintenance" data-tooltip="true" title="{{ trans('admin/maintenances/form.mark_complete') }}" data-url="{{ config('app.url') }}/maintenances/' + row.id + '/complete"><x-icon type="checkmark" class="fa-fw" /><span class="sr-only">{{ trans('admin/maintenances/form.mark_complete') }}</span></button>&nbsp;';
} else {
actions += '<button type="button" class="actions btn btn-sm btn-default hidden-print disabled" disabled data-tooltip="true" title="{{ trans('admin/maintenances/form.already_complete') }}"><x-icon type="checkmark" class="fa-fw" /><span class="sr-only">{{ trans('admin/maintenances/form.already_complete') }}</span></button>&nbsp;';
}
if ((row.available_actions) && (row.available_actions.delete === true)) {
actions += '<a href="{{ config('app.url') }}/maintenances/' + row.id + '" '
+ ' class="actions btn btn-danger btn-sm delete-asset hidden-print" data-tooltip="true" '
+ ' data-toggle="modal" data-icon="fa-trash"'
+ ' data-content="{{ trans('general.sure_to_delete') }}: ' + row.name + '?" '
+ ' data-title="{{ trans('general.delete') }}" onClick="return false;">'
+ '<x-icon type="delete" class="fa-fw" /><span class="sr-only">{{ trans('general.delete') }}</span></a>&nbsp;';
}
actions += '</nobr>';
return actions;
}
$('body').on('click', '.complete-maintenance', function () {
var url = $(this).data('url');
$('#completeMaintenanceForm').attr('action', url);
$('#completionNote').val('');
$('#completeMaintenanceModal').modal('show');
});
</script>
@stop
+83 -2
View File
@@ -21,6 +21,7 @@ use Carbon\Carbon;
<x-tabs>
<x-slot:tabnav>
<x-tabs.details-tab/>
<x-tabs.note-tab :item="$maintenance" count="{{ $maintenance->journal->count() }}"/>
<x-tabs.files-tab :item="$maintenance" count="{{ $maintenance->uploads()->count() }}"/>
<x-tabs.history-tab count="{{ $maintenance->history()->count() }}" :model="$maintenance"/>
<x-tabs.upload-tab :item="$maintenance"/>
@@ -103,7 +104,7 @@ use Carbon\Carbon;
{{ $snipeSettings->default_currency .' '. Helper::formatCurrencyOutput($maintenance->cost) }}
</x-data-row>
<x-data-row :label="trans('admin/maintenances/form.is_warranty')" copy_what="warranty_improvement">
<x-data-row :label="trans('admin/maintenances/form.is_warranty')">
@if ($maintenance->is_warranty=='1')
<x-icon type="checkmark" class="text-success"/>
{{ trans('general.yes') }}
@@ -111,8 +112,36 @@ use Carbon\Carbon;
<x-icon type="x" class="text-danger"/>
{{ trans('general.no') }}
@endif
</x-data-row>
@if ($maintenance->responsibleParty)
<x-data-row :label="trans('admin/maintenances/form.responsible_party')" copy_what="responsible_party">
{!! $maintenance->responsibleParty->present()->nameUrl() !!}
</x-data-row>
@endif
@if ($maintenance->checkedOutTo)
<x-data-row :label="trans('admin/maintenances/form.checked_out_to_at_creation')">
<x-icon type="{{ strtolower(class_basename($maintenance->checked_out_to_type)) }}" class="fa-fw"/>
{!! $maintenance->checkedOutTo->present()->formattedNameLink() !!}
<p class="help-block">
{{ trans('admin/maintenances/general.checked_out_to_help') }}
</p>
</x-data-row>
@endif
@if ($maintenance->completed_at)
<x-data-row :label="trans('admin/maintenances/form.completed_at')">
{{ Helper::getFormattedDateObject($maintenance->completed_at, 'datetime', false) }}
</x-data-row>
@if ($maintenance->completedByUser)
<x-data-row :label="trans('admin/maintenances/form.completed_by')">
{!! $maintenance->completedByUser->present()->nameUrl() !!}
</x-data-row>
@endif
@endif
</x-page-data>
<!-- ./ definition list content -->
<div class="clearfix"></div>
@@ -163,6 +192,15 @@ use Carbon\Carbon;
<x-table.files object_type="maintenances" :object="$maintenance"/>
</x-tabs.pane>
<x-tabs.pane name="notes">
<x-table.history
:table_header="trans('general.notes')"
:model="$maintenance"
:route="route('api.activity.index', ['item_id' => $maintenance->id, 'item_type' => 'maintenance', 'action_type' => 'note added'])"
:hide_fields="['id','action_type', 'item', 'changed', 'target','file','file_download','quantity','changed','serial','signature_file','log_meta']"
/>
</x-tabs.pane>
<x-tabs.pane name="history">
<x-table.history :model="$maintenance" :route="route('api.maintenances.history', $maintenance)"/>
</x-tabs.pane>
@@ -178,6 +216,20 @@ use Carbon\Carbon;
<x-slot:buttons>
<x-button.edit :item="$maintenance" :route="route('maintenances.edit', $maintenance->id)" />
<x-button.note :item="$maintenance"/>
@if (! $maintenance->completed_at)
@can('update', $maintenance->asset)
<button type="button" class="btn btn-success btn-sm" data-toggle="modal" data-target="#completeMaintenanceModal" data-tooltip="true" title="{{ trans('admin/maintenances/form.mark_complete') }}">
<x-icon type="checkmark" class="fa-fw"/>
<span class="sr-only">{{ trans('admin/maintenances/form.mark_complete') }}</span>
</button>
@endcan
@else
<span class="btn btn-sm btn-default disabled" data-tooltip="true" title="{{ trans('admin/maintenances/form.already_complete') }}: {{ Helper::getFormattedDateObject($maintenance->completed_at, 'datetime', false) }}">
<x-icon type="checkmark" class="fa-fw"/>
<span class="sr-only">{{ trans('admin/maintenances/form.already_complete') }}</span>
</span>
@endif
<x-button.delete :item="$maintenance" />
</x-slot:buttons>
@@ -193,6 +245,35 @@ use Carbon\Carbon;
@can('files', $maintenance)
@include ('modals.upload-file', ['item_type' => 'maintenances', 'item_id' => $maintenance->id])
@endcan
@can('journal', $maintenance)
@include ('modals.add-note', ['type' => 'maintenance', 'id' => $maintenance->id])
@endcan
<div class="modal fade" id="completeMaintenanceModal" tabindex="-1" role="dialog" aria-labelledby="completeMaintenanceModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="{{ trans('button.close') }}">
<span aria-hidden="true">&times;</span></button>
<h4 class="modal-title" id="completeMaintenanceModalLabel">{{ trans('admin/maintenances/form.mark_complete') }}</h4>
</div>
<form method="POST" action="{{ route('maintenances.complete', $maintenance->id) }}">
@csrf
<div class="modal-body">
<p>{{ trans('admin/maintenances/message.complete.confirm') }}</p>
<div class="form-group">
<label for="completionNote">{{ trans('admin/maintenances/form.completion_notes') }}</label>
<textarea class="form-control" id="completionNote" name="note" rows="3"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default pull-left" data-dismiss="modal">{{ trans('button.cancel') }}</button>
<button type="submit" class="btn btn-success pull-right">{{ trans('admin/maintenances/form.mark_complete') }}</button>
</div>
</form>
</div>
</div>
</div>
@include ('partials.bootstrap-table')
@endsection
@@ -0,0 +1,21 @@
{{-- See snipeit_modals.js for what powers this --}}
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
<h2 class="modal-title">{{ trans('admin/maintenance_types/general.create') }}</h2>
</div>
<div class="modal-body">
<form action="{{ route('api.maintenance-types.store') }}" onsubmit="return false">
<div class="dynamic-form-row">
@include('partials.forms.edit.name', ['item' => new \App\Models\MaintenanceType(), 'translated_name' => trans('general.name')])
</div>
</form>
</div>
<div class="dynamic-form-row">
@include('modals.partials.footer')
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
@@ -532,6 +532,9 @@
'columns',
'btnAdd',
'btnShowDeleted',
'btnToggleCompleted',
'btnDue',
'btnOverdue',
'btnShowAdmins',
'btnShowExpiring',
'btnShowInactive',
@@ -1161,6 +1164,44 @@
@endif
}
},
btnToggleCompleted: {
text: '{{ request()->input('completed', 'false') === 'true' ? trans('admin/maintenances/general.show_active') : trans('admin/maintenances/general.show_completed') }}',
icon: 'fa-regular fa-square-check',
event() {
var isShowingCompleted = '{{ request()->input('completed', 'false') }}' === 'true';
window.location.href = '{{ route('maintenances.index') }}?completed=' + (isShowingCompleted ? 'false' : 'true');
},
attributes: {
class: '{{ request()->input('completed', 'false') === 'true' ? 'btn-selected' : '' }}',
title: '{{ request()->input('completed', 'false') === 'true' ? trans('admin/maintenances/general.show_active') : trans('admin/maintenances/general.show_completed') }}',
},
},
btnDue: {
text: '{{ trans('admin/maintenances/general.due') }}',
icon: 'fa-regular fa-clock',
event() {
var isActive = '{{ request()->input('upcoming_status') }}' === 'due';
window.location.href = '{{ route('maintenances.index') }}' + (isActive ? '' : '?upcoming_status=due');
},
attributes: {
class: '{{ request()->input('upcoming_status') === 'due' ? 'btn-selected' : '' }}',
title: '{{ trans('admin/maintenances/general.due') }}',
},
},
btnOverdue: {
text: '{{ trans('admin/maintenances/general.overdue') }}',
icon: 'fa-solid fa-triangle-exclamation',
event() {
var isActive = '{{ request()->input('upcoming_status') }}' === 'overdue';
window.location.href = '{{ route('maintenances.index') }}' + (isActive ? '' : '?upcoming_status=overdue');
},
attributes: {
class: '{{ request()->input('upcoming_status') === 'overdue' ? 'btn-selected' : '' }}',
title: '{{ trans('admin/maintenances/general.overdue') }}',
},
},
});
@endcan
@@ -1,18 +1,45 @@
<!-- Improvement Type -->
<div class="form-group {{ $errors->has('asset_maintenance_type') ? ' has-error' : '' }}">
<label for="asset_maintenance_type" class="col-md-3 control-label">{{ trans('admin/maintenances/form.asset_maintenance_type') }}
<!-- Maintenance Type -->
<div class="form-group {{ $errors->has('maintenance_type_id') ? ' has-error' : '' }}">
<label for="maintenance_type_id" class="col-md-3 control-label">
{{ trans('admin/maintenances/form.asset_maintenance_type') }}
</label>
<div class="col-md-7">
<x-input.select
name="asset_maintenance_type"
:options="$maintenanceType"
:selected="old('asset_maintenance_type', $item->asset_maintenance_type)"
:required="Helper::checkIfRequired($item, 'asset_maintenance_type')"
data-placeholder="{{ trans('admin/maintenances/form.select_type')}}"
includeEmpty="true"
style="width:100%;"
aria-label="asset_maintenance_type"
/>
{!! $errors->first('asset_maintenance_type', '<span class="alert-msg" aria-hidden="true"><i class="fas fa-times" aria-hidden="true"></i> :message</span>') !!}
@if (isset($maintenanceTypes) && $maintenanceTypes->count())
<select name="maintenance_type_id" id="maintenance_type_id"
class="form-control select2"
data-placeholder="{{ trans('admin/maintenances/form.select_type') }}"
style="width: 100%;"
aria-label="maintenance_type_id" required>
<option value=""></option>
@foreach ($maintenanceTypes as $type)
<option value="{{ $type->id }}"
{{ old('maintenance_type_id', $item->maintenance_type_id) == $type->id ? 'selected' : '' }}>
{{ $type->name }}
</option>
@endforeach
</select>
@else
{{-- Fallback to legacy string-based dropdown if no types configured yet --}}
<x-input.select
name="asset_maintenance_type"
:options="$maintenanceType ?? []"
:selected="old('asset_maintenance_type', $item->asset_maintenance_type)"
data-placeholder="{{ trans('admin/maintenances/form.select_type')}}"
includeEmpty="true"
style="width:100%;"
aria-label="asset_maintenance_type"
/>
@endif
{!! $errors->first('maintenance_type_id', '<span class="alert-msg" aria-hidden="true"><i class="fas fa-times" aria-hidden="true"></i> :message</span>') !!}
</div>
<div class="col-md-1 col-sm-1 text-left">
@can('create', \App\Models\MaintenanceType::class)
<a href="{{ route('modal.show', 'maintenance-type') }}"
data-toggle="modal"
data-target="#createModal"
data-select="maintenance_type_id"
class="btn btn-sm btn-theme">{{ trans('button.new') }}</a>
@endcan
</div>
</div>
+29
View File
@@ -625,6 +625,18 @@ Route::group(['prefix' => 'v1', 'middleware' => ['api', 'api-throttle:api']], fu
]
)->name('api.maintenances.history')->withTrashed();
Route::get('/maintenances/{maintenance}/notes',
[Api\MaintenancesController::class, 'notesIndex']
)->name('api.maintenances.notes.index');
Route::post('/maintenances/{maintenance}/notes',
[Api\MaintenancesController::class, 'notesStore']
)->name('api.maintenances.notes.store');
Route::post('/maintenances/{maintenance}/complete',
[Api\MaintenancesController::class, 'complete']
)->name('api.maintenances.complete');
Route::resource('maintenances',
Api\MaintenancesController::class,
['names' => [
@@ -639,6 +651,23 @@ Route::group(['prefix' => 'v1', 'middleware' => ['api', 'api-throttle:api']], fu
]
); // end assets API routes
/**
* Maintenance types API routes
*/
Route::resource('maintenance-types',
Api\MaintenanceTypesController::class,
['names' => [
'index' => 'api.maintenance-types.index',
'show' => 'api.maintenance-types.show',
'store' => 'api.maintenance-types.store',
'update' => 'api.maintenance-types.update',
'destroy' => 'api.maintenance-types.destroy',
],
'except' => ['create', 'edit'],
'parameters' => ['maintenance-type' => 'maintenanceType'],
]
);
/**
* Imports API routes
*/
+6
View File
@@ -17,6 +17,7 @@ use App\Http\Controllers\DepreciationsController;
use App\Http\Controllers\GroupsController;
use App\Http\Controllers\HealthController;
use App\Http\Controllers\LabelsController;
use App\Http\Controllers\MaintenanceTypesController;
use App\Http\Controllers\ManufacturersController;
use App\Http\Controllers\ModalController;
use App\Http\Controllers\NotesController;
@@ -87,6 +88,11 @@ Route::group(['middleware' => 'auth'], function () {
Route::post('suppliers/bulk/delete', [BulkSuppliersController::class, 'destroy'])->name('suppliers.bulk.delete');
/*
* Maintenance Types
*/
Route::resource('maintenance-types', MaintenanceTypesController::class);
/*
* Depreciations
*/
+4
View File
@@ -181,6 +181,10 @@ Route::resource('maintenances',
['middleware' => ['auth'],
])->parameters(['maintenance' => 'maintenance', 'asset' => 'asset_id']);
Route::post('maintenances/{maintenance}/complete',
[MaintenancesController::class, 'complete']
)->name('maintenances.complete')->middleware(['auth']);
Route::get('ht/{any?}',
[AssetsController::class, 'getAssetByTag'])
->where('any', '.*')
@@ -0,0 +1,104 @@
<?php
namespace Tests\Feature\Maintenances\Api;
use App\Models\Actionlog;
use App\Models\Maintenance;
use App\Models\User;
use Carbon\Carbon;
use Tests\TestCase;
class CompleteMaintenanceTest extends TestCase
{
public function test_requires_permission()
{
$maintenance = Maintenance::factory()->create();
$this->actingAsForApi(User::factory()->create())
->postJson(route('api.maintenances.complete', $maintenance))
->assertForbidden();
}
public function test_can_mark_maintenance_complete()
{
$actor = User::factory()->superuser()->create();
$maintenance = Maintenance::factory()->create();
$this->actingAsForApi($actor)
->postJson(route('api.maintenances.complete', $maintenance))
->assertOk()
->assertStatusMessageIs('success');
$maintenance->refresh();
$this->assertNotNull($maintenance->completed_at);
$this->assertEquals($actor->id, $maintenance->completed_by);
$this->assertHasTheseActionLogs($maintenance, ['create', 'completed']);
}
public function test_marking_complete_does_not_create_update_log()
{
$actor = User::factory()->superuser()->create();
$maintenance = Maintenance::factory()->create();
$this->actingAsForApi($actor)
->postJson(route('api.maintenances.complete', $maintenance));
$updateLogs = Actionlog::where('item_type', Maintenance::class)
->where('item_id', $maintenance->id)
->where('action_type', 'update')
->count();
$this->assertEquals(0, $updateLogs);
}
public function test_cannot_mark_already_completed_maintenance_complete()
{
$actor = User::factory()->superuser()->create();
$maintenance = Maintenance::factory()->create(['completed_at' => now()->subDay(), 'completed_by' => $actor->id]);
$this->actingAsForApi($actor)
->postJson(route('api.maintenances.complete', $maintenance))
->assertStatusMessageIs('error');
$this->assertHasTheseActionLogs($maintenance, ['create']);
}
public function test_completion_note_is_saved_in_actionlog()
{
$actor = User::factory()->superuser()->create();
$maintenance = Maintenance::factory()->create();
$this->actingAsForApi($actor)
->postJson(route('api.maintenances.complete', $maintenance), ['note' => 'Fixed the thing'])
->assertOk();
$log = Actionlog::where('item_type', Maintenance::class)
->where('item_id', $maintenance->id)
->where('action_type', 'completed')
->first();
$this->assertNotNull($log);
$this->assertEquals('Fixed the thing', $log->note);
}
public function test_duration_is_calculated_from_created_at()
{
$actor = User::factory()->superuser()->create();
Carbon::setTestNow(Carbon::create(2026, 1, 1));
$maintenance = Maintenance::factory()->create(['start_date' => '2025-06-01']);
Carbon::setTestNow(Carbon::create(2026, 1, 11));
try {
$this->actingAsForApi($actor)
->postJson(route('api.maintenances.complete', $maintenance))
->assertOk();
$maintenance->refresh();
$this->assertEquals(10, $maintenance->asset_maintenance_time);
} finally {
Carbon::setTestNow();
}
}
}
@@ -3,7 +3,9 @@
namespace Tests\Feature\Maintenances\Api;
use App\Models\Asset;
use App\Models\Company;
use App\Models\Maintenance;
use App\Models\MaintenanceType;
use App\Models\Supplier;
use App\Models\User;
use Illuminate\Http\UploadedFile;
@@ -27,13 +29,14 @@ class CreateMaintenanceTest extends TestCase
$asset = Asset::factory()->create();
$supplier = Supplier::factory()->create();
$type = MaintenanceType::factory()->create();
$response = $this->actingAsForApi($actor)
->postJson(route('api.maintenances.store'), [
'name' => 'Test Maintenance',
'asset_id' => $asset->id,
'supplier_id' => $supplier->id,
'asset_maintenance_type' => 'Maintenance',
'maintenance_type_id' => $type->id,
'start_date' => '2021-01-01',
'completion_date' => '2021-01-10',
'is_warranty' => '1',
@@ -54,7 +57,8 @@ class CreateMaintenanceTest extends TestCase
$this->assertDatabaseHas('maintenances', [
'asset_id' => $asset->id,
'supplier_id' => $supplier->id,
'asset_maintenance_type' => 'Maintenance',
'maintenance_type_id' => $type->id,
'asset_maintenance_type' => $type->name,
'name' => 'Test Maintenance',
'is_warranty' => 1,
'start_date' => '2021-01-01',
@@ -67,4 +71,80 @@ class CreateMaintenanceTest extends TestCase
$this->assertHasTheseActionLogs($maintenance, ['create']);
}
public function test_bulk_create_creates_one_maintenance_per_asset()
{
$actor = User::factory()->superuser()->create();
$assets = Asset::factory()->count(3)->create();
$type = MaintenanceType::factory()->create();
$response = $this->actingAsForApi($actor)
->postJson(route('api.maintenances.store'), [
'name' => 'Bulk Test',
'asset_ids' => $assets->pluck('id')->toArray(),
'maintenance_type_id' => $type->id,
'start_date' => '2026-01-01',
'is_warranty' => 0,
])
->assertOk()
->assertJsonPath('status', 'success')
->assertJsonPath('payload.total', 3);
foreach ($assets as $asset) {
$this->assertDatabaseHas('maintenances', [
'name' => 'Bulk Test',
'asset_id' => $asset->id,
'maintenance_type_id' => $type->id,
]);
}
}
public function test_bulk_create_skips_inaccessible_assets_under_fmcs()
{
[$companyA, $companyB] = Company::factory()->count(2)->create();
$actor = $companyA->users()->save(User::factory()->editAssets()->make());
$this->settings->enableMultipleFullCompanySupport();
$ownAsset = Asset::factory()->create(['company_id' => $companyA->id]);
$otherAsset = Asset::factory()->create(['company_id' => $companyB->id]);
$type = MaintenanceType::factory()->create();
$this->actingAsForApi($actor)
->postJson(route('api.maintenances.store'), [
'name' => 'FMCS Bulk Test',
'asset_ids' => [$ownAsset->id, $otherAsset->id],
'maintenance_type_id' => $type->id,
'start_date' => '2026-01-01',
'is_warranty' => 0,
])
->assertOk()
->assertJsonPath('status', 'success')
->assertJsonPath('payload.total', 1);
$this->assertDatabaseHas('maintenances', ['asset_id' => $ownAsset->id, 'name' => 'FMCS Bulk Test']);
$this->assertDatabaseMissing('maintenances', ['asset_id' => $otherAsset->id, 'name' => 'FMCS Bulk Test']);
}
public function test_bulk_create_returns_error_when_all_assets_inaccessible()
{
[$companyA, $companyB] = Company::factory()->count(2)->create();
$actor = $companyA->users()->save(User::factory()->editAssets()->make());
$this->settings->enableMultipleFullCompanySupport();
$otherAsset = Asset::factory()->create(['company_id' => $companyB->id]);
$type = MaintenanceType::factory()->create();
$this->actingAsForApi($actor)
->postJson(route('api.maintenances.store'), [
'name' => 'All Denied Test',
'asset_ids' => [$otherAsset->id],
'maintenance_type_id' => $type->id,
'start_date' => '2026-01-01',
'is_warranty' => 0,
])
->assertOk()
->assertJsonPath('status', 'error');
}
}
@@ -5,6 +5,7 @@ namespace Tests\Feature\Maintenances\Api;
use App\Models\Actionlog;
use App\Models\Company;
use App\Models\Maintenance;
use App\Models\MaintenanceType;
use App\Models\Supplier;
use App\Models\User;
use Illuminate\Http\UploadedFile;
@@ -26,13 +27,14 @@ class EditMaintenanceTest extends TestCase
$actor = User::factory()->superuser()->create();
$supplier = Supplier::factory()->create();
$maintenance = Maintenance::factory()->create();
$type = MaintenanceType::factory()->create();
$response = $this->actingAs($actor)
->followingRedirects()
->patch(route('maintenances.update', $maintenance), [
'name' => 'Test Maintenance',
'supplier_id' => $supplier->id,
'asset_maintenance_type' => 'Maintenance',
'maintenance_type_id' => $type->id,
'start_date' => '2021-01-01',
'completion_date' => '2021-01-10',
'is_warranty' => '1',
@@ -50,12 +52,12 @@ class EditMaintenanceTest extends TestCase
$this->assertDatabaseHas('maintenances', [
'supplier_id' => $supplier->id,
'asset_maintenance_type' => 'Maintenance',
'maintenance_type_id' => $type->id,
'asset_maintenance_type' => $type->name,
'name' => 'Test Maintenance',
'is_warranty' => 1,
'start_date' => '2021-01-01',
'completion_date' => '2021-01-10',
'asset_maintenance_time' => '9',
'notes' => 'A note',
'url' => 'https://snipeitapp.com',
'image' => $maintenance->image,
@@ -0,0 +1,129 @@
<?php
namespace Tests\Feature\Maintenances\Api;
use App\Models\Maintenance;
use App\Models\MaintenanceType;
use App\Models\User;
use Tests\TestCase;
class IndexMaintenanceTest extends TestCase
{
public function test_requires_permission()
{
$this->actingAsForApi(User::factory()->create())
->getJson(route('api.maintenances.index'))
->assertForbidden();
}
public function test_completed_filter_returns_only_completed_maintenances()
{
$actor = User::factory()->superuser()->create();
$active = Maintenance::factory()->create(['completed_at' => null]);
$done = Maintenance::factory()->create(['completed_at' => now()]);
$response = $this->actingAsForApi($actor)
->getJson(route('api.maintenances.index', ['completed' => 'true']))
->assertOk();
$ids = collect($response->json('rows'))->pluck('id');
$this->assertContains($done->id, $ids);
$this->assertNotContains($active->id, $ids);
}
public function test_completed_false_filter_returns_only_active_maintenances()
{
$actor = User::factory()->superuser()->create();
$active = Maintenance::factory()->create(['completed_at' => null]);
$done = Maintenance::factory()->create(['completed_at' => now()]);
$response = $this->actingAsForApi($actor)
->getJson(route('api.maintenances.index', ['completed' => 'false']))
->assertOk();
$ids = collect($response->json('rows'))->pluck('id');
$this->assertContains($active->id, $ids);
$this->assertNotContains($done->id, $ids);
}
public function test_upcoming_status_overdue_returns_only_overdue()
{
$actor = User::factory()->superuser()->create();
$overdue = Maintenance::factory()->create([
'completion_date' => now()->subDay()->format('Y-m-d'),
'completed_at' => null,
]);
$fine = Maintenance::factory()->create([
'completion_date' => now()->addDays(30)->format('Y-m-d'),
'completed_at' => null,
]);
$response = $this->actingAsForApi($actor)
->getJson(route('api.maintenances.index', ['upcoming_status' => 'overdue']))
->assertOk();
$ids = collect($response->json('rows'))->pluck('id');
$this->assertContains($overdue->id, $ids);
$this->assertNotContains($fine->id, $ids);
}
public function test_upcoming_status_due_respects_warning_window()
{
$this->settings->setAuditWarningDays(7);
$actor = User::factory()->superuser()->create();
$due = Maintenance::factory()->create([
'completion_date' => now()->addDays(3)->format('Y-m-d'),
'completed_at' => null,
]);
$notDueYet = Maintenance::factory()->create([
'completion_date' => now()->addDays(30)->format('Y-m-d'),
'completed_at' => null,
]);
$response = $this->actingAsForApi($actor)
->getJson(route('api.maintenances.index', ['upcoming_status' => 'due']))
->assertOk();
$ids = collect($response->json('rows'))->pluck('id');
$this->assertContains($due->id, $ids);
$this->assertNotContains($notDueYet->id, $ids);
}
public function test_maintenance_type_is_returned_as_flat_string()
{
$actor = User::factory()->superuser()->create();
$type = MaintenanceType::factory()->create(['name' => 'Annual Checkup']);
$maintenance = Maintenance::factory()->create(['maintenance_type_id' => $type->id]);
$response = $this->actingAsForApi($actor)
->getJson(route('api.maintenances.show', $maintenance))
->assertOk();
$this->assertEquals('Annual Checkup', $response->json('maintenance_type'));
}
public function test_sort_by_maintenance_type_does_not_error()
{
$actor = User::factory()->superuser()->create();
Maintenance::factory()->count(3)->create();
$this->actingAsForApi($actor)
->getJson(route('api.maintenances.index', ['sort' => 'maintenance_type', 'order' => 'asc']))
->assertOk();
}
public function test_sort_by_completed_at_does_not_error()
{
$actor = User::factory()->superuser()->create();
Maintenance::factory()->count(2)->create(['completed_at' => null]);
Maintenance::factory()->create(['completed_at' => now()]);
$this->actingAsForApi($actor)
->getJson(route('api.maintenances.index', ['sort' => 'completed_at', 'order' => 'desc']))
->assertOk();
}
}
@@ -0,0 +1,111 @@
<?php
namespace Tests\Feature\Maintenances\Api;
use App\Models\Actionlog;
use App\Models\Maintenance;
use App\Models\User;
use Tests\TestCase;
class MaintenanceNotesTest extends TestCase
{
public function test_index_requires_permission()
{
$maintenance = Maintenance::factory()->create();
$this->actingAsForApi(User::factory()->create())
->getJson(route('api.maintenances.notes.index', $maintenance))
->assertForbidden();
}
public function test_index_returns_notes_for_maintenance()
{
$actor = User::factory()->superuser()->create();
$maintenance = Maintenance::factory()->create();
$log = new Actionlog;
$log->item_type = Maintenance::class;
$log->item_id = $maintenance->id;
$log->note = 'Test note content';
$log->created_by = $actor->id;
$log->logaction('note added');
$response = $this->actingAsForApi($actor)
->getJson(route('api.maintenances.notes.index', $maintenance))
->assertOk()
->assertStatusMessageIs('success');
$notes = $response->json('payload.notes');
$this->assertCount(1, $notes);
$this->assertEquals('Test note content', $notes[0]['note']);
}
public function test_index_does_not_return_other_action_types()
{
$actor = User::factory()->superuser()->create();
$maintenance = Maintenance::factory()->create();
$this->actingAsForApi($actor)
->getJson(route('api.maintenances.notes.index', $maintenance))
->assertOk();
// The create actionlog from factory should not appear (it's 'create' not 'note added')
$response = $this->actingAsForApi($actor)
->getJson(route('api.maintenances.notes.index', $maintenance))
->assertOk();
$notes = $response->json('payload.notes');
$this->assertEmpty($notes);
}
public function test_store_requires_permission()
{
$maintenance = Maintenance::factory()->create();
$this->actingAsForApi(User::factory()->create())
->postJson(route('api.maintenances.notes.store', $maintenance), ['note' => 'Test'])
->assertForbidden();
}
public function test_store_validates_note_is_required()
{
$actor = User::factory()->superuser()->create();
$maintenance = Maintenance::factory()->create();
$this->actingAsForApi($actor)
->postJson(route('api.maintenances.notes.store', $maintenance), ['note' => ''])
->assertStatus(422);
}
public function test_store_creates_note_actionlog()
{
$actor = User::factory()->superuser()->create();
$maintenance = Maintenance::factory()->create();
$this->actingAsForApi($actor)
->postJson(route('api.maintenances.notes.store', $maintenance), ['note' => 'Important note'])
->assertOk()
->assertStatusMessageIs('success');
$this->assertDatabaseHas('action_logs', [
'item_type' => Maintenance::class,
'item_id' => $maintenance->id,
'action_type' => 'note added',
'note' => 'Important note',
'created_by' => $actor->id,
]);
}
public function test_store_returns_note_in_response()
{
$actor = User::factory()->superuser()->create();
$maintenance = Maintenance::factory()->create();
$response = $this->actingAsForApi($actor)
->postJson(route('api.maintenances.notes.store', $maintenance), ['note' => 'My note'])
->assertOk();
$this->assertEquals('My note', $response->json('payload.note'));
$this->assertEquals($maintenance->id, $response->json('payload.item_id'));
}
}
@@ -0,0 +1,78 @@
<?php
namespace Tests\Feature\Maintenances\Api;
use App\Models\MaintenanceType;
use App\Models\User;
use Tests\TestCase;
class MaintenanceTypesTest extends TestCase
{
public function test_index_requires_permission()
{
$this->actingAsForApi(User::factory()->create())
->getJson(route('api.maintenance-types.index'))
->assertForbidden();
}
public function test_can_list_maintenance_types()
{
MaintenanceType::factory()->count(3)->create();
$this->actingAsForApi(User::factory()->superuser()->create())
->getJson(route('api.maintenance-types.index'))
->assertOk()
->assertJsonStructure(['total', 'rows']);
}
public function test_can_show_maintenance_type()
{
$type = MaintenanceType::factory()->create();
$this->actingAsForApi(User::factory()->superuser()->create())
->getJson(route('api.maintenance-types.show', $type))
->assertOk()
->assertJsonFragment(['name' => $type->name]);
}
public function test_can_create_maintenance_type()
{
$this->actingAsForApi(User::factory()->superuser()->create())
->postJson(route('api.maintenance-types.store'), ['name' => 'My Custom Type'])
->assertOk()
->assertStatusMessageIs('success');
$this->assertDatabaseHas('maintenance_types', ['name' => 'My Custom Type']);
}
public function test_create_requires_name()
{
$this->actingAsForApi(User::factory()->superuser()->create())
->postJson(route('api.maintenance-types.store'), [])
->assertStatusMessageIs('error');
}
public function test_can_update_maintenance_type()
{
$type = MaintenanceType::factory()->create(['name' => 'Old Name']);
$this->actingAsForApi(User::factory()->superuser()->create())
->putJson(route('api.maintenance-types.update', $type), ['name' => 'New Name'])
->assertOk()
->assertStatusMessageIs('success');
$this->assertDatabaseHas('maintenance_types', ['id' => $type->id, 'name' => 'New Name']);
}
public function test_can_delete_maintenance_type()
{
$type = MaintenanceType::factory()->create();
$this->actingAsForApi(User::factory()->superuser()->create())
->deleteJson(route('api.maintenance-types.destroy', $type))
->assertOk()
->assertStatusMessageIs('success');
$this->assertSoftDeleted($type);
}
}
@@ -0,0 +1,109 @@
<?php
namespace Tests\Feature\Maintenances\Ui;
use App\Models\Actionlog;
use App\Models\Maintenance;
use App\Models\User;
use Carbon\Carbon;
use Tests\TestCase;
class CompleteMaintenanceTest extends TestCase
{
public function test_requires_permission()
{
$maintenance = Maintenance::factory()->create();
$this->actingAs(User::factory()->create())
->post(route('maintenances.complete', $maintenance))
->assertForbidden();
$this->assertDatabaseMissing('maintenances', [
'id' => $maintenance->id,
'completed_at' => now(),
]);
}
public function test_can_mark_maintenance_complete()
{
$actor = User::factory()->superuser()->create();
$maintenance = Maintenance::factory()->create();
$this->actingAs($actor)
->post(route('maintenances.complete', $maintenance))
->assertSessionHasNoErrors()
->assertRedirect();
$maintenance->refresh();
$this->assertNotNull($maintenance->completed_at);
$this->assertEquals($actor->id, $maintenance->completed_by);
$this->assertHasTheseActionLogs($maintenance, ['create', 'completed']);
}
public function test_marking_complete_does_not_create_update_log()
{
$actor = User::factory()->superuser()->create();
$maintenance = Maintenance::factory()->create();
$this->actingAs($actor)
->post(route('maintenances.complete', $maintenance));
$updateLogs = Actionlog::where('item_type', Maintenance::class)
->where('item_id', $maintenance->id)
->where('action_type', 'update')
->count();
$this->assertEquals(0, $updateLogs);
}
public function test_cannot_mark_already_completed_maintenance_complete()
{
$actor = User::factory()->superuser()->create();
$maintenance = Maintenance::factory()->create(['completed_at' => now()->subDay(), 'completed_by' => $actor->id]);
$this->actingAs($actor)
->post(route('maintenances.complete', $maintenance))
->assertRedirect()
->assertSessionHas('warning');
$this->assertHasTheseActionLogs($maintenance, ['create']);
}
public function test_completion_note_is_saved_in_actionlog()
{
$actor = User::factory()->superuser()->create();
$maintenance = Maintenance::factory()->create();
$this->actingAs($actor)
->post(route('maintenances.complete', $maintenance), ['note' => 'Widget replaced']);
$log = Actionlog::where('item_type', Maintenance::class)
->where('item_id', $maintenance->id)
->where('action_type', 'completed')
->first();
$this->assertNotNull($log);
$this->assertEquals('Widget replaced', $log->note);
}
public function test_duration_is_calculated_from_created_at()
{
$actor = User::factory()->superuser()->create();
Carbon::setTestNow(Carbon::create(2026, 1, 1));
$maintenance = Maintenance::factory()->create(['start_date' => '2025-06-01']);
Carbon::setTestNow(Carbon::create(2026, 1, 11));
try {
$this->actingAs($actor)
->post(route('maintenances.complete', $maintenance))
->assertRedirect();
$maintenance->refresh();
$this->assertEquals(10, $maintenance->asset_maintenance_time);
} finally {
Carbon::setTestNow();
}
}
}
@@ -5,6 +5,7 @@ namespace Tests\Feature\Maintenances\Ui;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\Maintenance;
use App\Models\MaintenanceType;
use App\Models\Supplier;
use App\Models\User;
use Illuminate\Http\UploadedFile;
@@ -34,13 +35,14 @@ class CreateMaintenanceTest extends TestCase
$actor = User::factory()->superuser()->create();
$asset = Asset::factory()->create();
$supplier = Supplier::factory()->create();
$type = MaintenanceType::factory()->create();
$this->actingAs($actor)
->post(route('maintenances.store'), [
'name' => 'Test Maintenance',
'selected_assets' => [$asset->id],
'supplier_id' => $supplier->id,
'asset_maintenance_type' => 'Maintenance',
'maintenance_type_id' => $type->id,
'start_date' => '2021-01-01',
'completion_date' => '2021-01-10',
'is_warranty' => '1',
@@ -72,12 +74,12 @@ class CreateMaintenanceTest extends TestCase
$this->assertDatabaseHas('maintenances', [
'asset_id' => $asset->id,
'supplier_id' => $supplier->id,
'asset_maintenance_type' => 'Maintenance',
'maintenance_type_id' => $type->id,
'asset_maintenance_type' => $type->name,
'name' => 'Test Maintenance',
'is_warranty' => 1,
'start_date' => '2021-01-01',
'completion_date' => '2021-01-10',
'asset_maintenance_time' => '9',
'notes' => 'A note',
'url' => 'https://snipeitapp.com',
'cost' => '100.00',
@@ -0,0 +1,123 @@
<?php
namespace Tests\Feature\Maintenances\Ui;
use App\Models\Asset;
use App\Models\MaintenanceType;
use App\Models\User;
use Tests\TestCase;
class CreateMaintenanceTrackingTest extends TestCase
{
public function test_checkout_snapshot_is_captured_when_asset_is_checked_out()
{
$actor = User::factory()->superuser()->create();
$assignedUser = User::factory()->create();
$asset = Asset::factory()->assignedToUser($assignedUser)->create();
$type = MaintenanceType::factory()->create();
$this->actingAs($actor)
->post(route('maintenances.store'), [
'name' => 'Snapshot Test',
'selected_assets' => [$asset->id],
'maintenance_type_id' => $type->id,
'start_date' => '2026-01-01',
])
->assertSessionHasNoErrors()
->assertRedirect(route('maintenances.index'));
$this->assertDatabaseHas('maintenances', [
'asset_id' => $asset->id,
'checked_out_to_id' => $assignedUser->id,
'checked_out_to_type' => User::class,
]);
}
public function test_checkout_snapshot_is_null_when_asset_is_not_checked_out()
{
$actor = User::factory()->superuser()->create();
$asset = Asset::factory()->create();
$type = MaintenanceType::factory()->create();
$this->actingAs($actor)
->post(route('maintenances.store'), [
'name' => 'No Checkout Test',
'selected_assets' => [$asset->id],
'maintenance_type_id' => $type->id,
'start_date' => '2026-01-01',
])
->assertSessionHasNoErrors();
$this->assertDatabaseHas('maintenances', [
'asset_id' => $asset->id,
'checked_out_to_id' => null,
'checked_out_to_type' => null,
]);
}
public function test_responsible_party_defaults_to_creating_user()
{
$actor = User::factory()->superuser()->create();
$asset = Asset::factory()->create();
$type = MaintenanceType::factory()->create();
$this->actingAs($actor)
->post(route('maintenances.store'), [
'name' => 'RP Default Test',
'selected_assets' => [$asset->id],
'maintenance_type_id' => $type->id,
'start_date' => '2026-01-01',
])
->assertSessionHasNoErrors();
$this->assertDatabaseHas('maintenances', [
'asset_id' => $asset->id,
'responsible_party_id' => $actor->id,
]);
}
public function test_responsible_party_can_be_set_to_another_user()
{
$actor = User::factory()->superuser()->create();
$technician = User::factory()->create();
$asset = Asset::factory()->create();
$type = MaintenanceType::factory()->create();
$this->actingAs($actor)
->post(route('maintenances.store'), [
'name' => 'RP Explicit Test',
'selected_assets' => [$asset->id],
'maintenance_type_id' => $type->id,
'responsible_party_id' => $technician->id,
'start_date' => '2026-01-01',
])
->assertSessionHasNoErrors();
$this->assertDatabaseHas('maintenances', [
'asset_id' => $asset->id,
'responsible_party_id' => $technician->id,
]);
}
public function test_maintenance_type_id_syncs_legacy_asset_maintenance_type()
{
$actor = User::factory()->superuser()->create();
$asset = Asset::factory()->create();
$type = MaintenanceType::factory()->create(['name' => 'Custom Calibration']);
$this->actingAs($actor)
->post(route('maintenances.store'), [
'name' => 'Type Sync Test',
'selected_assets' => [$asset->id],
'maintenance_type_id' => $type->id,
'start_date' => '2026-01-01',
])
->assertSessionHasNoErrors();
$this->assertDatabaseHas('maintenances', [
'asset_id' => $asset->id,
'maintenance_type_id' => $type->id,
'asset_maintenance_type' => 'Custom Calibration',
]);
}
}
@@ -6,6 +6,7 @@ use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\Company;
use App\Models\Maintenance;
use App\Models\MaintenanceType;
use App\Models\Supplier;
use App\Models\User;
use Illuminate\Http\UploadedFile;
@@ -30,13 +31,14 @@ class EditMaintenanceTest extends TestCase
$asset = Asset::factory()->create();
$maintenance = Maintenance::factory()->create(['asset_id' => $asset]);
$supplier = Supplier::factory()->create();
$type = MaintenanceType::factory()->create();
$this->actingAs($actor)
->put(route('maintenances.update', $maintenance), [
'name' => 'Test Maintenance',
'asset_id' => $asset->id,
'supplier_id' => $supplier->id,
'asset_maintenance_type' => 'Maintenance',
'maintenance_type_id' => $type->id,
'start_date' => '2021-01-01',
'completion_date' => '2021-01-10',
'is_warranty' => 1,
@@ -68,12 +70,12 @@ class EditMaintenanceTest extends TestCase
$this->assertDatabaseHas('maintenances', [
'asset_id' => $asset->id,
'supplier_id' => $supplier->id,
'asset_maintenance_type' => 'Maintenance',
'maintenance_type_id' => $type->id,
'asset_maintenance_type' => $type->name,
'name' => 'Test Maintenance',
'is_warranty' => 1,
'start_date' => '2021-01-01',
'completion_date' => '2021-01-10',
'asset_maintenance_time' => '9',
'notes' => 'A note',
'url' => 'https://snipeitapp.com',
'cost' => '100.99',
@@ -111,7 +113,7 @@ class EditMaintenanceTest extends TestCase
->put(route('maintenances.update', $maintenanceForCompanyB), [
'name' => 'Should Not Update',
'asset_id' => $maintenanceForCompanyB->asset_id,
'asset_maintenance_type' => $maintenanceForCompanyB->asset_maintenance_type,
'maintenance_type_id' => $maintenanceForCompanyB->maintenance_type_id,
'start_date' => $maintenanceForCompanyB->start_date,
])
->assertRedirectToRoute('maintenances.index');
+49 -1
View File
@@ -3,6 +3,7 @@
namespace Tests\Feature\Notes;
use App\Models\Asset;
use App\Models\Maintenance;
use App\Models\User;
use Tests\TestCase;
@@ -52,7 +53,7 @@ class CreateNotesTest extends TestCase
'type' => 'asset',
'note' => 'my special note',
])
->assertRedirect(route('hardware.show', $asset->id).'#history')
->assertRedirect(route('hardware.show', $asset->id).'#notes')
->assertSessionHas('success', trans('general.note_added'));
$this->assertDatabaseHas('action_logs', [
@@ -67,4 +68,51 @@ class CreateNotesTest extends TestCase
'user_agent' => 'Custom User Agent For Test',
]);
}
public function test_can_create_note_for_maintenance()
{
$actor = User::factory()->editAssets()->create();
$maintenance = Maintenance::factory()->create();
$this->actingAs($actor)
->post(route('notes.store'), [
'id' => $maintenance->id,
'type' => 'maintenance',
'note' => 'maintenance note text',
])
->assertRedirect(route('maintenances.show', $maintenance->id).'#notes')
->assertSessionHas('success', trans('general.note_added'));
$this->assertDatabaseHas('action_logs', [
'created_by' => $actor->id,
'action_type' => 'note added',
'note' => 'maintenance note text',
'item_type' => Maintenance::class,
'item_id' => $maintenance->id,
]);
}
public function test_maintenance_note_requires_asset_update_permission()
{
$maintenance = Maintenance::factory()->create();
$this->actingAs(User::factory()->create())
->post(route('notes.store'), [
'id' => $maintenance->id,
'type' => 'maintenance',
'note' => 'should fail',
])
->assertForbidden();
}
public function test_maintenance_must_exist_for_note()
{
$this->actingAs(User::factory()->editAssets()->create())
->post(route('notes.store'), [
'id' => 999_999,
'type' => 'maintenance',
'note' => 'ghost note',
])
->assertStatus(302);
}
}
+126
View File
@@ -0,0 +1,126 @@
<?php
namespace Tests\Unit;
use App\Models\Maintenance;
use App\Models\Setting;
use Tests\TestCase;
class MaintenanceQueryBuilderTest extends TestCase
{
public function test_active_scope_excludes_completed_maintenances()
{
$active = Maintenance::factory()->create(['completed_at' => null]);
$done = Maintenance::factory()->create(['completed_at' => now()]);
$ids = Maintenance::active()->pluck('id');
$this->assertContains($active->id, $ids);
$this->assertNotContains($done->id, $ids);
}
public function test_completed_scope_excludes_active_maintenances()
{
$active = Maintenance::factory()->create(['completed_at' => null]);
$done = Maintenance::factory()->create(['completed_at' => now()]);
$ids = Maintenance::completed()->pluck('id');
$this->assertNotContains($active->id, $ids);
$this->assertContains($done->id, $ids);
}
public function test_due_for_completion_returns_items_in_warning_window()
{
$settings = Setting::factory()->create(['audit_warning_days' => 7]);
$due = Maintenance::factory()->create([
'completion_date' => now()->addDays(3)->format('Y-m-d'),
'completed_at' => null,
]);
$notDueYet = Maintenance::factory()->create([
'completion_date' => now()->addDays(30)->format('Y-m-d'),
'completed_at' => null,
]);
$alreadyDone = Maintenance::factory()->create([
'completion_date' => now()->addDays(3)->format('Y-m-d'),
'completed_at' => now(),
]);
$ids = Maintenance::dueForCompletion($settings)->pluck('id');
$this->assertContains($due->id, $ids);
$this->assertNotContains($notDueYet->id, $ids);
$this->assertNotContains($alreadyDone->id, $ids);
}
public function test_overdue_for_completion_returns_past_due_items()
{
$overdue = Maintenance::factory()->create([
'completion_date' => now()->subDay()->format('Y-m-d'),
'completed_at' => null,
]);
$futureDate = Maintenance::factory()->create([
'completion_date' => now()->addDays(5)->format('Y-m-d'),
'completed_at' => null,
]);
$alreadyDone = Maintenance::factory()->create([
'completion_date' => now()->subDay()->format('Y-m-d'),
'completed_at' => now(),
]);
$ids = Maintenance::overdueForCompletion()->pluck('id');
$this->assertContains($overdue->id, $ids);
$this->assertNotContains($futureDate->id, $ids);
$this->assertNotContains($alreadyDone->id, $ids);
}
public function test_overdue_for_completion_excludes_items_with_no_completion_date()
{
$noDate = Maintenance::factory()->create([
'completion_date' => null,
'completed_at' => null,
]);
$ids = Maintenance::overdueForCompletion()->pluck('id');
$this->assertNotContains($noDate->id, $ids);
}
public function test_due_or_overdue_returns_both_overdue_and_due()
{
$settings = Setting::factory()->create(['audit_warning_days' => 7]);
$overdue = Maintenance::factory()->create([
'completion_date' => now()->subDay()->format('Y-m-d'),
'completed_at' => null,
]);
$due = Maintenance::factory()->create([
'completion_date' => now()->addDays(3)->format('Y-m-d'),
'completed_at' => null,
]);
$fine = Maintenance::factory()->create([
'completion_date' => now()->addDays(30)->format('Y-m-d'),
'completed_at' => null,
]);
$done = Maintenance::factory()->create([
'completion_date' => now()->subDay()->format('Y-m-d'),
'completed_at' => now(),
]);
$ids = Maintenance::dueOrOverdueForCompletion($settings)->pluck('id');
$this->assertContains($overdue->id, $ids);
$this->assertContains($due->id, $ids);
$this->assertNotContains($fine->id, $ids);
$this->assertNotContains($done->id, $ids);
}
}