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.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.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 @@
-
+
@@ -51,30 +51,30 @@
-
+
-
+
-
+
-
+
@@ -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.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