This commit is contained in:
Dwi Swandhana
2026-02-21 05:12:55 +07:00
parent 1dac4b9e2a
commit 3b61faafbd
10 changed files with 603 additions and 1 deletions
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class BioCabinet extends Model
{
protected $table = 'bio_cabinets';
protected $guarded = [];
public function racks()
{
return $this->hasMany(BioRack::class, 'cabinet_id');
}
public function specimens()
{
return $this->hasMany(BioSpecimen::class, 'cabinet_id');
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class BioRack extends Model
{
protected $table = 'bio_racks';
protected $guarded = [];
public function cabinet()
{
return $this->belongsTo(BioCabinet::class, 'cabinet_id');
}
public function specimens()
{
return $this->hasMany(BioSpecimen::class, 'rack_id');
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
class BioSpecimen extends Model
{
protected $table = 'bio_specimens';
protected $guarded = [];
protected $appends = ['storage_days'];
public function cabinet()
{
return $this->belongsTo(BioCabinet::class, 'cabinet_id');
}
public function rack()
{
return $this->belongsTo(BioRack::class, 'rack_id');
}
public function getStorageDaysAttribute()
{
if (!$this->stored_at) {
return 0;
}
return Carbon::parse($this->stored_at)->diffInDays(Carbon::now());
}
}
@@ -0,0 +1,143 @@
<?php
namespace App\Http\Controllers;
use App\BioCabinet;
use App\BioRack;
use App\BioSpecimen;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Session;
use Validator;
class BiorepositoryController extends Controller
{
public function index()
{
if (Session::get('previlage') == '') {
return redirect('/login');
}
$data = [];
$data['cabinetOptions'] = BioCabinet::orderBy('name', 'ASC')->get();
$data['rackOptions'] = BioRack::with('cabinet')->orderBy('name', 'ASC')->get();
$data['totalCabinets'] = BioCabinet::count();
$data['totalRacks'] = BioRack::count();
$data['totalSpecimens'] = BioSpecimen::count();
$data['cabinets'] = BioCabinet::with([
'racks' => function ($query) {
$query->orderBy('level', 'ASC')->orderBy('name', 'ASC');
},
'racks.specimens' => function ($query) {
$query->orderBy('stored_at', 'ASC');
},
])->orderBy('name', 'ASC')->get();
$data['oldestSpecimen'] = BioSpecimen::with(['cabinet', 'rack'])
->whereNotNull('stored_at')
->orderBy('stored_at', 'ASC')
->first();
$data['oldestStorageDays'] = 0;
if ($data['oldestSpecimen']) {
$data['oldestStorageDays'] = Carbon::parse($data['oldestSpecimen']->stored_at)->diffInDays(Carbon::now());
}
return view('admin.biorepository', $data);
}
public function storeCabinet(Request $request)
{
$validator = Validator::make($request->all(), [
'code' => 'required|max:50|unique:bio_cabinets,code',
'name' => 'required|max:150',
'location' => 'nullable|max:200',
'notes' => 'nullable',
]);
if ($validator->fails()) {
return back()->withErrors($validator)->withInput();
}
BioCabinet::create([
'code' => $request->input('code'),
'name' => $request->input('name'),
'location' => $request->input('location'),
'notes' => $request->input('notes'),
]);
return redirect('/biorepository')->with('success', 'Lemari biorepository berhasil ditambahkan.');
}
public function storeRack(Request $request)
{
$validator = Validator::make($request->all(), [
'cabinet_id' => 'required|exists:bio_cabinets,id',
'code' => 'required|max:50',
'name' => 'required|max:150',
'level' => 'required|integer|min:1',
'capacity' => 'nullable|integer|min:0',
'notes' => 'nullable',
]);
if ($validator->fails()) {
return back()->withErrors($validator)->withInput();
}
$exists = BioRack::where('cabinet_id', $request->input('cabinet_id'))
->where('code', $request->input('code'))
->exists();
if ($exists) {
return back()->withErrors(['code' => 'Kode rack sudah dipakai pada lemari ini.'])->withInput();
}
BioRack::create([
'cabinet_id' => $request->input('cabinet_id'),
'code' => $request->input('code'),
'name' => $request->input('name'),
'level' => $request->input('level'),
'capacity' => $request->input('capacity') ?? 0,
'notes' => $request->input('notes'),
]);
return redirect('/biorepository')->with('success', 'Rack berhasil ditambahkan.');
}
public function storeSpecimen(Request $request)
{
$validator = Validator::make($request->all(), [
'rack_id' => 'required|exists:bio_racks,id',
'specimen_code' => 'required|max:100|unique:bio_specimens,specimen_code',
'specimen_name' => 'required|max:200',
'patient_name' => 'nullable|max:200',
'collected_at' => 'nullable|date',
'stored_at' => 'required|date',
'volume' => 'nullable|max:50',
'storage_condition' => 'nullable|max:100',
'notes' => 'nullable',
]);
if ($validator->fails()) {
return back()->withErrors($validator)->withInput();
}
$rack = BioRack::find($request->input('rack_id'));
BioSpecimen::create([
'cabinet_id' => $rack->cabinet_id,
'rack_id' => $rack->id,
'specimen_code' => $request->input('specimen_code'),
'specimen_name' => $request->input('specimen_name'),
'patient_name' => $request->input('patient_name'),
'collected_at' => $request->input('collected_at'),
'stored_at' => $request->input('stored_at'),
'volume' => $request->input('volume'),
'storage_condition' => $request->input('storage_condition'),
'notes' => $request->input('notes'),
]);
return redirect('/biorepository')->with('success', 'Spesimen berhasil ditambahkan.');
}
}
@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('bio_cabinets', function (Blueprint $table) {
$table->id();
$table->string('code', 50)->unique();
$table->string('name', 150);
$table->string('location', 200)->nullable();
$table->text('notes')->nullable();
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent();
});
}
public function down(): void
{
Schema::dropIfExists('bio_cabinets');
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('bio_racks', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('cabinet_id')->index();
$table->string('code', 50);
$table->string('name', 150);
$table->integer('level')->default(1);
$table->integer('capacity')->default(0);
$table->text('notes')->nullable();
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent();
$table->unique(['cabinet_id', 'code']);
});
}
public function down(): void
{
Schema::dropIfExists('bio_racks');
}
};
@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('bio_specimens', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('cabinet_id')->index();
$table->unsignedBigInteger('rack_id')->index();
$table->string('specimen_code', 100)->unique();
$table->string('specimen_name', 200);
$table->string('patient_name', 200)->nullable();
$table->date('collected_at')->nullable();
$table->date('stored_at');
$table->string('volume', 50)->nullable();
$table->string('storage_condition', 100)->nullable();
$table->text('notes')->nullable();
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent();
});
}
public function down(): void
{
Schema::dropIfExists('bio_specimens');
}
};
@@ -0,0 +1,289 @@
@extends('base.layout')
@push('styles')
<style>
.bio-summary-card {
border-left: 4px solid #188ae2;
}
.cabinet-visual {
border: 2px solid #2d3e50;
border-radius: 12px;
background: #f7fbff;
padding: 15px;
margin-bottom: 18px;
}
.rack-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.rack-box {
border: 1px solid #d7e3ef;
border-radius: 10px;
background: #ffffff;
padding: 12px;
min-height: 180px;
}
.rack-header {
font-weight: 700;
color: #1b3a57;
margin-bottom: 6px;
}
.specimen-preview {
margin: 0;
padding-left: 15px;
font-size: 12px;
}
.specimen-preview li {
margin-bottom: 4px;
}
.oldest-highlight {
border: 1px solid #ffd58f;
background: #fff8ea;
border-radius: 10px;
padding: 12px;
}
</style>
@endpush
@section('content')
<div class="wrapper">
<div class="container-fluid">
<div class="row">
<div class="col-sm-12">
<div class="page-title-box">
<div class="btn-group pull-right">
<ol class="breadcrumb hide-phone p-0 m-0">
<li class="breadcrumb-item active">Biorepository</li>
</ol>
</div>
<h4 class="page-title">Biorepository Lab Mikrobiologi</h4>
</div>
</div>
</div>
@if(session('success'))
<div class="row">
<div class="col-lg-12">
<div class="alert alert-success">{{ session('success') }}</div>
</div>
</div>
@endif
@if($errors->any())
<div class="row">
<div class="col-lg-12">
<div class="alert alert-danger">
@foreach($errors->all() as $err)
<div>{{ $err }}</div>
@endforeach
</div>
</div>
</div>
@endif
<div class="row">
<div class="col-md-4">
<div class="card-box bio-summary-card">
<h5>Total Lemari</h5>
<h3>{{ $totalCabinets }}</h3>
</div>
</div>
<div class="col-md-4">
<div class="card-box bio-summary-card">
<h5>Total Rack</h5>
<h3>{{ $totalRacks }}</h3>
</div>
</div>
<div class="col-md-4">
<div class="card-box bio-summary-card">
<h5>Total Spesimen</h5>
<h3>{{ $totalSpecimens }}</h3>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-4">
<div class="card-box ribbon-box">
<div class="ribbon ribbon-primary">Tambah Lemari</div>
<p class="m-b-0"></p>
<form method="POST" action="{{ route('biorepository.storeCabinet') }}">
@csrf
<div class="form-group">
<label>Kode Lemari</label>
<input type="text" name="code" class="form-control" placeholder="LMR-A01" required>
</div>
<div class="form-group">
<label>Nama Lemari</label>
<input type="text" name="name" class="form-control" required>
</div>
<div class="form-group">
<label>Lokasi</label>
<input type="text" name="location" class="form-control" placeholder="Ruang kultur 1">
</div>
<div class="form-group">
<label>Catatan</label>
<textarea name="notes" class="form-control" rows="2"></textarea>
</div>
<button class="btn btn-custom btn-block" type="submit">Simpan Lemari</button>
</form>
</div>
</div>
<div class="col-lg-4">
<div class="card-box ribbon-box">
<div class="ribbon ribbon-info">Tambah Rack</div>
<p class="m-b-0"></p>
<form method="POST" action="{{ route('biorepository.storeRack') }}">
@csrf
<div class="form-group">
<label>Pilih Lemari</label>
<select name="cabinet_id" class="form-control" required>
<option value="">-- Pilih --</option>
@foreach($cabinetOptions as $cab)
<option value="{{ $cab->id }}">{{ $cab->code }} - {{ $cab->name }}</option>
@endforeach
</select>
</div>
<div class="form-group">
<label>Kode Rack</label>
<input type="text" name="code" class="form-control" placeholder="R01" required>
</div>
<div class="form-group">
<label>Nama Rack</label>
<input type="text" name="name" class="form-control" required>
</div>
<div class="form-row">
<div class="form-group col-6">
<label>Level</label>
<input type="number" name="level" class="form-control" min="1" value="1" required>
</div>
<div class="form-group col-6">
<label>Kapasitas</label>
<input type="number" name="capacity" class="form-control" min="0" value="0">
</div>
</div>
<button class="btn btn-info btn-block" type="submit">Simpan Rack</button>
</form>
</div>
</div>
<div class="col-lg-4">
<div class="card-box ribbon-box">
<div class="ribbon ribbon-success">Tambah Spesimen</div>
<p class="m-b-0"></p>
<form method="POST" action="{{ route('biorepository.storeSpecimen') }}">
@csrf
<div class="form-group">
<label>Pilih Rack</label>
<select name="rack_id" class="form-control" required>
<option value="">-- Pilih --</option>
@foreach($rackOptions as $rack)
<option value="{{ $rack->id }}">{{ $rack->cabinet->code ?? '-' }} | {{ $rack->code }} - {{ $rack->name }}</option>
@endforeach
</select>
</div>
<div class="form-group">
<label>Kode Spesimen</label>
<input type="text" name="specimen_code" class="form-control" placeholder="SP-0001" required>
</div>
<div class="form-group">
<label>Nama Spesimen</label>
<input type="text" name="specimen_name" class="form-control" placeholder="Darah, sputum, urin" required>
</div>
<div class="form-group">
<label>Nama Pasien (Opsional)</label>
<input type="text" name="patient_name" class="form-control">
</div>
<div class="form-row">
<div class="form-group col-6">
<label>Tanggal Ambil</label>
<input type="date" name="collected_at" class="form-control">
</div>
<div class="form-group col-6">
<label>Tanggal Simpan</label>
<input type="date" name="stored_at" class="form-control" required>
</div>
</div>
<div class="form-row">
<div class="form-group col-6">
<label>Volume</label>
<input type="text" name="volume" class="form-control" placeholder="2 ml">
</div>
<div class="form-group col-6">
<label>Kondisi Simpan</label>
<input type="text" name="storage_condition" class="form-control" placeholder="-20 C">
</div>
</div>
<button class="btn btn-success btn-block" type="submit">Simpan Spesimen</button>
</form>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-12">
<div class="card-box">
<h4 class="m-b-15">Spesimen dengan Waktu Simpan Paling Lama</h4>
@if($oldestSpecimen)
<div class="oldest-highlight">
<strong>{{ $oldestSpecimen->specimen_code }}</strong> - {{ $oldestSpecimen->specimen_name }}<br>
Lemari: {{ $oldestSpecimen->cabinet->name ?? '-' }} | Rack: {{ $oldestSpecimen->rack->name ?? '-' }}<br>
Disimpan sejak: {{ $oldestSpecimen->stored_at }} ({{ $oldestStorageDays }} hari)
</div>
@else
<div class="alert alert-warning m-b-0">Belum ada data spesimen.</div>
@endif
</div>
</div>
</div>
<div class="row">
<div class="col-lg-12">
<div class="card-box">
<h4 class="m-b-20">Visualisasi Lemari dan Rack</h4>
@forelse($cabinets as $cabinet)
<div class="cabinet-visual">
<h5 class="m-b-15">{{ $cabinet->code }} - {{ $cabinet->name }} <small class="text-muted">({{ $cabinet->location ?? 'Lokasi belum diisi' }})</small></h5>
<div class="rack-grid">
@forelse($cabinet->racks as $rack)
<div class="rack-box">
<div class="rack-header">{{ $rack->code }} - {{ $rack->name }} (Lv. {{ $rack->level }})</div>
<div>Kapasitas: {{ $rack->capacity }}</div>
<div>Total spesimen: {{ $rack->specimens->count() }}</div>
@if($rack->specimens->count() > 0)
@php
$oldestRackSpecimen = $rack->specimens->first();
$preview = $rack->specimens->take(4);
@endphp
<div class="text-muted" style="font-size:12px; margin-top:4px;">Terlama di rack ini: {{ $oldestRackSpecimen->specimen_code }} ({{ $oldestRackSpecimen->storage_days }} hari)</div>
<div style="margin-top:6px; font-size:12px;"><strong>Preview spesimen:</strong></div>
<ul class="specimen-preview">
@foreach($preview as $sp)
<li>
{{ $sp->specimen_code }} - {{ $sp->specimen_name }}<br>
<span class="text-muted">Simpan {{ $sp->stored_at }} ({{ $sp->storage_days }} hari)</span>
</li>
@endforeach
</ul>
@else
<div class="text-muted" style="margin-top:8px;">Belum ada spesimen pada rack ini.</div>
@endif
</div>
@empty
<div class="alert alert-light m-b-0">Belum ada rack pada lemari ini.</div>
@endforelse
</div>
</div>
@empty
<div class="alert alert-warning">Belum ada data lemari biorepository.</div>
@endforelse
</div>
</div>
</div>
</div>
</div>
@endsection
@@ -65,6 +65,9 @@
<div class="container-fluid">
<div id="navigation">
<ul class="navigation-menu">
@if(Session::get('previlage') != '')
<li><a href="/biorepository"><i class="fa fa-archive"></i>Biorepository</a></li>
@endif
@if(Session::get('previlage') == 'developer')
<li><a href="/modemobile"><i class="fa fa-h-square"></i>Tab Mode</a></li>
<li><a href="/reportppds"><i class="fa fa-h-square"></i>SPV Verification</a></li>
+5 -1
View File
@@ -11,6 +11,7 @@ use App\Http\Controllers\ListController;
use App\Http\Controllers\TemplateController;
use App\Http\Controllers\LogbookController;
use App\Http\Controllers\JsonTransferController;
use App\Http\Controllers\BiorepositoryController;
Route::get('/', [FrontpageController::class, 'index']);
@@ -65,6 +66,10 @@ Route::group(['middleware' => 'project.ipg'], function() {
Route::post('poli/deleteruangan', [PoliController::class, 'deleteRuangan'])->name('deleteRuangan');
Route::get('gudang', [PoliController::class, 'viewGudangIndex']);
Route::get('biorepository', [BiorepositoryController::class, 'index'])->name('biorepository.index');
Route::post('biorepository/store-cabinet', [BiorepositoryController::class, 'storeCabinet'])->name('biorepository.storeCabinet');
Route::post('biorepository/store-rack', [BiorepositoryController::class, 'storeRack'])->name('biorepository.storeRack');
Route::post('biorepository/store-specimen', [BiorepositoryController::class, 'storeSpecimen'])->name('biorepository.storeSpecimen');
Route::post('simbhp/exaddbarang', [PoliController::class, 'exAddbarang'])->name('exAddBarang');
Route::post('simbhp/reportbhp', [PoliController::class, 'jsonReportbhp'])->name('reportBHP');
Route::post('simbhp/kwitansi', [PoliController::class, 'exKwitansi'])->name('kwitansiBHP');
@@ -170,4 +175,3 @@ Route::group(['middleware' => 'project.ipg'], function() {
Route::post('/json-transfer/import/{table}', [JsonTransferController::class, 'importTable'])->name('json.importTable');
Route::post('/git/pull', [JsonTransferController::class, 'pull'])->name('git.pull');
});