From ff6be8ba1e0b7b3ca27abdd78cdd756aedd53259 Mon Sep 17 00:00:00 2001 From: Khantey Long Date: Mon, 17 Aug 2026 10:18:11 +0700 Subject: [PATCH] changes to management --- app/Http/Controllers/BoxController.php | 156 +++++ app/Http/Controllers/RackController.php | 146 +++++ .../Controllers/StoragePositionController.php | 472 ++++++++++++++ app/Models/AliqoutMovement.php | 56 ++ app/Models/Aliquot.php | 72 +++ app/Models/AliquotTransaction.php | 55 ++ app/Models/Box.php | 56 ++ app/Models/Building.php | 7 + app/Models/Equipment.php | 3 + app/Models/Organization.php | 4 + app/Models/PathogenInventorySample.php | 62 ++ app/Models/Position.php | 49 ++ app/Models/Rack.php | 52 ++ app/Models/Room.php | 3 + lang/en/general.php | 13 +- lang/en/sidebar.php | 13 + lang/kh/general.php | 13 +- lang/kh/sidebar.php | 15 +- public/storage/assets/js/admin/box.js | 112 ++++ .../storage/assets/js/admin/box_position.js | 308 +++++++++ public/storage/assets/js/admin/rack.js | 109 ++++ resources/views/box.blade.php | 196 ++++++ resources/views/box_positions.blade.php | 610 ++++++++++++++++++ resources/views/layout/sidebar.blade.php | 32 +- resources/views/rack.blade.php | 156 +++++ routes/web.php | 26 + storage/app/.gitignore | 4 + storage/app/private/.gitignore | 2 + storage/app/public/.gitignore | 2 + 29 files changed, 2785 insertions(+), 19 deletions(-) create mode 100644 app/Http/Controllers/BoxController.php create mode 100644 app/Http/Controllers/RackController.php create mode 100644 app/Http/Controllers/StoragePositionController.php create mode 100644 app/Models/AliqoutMovement.php create mode 100644 app/Models/Aliquot.php create mode 100644 app/Models/AliquotTransaction.php create mode 100644 app/Models/Box.php create mode 100644 app/Models/PathogenInventorySample.php create mode 100644 app/Models/Position.php create mode 100644 app/Models/Rack.php create mode 100644 public/storage/assets/js/admin/box.js create mode 100644 public/storage/assets/js/admin/box_position.js create mode 100644 public/storage/assets/js/admin/rack.js create mode 100644 resources/views/box.blade.php create mode 100644 resources/views/box_positions.blade.php create mode 100644 resources/views/rack.blade.php create mode 100644 storage/app/.gitignore create mode 100644 storage/app/private/.gitignore create mode 100644 storage/app/public/.gitignore diff --git a/app/Http/Controllers/BoxController.php b/app/Http/Controllers/BoxController.php new file mode 100644 index 0000000..927e6a4 --- /dev/null +++ b/app/Http/Controllers/BoxController.php @@ -0,0 +1,156 @@ +baseOrganizationId = Session::get('base_organization_id'); + $this->base = Session::get('base_organization'); + $this->model = new Box(); + $this->rackModel = new Rack(); + } + + public function index(Request $request) + { + $recordStatusCondition = Auth::id() == 1 ? [RecordStatusEnum::DELETE, RecordStatusEnum::ACTIVE] : [RecordStatusEnum::ACTIVE]; + $racks = $this->rackModel::with(['equipment.room.building']) + ->where(BaseModel::RECORD_STATUS_FIELD, RecordStatusEnum::ACTIVE) + ->whereHas('equipment', function ($query) { + $query->where('organization_id', $this->baseOrganizationId); + }) + ->get(); + + $boxes = $this->model::with(['rack.equipment.room.building']) + ->whereIn(BaseModel::RECORD_STATUS_FIELD, $recordStatusCondition) + ->whereIn('rack_id', $racks->pluck('id')->toArray()) + ->when(!empty($request->kword), function ($query) use ($request) { + $keyword = str_replace(' ', '', $request->kword); + $query->where(function ($query) use ($keyword) { + $query->whereRaw("replace(name, ' ','') like ?", ['%' . $keyword . '%']) + ->orWhereRaw("replace(code, ' ','') like ?", ['%' . $keyword . '%']); + }); + }) + ->when(!empty(trim($request->rack_id)), function ($query) use ($request) { + $query->where('rack_id', $request->rack_id); + }) + ->orderBy(BaseModel::CREATED_AT_FIELD, 'desc') + ->paginate(config('nrml.pagination.perpage', 10)); + + return view('box', ['boxes' => $boxes, 'racks' => $racks]); + } + + public function save(Request $request) + { + try { + $validator = \Validator::make($request->all(), [ + 'rack_id' => 'required', + 'code' => 'required', + 'name' => 'required', + 'rows' => 'required|integer|min:1', + 'columns' => 'required|integer|min:1', + ]); + + if ($validator->fails()) { + return response()->json(['success' => false, 'message' => 'Unable to create box.', 'errors' => $validator->errors()->all()]); + } + + $this->model::query()->insert([ + 'rack_id' => $request->rack_id, + 'code' => $request->code, + 'name' => $request->name, + 'rows' => $request->rows, + 'columns' => $request->columns, + 'capacity' => $request->capacity ?: ((int) $request->rows * (int) $request->columns), + BaseModel::CREATED_AT_FIELD => date('Y-m-d H:i:s'), + BaseModel::CREATED_BY_FIELD => Auth::id(), + ]); + + return response()->json(['success' => true, 'message' => 'Box has been created.']); + } catch (\Exception $e) { + return response()->json(['success' => false, 'message' => 'Unable to create box.', 'errors' => $e->getMessage()]); + } + } + + public function update(Request $request) + { + try { + $validator = \Validator::make($request->all(), [ + 'box_id' => 'required', + 'rack_id' => 'required', + 'code' => 'required', + 'name' => 'required', + 'rows' => 'required|integer|min:1', + 'columns' => 'required|integer|min:1', + ]); + + if ($validator->fails()) { + return response()->json(['success' => false, 'message' => 'Unable to update box.', 'errors' => $validator->errors()->all()]); + } + + $this->model::query()->where('id', $request->box_id)->update([ + 'rack_id' => $request->rack_id, + 'code' => $request->code, + 'name' => $request->name, + 'rows' => $request->rows, + 'columns' => $request->columns, + 'capacity' => $request->capacity ?: ((int) $request->rows * (int) $request->columns), + BaseModel::UPDATED_AT_FIELD => date('Y-m-d H:i:s'), + BaseModel::UPDATED_BY_FIELD => Auth::id(), + ]); + + return response()->json(['success' => true, 'message' => 'Box has been updated.']); + } catch (\Exception $e) { + return response()->json(['success' => false, 'message' => 'Unable to update box.', 'errors' => $e->getMessage()]); + } + } + + public function get(Request $request) + { + try { + $box = $this->model::query()->where('id', $request->box_id)->first(); + return response()->json(['success' => true, 'message' => 'Box has been retrieved.', 'data' => $box]); + } catch (\Exception $e) { + return response()->json(['success' => false, 'message' => 'Unable to retrieve box.', 'errors' => $e->getMessage()]); + } + } + + public function delete(Request $request) + { + try { + $this->model::query()->where('id', $request->box_id)->update([ + BaseModel::DELETED_AT_FIELD => date('Y-m-d H:i:s'), + BaseModel::DELETED_BY_FIELD => Auth::id(), + BaseModel::RECORD_STATUS_FIELD => RecordStatusEnum::DELETE, + ]); + + return response()->json(['success' => true, 'message' => 'Box has been deleted.']); + } catch (\Exception $e) { + return response()->json(['success' => false, 'message' => 'Unable to delete box.', 'errors' => $e->getMessage()]); + } + } + + public function restore(Request $request) + { + try { + $this->model::query()->where('id', $request->box_id)->update([BaseModel::RECORD_STATUS_FIELD => RecordStatusEnum::ACTIVE]); + return response()->json(['success' => true, 'message' => 'Box has been restored.']); + } catch (\Exception $e) { + return response()->json(['success' => false, 'message' => 'Unable to restore box.', 'errors' => $e->getMessage()]); + } + } +} diff --git a/app/Http/Controllers/RackController.php b/app/Http/Controllers/RackController.php new file mode 100644 index 0000000..aa7ba49 --- /dev/null +++ b/app/Http/Controllers/RackController.php @@ -0,0 +1,146 @@ +baseOrganizationId = Session::get('base_organization_id'); + $this->base = Session::get('base_organization'); + $this->model = new Rack(); + $this->equipmentModel = new Equipment(); + } + + public function index(Request $request) + { + $recordStatusCondition = Auth::id() == 1 ? [RecordStatusEnum::DELETE, RecordStatusEnum::ACTIVE] : [RecordStatusEnum::ACTIVE]; + $equipments = $this->equipmentModel::with(['room.building']) + ->where(BaseModel::RECORD_STATUS_FIELD, RecordStatusEnum::ACTIVE) + ->where('organization_id', $this->baseOrganizationId) + ->get(); + + $racks = $this->model::with(['equipment.room.building']) + ->whereIn(BaseModel::RECORD_STATUS_FIELD, $recordStatusCondition) + ->whereIn('equipment_id', $equipments->pluck('id')->toArray()) + ->when(!empty($request->kword), function ($query) use ($request) { + $keyword = str_replace(' ', '', $request->kword); + $query->where(function ($query) use ($keyword) { + $query->whereRaw("replace(name, ' ','') like ?", ['%' . $keyword . '%']) + ->orWhereRaw("replace(code, ' ','') like ?", ['%' . $keyword . '%']); + }); + }) + ->when(!empty(trim($request->equipment_id)), function ($query) use ($request) { + $query->where('equipment_id', $request->equipment_id); + }) + ->orderBy(BaseModel::CREATED_AT_FIELD, 'desc') + ->paginate(config('nrml.pagination.perpage', 10)); + + return view('rack', ['racks' => $racks, 'equipments' => $equipments]); + } + + public function save(Request $request) + { + try { + $validator = \Validator::make($request->all(), [ + 'equipment_id' => 'required', + 'code' => 'required', + 'name' => 'required', + ]); + + if ($validator->fails()) { + return response()->json(['success' => false, 'message' => 'Unable to create rack.', 'errors' => $validator->errors()->all()]); + } + + $this->model::query()->insert([ + 'equipment_id' => $request->equipment_id, + 'code' => $request->code, + 'name' => $request->name, + 'description' => $request->description, + BaseModel::CREATED_AT_FIELD => date('Y-m-d H:i:s'), + BaseModel::CREATED_BY_FIELD => Auth::id(), + ]); + + return response()->json(['success' => true, 'message' => 'Rack has been created.']); + } catch (\Exception $e) { + return response()->json(['success' => false, 'message' => 'Unable to create rack.', 'errors' => $e->getMessage()]); + } + } + + public function update(Request $request) + { + try { + $validator = \Validator::make($request->all(), [ + 'rack_id' => 'required', + 'equipment_id' => 'required', + 'code' => 'required', + 'name' => 'required', + ]); + + if ($validator->fails()) { + return response()->json(['success' => false, 'message' => 'Unable to update rack.', 'errors' => $validator->errors()->all()]); + } + + $this->model::query()->where('id', $request->rack_id)->update([ + 'equipment_id' => $request->equipment_id, + 'code' => $request->code, + 'name' => $request->name, + 'description' => $request->description, + BaseModel::UPDATED_AT_FIELD => date('Y-m-d H:i:s'), + BaseModel::UPDATED_BY_FIELD => Auth::id(), + ]); + + return response()->json(['success' => true, 'message' => 'Rack has been updated.']); + } catch (\Exception $e) { + return response()->json(['success' => false, 'message' => 'Unable to update rack.', 'errors' => $e->getMessage()]); + } + } + + public function get(Request $request) + { + try { + $rack = $this->model::query()->where('id', $request->rack_id)->first(); + return response()->json(['success' => true, 'message' => 'Rack has been retrieved.', 'data' => $rack]); + } catch (\Exception $e) { + return response()->json(['success' => false, 'message' => 'Unable to retrieve rack.', 'errors' => $e->getMessage()]); + } + } + + public function delete(Request $request) + { + try { + $this->model::query()->where('id', $request->rack_id)->update([ + BaseModel::DELETED_AT_FIELD => date('Y-m-d H:i:s'), + BaseModel::DELETED_BY_FIELD => Auth::id(), + BaseModel::RECORD_STATUS_FIELD => RecordStatusEnum::DELETE, + ]); + + return response()->json(['success' => true, 'message' => 'Rack has been deleted.']); + } catch (\Exception $e) { + return response()->json(['success' => false, 'message' => 'Unable to delete rack.', 'errors' => $e->getMessage()]); + } + } + + public function restore(Request $request) + { + try { + $this->model::query()->where('id', $request->rack_id)->update([BaseModel::RECORD_STATUS_FIELD => RecordStatusEnum::ACTIVE]); + return response()->json(['success' => true, 'message' => 'Rack has been restored.']); + } catch (\Exception $e) { + return response()->json(['success' => false, 'message' => 'Unable to restore rack.', 'errors' => $e->getMessage()]); + } + } +} diff --git a/app/Http/Controllers/StoragePositionController.php b/app/Http/Controllers/StoragePositionController.php new file mode 100644 index 0000000..761d126 --- /dev/null +++ b/app/Http/Controllers/StoragePositionController.php @@ -0,0 +1,472 @@ +baseOrganizationId = Session::get('base_organization_id'); + $this->base = Session::get('base_organization'); + } + + public function index(Box $box) + { + $box->load(['rack.equipment.room.building']); + + $positions = Position::with(['aliquot.sample.pathogen', 'aliquot.containerType.containerName']) + ->where('box_id', $box->id) + ->where(BaseModel::RECORD_STATUS_FIELD, RecordStatusEnum::ACTIVE) + ->orderBy('row_label') + ->orderByRaw('CAST(column_label AS UNSIGNED)') + ->get(); + + $samples = PathogenInventorySample::with(['pathogen']) + ->where(BaseModel::RECORD_STATUS_FIELD, RecordStatusEnum::ACTIVE) + ->where('organization_id', $this->baseOrganizationId) + ->orderBy(BaseModel::CREATED_AT_FIELD, 'desc') + ->limit(200) + ->get(); + + $pathogens = Pathogen::with(['riskGroup', 'bsl']) + ->where(BaseModel::RECORD_STATUS_FIELD, RecordStatusEnum::ACTIVE) + ->orderBy('scientific_name') + ->get(); + + $containerTypes = ContainerType::with(['containerName']) + ->where(BaseModel::RECORD_STATUS_FIELD, RecordStatusEnum::ACTIVE) + ->get(); + + $emptyPositions = Position::with(['box.rack.equipment.room.building']) + ->where(BaseModel::RECORD_STATUS_FIELD, RecordStatusEnum::ACTIVE) + ->where('occupied', 0) + ->orderByRaw('box_id = ? DESC', [$box->id]) + ->orderBy('box_id') + ->orderBy('row_label') + ->orderByRaw('CAST(column_label AS UNSIGNED)') + ->get(); + + $recentMovements = AliqoutMovement::with([ + 'aliquot.sample.pathogen', + 'fromPosition.box.rack.equipment.room.building', + 'toPosition.box.rack.equipment.room.building', + ]) + ->where(BaseModel::RECORD_STATUS_FIELD, RecordStatusEnum::ACTIVE) + ->where(function ($query) use ($box) { + $query->whereHas('fromPosition', function ($query) use ($box) { + $query->where('box_id', $box->id); + })->orWhereHas('toPosition', function ($query) use ($box) { + $query->where('box_id', $box->id); + }); + }) + ->orderBy(BaseModel::CREATED_AT_FIELD, 'desc') + ->limit(10) + ->get(); + + return view('box_positions', [ + 'box' => $box, + 'positions' => $positions, + 'samples' => $samples, + 'pathogens' => $pathogens, + 'containerTypes' => $containerTypes, + 'emptyPositions' => $emptyPositions, + 'recentMovements' => $recentMovements, + ]); + } + + public function generate(Request $request) + { + $request->validate(['box_id' => 'required']); + + try { + $box = Box::query()->findOrFail($request->box_id); + + if ($box->rows < 1 || $box->columns < 1) { + return response()->json(['success' => false, 'message' => 'Box rows and columns must be configured before generating positions.']); + } + + DB::beginTransaction(); + + for ($row = 1; $row <= $box->rows; $row++) { + $rowLabel = $this->rowLabel($row); + + for ($column = 1; $column <= $box->columns; $column++) { + Position::query()->firstOrCreate( + [ + 'box_id' => $box->id, + 'row_label' => $rowLabel, + 'column_label' => (string) $column, + ], + [ + 'code' => $rowLabel . $column, + 'occupied' => 0, + BaseModel::CREATED_AT_FIELD => date('Y-m-d H:i:s'), + BaseModel::CREATED_BY_FIELD => Auth::id(), + BaseModel::RECORD_STATUS_FIELD => RecordStatusEnum::ACTIVE, + ] + ); + } + } + + DB::commit(); + + return response()->json(['success' => true, 'message' => 'Box positions generated successfully.']); + } catch (\Exception $e) { + DB::rollBack(); + return response()->json(['success' => false, 'message' => 'Unable to generate box positions.', 'errors' => $e->getMessage()]); + } + } + + public function storeAliquot(Request $request) + { + try { + $validator = \Validator::make($request->all(), [ + 'storage_position_id' => 'required', + 'aliquot_number' => 'required', + 'initial_volume' => 'required|numeric|min:0', + 'remaining_volume' => 'required|numeric|min:0', + 'volume_unit' => 'required', + 'status' => 'required', + ]); + + if ($validator->fails()) { + return response()->json(['success' => false, 'message' => 'Unable to save aliquot.', 'errors' => $validator->errors()->all()]); + } + + $position = Position::with(['aliquot'])->findOrFail($request->storage_position_id); + if (!empty($position->aliquot)) { + return response()->json(['success' => false, 'message' => 'This position is already occupied. Remove or transfer the current aliquot first.']); + } + + DB::beginTransaction(); + + $sampleId = $request->sample_id; + if (empty($sampleId) && !empty($request->pathogen_id)) { + $sampleId = PathogenInventorySample::query()->insertGetId([ + 'organization_id' => $this->baseOrganizationId, + 'pathogen_id' => $request->pathogen_id, + 'sample_code' => $request->sample_code ?: $request->aliquot_number, + 'initial_volume' => $request->initial_volume, + 'remaining_volume' => $request->remaining_volume, + 'volume_unit' => $request->volume_unit, + 'status' => 'Stored', + BaseModel::CREATED_AT_FIELD => date('Y-m-d H:i:s'), + BaseModel::CREATED_BY_FIELD => Auth::id(), + BaseModel::RECORD_STATUS_FIELD => RecordStatusEnum::ACTIVE, + ]); + } + + $aliquotId = Aliquot::query()->insertGetId([ + 'sample_id' => $sampleId, + 'container_type_id' => $request->container_type_id, + 'aliquot_number' => $request->aliquot_number, + 'initial_volume' => $request->initial_volume, + 'remaining_volume' => $request->remaining_volume, + 'volume_unit' => $request->volume_unit, + 'concentration' => $request->concentration, + 'storage_position_id' => $request->storage_position_id, + 'expiry_date' => !empty($request->expiry_date) ? date('Y-m-d H:i:s', strtotime($request->expiry_date)) : null, + 'status' => $request->status, + 'last_accessed_at' => date('Y-m-d H:i:s'), + 'description' => $request->description, + BaseModel::CREATED_AT_FIELD => date('Y-m-d H:i:s'), + BaseModel::CREATED_BY_FIELD => Auth::id(), + BaseModel::RECORD_STATUS_FIELD => RecordStatusEnum::ACTIVE, + ]); + + $position->update([ + 'occupied' => 1, + BaseModel::UPDATED_AT_FIELD => date('Y-m-d H:i:s'), + BaseModel::UPDATED_BY_FIELD => Auth::id(), + ]); + + AliqoutMovement::query()->insert([ + 'aliquot_id' => $aliquotId, + 'from_position_id' => null, + 'to_position_id' => $request->storage_position_id, + 'movement_reason' => 'Initial storage placement', + 'moved_by' => optional(Auth::user())->name ?? optional(Auth::user())->email ?? (string) Auth::id(), + BaseModel::CREATED_AT_FIELD => date('Y-m-d H:i:s'), + BaseModel::CREATED_BY_FIELD => Auth::id(), + BaseModel::RECORD_STATUS_FIELD => RecordStatusEnum::ACTIVE, + ]); + + DB::commit(); + + return response()->json(['success' => true, 'message' => 'Aliquot placed in storage position.']); + } catch (\Exception $e) { + DB::rollBack(); + return response()->json(['success' => false, 'message' => 'Unable to save aliquot.', 'errors' => $e->getMessage()]); + } + } + + public function removeAliquot(Request $request) + { + try { + $validator = \Validator::make($request->all(), [ + 'aliquot_id' => 'required', + ]); + + if ($validator->fails()) { + return response()->json(['success' => false, 'message' => 'Unable to remove aliquot.', 'errors' => $validator->errors()->all()]); + } + + $aliquot = Aliquot::query()->findOrFail($request->aliquot_id); + + DB::beginTransaction(); + + $positionId = $aliquot->storage_position_id; + $aliquot->update([ + 'status' => 'Removed', + BaseModel::DELETED_AT_FIELD => date('Y-m-d H:i:s'), + BaseModel::DELETED_BY_FIELD => Auth::id(), + BaseModel::RECORD_STATUS_FIELD => RecordStatusEnum::DELETE, + ]); + + Position::query()->where('id', $positionId)->update([ + 'occupied' => 0, + BaseModel::UPDATED_AT_FIELD => date('Y-m-d H:i:s'), + BaseModel::UPDATED_BY_FIELD => Auth::id(), + ]); + + AliqoutMovement::query()->insert([ + 'aliquot_id' => $aliquot->id, + 'from_position_id' => $positionId, + 'to_position_id' => null, + 'movement_reason' => $request->movement_reason ?: 'Removed from storage', + 'moved_by' => optional(Auth::user())->name ?? optional(Auth::user())->email ?? (string) Auth::id(), + BaseModel::CREATED_AT_FIELD => date('Y-m-d H:i:s'), + BaseModel::CREATED_BY_FIELD => Auth::id(), + BaseModel::RECORD_STATUS_FIELD => RecordStatusEnum::ACTIVE, + ]); + + DB::commit(); + + return response()->json(['success' => true, 'message' => 'Aliquot removed from this position.']); + } catch (\Exception $e) { + DB::rollBack(); + return response()->json(['success' => false, 'message' => 'Unable to remove aliquot.', 'errors' => $e->getMessage()]); + } + } + + public function moveAliquot(Request $request) + { + try { + $validator = \Validator::make($request->all(), [ + 'aliquot_id' => 'required', + 'to_position_id' => 'required', + 'movement_reason' => 'required', + ]); + + if ($validator->fails()) { + return response()->json(['success' => false, 'message' => 'Unable to move aliquot.', 'errors' => $validator->errors()->all()]); + } + + $aliquot = Aliquot::query() + ->where(BaseModel::RECORD_STATUS_FIELD, RecordStatusEnum::ACTIVE) + ->findOrFail($request->aliquot_id); + + $fromPositionId = $aliquot->storage_position_id; + $toPosition = Position::with(['aliquot'])->findOrFail($request->to_position_id); + + if ((int) $fromPositionId === (int) $toPosition->id) { + return response()->json(['success' => false, 'message' => 'The aliquot is already in that position.']); + } + + if (!empty($toPosition->aliquot) || (int) $toPosition->occupied === 1) { + return response()->json(['success' => false, 'message' => 'Target position is occupied. Choose an empty position.']); + } + + DB::beginTransaction(); + + Position::query()->where('id', $fromPositionId)->update([ + 'occupied' => 0, + BaseModel::UPDATED_AT_FIELD => date('Y-m-d H:i:s'), + BaseModel::UPDATED_BY_FIELD => Auth::id(), + ]); + + Position::query()->where('id', $toPosition->id)->update([ + 'occupied' => 1, + BaseModel::UPDATED_AT_FIELD => date('Y-m-d H:i:s'), + BaseModel::UPDATED_BY_FIELD => Auth::id(), + ]); + + $aliquot->update([ + 'storage_position_id' => $toPosition->id, + 'last_accessed_at' => date('Y-m-d H:i:s'), + BaseModel::UPDATED_AT_FIELD => date('Y-m-d H:i:s'), + BaseModel::UPDATED_BY_FIELD => Auth::id(), + ]); + + AliqoutMovement::query()->insert([ + 'aliquot_id' => $aliquot->id, + 'from_position_id' => $fromPositionId, + 'to_position_id' => $toPosition->id, + 'movement_reason' => $request->movement_reason, + 'moved_by' => optional(Auth::user())->name ?? optional(Auth::user())->email ?? (string) Auth::id(), + BaseModel::CREATED_AT_FIELD => date('Y-m-d H:i:s'), + BaseModel::CREATED_BY_FIELD => Auth::id(), + BaseModel::RECORD_STATUS_FIELD => RecordStatusEnum::ACTIVE, + ]); + + DB::commit(); + + return response()->json(['success' => true, 'message' => 'Aliquot moved successfully.']); + } catch (\Exception $e) { + DB::rollBack(); + return response()->json(['success' => false, 'message' => 'Unable to move aliquot.', 'errors' => $e->getMessage()]); + } + } + + public function movementHistory(Request $request) + { + try { + $validator = \Validator::make($request->all(), [ + 'aliquot_id' => 'required', + ]); + + if ($validator->fails()) { + return response()->json(['success' => false, 'message' => 'Unable to retrieve movement history.', 'errors' => $validator->errors()->all()]); + } + + $movements = AliqoutMovement::with([ + 'fromPosition.box.rack.equipment.room.building', + 'toPosition.box.rack.equipment.room.building', + ]) + ->where('aliquot_id', $request->aliquot_id) + ->where(BaseModel::RECORD_STATUS_FIELD, RecordStatusEnum::ACTIVE) + ->orderBy(BaseModel::CREATED_AT_FIELD, 'desc') + ->get() + ->map(function ($movement) { + return [ + 'from' => $this->positionLabel($movement->fromPosition), + 'to' => $this->positionLabel($movement->toPosition), + 'reason' => $movement->movement_reason, + 'moved_by' => $movement->moved_by, + 'moved_at' => $movement->created_at, + ]; + }); + + return response()->json(['success' => true, 'message' => 'Movement history retrieved.', 'data' => $movements]); + } catch (\Exception $e) { + return response()->json(['success' => false, 'message' => 'Unable to retrieve movement history.', 'errors' => $e->getMessage()]); + } + } + + public function useVolume(Request $request) + { + try { + $validator = \Validator::make($request->all(), [ + 'aliquot_id' => 'required', + 'used_volume' => 'required|numeric|min:0.01', + 'use_reason' => 'required', + ]); + + if ($validator->fails()) { + return response()->json(['success' => false, 'message' => 'Unable to record aliquot use.', 'errors' => $validator->errors()->all()]); + } + + $aliquot = Aliquot::query() + ->where(BaseModel::RECORD_STATUS_FIELD, RecordStatusEnum::ACTIVE) + ->findOrFail($request->aliquot_id); + + $sourceVolume = (float) $aliquot->remaining_volume; + $usedVolume = (float) $request->used_volume; + + if ($usedVolume > $sourceVolume) { + return response()->json(['success' => false, 'message' => 'Used volume cannot be greater than remaining volume.']); + } + + $remainingVolume = $sourceVolume - $usedVolume; + + DB::beginTransaction(); + + $aliquot->update([ + 'remaining_volume' => $remainingVolume, + 'status' => $remainingVolume <= 0 ? 'Consumed' : $aliquot->status, + 'last_accessed_at' => date('Y-m-d H:i:s'), + BaseModel::UPDATED_AT_FIELD => date('Y-m-d H:i:s'), + BaseModel::UPDATED_BY_FIELD => Auth::id(), + ]); + + AliquotTransaction::query()->insert([ + 'aliquot_id' => $aliquot->id, + 'transaction_at' => date('Y-m-d H:i:s'), + 'performed_by' => optional(Auth::user())->name ?? optional(Auth::user())->email ?? (string) Auth::id(), + 'source_volumn' => $sourceVolume, + 'affected_volume' => $usedVolume, + 'remaining_volume' => $remainingVolume, + 'volume_unit' => $aliquot->volume_unit, + 'storage_position_id' => $aliquot->storage_position_id, + 'reference_number' => $request->reference_number, + 'description' => $request->use_reason, + 'state' => 'approved', + BaseModel::CREATED_AT_FIELD => date('Y-m-d H:i:s'), + BaseModel::CREATED_BY_FIELD => Auth::id(), + BaseModel::RECORD_STATUS_FIELD => RecordStatusEnum::ACTIVE, + ]); + + DB::commit(); + + return response()->json(['success' => true, 'message' => 'Aliquot use recorded. Remaining volume: ' . $remainingVolume . ' ' . $aliquot->volume_unit]); + } catch (\Exception $e) { + DB::rollBack(); + return response()->json(['success' => false, 'message' => 'Unable to record aliquot use.', 'errors' => $e->getMessage()]); + } + } + + private function rowLabel(int $row): string + { + $label = ''; + + while ($row > 0) { + $row--; + $label = chr(65 + ($row % 26)) . $label; + $row = intdiv($row, 26); + } + + return $label; + } + + private function positionLabel($position): string + { + if (empty($position)) { + return 'Outside storage'; + } + + $box = $position->box; + $rack = optional($box)->rack; + $equipment = optional($rack)->equipment; + $room = optional($equipment)->room; + $building = optional($room)->building; + + return trim( + collect([ + optional($building)->name, + optional($room)->name, + optional($equipment)->name, + optional($rack)->name, + optional($box)->name, + $position->code, + ])->filter()->implode(' / ') + ); + } +} diff --git a/app/Models/AliqoutMovement.php b/app/Models/AliqoutMovement.php new file mode 100644 index 0000000..86f7f57 --- /dev/null +++ b/app/Models/AliqoutMovement.php @@ -0,0 +1,56 @@ +belongsTo(Aliquot::class, 'aliquot_id', 'id'); + } + + public function fromPosition() + { + return $this->belongsTo(Position::class, 'from_position_id', 'id'); + } + + public function toPosition() + { + return $this->belongsTo(Position::class, 'to_position_id', 'id'); + } +} diff --git a/app/Models/Aliquot.php b/app/Models/Aliquot.php new file mode 100644 index 0000000..1d1b75d --- /dev/null +++ b/app/Models/Aliquot.php @@ -0,0 +1,72 @@ +belongsTo(Position::class, 'storage_position_id', 'id'); + } + + public function sample() + { + return $this->belongsTo(PathogenInventorySample::class, 'sample_id', 'id'); + } + + public function containerType() + { + return $this->belongsTo(ContainerType::class, 'container_type_id', 'id'); + } + + public function movements() + { + return $this->hasMany(AliqoutMovement::class, 'aliquot_id', 'id'); + } + + public function transactions() + { + return $this->hasMany(AliquotTransaction::class, 'aliquot_id', 'id'); + } +} diff --git a/app/Models/AliquotTransaction.php b/app/Models/AliquotTransaction.php new file mode 100644 index 0000000..d3d36c6 --- /dev/null +++ b/app/Models/AliquotTransaction.php @@ -0,0 +1,55 @@ +belongsTo(Aliquot::class, 'aliquot_id', 'id'); + } + + public function position() + { + return $this->belongsTo(Position::class, 'storage_position_id', 'id'); + } +} diff --git a/app/Models/Box.php b/app/Models/Box.php new file mode 100644 index 0000000..b088fc0 --- /dev/null +++ b/app/Models/Box.php @@ -0,0 +1,56 @@ +belongsTo(Rack::class, 'rack_id', 'id'); + } + + public function positions() + { + return $this->hasMany(Position::class, 'box_id', 'id'); + } +} diff --git a/app/Models/Building.php b/app/Models/Building.php index f84fe13..6feb404 100644 --- a/app/Models/Building.php +++ b/app/Models/Building.php @@ -36,5 +36,12 @@ class Building extends BaseModel parent::boot(); } + public function organization(){ + return $this->belongsTo(Organization::class, 'organization_id', 'id'); + } + + public function rooms(){ + return $this->hasMany(Room::class, 'building_id', 'id'); + } } diff --git a/app/Models/Equipment.php b/app/Models/Equipment.php index b4539fd..09f3e05 100644 --- a/app/Models/Equipment.php +++ b/app/Models/Equipment.php @@ -46,5 +46,8 @@ class Equipment extends BaseModel return $this->belongsTo(Room::class, 'room_id', 'id'); } + public function racks(){ + return $this->hasMany(Rack::class, 'equipment_id', 'id'); + } } diff --git a/app/Models/Organization.php b/app/Models/Organization.php index 417ce18..8785a18 100644 --- a/app/Models/Organization.php +++ b/app/Models/Organization.php @@ -44,4 +44,8 @@ class Organization extends BaseModel // return $this->hasMany('App\Models\UserOrganization', 'organization_id','id')->where('record_status_id', RecordStatusEnum::ACTIVE)->where('organization_id', Session::get('base_organization_id')); // } + public function buildings(){ + return $this->hasMany(Building::class, 'organization_id', 'id'); + } + } diff --git a/app/Models/PathogenInventorySample.php b/app/Models/PathogenInventorySample.php new file mode 100644 index 0000000..969907c --- /dev/null +++ b/app/Models/PathogenInventorySample.php @@ -0,0 +1,62 @@ +belongsTo(Pathogen::class, 'pathogen_id', 'id'); + } + + public function aliquots() + { + return $this->hasMany(Aliquot::class, 'sample_id', 'id'); + } +} diff --git a/app/Models/Position.php b/app/Models/Position.php new file mode 100644 index 0000000..32458f0 --- /dev/null +++ b/app/Models/Position.php @@ -0,0 +1,49 @@ +belongsTo(Box::class, 'box_id', 'id'); + } + + public function aliquot() + { + return $this->hasOne(Aliquot::class, 'storage_position_id', 'id') + ->where(BaseModel::RECORD_STATUS_FIELD, BaseModel::RECORD_STATUS_ACTIVE); + } +} diff --git a/app/Models/Rack.php b/app/Models/Rack.php new file mode 100644 index 0000000..d6e8c9c --- /dev/null +++ b/app/Models/Rack.php @@ -0,0 +1,52 @@ +belongsTo(Equipment::class, 'equipment_id', 'id'); + } + + public function boxes() + { + return $this->hasMany(Box::class, 'rack_id', 'id'); + } +} diff --git a/app/Models/Room.php b/app/Models/Room.php index 85bc6d1..28c377c 100644 --- a/app/Models/Room.php +++ b/app/Models/Room.php @@ -44,5 +44,8 @@ class Room extends BaseModel return $this->belongsTo(Concept::class, 'biosafety_level_concept_id', 'id'); } + public function equipments(){ + return $this->hasMany(Equipment::class, 'room_id', 'id'); + } } diff --git a/lang/en/general.php b/lang/en/general.php index 2b4b2b3..88252f0 100644 --- a/lang/en/general.php +++ b/lang/en/general.php @@ -92,7 +92,18 @@ return [ 'sample_entry_by' => 'Entered By', 'modify_by' => 'Modified By', - 'code' => 'Code' + 'code' => 'Code', + 'name' => 'Name', + 'rack' => 'Rack', + 'rows' => 'Rows', + 'columns' => 'Columns', + 'capacity' => 'Capacity', + 'box_name' => 'Box Name', + 'rack_name' => 'Rack Name', + 'freezer' => 'Freezer', + 'description' => 'Description', + 'sample_box' => 'Sample Boxes', + 'sample_rack' => 'Sample Racks' diff --git a/lang/en/sidebar.php b/lang/en/sidebar.php index e602d95..8f8cb83 100644 --- a/lang/en/sidebar.php +++ b/lang/en/sidebar.php @@ -54,5 +54,18 @@ return [ 'create_sample' => 'Create Sample', 'appointment' => 'Appointments', 'view_sample' => 'Sample List', + 'aliquot_management' => 'Aliquot Management', + 'incident_records' => 'Incident Records', + 'incident_table' => 'Incident Table', + 'incident_type' => 'Incident Types', + 'management' => 'Management', + 'building' => 'Buildings', + 'safe_room' => 'Safety Rooms', + 'freezer' => 'Freezers', + 'rack' => 'Racks', + 'sample_box' => 'Sample Boxes', + 'reference_lists' => 'Reference Lists', + 'pathogen_type' => 'Pathogen Types / Toxins', + 'sample_container_type' => 'Sample Container Types', ]; diff --git a/lang/kh/general.php b/lang/kh/general.php index 76f2889..0dc91ee 100644 --- a/lang/kh/general.php +++ b/lang/kh/general.php @@ -89,6 +89,17 @@ return [ 'sample_entry_by' => 'បញ្ចូលលទ្ធផលដោយ', 'modify_by' => 'កែប្រែលទ្ធផលដោយ', - 'code' => 'លេខកូដ' + 'code' => 'លេខកូដ', + 'name' => 'ឈ្មោះ', + 'rack' => 'ធ្នើ', + 'rows' => 'ជួរដេក', + 'columns' => 'ជួរឈរ', + 'capacity' => 'ចំណុះ', + 'box_name' => 'ឈ្មោះប្រអប់', + 'rack_name' => 'ឈ្មោះធ្នើ', + 'freezer' => 'ទូររក្សាសំណាក', + 'description' => 'បរិយាយ', + 'sample_box' => 'ប្រអប់សំណាក', + 'sample_rack' => 'ធ្នើរក្សាសំណាក' ]; diff --git a/lang/kh/sidebar.php b/lang/kh/sidebar.php index 566e98b..1f1e1a3 100644 --- a/lang/kh/sidebar.php +++ b/lang/kh/sidebar.php @@ -53,7 +53,20 @@ return [ 'payment'=> 'តារាងបង់ប្រាក់', 'sample_management' => 'គ្រប់គ្រងសំណាក', 'create_sample' => 'បញ្ចូលសំណាកថ្មី', - 'view_sample' => 'តារាងសំណាក' + 'view_sample' => 'តារាងសំណាក', + 'aliquot_management' => 'គ្រប់គ្រង Aliquots', + 'incident_records' => 'កត់ត្រាឧប្បត្តិហេតុ', + 'incident_table' => 'តារាងឧប្បត្តិហេតុ', + 'incident_type' => 'ប្រភេទឧប្បត្តិហេតុ', + 'management' => 'គ្រប់គ្រង', + 'building' => 'អគារ', + 'safe_room' => 'បន្ទប់សុវត្ថិភាព', + 'freezer' => 'ទូររក្សាសំណាក', + 'rack' => 'ធ្នើ', + 'sample_box' => 'ប្រអប់សំណាក', + 'reference_lists' => 'បញ្ជីគម្រូ', + 'pathogen_type' => 'ប្រភេទមេរោគ/ជាតិពុល', + 'sample_container_type' => 'ប្រភេទទីបផ្ទុកសំណាក' diff --git a/public/storage/assets/js/admin/box.js b/public/storage/assets/js/admin/box.js new file mode 100644 index 0000000..83813e0 --- /dev/null +++ b/public/storage/assets/js/admin/box.js @@ -0,0 +1,112 @@ +$("#btnSave").on('click', function(e){ + e.preventDefault(); + let box_id = $('#box_id').val(); + url = parseInt(box_id) === 0 ? save_url : update_url; + $.ajax({ + url: url, + type: "POST", + data: { + "_token": csrf_token, + box_id: box_id, + rack_id: $('#rack_id').val(), + code: $('#code').val(), + name: $('#name').val(), + rows: $('#rows').val(), + columns: $('#columns').val(), + capacity: $('#capacity').val(), + }, + success: function(res){ + handleMessage(res.success, res.message) + if(res.success){ setTimeout(function () {location.reload()}, messageDuration)} + } + }); +}); + +function delete_lab(box_id){ + Swal.fire({ + text: confirm_delete_record, + icon: 'question', + showCancelButton: true, + cancelButtonClass: 'danger', + confirmButtonText: confirm_ok_delete, + cancelButtonText: confirm_cancel + }).then((result) => { + if (result.value) { + $.ajax({ + url: delete_url, + method: 'POST', + data: { + "_token": csrf_token, + box_id: box_id, + }, + success: function(res){ + handleMessage(res.success, res.message) + if(res.success) { setTimeout(function () {location.reload()}, messageDuration) } + } + }) + } + }); +} + +function restore_lab(box_id){ + Swal.fire({ + text: confirm_restore_record, + icon: 'question', + showCancelButton: true, + cancelButtonClass: 'danger', + confirmButtonText: confirm_ok_restore, + cancelButtonText: confirm_cancel_restore, + }).then((result) => { + if (result.value) { + $.ajax({ + url: restore_url, + method: 'POST', + data: { + "_token": csrf_token, + box_id: box_id, + }, + success: function(res){ + handleMessage(res.success, res.message) + if(res.success) { setTimeout(function () {location.reload()}, messageDuration)} + } + }) + } + }); +} + +$('#modal-box').on('show.bs.modal', function (event) { + var button = $(event.relatedTarget) + var action = button.data('action') + var modal = $(this); + if(action === "new"){ + modal.find('.modal-body input').val(""); + modal.find('.modal-body select[name="rack_id"]').val("").trigger('change'); + modal.find('.modal-body input[name="box_id"]').val(0); + modal.find('.modal-title').html(modal_add_title); + }else if(action === "edit"){ + box_id = button.data('box_id'); + modal.find('.modal-body input[name="box_id"]').val(box_id); + modal.find('.modal-title').html(modal_edit_title); + $.ajax({ + url: get_url, + type: "POST", + data: { + "_token": csrf_token, + box_id: box_id, + }, + success: function(res){ + if(res.success) { + let data = res.data; + modal.find('.modal-body input[name="code"]').val(data.code); + modal.find('.modal-body input[name="name"]').val(data.name); + modal.find('.modal-body input[name="rows"]').val(data.rows); + modal.find('.modal-body input[name="columns"]').val(data.columns); + modal.find('.modal-body input[name="capacity"]').val(data.capacity); + modal.find('.modal-body select[name="rack_id"]').val(data.rack_id).trigger('change'); + } else{ + handleMessage(res.success, res.message) + } + }, + }); + } +}) diff --git a/public/storage/assets/js/admin/box_position.js b/public/storage/assets/js/admin/box_position.js new file mode 100644 index 0000000..7421a8e --- /dev/null +++ b/public/storage/assets/js/admin/box_position.js @@ -0,0 +1,308 @@ +$("#btnGeneratePositions").on('click', function(e){ + e.preventDefault(); + let box_id = $(this).data('box_id'); + $.ajax({ + url: generate_positions_url, + type: "POST", + data: { + "_token": csrf_token, + box_id: box_id, + }, + success: function(res){ + handleMessage(res.success, res.message) + if(res.success){ setTimeout(function () {location.reload()}, messageDuration)} + } + }); +}); + +let selectedPositionCard = null; + +function setActionButtonsDefaultForOccupied() +{ + $("#btnRemoveAliquot").show(); + $("#btnShowMove").show(); + $("#btnShowHistory").show(); + $("#btnShowUseVolume").show(); + $("#btnBackActions").hide(); + $("#btnMoveAliquot").hide(); + $("#btnUseVolume").hide(); +} + +function openPositionModal(card) +{ + if (card === undefined || !card.length) { + card = selectedPositionCard; + } + if (!card || !card.length) { + return; + } + + let modal = $("#modal-aliquot"); + let aliquotId = card.data('aliquot_id'); + + modal.find('.modal-title').html('Storage Position ' + card.data('position_code')); + modal.find('input[name="storage_position_id"]').val(card.data('position_id')); + modal.find('input[name="aliquot_id"]').val(aliquotId || ''); + + if (aliquotId) { + $("#empty-form").hide(); + $("#btnSaveAliquot").hide(); + setActionButtonsDefaultForOccupied(); + $("#movement-panel").hide(); + $("#movement-history-panel").hide(); + $("#use-volume-panel").hide(); + $("#movement-history-body").html(''); + modal.find('.modal-body select[name="to_position_id"]').val("").trigger('change'); + modal.find('.modal-body textarea[name="movement_reason"]').val(""); + modal.find('.modal-body input[name="used_volume"]').val(""); + modal.find('.modal-body input[name="reference_number"]').val(""); + modal.find('.modal-body textarea[name="use_reason"]').val(""); + $("#current_remaining_volume").text((card.data('remaining_volume') || '0') + ' ' + (card.data('volume_unit') || '')); + $("#occupied-summary").show().html( + 'Occupied: ' + (card.data('aliquot_number') || '') + + '
Sample: ' + (card.data('sample') || '-') + + '
Pathogen: ' + (card.data('pathogen') || '-') + + '
Remaining: ' + (card.data('remaining_volume') || '0') + ' ' + (card.data('volume_unit') || '') + + '
Status: ' + (card.data('status') || '-') + ); + } else { + $("#occupied-summary").hide().html(''); + $("#empty-form").show(); + $("#btnSaveAliquot").show(); + $("#btnRemoveAliquot").hide(); + $("#btnShowMove").hide(); + $("#btnShowHistory").hide(); + $("#btnShowUseVolume").hide(); + $("#btnBackActions").hide(); + $("#btnMoveAliquot").hide(); + $("#btnUseVolume").hide(); + $("#movement-panel").hide(); + $("#movement-history-panel").hide(); + $("#use-volume-panel").hide(); + $("#movement-history-body").html(''); + modal.find('.modal-body input[type="text"], .modal-body input[type="number"], .modal-body input[type="date"], .modal-body textarea').val(""); + modal.find('.modal-body select[name="sample_id"]').val("").trigger('change'); + modal.find('.modal-body select[name="pathogen_id"]').val("").trigger('change'); + modal.find('.modal-body select[name="container_type_id"]').val("").trigger('change'); + modal.find('.modal-body select[name="volume_unit"]').val("µL"); + modal.find('.modal-body select[name="status"]').val("Available"); + } + + modal.modal({backdrop: 'static'}); +} + +$(".position-card").on('click', function(){ + let card = $(this); + selectedPositionCard = card; + $(".position-card").removeClass("selected"); + card.addClass("selected"); + + $("#selected_position_code").text(card.data('position_code') || '-'); + $("#selected_status").text(card.data('status') || 'Empty') + .removeClass('badge-light badge-success badge-warning badge-danger') + .addClass(card.data('aliquot_id') ? 'badge-success' : 'badge-light'); + $("#selected_tube").text(card.data('aliquot_number') || card.data('sample') || '-'); + $("#selected_pathogen").text(card.data('pathogen') || '-'); + let volume = card.data('remaining_volume') ? (card.data('remaining_volume') + ' ' + (card.data('volume_unit') || '')) : '-'; + $("#selected_volume").text(volume); + $("#btnManageSelected").prop('disabled', false); +}); + +$(".position-card").on('dblclick', function(){ + selectedPositionCard = $(this); + openPositionModal(selectedPositionCard); +}); + +$("#btnManageSelected").on('click', function(e){ + e.preventDefault(); + openPositionModal(selectedPositionCard); +}); + +$("#btnClearSelection").on('click', function(e){ + e.preventDefault(); + selectedPositionCard = null; + $(".position-card").removeClass("selected"); + $("#selected_position_code").text('-'); + $("#selected_status").text('No selection').removeClass('badge-success badge-warning badge-danger').addClass('badge-light'); + $("#selected_tube").text('-'); + $("#selected_pathogen").text('-'); + $("#selected_volume").text('-'); + $("#btnManageSelected").prop('disabled', true); +}); + +$("#btnSaveAliquot").on('click', function(e){ + e.preventDefault(); + $.ajax({ + url: save_aliquot_url, + type: "POST", + data: { + "_token": csrf_token, + storage_position_id: $('#storage_position_id').val(), + sample_id: $('#sample_id').val(), + pathogen_id: $('#pathogen_id').val(), + sample_code: $('#sample_code').val(), + container_type_id: $('#container_type_id').val(), + aliquot_number: $('#aliquot_number').val(), + initial_volume: $('#initial_volume').val(), + remaining_volume: $('#remaining_volume').val(), + volume_unit: $('#volume_unit').val(), + concentration: $('#concentration').val(), + expiry_date: $('#expiry_date').val(), + status: $('#status').val(), + description: $('#description').val(), + }, + success: function(res){ + handleMessage(res.success, res.message) + if(res.success){ setTimeout(function () {location.reload()}, messageDuration)} + } + }); +}); + +$("#btnShowMove").on('click', function(e){ + e.preventDefault(); + $("#movement-history-panel").hide(); + $("#use-volume-panel").hide(); + $("#btnUseVolume").hide(); + $("#btnShowMove").hide(); + $("#btnShowHistory").hide(); + $("#btnShowUseVolume").hide(); + $("#btnRemoveAliquot").hide(); + $("#btnBackActions").show(); + $("#movement-panel").show(); + $("#btnMoveAliquot").show(); +}); + +$("#btnMoveAliquot").on('click', function(e){ + e.preventDefault(); + $.ajax({ + url: move_aliquot_url, + type: "POST", + data: { + "_token": csrf_token, + aliquot_id: $('#aliquot_id').val(), + to_position_id: $('#to_position_id').val(), + movement_reason: $('#movement_reason').val(), + }, + success: function(res){ + handleMessage(res.success, res.message) + if(res.success){ setTimeout(function () {location.reload()}, messageDuration)} + } + }); +}); + +$("#btnShowHistory").on('click', function(e){ + e.preventDefault(); + $("#movement-panel").hide(); + $("#use-volume-panel").hide(); + $("#btnMoveAliquot").hide(); + $("#btnUseVolume").hide(); + $("#btnShowMove").hide(); + $("#btnShowHistory").hide(); + $("#btnShowUseVolume").hide(); + $("#btnRemoveAliquot").hide(); + $("#btnBackActions").show(); + $.ajax({ + url: movement_history_url, + type: "POST", + data: { + "_token": csrf_token, + aliquot_id: $('#aliquot_id').val(), + }, + success: function(res){ + if(res.success) { + let rows = ''; + if (res.data.length === 0) { + rows = 'No movement history found.'; + } else { + res.data.forEach(function(item){ + rows += '' + + '' + (item.moved_at || '-') + '' + + '' + (item.from || '-') + '' + + '' + (item.to || '-') + '' + + '' + (item.reason || '-') + '' + + '' + (item.moved_by || '-') + '' + + ''; + }); + } + $("#movement-history-body").html(rows); + $("#movement-history-panel").show(); + } else { + handleMessage(res.success, res.message) + } + } + }); +}); + +$("#btnShowUseVolume").on('click', function(e){ + e.preventDefault(); + $("#movement-panel").hide(); + $("#movement-history-panel").hide(); + $("#btnMoveAliquot").hide(); + $("#btnShowMove").hide(); + $("#btnShowHistory").hide(); + $("#btnShowUseVolume").hide(); + $("#btnRemoveAliquot").hide(); + $("#btnBackActions").show(); + $("#use-volume-panel").show(); + $("#btnUseVolume").show(); +}); + +$("#btnBackActions").on('click', function(e){ + e.preventDefault(); + $("#movement-panel").hide(); + $("#movement-history-panel").hide(); + $("#use-volume-panel").hide(); + $("#btnMoveAliquot").hide(); + $("#btnUseVolume").hide(); + $("#btnBackActions").hide(); + $("#btnRemoveAliquot").show(); + $("#btnShowMove").show(); + $("#btnShowHistory").show(); + $("#btnShowUseVolume").show(); +}); + +$("#btnUseVolume").on('click', function(e){ + e.preventDefault(); + $.ajax({ + url: use_volume_url, + type: "POST", + data: { + "_token": csrf_token, + aliquot_id: $('#aliquot_id').val(), + used_volume: $('#used_volume').val(), + reference_number: $('#reference_number').val(), + use_reason: $('#use_reason').val(), + }, + success: function(res){ + handleMessage(res.success, res.message) + if(res.success){ setTimeout(function () {location.reload()}, messageDuration)} + } + }); +}); + +$("#btnRemoveAliquot").on('click', function(e){ + e.preventDefault(); + Swal.fire({ + text: 'Remove this aliquot from the storage position?', + icon: 'question', + showCancelButton: true, + cancelButtonClass: 'danger', + confirmButtonText: confirm_ok_delete, + cancelButtonText: confirm_cancel + }).then((result) => { + if (result.value) { + $.ajax({ + url: remove_aliquot_url, + method: 'POST', + data: { + "_token": csrf_token, + aliquot_id: $('#aliquot_id').val(), + }, + success: function(res){ + handleMessage(res.success, res.message) + if(res.success) { setTimeout(function () {location.reload()}, messageDuration) } + } + }) + } + }); +}); diff --git a/public/storage/assets/js/admin/rack.js b/public/storage/assets/js/admin/rack.js new file mode 100644 index 0000000..b1ee310 --- /dev/null +++ b/public/storage/assets/js/admin/rack.js @@ -0,0 +1,109 @@ +$("#btnSave").on('click', function(e){ + e.preventDefault(); + let rack_id = $('#rack_id').val(); + url = parseInt(rack_id) === 0 ? save_url : update_url; + $.ajax({ + url: url, + type: "POST", + data: { + "_token": csrf_token, + rack_id: rack_id, + equipment_id: $('#equipment_id').val(), + code: $('#code').val(), + name: $('#name').val(), + description: $('#description').val(), + }, + success: function(res){ + handleMessage(res.success, res.message) + if(res.success){ setTimeout(function () {location.reload()}, messageDuration)} + } + }); +}); + +function delete_lab(rack_id){ + Swal.fire({ + text: confirm_delete_record, + icon: 'question', + showCancelButton: true, + cancelButtonClass: 'danger', + confirmButtonText: confirm_ok_delete, + cancelButtonText: confirm_cancel + }).then((result) => { + if (result.value) { + $.ajax({ + url: delete_url, + method: 'POST', + data: { + "_token": csrf_token, + rack_id: rack_id, + }, + success: function(res){ + handleMessage(res.success, res.message) + if(res.success) { setTimeout(function () {location.reload()}, messageDuration) } + } + }) + } + }); +} + +function restore_lab(rack_id){ + Swal.fire({ + text: confirm_restore_record, + icon: 'question', + showCancelButton: true, + cancelButtonClass: 'danger', + confirmButtonText: confirm_ok_restore, + cancelButtonText: confirm_cancel_restore, + }).then((result) => { + if (result.value) { + $.ajax({ + url: restore_url, + method: 'POST', + data: { + "_token": csrf_token, + rack_id: rack_id, + }, + success: function(res){ + handleMessage(res.success, res.message) + if(res.success) { setTimeout(function () {location.reload()}, messageDuration)} + } + }) + } + }); +} + +$('#modal-rack').on('show.bs.modal', function (event) { + var button = $(event.relatedTarget) + var action = button.data('action') + var modal = $(this); + if(action === "new"){ + modal.find('.modal-body input').val(""); + modal.find('.modal-body textarea').val(""); + modal.find('.modal-body select[name="equipment_id"]').val("").trigger('change'); + modal.find('.modal-body input[name="rack_id"]').val(0); + modal.find('.modal-title').html(modal_add_title); + }else if(action === "edit"){ + rack_id = button.data('rack_id'); + modal.find('.modal-body input[name="rack_id"]').val(rack_id); + modal.find('.modal-title').html(modal_edit_title); + $.ajax({ + url: get_url, + type: "POST", + data: { + "_token": csrf_token, + rack_id: rack_id, + }, + success: function(res){ + if(res.success) { + let data = res.data; + modal.find('.modal-body input[name="code"]').val(data.code); + modal.find('.modal-body input[name="name"]').val(data.name); + modal.find('.modal-body textarea[name="description"]').val(data.description); + modal.find('.modal-body select[name="equipment_id"]').val(data.equipment_id).trigger('change'); + } else{ + handleMessage(res.success, res.message) + } + }, + }); + } +}) diff --git a/resources/views/box.blade.php b/resources/views/box.blade.php new file mode 100644 index 0000000..3cfc493 --- /dev/null +++ b/resources/views/box.blade.php @@ -0,0 +1,196 @@ + + + + @include('layout.header') + + + +
+@include('layout.navbar') +@include('layout.breadscrum') +
+ @include('layout.sidebar') +
+
+
+
+
+
+

{{__('general.sample_box')}}

+
+
+
+
+ +
+ + + +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + @foreach($boxes as $k=>$box) + + + + + + + + + + + + @endforeach + +
{{__('laboratory.table_no')}}{{__('laboratory.table_action')}}{{__('general.code')}}{{__('general.name')}}{{__('general.rack')}}{{__('general.rows')}}{{__('general.columns')}}{{__('general.capacity')}}{{__('laboratory.table_status')}}
{{($k+1) + (($_GET['page'] ?? 1) * 20) - 20}} +
+ + + @if($box->record_status_id==0) + + @else + + @endif +
+
{{$box->code}}{{$box->name}}{{optional(optional($box->rack)->equipment)->name}} / {{optional($box->rack)->name}}{{$box->rows}}{{$box->columns}}{{$box->capacity}}
+
+
+
+ {!! $boxes->links('pagination.custom') !!} +
+
+
+
+
+
+ + @include('layout.footer') +
+
+
+@include('layout.common_script') + + + + diff --git a/resources/views/box_positions.blade.php b/resources/views/box_positions.blade.php new file mode 100644 index 0000000..fde008d --- /dev/null +++ b/resources/views/box_positions.blade.php @@ -0,0 +1,610 @@ + + + + @include('layout.header') + + + +
+@include('layout.navbar') +@include('layout.breadscrum') +
+ @include('layout.sidebar') +
+
+
+
+
+

Register Freezer Box

+

Register and manage a freezer box to store sample tubes in the inventory system.

+
+
+ +
+
+
+
1 Box Information
+
Box ID *
{{$box->code}}
+
Box Name *
{{$box->name}}
+
Barcode
{{$box->code ? 'BCX'.$box->code : '-'}}
+
Box Type *
{{$box->capacity}} Position
+
Position Format
{{$box->rows}} x {{$box->columns}} ({{$box->capacity}})
+
Material
Cryobox
+
Color
Blue
+
Status
Active
+
+ +
+
2 Storage Location
+
Facility *
{{session('base_organization_name') ?? 'National Biobank'}}
+
Building *
{{optional(optional(optional(optional($box->rack)->equipment)->room)->building)->name}}
+
Room *
{{optional(optional($box->rack)->equipment)->room ? optional(optional($box->rack)->equipment)->room->name : '-'}}
+
Freezer *
{{optional($box->rack)->equipment ? optional($box->rack->equipment)->name : '-'}}
+
Rack *
{{optional($box->rack)->name}}
+
Position *
-
+
+ +
+
3 Additional Properties
+
Owner Lab
Virology Unit
+
Notes
Enter notes (optional)...
+
Created By
{{optional(Auth::user())->name ?? 'ratha.leng'}}
+
Created Date
{{$box->created_at}}
+
+
+ +
+
+
+
4 Box Layout ({{$box->capacity}} Position: {{$box->rows}} x {{$box->columns}})
+
+ + +
+
+ + @if($positions->count() == 0) +
+ No positions have been generated for this box yet. Click Generate Positions to create A1, A2, B1... slots from the box rows and columns. +
+ @else +
+
+
+ @for($column = 1; $column <= $box->columns; $column++) +
{{$column}}
+ @endfor +
+
+
+ @for($row = 1; $row <= $box->rows; $row++) +
{{chr(64 + $row)}}
+ @endfor +
+
+ @foreach($positions as $position) + @php($aliquot = $position->aliquot) + @php($statusClass = $aliquot ? strtolower($aliquot->status ?? 'occupied') : '') +
+
+ {{$position->code}} + {{$aliquot ? ($aliquot->status ?: 'Stored') : 'Empty'}} +
+ @if($aliquot) +
{{$aliquot->aliquot_number ?: optional(optional($aliquot)->sample)->sample_code}}
+ @else +
Empty
+ @endif +
+ @endforeach +
+
+
+ Empty + Occupied + Reserved + Quarantine + Destroyed +
+
+
+ + Total: {{$positions->count()}} + Occupied: {{$positions->where('occupied', 1)->count()}} + Reserved: {{$positions->filter(fn($p) => optional($p->aliquot)->status === 'Reserved')->count()}} + Empty: {{$positions->where('occupied', 0)->count()}} +
+ @endif +
+
+ +
+
+
+ Selected Position + +
+
-
+ No selection +
Sample / Tube
+
-
+
Pathogen
+
-
+
Volume
+
-
+ +
+
+
+ +
+
+
+
+
Audit Trail / Recent Movements
+
+ + + + + + + + + + + + + + @forelse($recentMovements as $movement) + + + + + + + + + + @empty + + @endforelse + +
Date & TimeActionFromToSample / TubePerformed ByReason
{{$movement->created_at}}{{empty($movement->from_position_id) ? 'Sample Stored' : (empty($movement->to_position_id) ? 'Removed' : 'Sample Moved')}}{{$movement->fromPosition ? $movement->fromPosition->code : '-'}}{{$movement->toPosition ? $movement->toPosition->code : '-'}}{{optional($movement->aliquot)->aliquot_number ?: optional(optional($movement->aliquot)->sample)->sample_code}}{{$movement->moved_by}}{{$movement->movement_reason}}
No recent movements found.
+
+
+
+
+
+
+ + + @include('layout.footer') +
+
+
+@include('layout.common_script') + + + + diff --git a/resources/views/layout/sidebar.blade.php b/resources/views/layout/sidebar.blade.php index 8e7f4b7..27174a9 100644 --- a/resources/views/layout/sidebar.blade.php +++ b/resources/views/layout/sidebar.blade.php @@ -16,7 +16,7 @@ - @@ -82,14 +82,14 @@ diff --git a/resources/views/rack.blade.php b/resources/views/rack.blade.php new file mode 100644 index 0000000..d2c3ded --- /dev/null +++ b/resources/views/rack.blade.php @@ -0,0 +1,156 @@ + + + + @include('layout.header') + + +
+@include('layout.navbar') +@include('layout.breadscrum') +
+ @include('layout.sidebar') +
+
+
+
+
+
+

{{__('general.sample_rack')}}

+
+
+
+
+ +
+ + + +
+
+
+
+
+
+
+ + + + + + + + + + + + + + @foreach($racks as $k=>$rack) + + + + + + + + + + @endforeach + +
{{__('laboratory.table_no')}}{{__('laboratory.table_action')}}{{__('general.code')}}{{__('general.name')}}{{__('general.freezer')}}{{__('general.description')}}{{__('laboratory.table_status')}}
{{($k+1) + (($_GET['page'] ?? 1) * 20) - 20}} +
+ + @if($rack->record_status_id==0) + + @else + + @endif +
+
{{$rack->code}}{{$rack->name}}{{optional(optional(optional($rack->equipment)->room)->building)->name}} / {{optional(optional($rack->equipment)->room)->name}} / {{optional($rack->equipment)->name}}{{$rack->description}}
+
+
+
+ {!! $racks->links('pagination.custom') !!} +
+
+
+
+
+
+ + @include('layout.footer') +
+
+
+@include('layout.common_script') + + + + diff --git a/routes/web.php b/routes/web.php index c7845e8..d4ae282 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,6 +2,7 @@ use App\Http\Controllers\AntibioticController; use App\Http\Controllers\BaseController; +use App\Http\Controllers\BoxController; use App\Http\Controllers\BuildingController; use App\Http\Controllers\CommentController; use App\Http\Controllers\ConceptController; @@ -20,9 +21,11 @@ use App\Http\Controllers\QuantityController; use App\Http\Controllers\ReportController; use App\Http\Controllers\RoleController; use App\Http\Controllers\RoomController; +use App\Http\Controllers\RackController; use App\Http\Controllers\SampleController; use App\Http\Controllers\SampleSourceController; use App\Http\Controllers\SampleTypeController; +use App\Http\Controllers\StoragePositionController; use App\Http\Controllers\TestController; use App\Http\Controllers\TestGroupController; use App\Http\Controllers\UserController; @@ -140,6 +143,29 @@ Route::middleware(['auth'])->group(function () { Route::post('equipment.delete', [EquipmentController::class, 'delete'])->middleware(VerifyBaseLab::class); Route::post('equipment.restore', [EquipmentController::class, 'restore'])->middleware(VerifyBaseLab::class); + Route::get('rack', [RackController::class, 'index'])->name('rack')->middleware(VerifyBaseLab::class); + Route::get('rack/search', [RackController::class, 'index'])->name('rack.search')->middleware(VerifyBaseLab::class); + Route::post('rack.save', [RackController::class, 'save'])->middleware(VerifyBaseLab::class); + Route::post('rack.get', [RackController::class, 'get'])->middleware(VerifyBaseLab::class); + Route::post('rack.update', [RackController::class, 'update'])->middleware(VerifyBaseLab::class); + Route::post('rack.delete', [RackController::class, 'delete'])->middleware(VerifyBaseLab::class); + Route::post('rack.restore', [RackController::class, 'restore'])->middleware(VerifyBaseLab::class); + + Route::get('box', [BoxController::class, 'index'])->name('box')->middleware(VerifyBaseLab::class); + Route::get('box/search', [BoxController::class, 'index'])->name('box.search')->middleware(VerifyBaseLab::class); + Route::post('box.save', [BoxController::class, 'save'])->middleware(VerifyBaseLab::class); + Route::post('box.get', [BoxController::class, 'get'])->middleware(VerifyBaseLab::class); + Route::post('box.update', [BoxController::class, 'update'])->middleware(VerifyBaseLab::class); + Route::post('box.delete', [BoxController::class, 'delete'])->middleware(VerifyBaseLab::class); + Route::post('box.restore', [BoxController::class, 'restore'])->middleware(VerifyBaseLab::class); + Route::get('box/{box}/positions', [StoragePositionController::class, 'index'])->middleware(VerifyBaseLab::class); + Route::post('storage-position.generate', [StoragePositionController::class, 'generate'])->middleware(VerifyBaseLab::class); + Route::post('storage-position.aliquot.save', [StoragePositionController::class, 'storeAliquot'])->middleware(VerifyBaseLab::class); + Route::post('storage-position.aliquot.remove', [StoragePositionController::class, 'removeAliquot'])->middleware(VerifyBaseLab::class); + Route::post('storage-position.aliquot.move', [StoragePositionController::class, 'moveAliquot'])->middleware(VerifyBaseLab::class); + Route::post('storage-position.aliquot.history', [StoragePositionController::class, 'movementHistory'])->middleware(VerifyBaseLab::class); + Route::post('storage-position.aliquot.use-volume', [StoragePositionController::class, 'useVolume'])->middleware(VerifyBaseLab::class); + Route::get('concept', [ConceptController::class, 'index'])->name('concept')->middleware(VerifyBaseLab::class); Route::get('concept/search', [ConceptController::class, 'index'])->name('concept.search')->middleware(VerifyBaseLab::class); Route::post('concept.save', [ConceptController::class, 'save'])->middleware(VerifyBaseLab::class); diff --git a/storage/app/.gitignore b/storage/app/.gitignore new file mode 100644 index 0000000..fedb287 --- /dev/null +++ b/storage/app/.gitignore @@ -0,0 +1,4 @@ +* +!private/ +!public/ +!.gitignore diff --git a/storage/app/private/.gitignore b/storage/app/private/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/app/private/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/app/public/.gitignore b/storage/app/public/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/app/public/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore