This commit is contained in:
Dwi Swandhana
2026-08-02 10:46:34 +07:00
parent 334728e16e
commit 3e1ed72727
25 changed files with 4977 additions and 194 deletions
@@ -7,7 +7,9 @@ use App\CriticalValueSample;
use App\Organisms;
use App\Periksa;
use App\PeriksaSYNC;
use App\Paslab;
use App\Riwayat;
use App\SIMBHPReport;
use App\Subjawaban;
use App\User;
use Carbon\Carbon;
@@ -15,6 +17,7 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Session;
class MobileApiController extends Controller
@@ -154,6 +157,7 @@ class MobileApiController extends Controller
'klinisi' => $row->klinisi,
'klinis' => $row->klinis,
'nmdokter' => $row->nmdokter,
'kd_spesimen' => $row->kd_spesimen,
'nm_spesimen' => $row->nm_spesimen,
'kesimpulan' => $row->kesimpulan,
'dlp' => $row->dlp,
@@ -230,19 +234,187 @@ class MobileApiController extends Controller
'butuh_verifikasi' => Periksa::where('status', 'like', '%Un Verified%')->count(),
'antrian_hari_ini' => Periksa::whereDate('daftar', Carbon::today())->count(),
'antrian_sinkron' => PeriksaSYNC::where('created_by', $user->username)->where('status', '')->count(),
'criticalNotificationCount' => CriticalValueSample::whereNull('followed_up_at')->count(),
],
'books' => $books,
'early_warning_groups' => $this->earlyWarningGroups(),
]);
}
public function criticalValueNotifications(Request $request)
{
$this->requireUser($request);
$range = $this->resolveCriticalValueReportRange($request);
return response()->json([
'count' => CriticalValueSample::whereNull('followed_up_at')->count(),
'pending_items' => $this->criticalValueItems(
CriticalValueSample::whereNull('followed_up_at')
->orderBy('critical_set_at', 'asc')
->limit(100)
->get()
),
'followed_items' => $this->criticalValueItems(
CriticalValueSample::whereNotNull('followed_up_at')
->where('followed_up_at', '>=', $range['start_at'])
->where('followed_up_at', '<=', $range['end_at'])
->orderBy('followed_up_at', 'desc')
->limit(100)
->get()
),
'start_date' => $range['start_date'],
'end_date' => $range['end_date'],
]);
}
public function criticalValueNotificationMarkRead(Request $request, int $id)
{
$user = $this->requireUser($request);
$criticalSample = CriticalValueSample::find($id);
abort_if(!$criticalSample, 404, 'Sample nilai kritis tidak ditemukan.');
if (!empty($criticalSample->followed_up_at)) {
return response()->json([
'message' => 'Sample nilai kritis ini sudah ditindaklanjuti sebelumnya.',
'item' => $this->criticalValueItem($criticalSample),
]);
}
$followUpMethod = trim((string) $request->input('caratindaklanjut', ''));
$followUpRecipient = trim((string) $request->input('penerima_laporan', ''));
$followUpReason = trim((string) $request->input('alasan_belum_laporkan', ''));
if ($followUpMethod === '') {
if ($followUpReason === '') {
return response()->json([
'message' => 'Alasan belum dilaporkan wajib diisi jika belum dilaporkan.',
], 422);
}
} else {
$allowed = ['Telpon', 'Whatshap', 'Email', 'Ketemu di Jalan'];
if (!in_array($followUpMethod, $allowed, true)) {
return response()->json(['message' => 'Cara tindak lanjut tidak valid.'], 422);
}
if ($followUpRecipient === '') {
return response()->json([
'message' => 'Penerima laporan wajib diisi jika sudah dilaporkan.',
], 422);
}
}
$criticalSample->followed_up_at = Carbon::now();
$criticalSample->followed_up_by_user_id = $user->id;
$criticalSample->followed_up_by_name = $user->nama;
$criticalSample->follow_up_status = $followUpMethod === '' ? 'not_reported' : 'reported';
$criticalSample->follow_up_method = $followUpMethod !== '' ? $followUpMethod : null;
$criticalSample->follow_up_recipient = $followUpRecipient !== '' ? $followUpRecipient : null;
$criticalSample->follow_up_reason = $followUpReason !== '' ? $followUpReason : null;
$criticalSample->save();
return response()->json([
'message' => 'Sample nilai kritis sudah ditandai sudah ditindaklanjuti.',
'item' => $this->criticalValueItem($criticalSample),
'count' => CriticalValueSample::whereNull('followed_up_at')->count(),
]);
}
protected function resolveCriticalValueReportRange(Request $request): array
{
$defaultStart = Carbon::now()->startOfMonth()->toDateString();
$defaultEnd = Carbon::now()->endOfMonth()->toDateString();
$startDate = trim((string) $request->query('start_date', $defaultStart));
$endDate = trim((string) $request->query('end_date', $defaultEnd));
try {
$start = Carbon::createFromFormat('Y-m-d', $startDate)->startOfDay();
} catch (\Throwable $e) {
$start = Carbon::createFromFormat('Y-m-d', $defaultStart)->startOfDay();
$startDate = $defaultStart;
}
try {
$end = Carbon::createFromFormat('Y-m-d', $endDate)->endOfDay();
} catch (\Throwable $e) {
$end = Carbon::createFromFormat('Y-m-d', $defaultEnd)->endOfDay();
$endDate = $defaultEnd;
}
if ($start->gt($end)) {
[$start, $end] = [$end->copy()->startOfDay(), $start->copy()->endOfDay()];
$startDate = $start->toDateString();
$endDate = $end->toDateString();
}
return [
'start_date' => $startDate,
'end_date' => $endDate,
'start_at' => $start,
'end_at' => $end,
];
}
protected function criticalValueItems($items): array
{
return $items->map(fn ($item) => $this->criticalValueItem($item))->values()->all();
}
protected function criticalValueItem(CriticalValueSample $item): array
{
$tatMinutes = null;
$tatDurationLabel = '-';
$tatStatusLabel = '-';
if (!empty($item->critical_set_at) && !empty($item->followed_up_at)) {
$criticalSetAt = Carbon::parse($item->critical_set_at);
$followedUpAt = Carbon::parse($item->followed_up_at);
$tatMinutes = $criticalSetAt->diffInMinutes($followedUpAt);
$tatDurationLabel = $this->formatCriticalValueTatDuration($tatMinutes);
$tatStatusLabel = $tatMinutes <= 30 ? 'Memenuhi TAT' : 'Tidak Memenuhi TAT';
}
return [
'id' => $item->id,
'periksa_id' => $item->periksa_id,
'nofoto' => $item->nofoto,
'noregister' => $item->noregister,
'nmpasien' => $item->nmpasien,
'asalpasien' => $item->asalpasien,
'nm_spesimen' => $item->nm_spesimen,
'critical_set_at' => $item->critical_set_at,
'critical_set_by_name' => $item->critical_set_by_name,
'followed_up_at' => $item->followed_up_at,
'followed_up_by_name' => $item->followed_up_by_name,
'tat_minutes' => $tatMinutes,
'tat_duration_label' => $tatDurationLabel,
'tat_status_label' => $tatStatusLabel,
'follow_up_status' => $item->follow_up_status,
'follow_up_method' => $item->follow_up_method,
'follow_up_recipient' => $item->follow_up_recipient,
'follow_up_reason' => $item->follow_up_reason,
];
}
protected function formatCriticalValueTatDuration(int $totalMinutes): string
{
$hours = intdiv($totalMinutes, 60);
$minutes = $totalMinutes % 60;
if ($hours > 0) {
return $hours.' jam '.$minutes.' menit';
}
return $minutes.' menit';
}
public function examinations(Request $request, string $master)
{
$this->requireUser($request);
$query = $this->activeExaminationQuery();
$mapped = collect($this->bookPoliMap())->flatten()->all();
if ($master === 'buku0') {
if ($master === 'ALL') {
// Semua spesimen: tidak dibatasi kategori buku/poli.
} elseif ($master === 'buku0') {
$query->whereNotIn('poli_id', $mapped);
} elseif (isset($this->bookPoliMap()[$master])) {
$query->whereIn('poli_id', $this->bookPoliMap()[$master]);
@@ -260,15 +432,237 @@ class MobileApiController extends Controller
});
}
$items = $query->orderBy('daftar', 'ASC')->limit(250)->get()->map(fn ($row) => $this->examinationPayload($row));
$query->orderBy('daftar', 'ASC');
if ($master !== 'ALL') {
$query->limit(250);
}
$items = $query->get()->map(fn ($row) => $this->examinationPayload($row));
return response()->json([
'master' => $master,
'label' => $this->bookLabels[$master],
'label' => $master === 'ALL' ? 'Semua Spesimen' : $this->bookLabels[$master],
'items' => $items,
]);
}
public function searchExamination(Request $request)
{
$this->requireUser($request);
$search = trim((string) $request->query('search', ''));
abort_if($search === '', 422, 'Kata kunci pencarian wajib diisi.');
$exactColumns = $this->availablePeriksaColumns(['nofoto', 'noregister', 'orderid', 'acc_number', 'barcode']);
$likeColumns = $this->availablePeriksaColumns(['nofoto', 'noregister', 'nmpasien', 'reques', 'orderid', 'acc_number', 'barcode']);
$row = $this->activeExaminationQuery()
->where(function ($q) use ($search, $exactColumns) {
foreach ($exactColumns as $index => $column) {
$index === 0
? $q->where($column, $search)
: $q->orWhere($column, $search);
}
})
->first();
if (!$row) {
$row = $this->activeExaminationQuery()
->where(function ($q) use ($search, $likeColumns) {
foreach ($likeColumns as $index => $column) {
$index === 0
? $q->where($column, 'like', "%{$search}%")
: $q->orWhere($column, 'like', "%{$search}%");
}
})
->orderBy('daftar', 'desc')
->first();
}
if (!$row) {
return response()->json(['message' => 'Data pemeriksaan tidak ditemukan.'], 404);
}
return response()->json([
'item' => $this->examinationPayload($row),
]);
}
protected function availablePeriksaColumns(array $columns): array
{
return array_values(array_filter(
$columns,
fn ($column) => Schema::hasColumn('periksa', $column)
));
}
public function initialWorkSamples(Request $request)
{
$this->requireUser($request);
$items = Periksa::query()
->whereIn('status', [
'Penerimaan Sampel',
'Pemeriksaan Sampel',
'Pemeriksaan Awal',
'Pemeriksaan awal',
])
->orderBy('id', 'DESC')
->limit(500)
->get()
->map(fn ($row) => $this->examinationPayload($row))
->values();
return response()->json([
'items' => $items,
'total' => $items->count(),
]);
}
public function initialWorkAccept(Request $request, int $id)
{
$user = $this->requireUser($request);
$row = Periksa::find($id);
abort_if(!$row, 404, 'Data pemeriksaan tidak ditemukan.');
$mediaTanam = $request->input('mediatanam', []);
if (!is_array($mediaTanam)) {
$mediaTanam = [$mediaTanam];
}
$mediaTanam = array_values(array_filter($mediaTanam, fn ($item) => trim((string) $item) !== ''));
if (count($mediaTanam) === 0) {
return response()->json([
'message' => 'Media Tanam Yang di gunakan wajib dipilih.',
], 422);
}
$jenis = trim((string) $request->input('jenis', ''));
$bulan = trim((string) $request->input('bulan', ''));
if ($jenis !== '' && $jenis !== 'F' && $bulan === '') {
return response()->json([
'message' => 'Jika jenis pengobatan bukan Follow Up, Bulan Ke wajib diisi.',
], 422);
}
if ($jenis !== '') {
$this->acceptInitialWorkWithTreatmentCode($row, $user, $jenis, $bulan);
} else {
$this->acceptInitialWork($row, $user);
}
$this->recordInitialWorkMediaUsage($row->fresh(), $mediaTanam, $user);
return response()->json([
'message' => 'Sampel berhasil diterima.',
'item' => $this->examinationPayload($row->fresh()),
]);
}
public function initialWorkReject(Request $request, int $id)
{
$this->requireUser($request);
$updated = Periksa::where('id', $id)->update([
'status' => null,
]);
if (!$updated) {
return response()->json([
'message' => 'Data pemeriksaan tidak ditemukan atau tidak dapat ditolak.',
], 404);
}
return response()->json([
'message' => 'Sampel dikembalikan ke loket.',
]);
}
protected function acceptInitialWork(Periksa $row, User $user): void
{
$row->status = 'Pemeriksaan awal';
$row->tgldraft = Carbon::now();
if (empty($row->mulai)) {
$row->mulai = Carbon::now();
}
$row->excutor = $user->id;
$row->nmppdsmiddle2 = $user->nama;
$row->save();
}
protected function acceptInitialWorkWithTreatmentCode(Periksa $row, User $user, string $jenis, string $bulan): void
{
$nofoto = $row->nofoto.$jenis.$bulan;
Paslab::updateOrCreate(
['rnoreg' => $nofoto],
[
'nama' => $row->nmpasien,
'norm' => $row->noregister,
'rtglast' => $row->daftar,
'alamat' => $row->alamatpasien,
'rjenis' => $row->jkpasien,
'umur' => $row->usia,
'namadok' => $row->ktp,
'ruangan' => $row->asalpasien,
'tes' => $row->reques,
'alat' => 'ALL',
'kd_spesimen' => $row->kd_spesimen,
'nm_spesimen' => $row->nm_spesimen,
'tgllahir' => $row->tgllahirpasien,
'flg_vitek1' => true,
'flg_vitek2' => true,
'flg_bd1' => true,
'flg_bd2' => false,
'flg_gxp1' => true,
'flg_gxp2' => true,
'flg_gxp3' => true,
'flg_vitek3' => true,
]
);
$row->nofoto = $nofoto;
$row->status = 'Pemeriksaan awal';
$row->tgldraft = Carbon::now();
$row->mulai = Carbon::now();
$row->excutor = $user->id;
$row->nmppdsmiddle2 = $user->nama;
$row->save();
}
protected function recordInitialWorkMediaUsage(?Periksa $row, array $mediaTanam, User $user): void
{
if (!$row) {
return;
}
foreach ($mediaTanam as $media) {
$jenis = $this->mediaTanamReportName((string) $media);
if ($jenis === '') {
continue;
}
$today = Carbon::today();
SIMBHPReport::create([
'tanggal' => (int) $today->format('d'),
'bulan' => (int) $today->format('m'),
'tahun' => (int) $today->format('Y'),
'deskripsi' => 'Pemeriksaan Awal Nomor Lab.'.$row->nofoto.' petugas '.$user->nama,
'pemasukan' => null,
'pengeluaran' => 1,
'qty_base' => 1,
'satuan_transaksi' => 'besar',
'jenis' => $jenis,
'keterangan' => '',
'marking' => '',
]);
}
}
protected function mediaTanamReportName(string $media): string
{
return match ($media) {
'Media BAP' => 'PLATE BAP (Blood Agar Plate) MEDIA',
'Media CAP' => 'PLATE CAP (Chocolate Agar Plate) Media',
'Media Mc Conkey' => 'PLATE MC (Mac Conkey Agar Plate) MEDIA',
'Media SDA R1', 'Media SDA R2', 'Media SDA I1', 'Media SDA I2' => 'PLATE SDA (Saboroud Dextrose Agar Plate) MEDIA',
default => '',
};
}
public function examination(Request $request, int $id)
{
$this->requireUser($request);
@@ -6605,8 +6605,59 @@
});
});
$("#btnsendexpertisetopacs").click(function(){
$('#id_iframepreviewexpertise').attr('src', '{{ url("/") }}/hasil/' + document.getElementById('nofoto').value);
$('#modalpreviewsendexpertise').modal('show');
var keterangan = CKEDITOR.instances['keterangan'].getData();
var lsg_pewarnaanlain = CKEDITOR.instances['lsg_pewarnaanlain'].getData();
var viralload = CKEDITOR.instances['viralload'].getData();
var periksa_id = document.getElementById('periksa_id').value;
var template_jenis = document.getElementById('template_jenis').value;
var id_dokter = document.getElementById('dokter').value;
var acc_number = document.getElementById('nofoto').value;
const checkboxMap = {
inlineCheckbox1: 'TBC SO',
inlineCheckbox2: 'TBC RO',
inlineCheckbox3: 'Anak',
inlineCheckbox4: 'HIV',
inlineCheckbox5: 'DM',
inlineCheckbox6: 'Nanah Lendir',
inlineCheckbox7: 'Bercak darah',
inlineCheckbox8: 'Air liur',
inlineCheckbox9: 'Nanah Lendir',
inlineCheckbox10: 'Bercak darah',
inlineCheckbox11: 'Air liur',
};
const checkboxValues = {};
$.each(checkboxMap, function (id, label) {
checkboxValues[id] = $(`#${id}`).is(':checked') ? label : '';
});
var formdata = new FormData($('#kt_form')[0]);
formdata.set('periksa_id', periksa_id);
formdata.set('acc_number', acc_number);
formdata.set('val01', 'Draft');
formdata.set('val10', template_jenis);
formdata.set('keterangan', keterangan);
formdata.set('lsg_pewarnaanlain', lsg_pewarnaanlain);
formdata.set('viralload', viralload);
formdata.set('_token', '{{ csrf_token() }}');
$.each(checkboxValues, (key, val) => formdata.set(key, val));
$.ajax({
url : '{{ route("exexpertisepacs") }}',
data : formdata,
type : 'POST',
contentType : false,
processData : false,
success: function (data) {
$('#id_iframepreviewexpertise').attr('src', '{{ url("/") }}/hasil/' + document.getElementById('nofoto').value);
$('#modalpreviewsendexpertise').modal('show');
},
error: function (xhr, status, error) {
swal({
title : 'Stop',
text : xhr.responseText,
type : 'warning',
})
}
});
});
$("#btnapproveexpertise").click(function(){
var keterangan = CKEDITOR.instances['keterangan'].getData();
+53 -2
View File
@@ -6663,8 +6663,59 @@
});
});
$("#btnsendexpertisetopacs").click(function(){
$('#id_iframepreviewexpertise').attr('src', '{{ url("/") }}/hasil/' + document.getElementById('nofoto').value);
$('#modalpreviewsendexpertise').modal('show');
var keterangan = CKEDITOR.instances['keterangan'].getData();
var lsg_pewarnaanlain = CKEDITOR.instances['lsg_pewarnaanlain'].getData();
var viralload = CKEDITOR.instances['viralload'].getData();
var periksa_id = document.getElementById('periksa_id').value;
var template_jenis = document.getElementById('template_jenis').value;
var id_dokter = document.getElementById('dokter').value;
var acc_number = document.getElementById('nofoto').value;
const checkboxMap = {
inlineCheckbox1: 'TBC SO',
inlineCheckbox2: 'TBC RO',
inlineCheckbox3: 'Anak',
inlineCheckbox4: 'HIV',
inlineCheckbox5: 'DM',
inlineCheckbox6: 'Nanah Lendir',
inlineCheckbox7: 'Bercak darah',
inlineCheckbox8: 'Air liur',
inlineCheckbox9: 'Nanah Lendir',
inlineCheckbox10: 'Bercak darah',
inlineCheckbox11: 'Air liur',
};
const checkboxValues = {};
$.each(checkboxMap, function (id, label) {
checkboxValues[id] = $(`#${id}`).is(':checked') ? label : '';
});
var formdata = new FormData($('#kt_form')[0]);
formdata.set('periksa_id', periksa_id);
formdata.set('acc_number', acc_number);
formdata.set('val01', 'Draft');
formdata.set('val10', template_jenis);
formdata.set('keterangan', keterangan);
formdata.set('lsg_pewarnaanlain', lsg_pewarnaanlain);
formdata.set('viralload', viralload);
formdata.set('_token', '{{ csrf_token() }}');
$.each(checkboxValues, (key, val) => formdata.set(key, val));
$.ajax({
url : '{{ route("exexpertisepacs") }}',
data : formdata,
type : 'POST',
contentType : false,
processData : false,
success: function (data) {
$('#id_iframepreviewexpertise').attr('src', '{{ url("/") }}/hasil/' + document.getElementById('nofoto').value);
$('#modalpreviewsendexpertise').modal('show');
},
error: function (xhr, status, error) {
swal({
title : 'Stop',
text : xhr.responseText,
type : 'warning',
})
}
});
});
$("#btnapproveexpertise").click(function(){
var keterangan = CKEDITOR.instances['keterangan'].getData();
@@ -6812,8 +6812,59 @@
}
});
$("#btnsendexpertisetopacs").click(function(){
$('#id_iframepreviewexpertise').attr('src', '{{ url("/") }}/hasil/' + document.getElementById('nofoto').value);
$('#modalpreviewsendexpertise').modal('show');
var keterangan = CKEDITOR.instances['keterangan'].getData();
var lsg_pewarnaanlain = CKEDITOR.instances['lsg_pewarnaanlain'].getData();
var viralload = CKEDITOR.instances['viralload'].getData();
var periksa_id = document.getElementById('periksa_id').value;
var template_jenis = document.getElementById('template_jenis').value;
var id_dokter = document.getElementById('dokter').value;
var acc_number = document.getElementById('nofoto').value;
const checkboxMap = {
inlineCheckbox1: 'TBC SO',
inlineCheckbox2: 'TBC RO',
inlineCheckbox3: 'Anak',
inlineCheckbox4: 'HIV',
inlineCheckbox5: 'DM',
inlineCheckbox6: 'Nanah Lendir',
inlineCheckbox7: 'Bercak darah',
inlineCheckbox8: 'Air liur',
inlineCheckbox9: 'Nanah Lendir',
inlineCheckbox10: 'Bercak darah',
inlineCheckbox11: 'Air liur',
};
const checkboxValues = {};
$.each(checkboxMap, function (id, label) {
checkboxValues[id] = $(`#${id}`).is(':checked') ? label : '';
});
var formdata = new FormData($('#kt_form')[0]);
formdata.set('periksa_id', periksa_id);
formdata.set('acc_number', acc_number);
formdata.set('val01', 'Draft');
formdata.set('val10', template_jenis);
formdata.set('keterangan', keterangan);
formdata.set('lsg_pewarnaanlain', lsg_pewarnaanlain);
formdata.set('viralload', viralload);
formdata.set('_token', '{{ csrf_token() }}');
$.each(checkboxValues, (key, val) => formdata.set(key, val));
$.ajax({
url : '{{ route("exexpertisepacs") }}',
data : formdata,
type : 'POST',
contentType : false,
processData : false,
success: function (data) {
$('#id_iframepreviewexpertise').attr('src', '{{ url("/") }}/hasil/' + document.getElementById('nofoto').value);
$('#modalpreviewsendexpertise').modal('show');
},
error: function (xhr, status, error) {
swal({
title : 'Stop',
text : xhr.responseText,
type : 'warning',
})
}
});
});
$("#btnapproveexpertise").click(function(){
var keterangan = CKEDITOR.instances['keterangan'].getData();
+6
View File
@@ -23,7 +23,13 @@ Route::prefix('mobile')->group(function () {
Route::get('ping', [MobileApiController::class, 'ping']);
Route::post('login', [MobileApiController::class, 'login']);
Route::get('dashboard', [MobileApiController::class, 'dashboard']);
Route::get('critical-values', [MobileApiController::class, 'criticalValueNotifications']);
Route::post('critical-values/{id}/mark-read', [MobileApiController::class, 'criticalValueNotificationMarkRead']);
Route::get('early-warning', [MobileApiController::class, 'earlyWarning']);
Route::get('examinations/search', [MobileApiController::class, 'searchExamination']);
Route::get('initial-work/samples', [MobileApiController::class, 'initialWorkSamples']);
Route::post('initial-work/samples/{id}/accept', [MobileApiController::class, 'initialWorkAccept']);
Route::post('initial-work/samples/{id}/reject', [MobileApiController::class, 'initialWorkReject']);
Route::get('books/{master}/examinations', [MobileApiController::class, 'examinations']);
Route::get('examinations/{id}', [MobileApiController::class, 'examination']);
Route::get('examinations/{id}/expertise', [MobileApiController::class, 'expertise']);
@@ -1,5 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<application
android:label="MyLIS"
android:name="${applicationName}"
+2
View File
@@ -26,6 +26,8 @@
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>MyLIS memakai kamera untuk scan barcode sampel.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
+8 -2
View File
@@ -5,6 +5,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:http/io_client.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -13,6 +14,12 @@ part '../services/api_client.dart';
part '../services/session_store.dart';
part '../screens/login/login_screen.dart';
part '../screens/dashboard/dashboard_screen.dart';
part '../screens/dashboard/critical_values_screen.dart';
part '../screens/dashboard/barcode_scanner_screen.dart';
part '../screens/dashboard/ews_screen.dart';
part '../screens/dashboard/sample_groups_screen.dart';
part '../screens/dashboard/specimen_register_screen.dart';
part '../screens/dashboard/initial_work_screen.dart';
part '../screens/examinations/examination_list_screen.dart';
part '../screens/examinations/examination_detail_screen.dart';
part '../screens/expertise/expertise_screen.dart';
@@ -29,8 +36,7 @@ part '../data/expertise_data.dart';
part '../widgets/common_widgets.dart';
const String kAppShortName = 'MyLIS';
const String kAppLongName =
'Mikrobiology Laboratory Information System';
const String kAppLongName = 'Mikrobiology Laboratory Information System';
const String kHospitalName = 'Rumah Sakit Umum Daerah Dr. Saiful Anwar';
const String kAppLogoAsset = 'assets/branding/logo.png';
const String kHospitalLogoAsset = 'assets/branding/logo_rssa.png';
@@ -0,0 +1,169 @@
part of '../../app/app.dart';
class BarcodeScannerScreen extends StatefulWidget {
const BarcodeScannerScreen({super.key});
@override
State<BarcodeScannerScreen> createState() => _BarcodeScannerScreenState();
}
class _BarcodeScannerScreenState extends State<BarcodeScannerScreen> {
final MobileScannerController _controller = MobileScannerController();
final _manualCode = TextEditingController();
bool _handled = false;
@override
void dispose() {
_manualCode.dispose();
_controller.dispose();
super.dispose();
}
void _handleDetect(BarcodeCapture capture) {
if (_handled) {
return;
}
final value = capture.barcodes
.map((barcode) => barcode.rawValue?.trim() ?? '')
.firstWhere((text) => text.isNotEmpty, orElse: () => '');
if (value.isEmpty) {
return;
}
_handled = true;
Navigator.of(context).pop(value);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Scan Barcode Sampel'),
actions: [
IconButton(
onPressed: () => _controller.toggleTorch(),
icon: const Icon(Icons.flashlight_on_outlined),
tooltip: 'Lampu',
),
],
),
body: Stack(
children: [
MobileScanner(
controller: _controller,
onDetect: _handleDetect,
errorBuilder: (context, error) => _ScannerFallback(
controller: _manualCode,
message:
'Scanner belum tersedia. Tutup aplikasi lalu jalankan ulang full rebuild, atau masukkan barcode manual.',
onSubmit: _submitManualCode,
),
placeholderBuilder: (context) =>
const Center(child: CircularProgressIndicator()),
),
Center(
child: Container(
width: 260,
height: 180,
decoration: BoxDecoration(
border: Border.all(color: Colors.white, width: 3),
borderRadius: BorderRadius.circular(16),
),
),
),
Positioned(
left: 18,
right: 18,
bottom: 24,
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.62),
borderRadius: BorderRadius.circular(12),
),
child: const Text(
'Arahkan kamera ke barcode nomor sampel.',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
);
}
void _submitManualCode() {
final code = _manualCode.text.trim();
if (code.isEmpty) {
return;
}
Navigator.of(context).pop(code);
}
}
class _ScannerFallback extends StatelessWidget {
const _ScannerFallback({
required this.controller,
required this.message,
required this.onSubmit,
});
final TextEditingController controller;
final String message;
final VoidCallback onSubmit;
@override
Widget build(BuildContext context) {
return Container(
color: const Color(0xFFF7FAF9),
padding: const EdgeInsets.all(18),
child: Center(
child: Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(
Icons.qr_code_scanner_rounded,
size: 42,
color: Color(0xFF0F766E),
),
const SizedBox(height: 10),
Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.black54),
),
const SizedBox(height: 14),
TextField(
controller: controller,
autofocus: true,
textInputAction: TextInputAction.search,
onSubmitted: (_) => onSubmit(),
decoration: const InputDecoration(
labelText: 'Barcode / No. Sampel',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: onSubmit,
icon: const Icon(Icons.manage_search_rounded),
label: const Text('Gunakan Kode'),
),
],
),
),
),
),
);
}
}
@@ -0,0 +1,567 @@
part of '../../app/app.dart';
class CriticalValuesScreen extends StatefulWidget {
const CriticalValuesScreen({
super.key,
required this.token,
required this.baseUrl,
});
final String token;
final String baseUrl;
@override
State<CriticalValuesScreen> createState() => _CriticalValuesScreenState();
}
class _CriticalValuesScreenState extends State<CriticalValuesScreen> {
late final ApiClient _api;
late Future<Map<String, dynamic>> _future;
@override
void initState() {
super.initState();
_api = ApiClient(baseUrl: widget.baseUrl, token: widget.token);
_future = _load();
}
Future<Map<String, dynamic>> _load() {
return _api.get('api/mobile/critical-values');
}
void _reload() {
setState(() {
_future = _load();
});
}
Future<void> _markRead(Map<String, dynamic> item) async {
final method = ValueNotifier<String>('');
final recipient = TextEditingController();
final reason = TextEditingController();
await showModalBottomSheet<void>(
context: context,
showDragHandle: true,
isScrollControlled: true,
builder: (context) => SafeArea(
child: Padding(
padding: EdgeInsets.fromLTRB(
18,
0,
18,
MediaQuery.viewInsetsOf(context).bottom + 18,
),
child: SingleChildScrollView(
child: ValueListenableBuilder<String>(
valueListenable: method,
builder: (context, value, _) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Tindak Lanjut Nilai Kritis',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 6),
Text(
'${item['nofoto'] ?? '-'}${item['nmpasien'] ?? '-'}',
style: const TextStyle(color: Colors.black54),
),
const SizedBox(height: 14),
DropdownButtonFormField<String>(
initialValue: value.isEmpty ? null : value,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Melalui',
helperText: 'Kosongkan jika belum dilaporkan',
border: OutlineInputBorder(),
),
items: const [
DropdownMenuItem(value: 'Telpon', child: Text('Telpon')),
DropdownMenuItem(
value: 'Whatshap',
child: Text('Whatshap'),
),
DropdownMenuItem(value: 'Email', child: Text('Email')),
DropdownMenuItem(
value: 'Ketemu di Jalan',
child: Text('Ketemu di Jalan'),
),
],
onChanged: (next) => method.value = next ?? '',
),
const SizedBox(height: 12),
TextField(
controller: recipient,
decoration: const InputDecoration(
labelText: 'Penerima Laporan',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: reason,
minLines: 2,
maxLines: 4,
decoration: const InputDecoration(
labelText: 'Alasan belum dilaporkan/Pasien meninggal',
hintText: 'Meninggal / Pindah Rumah Sakit / dll',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 14),
FilledButton.icon(
onPressed: () async {
try {
await _api.post(
'api/mobile/critical-values/${item['id']}/mark-read',
{
'caratindaklanjut': method.value,
'penerima_laporan': recipient.text.trim(),
'alasan_belum_laporkan': reason.text.trim(),
},
);
if (!context.mounted) return;
Navigator.of(context).pop();
_reload();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Sample nilai kritis sudah ditindaklanjuti.',
),
),
);
} on ApiException catch (error) {
if (!context.mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.message)));
}
},
icon: const Icon(Icons.done_all_outlined),
label: const Text('Mark as Read'),
),
],
),
),
),
),
),
);
recipient.dispose();
reason.dispose();
method.dispose();
}
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
child: Scaffold(
body: FutureBuilder<Map<String, dynamic>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const LoadingScreen();
}
if (snapshot.hasError) {
return ErrorView(
message: _message(snapshot.error),
onRetry: _reload,
);
}
final data = snapshot.data!;
final pending = asList(data['pending_items']);
final followed = asList(data['followed_items']);
return SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(18, 18, 18, 0),
child: _CriticalValuesHeader(
pendingCount: pending.length,
onRefresh: _reload,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(18, 22, 18, 0),
child: _CriticalValuesSummary(
pendingCount: pending.length,
followedCount: followed.length,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(18, 18, 18, 0),
child: Container(
height: 48,
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(12),
),
child: const TabBar(
indicatorSize: TabBarIndicatorSize.tab,
dividerColor: Colors.transparent,
indicator: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(Radius.circular(9)),
),
labelColor: Color(0xFF0B7BFF),
unselectedLabelColor: Color(0xFF47517A),
labelStyle: TextStyle(fontWeight: FontWeight.w900),
tabs: [
Tab(text: 'Belum Dilaporkan'),
Tab(text: 'Riwayat'),
],
),
),
),
Expanded(
child: TabBarView(
children: [
CriticalValueList(
items: pending,
emptyText:
'Tidak ada sample nilai kritis yang perlu dilaporkan.',
onMarkRead: _markRead,
),
CriticalValueList(
items: followed,
emptyText: 'Belum ada riwayat tindak lanjut.',
),
],
),
),
],
),
);
},
),
),
);
}
}
class _CriticalValuesHeader extends StatelessWidget {
const _CriticalValuesHeader({
required this.pendingCount,
required this.onRefresh,
});
final int pendingCount;
final VoidCallback onRefresh;
@override
Widget build(BuildContext context) {
return Row(
children: [
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.arrow_back_ios_new_rounded),
color: const Color(0xFF080D3D),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
'Nilai Kritis',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(
color: const Color(0xFF080D3D),
fontWeight: FontWeight.w900,
),
),
),
const SizedBox(width: 8),
BadgeLabel(
text: '$pendingCount',
color: const Color(0xFFFF1D25),
),
],
),
const SizedBox(height: 4),
Text(
'Notifikasi & Tindak Lanjut',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: const Color(0xFF47517A),
fontWeight: FontWeight.w700,
),
),
],
),
),
IconButton(
onPressed: onRefresh,
icon: const Icon(Icons.refresh_rounded),
color: const Color(0xFF063B60),
tooltip: 'Refresh',
),
],
);
}
}
class _CriticalValuesSummary extends StatelessWidget {
const _CriticalValuesSummary({
required this.pendingCount,
required this.followedCount,
});
final int pendingCount;
final int followedCount;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: const Color(0xFFE1E8F5)),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: const Color(0xFF2563EB).withValues(alpha: 0.05),
blurRadius: 18,
offset: const Offset(0, 10),
),
],
),
child: Row(
children: [
Expanded(
child: _CriticalSummaryItem(
label: 'Belum Dilaporkan',
value: pendingCount,
color: const Color(0xFFFF1D25),
),
),
Container(width: 1, height: 48, color: const Color(0xFFE1E8F5)),
Expanded(
child: _CriticalSummaryItem(
label: 'Riwayat',
value: followedCount,
color: const Color(0xFF0B7BFF),
),
),
],
),
);
}
}
class _CriticalSummaryItem extends StatelessWidget {
const _CriticalSummaryItem({
required this.label,
required this.value,
required this.color,
});
final String label;
final int value;
final Color color;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text(
'$value',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: color,
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 4),
Text(
label,
textAlign: TextAlign.center,
style: const TextStyle(
color: Color(0xFF47517A),
fontWeight: FontWeight.w800,
),
),
],
);
}
}
class CriticalValueList extends StatelessWidget {
const CriticalValueList({
super.key,
required this.items,
required this.emptyText,
this.onMarkRead,
});
final List<dynamic> items;
final String emptyText;
final ValueChanged<Map<String, dynamic>>? onMarkRead;
@override
Widget build(BuildContext context) {
if (items.isEmpty) {
return EmptyPanel(text: emptyText);
}
return RefreshIndicator(
onRefresh: () async {},
child: ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 24),
itemCount: items.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (context, index) {
final item = asMap(items[index]);
return CriticalValueCard(
item: item,
index: index,
onMarkRead: onMarkRead == null ? null : () => onMarkRead!(item),
);
},
),
);
}
}
class CriticalValueCard extends StatelessWidget {
const CriticalValueCard({
super.key,
required this.item,
required this.index,
this.onMarkRead,
});
final Map<String, dynamic> item;
final int index;
final VoidCallback? onMarkRead;
@override
Widget build(BuildContext context) {
final followed = item['followed_up_at']?.toString().isNotEmpty == true;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: Color(0xFFE1E8F5)),
),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
backgroundColor: const Color(
0xFFFF1D25,
).withValues(alpha: 0.12),
child: Text(
'${index + 1}',
style: const TextStyle(
color: Color(0xFFFF1D25),
fontWeight: FontWeight.w900,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item['nmpasien']?.toString() ?? '-',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w900),
),
const SizedBox(height: 3),
Text(
'${item['nofoto'] ?? '-'} / RM ${item['noregister'] ?? '-'}',
style: const TextStyle(color: Colors.black54),
),
],
),
),
StatusPill(
status: followed
? item['tat_status_label']?.toString() ?? '-'
: 'Belum Dilaporkan',
),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 10,
runSpacing: 8,
children: [
BadgeLabel(
text: item['asalpasien']?.toString() ?? '-',
color: const Color(0xFF0F766E),
),
BadgeLabel(
text: item['nm_spesimen']?.toString() ?? '-',
color: const Color(0xFF2563EB),
),
],
),
const SizedBox(height: 12),
DetailRow(
label: 'Waktu Set Nilai Kritis',
value: _timeWithUser(
item['critical_set_at'],
item['critical_set_by_name'],
),
),
if (followed) ...[
DetailRow(
label: 'Waktu Ditindaklanjuti',
value: _timeWithUser(
item['followed_up_at'],
item['followed_up_by_name'],
),
),
DetailRow(
label: 'Selisih Waktu',
value: item['tat_duration_label'],
),
DetailRow(label: 'Aksi', value: _followUpLabel(item)),
],
if (onMarkRead != null)
Align(
alignment: Alignment.centerRight,
child: FilledButton.icon(
onPressed: onMarkRead,
icon: const Icon(Icons.done_all_outlined),
label: const Text('Mark as Read'),
),
),
],
),
),
);
}
String _timeWithUser(Object? time, Object? user) {
final first = time?.toString().isNotEmpty == true ? time.toString() : '-';
final second = user?.toString().isNotEmpty == true ? '\noleh $user' : '';
return '$first$second';
}
String _followUpLabel(Map<String, dynamic> item) {
final status = item['follow_up_status']?.toString() ?? '';
if (status == 'reported') {
return 'Dilaporkan\nMelalui: ${item['follow_up_method'] ?? '-'}\nPenerima: ${item['follow_up_recipient'] ?? '-'}';
}
if (status == 'not_reported') {
return 'Belum Dilaporkan\nAlasan: ${item['follow_up_reason'] ?? '-'}';
}
return '-';
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,44 @@
part of '../../app/app.dart';
class EwsScreen extends StatelessWidget {
const EwsScreen({
super.key,
required this.warnings,
required this.token,
required this.baseUrl,
});
final List<dynamic> warnings;
final String token;
final String baseUrl;
@override
Widget build(BuildContext context) {
final total = warnings.fold<int>(
0,
(sum, item) => sum + (asMap(item)['total'] as num? ?? 0).toInt(),
);
return Scaffold(
appBar: AppBar(title: const Text('Early Warning Sistem')),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
children: [
SectionTitle(
title: 'Early Warning Sistem',
actionLabel: '$total kasus',
),
if (warnings.isEmpty)
const EmptyPanel(text: 'Tidak ada data Early Warning.')
else
...warnings.map(
(group) => EarlyWarningGroupCard(
group: asMap(group),
token: token,
baseUrl: baseUrl,
),
),
],
),
);
}
}
@@ -0,0 +1,878 @@
part of '../../app/app.dart';
class InitialWorkScreen extends StatefulWidget {
const InitialWorkScreen({
super.key,
required this.token,
required this.baseUrl,
required this.user,
required this.notificationCount,
});
final String token;
final String baseUrl;
final Map<String, dynamic> user;
final int notificationCount;
@override
State<InitialWorkScreen> createState() => _InitialWorkScreenState();
}
class _InitialWorkScreenState extends State<InitialWorkScreen> {
late final ApiClient _api;
late Future<Map<String, dynamic>> _future;
final _search = TextEditingController();
static const int _pageSize = 10;
int _page = 0;
@override
void initState() {
super.initState();
_api = ApiClient(baseUrl: widget.baseUrl, token: widget.token);
_future = _load();
_search.addListener(_onSearchChanged);
}
@override
void dispose() {
_search.removeListener(_onSearchChanged);
_search.dispose();
super.dispose();
}
Future<Map<String, dynamic>> _load() {
return _api.get('api/mobile/initial-work/samples');
}
void _reload() {
setState(() {
_future = _load();
});
}
void _onSearchChanged() {
if (!mounted) return;
setState(() {
_page = 0;
});
}
Future<void> _openCriticalValues() async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) =>
CriticalValuesScreen(token: widget.token, baseUrl: widget.baseUrl),
),
);
}
void _showProfile() {
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (context) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
CircleAvatar(
radius: 28,
backgroundColor: const Color(0xFF0F766E),
child: Text(
_initials(widget.user['nama']?.toString() ?? 'SP'),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w900,
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.user['nama']?.toString() ?? '-',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w900),
),
const SizedBox(height: 2),
Text(
'${widget.user['username'] ?? '-'} - ${widget.user['previlage'] ?? '-'}',
style: const TextStyle(color: Colors.black54),
),
],
),
),
],
),
const SizedBox(height: 18),
FilledButton.icon(
onPressed: () async {
await SessionStore.clear();
if (!context.mounted) return;
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const LoginScreen()),
(_) => false,
);
},
icon: const Icon(Icons.logout),
label: const Text('Logout'),
),
],
),
),
),
);
}
Future<void> _showActions(Map<String, dynamic> item) async {
await showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (context) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(18, 4, 18, 18),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
item['nofoto']?.toString() ?? '-',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900),
),
const SizedBox(height: 4),
Text(
'${item['nmpasien'] ?? '-'} / RM ${item['noregister'] ?? '-'}',
style: const TextStyle(color: Color(0xFF47517A)),
),
const SizedBox(height: 18),
FilledButton.icon(
onPressed: () {
Navigator.of(context).pop();
_showAcceptDialog(item);
},
icon: const Icon(Icons.check_circle_outline_rounded),
label: const Text('Terima'),
),
const SizedBox(height: 10),
OutlinedButton.icon(
onPressed: () {
Navigator.of(context).pop();
_confirmReject(item);
},
icon: const Icon(Icons.block_rounded),
label: const Text('Tolak'),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFFDC2626),
),
),
],
),
),
),
);
}
Future<void> _showAcceptDialog(Map<String, dynamic> item) async {
await showDialog<void>(
context: context,
builder: (context) => _AcceptInitialWorkDialog(
item: item,
onSubmit: (payload) => _accept(item, payload),
),
);
}
Future<bool> _accept(
Map<String, dynamic> item,
Map<String, dynamic> payload,
) async {
final id = (item['id'] as num?)?.toInt();
if (id == null) return false;
try {
final response = await _api.post(
'api/mobile/initial-work/samples/$id/accept',
payload,
);
if (!mounted) return false;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(response['message']?.toString() ?? 'Sampel diterima.'),
),
);
_reload();
return true;
} on ApiException catch (error) {
if (!mounted) return false;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.message)));
return false;
}
}
Future<void> _confirmReject(Map<String, dynamic> item) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Tolak Sampel'),
content: Text('Kembalikan ${item['nofoto'] ?? 'sampel ini'} ke loket?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Batal'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFDC2626),
),
child: const Text('Tolak'),
),
],
),
);
if (confirmed != true) return;
await _reject(item);
}
Future<void> _reject(Map<String, dynamic> item) async {
final id = (item['id'] as num?)?.toInt();
if (id == null) return;
try {
final response = await _api.post(
'api/mobile/initial-work/samples/$id/reject',
const {},
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(response['message']?.toString() ?? 'Sampel ditolak.'),
),
);
_reload();
} on ApiException catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.message)));
}
}
List<Map<String, dynamic>> _filtered(List<dynamic> items) {
final query = _search.text.trim().toLowerCase();
final mapped = items.map(asMap).toList();
if (query.isEmpty) {
return mapped;
}
return mapped.where((item) {
final haystack = [
item['nofoto'],
item['noregister'],
item['nmpasien'],
item['reques'],
item['status'],
item['nm_spesimen'],
].whereType<Object>().join(' ').toLowerCase();
return haystack.contains(query);
}).toList();
}
String _initials(String name) {
final parts = name
.trim()
.split(RegExp(r'\s+'))
.where((part) => part.isNotEmpty)
.toList();
if (parts.isEmpty) {
return 'SP';
}
return parts.take(2).map((part) => part[0].toUpperCase()).join();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: FutureBuilder<Map<String, dynamic>>(
future: _future,
builder: (context, snapshot) {
final items = _filtered(asList(snapshot.data?['items']));
final maxPage = items.isEmpty ? 0 : (items.length - 1) ~/ _pageSize;
final page = _page.clamp(0, maxPage);
final start = items.isEmpty ? 0 : page * _pageSize;
final end = items.isEmpty
? 0
: (start + _pageSize).clamp(0, items.length);
final visible = items.sublist(start, end);
return ListView(
padding: const EdgeInsets.fromLTRB(18, 18, 18, 28),
children: [
_InitialWorkHeader(
total: items.length,
user: widget.user,
notificationCount: widget.notificationCount,
onNotifications: _openCriticalValues,
onProfile: _showProfile,
),
const SizedBox(height: 22),
TextField(
controller: _search,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search),
hintText: 'Cari No. Sampel, No. RM, atau Nama Pasien',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
const SizedBox(height: 26),
Row(
children: [
Expanded(
child: Text(
'Sampel dengan status Penerimaan Sampel',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFF080D3D),
),
),
),
BadgeLabel(
text: '${items.length} sampel',
color: const Color(0xFF0B7BFF),
),
],
),
const SizedBox(height: 14),
if (snapshot.connectionState != ConnectionState.done)
const SizedBox(
height: 180,
child: Center(child: CircularProgressIndicator()),
)
else if (snapshot.hasError)
ErrorView(message: _message(snapshot.error), onRetry: _reload)
else if (items.isEmpty)
const EmptyPanel(text: 'Tidak ada sampel penerimaan.')
else ...[
...visible.map(
(item) => _InitialWorkCard(
item: item,
onTap: () => _showActions(item),
),
),
const SizedBox(height: 16),
_InitialWorkPager(
start: start + 1,
end: end,
total: items.length,
canPrevious: page > 0,
canNext: page < maxPage,
onPrevious: () => setState(() => _page = page - 1),
onNext: () => setState(() => _page = page + 1),
),
],
],
);
},
),
),
);
}
}
class _InitialWorkHeader extends StatelessWidget {
const _InitialWorkHeader({
required this.total,
required this.user,
required this.notificationCount,
required this.onNotifications,
required this.onProfile,
});
final int total;
final Map<String, dynamic> user;
final int notificationCount;
final VoidCallback onNotifications;
final VoidCallback onProfile;
@override
Widget build(BuildContext context) {
return Row(
children: [
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.arrow_back_ios_new_rounded),
color: const Color(0xFF080D3D),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
'Pengerjaan Awal',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFF080D3D),
),
),
),
const SizedBox(width: 8),
BadgeLabel(text: '$total', color: const Color(0xFF0B7BFF)),
],
),
const SizedBox(height: 4),
Text(
'Penerimaan Sampel',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: const Color(0xFF47517A),
fontWeight: FontWeight.w700,
),
),
],
),
),
Stack(
clipBehavior: Clip.none,
children: [
IconButton(
onPressed: onNotifications,
icon: const Icon(Icons.notifications_none_rounded, size: 30),
color: const Color(0xFF063B60),
tooltip: 'Notifikasi nilai kritis',
),
if (notificationCount > 0)
Positioned(
top: 6,
right: 6,
child: Container(
padding: const EdgeInsets.all(5),
constraints: const BoxConstraints(minWidth: 20),
decoration: const BoxDecoration(
color: Color(0xFFFF1D25),
shape: BoxShape.circle,
),
child: Text(
notificationCount > 99 ? '99+' : '$notificationCount',
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w800,
),
),
),
),
],
),
const SizedBox(width: 10),
InkWell(
borderRadius: BorderRadius.circular(28),
onTap: onProfile,
child: CircleAvatar(
radius: 27,
backgroundColor: const Color(0xFF12BFA5),
child: Text(
_initials(user['nama']?.toString() ?? 'SP'),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w900,
),
),
),
),
],
);
}
String _initials(String name) {
final parts = name
.trim()
.split(RegExp(r'\s+'))
.where((part) => part.isNotEmpty)
.toList();
if (parts.isEmpty) {
return 'SP';
}
return parts.take(2).map((part) => part[0].toUpperCase()).join();
}
}
class _InitialWorkCard extends StatelessWidget {
const _InitialWorkCard({required this.item, required this.onTap});
final Map<String, dynamic> item;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final dateTime = _splitDateTime(item['daftar']?.toString() ?? '');
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: const BorderSide(color: Color(0xFFE1E8F5)),
),
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item['nofoto']?.toString() ?? '-',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: const Color(0xFF080D3D),
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 6),
BadgeLabel(
text: item['status']?.toString() ?? 'Penerimaan Sampel',
color: const Color(0xFF0B7BFF),
),
const SizedBox(height: 12),
_CardMeta(text: dateTime.$1),
],
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'No. RM',
style: TextStyle(
color: Color(0xFF47517A),
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 8),
Text(
item['noregister']?.toString() ?? '-',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFF080D3D),
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 14),
const Text(
'Nama',
style: TextStyle(
color: Color(0xFF47517A),
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 8),
Text(
item['nmpasien']?.toString() ?? '-',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFF080D3D),
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 12),
_CardMeta(text: dateTime.$2),
],
),
),
],
),
),
),
);
}
(String, String) _splitDateTime(String value) {
if (value.isEmpty) {
return ('-', '-');
}
final normalized = value.replaceFirst('T', ' ');
final parts = normalized.split(RegExp(r'\s+'));
final date = parts.isNotEmpty ? parts.first : '-';
final time = parts.length > 1 ? parts[1].split('.').first : '-';
return (date, time);
}
}
class _CardMeta extends StatelessWidget {
const _CardMeta({required this.text});
final String text;
@override
Widget build(BuildContext context) {
return Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFF47517A),
fontWeight: FontWeight.w800,
fontSize: 13,
),
);
}
}
class _InitialWorkPager extends StatelessWidget {
const _InitialWorkPager({
required this.start,
required this.end,
required this.total,
required this.canPrevious,
required this.canNext,
required this.onPrevious,
required this.onNext,
});
final int start;
final int end;
final int total;
final bool canPrevious;
final bool canNext;
final VoidCallback onPrevious;
final VoidCallback onNext;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Text(
'$start-$end dari $total sampel',
style: const TextStyle(
color: Color(0xFF47517A),
fontWeight: FontWeight.w800,
),
),
),
const Text(
'Tampilkan',
style: TextStyle(
color: Color(0xFF47517A),
fontWeight: FontWeight.w800,
),
),
const SizedBox(width: 10),
BadgeLabel(text: '10', color: const Color(0xFF667095)),
const SizedBox(width: 8),
IconButton.filledTonal(
onPressed: canPrevious ? onPrevious : null,
icon: const Icon(Icons.chevron_left_rounded),
),
const SizedBox(width: 8),
IconButton.filledTonal(
onPressed: canNext ? onNext : null,
icon: const Icon(Icons.chevron_right_rounded),
),
],
);
}
}
class _AcceptInitialWorkDialog extends StatefulWidget {
const _AcceptInitialWorkDialog({required this.item, required this.onSubmit});
final Map<String, dynamic> item;
final Future<bool> Function(Map<String, dynamic> payload) onSubmit;
@override
State<_AcceptInitialWorkDialog> createState() =>
_AcceptInitialWorkDialogState();
}
class _AcceptInitialWorkDialogState extends State<_AcceptInitialWorkDialog> {
static const _mediaOptions = [
'-',
'Media BAP',
'Media CAP',
'Media Mc Conkey',
'Media SDA R1',
'Media SDA R2',
'Media SDA I1',
'Media SDA I2',
];
final Set<String> _selectedMedia = {};
String _jenis = '';
String _bulan = '';
bool _saving = false;
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Terima Sampel'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.item['nofoto']?.toString() ?? '-',
style: const TextStyle(fontWeight: FontWeight.w900),
),
const SizedBox(height: 4),
Text(widget.item['nmpasien']?.toString() ?? '-'),
const SizedBox(height: 18),
Text(
'Media Tanam Yang digunakan',
style: Theme.of(
context,
).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w900),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: _mediaOptions.map((media) {
final selected = _selectedMedia.contains(media);
return FilterChip(
label: Text(media == '-' ? 'Tidak Menggunakan Media' : media),
selected: selected,
onSelected: (_) => setState(() {
if (selected) {
_selectedMedia.remove(media);
} else {
_selectedMedia.add(media);
}
}),
);
}).toList(),
),
const SizedBox(height: 18),
Text(
'Khusus Yang Akan dikirim ke BD MGIT',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: const Color(0xFF667095),
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 10),
DropdownButtonFormField<String>(
initialValue: _jenis,
decoration: const InputDecoration(
labelText: 'Jenis Pengobatan Terduga/Pasien TBC',
border: OutlineInputBorder(),
),
items: const [
DropdownMenuItem(value: '', child: Text('Pilih Salah Satu')),
DropdownMenuItem(value: 'F', child: Text('Follow Up')),
DropdownMenuItem(value: 'K', child: Text('Kontrol Bulan Ke')),
DropdownMenuItem(
value: 'P',
child: Text('Pasca Pengobatan Bulan Ke'),
),
],
onChanged: (value) => setState(() {
_jenis = value ?? '';
if (_jenis == 'F' || _jenis.isEmpty) {
_bulan = '';
}
}),
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: _bulan,
decoration: const InputDecoration(
labelText: 'Bulan Ke',
border: OutlineInputBorder(),
),
items: [
const DropdownMenuItem(
value: '',
child: Text('Pilih Salah Satu'),
),
...List.generate(
24,
(index) => DropdownMenuItem(
value: '${index + 1}',
child: Text('Bulan Ke - ${index + 1}'),
),
),
],
onChanged: _jenis == 'K' || _jenis == 'P'
? (value) => setState(() => _bulan = value ?? '')
: null,
),
],
),
),
actions: [
TextButton(
onPressed: _saving ? null : () => Navigator.of(context).pop(),
child: const Text('Batal'),
),
FilledButton(
onPressed: _saving ? null : _submit,
child: _saving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Terima'),
),
],
);
}
Future<void> _submit() async {
if (_selectedMedia.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Media Tanam Yang digunakan wajib dipilih.'),
),
);
return;
}
if ((_jenis == 'K' || _jenis == 'P') && _bulan.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Jika bukan Follow Up, Bulan Ke wajib diisi.'),
),
);
return;
}
setState(() => _saving = true);
final saved = await widget.onSubmit({
'mediatanam': _selectedMedia.toList(),
'jenis': _jenis,
'bulan': _bulan,
});
if (!mounted) return;
if (saved) {
Navigator.of(context).pop();
} else {
setState(() => _saving = false);
}
}
}
@@ -0,0 +1,421 @@
part of '../../app/app.dart';
class _DashboardSubHeader extends StatelessWidget {
const _DashboardSubHeader({
required this.title,
required this.subtitle,
required this.badgeText,
required this.user,
required this.notificationCount,
required this.onNotifications,
required this.onProfile,
});
final String title;
final String subtitle;
final String badgeText;
final Map<String, dynamic> user;
final int notificationCount;
final VoidCallback onNotifications;
final VoidCallback onProfile;
@override
Widget build(BuildContext context) {
return Row(
children: [
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.arrow_back_ios_new_rounded),
color: const Color(0xFF080D3D),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(
color: const Color(0xFF080D3D),
fontWeight: FontWeight.w900,
),
),
),
const SizedBox(width: 8),
BadgeLabel(text: badgeText, color: const Color(0xFF0B7BFF)),
],
),
const SizedBox(height: 4),
Text(
subtitle,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: const Color(0xFF47517A),
fontWeight: FontWeight.w700,
),
),
],
),
),
Stack(
clipBehavior: Clip.none,
children: [
IconButton(
onPressed: onNotifications,
icon: const Icon(Icons.notifications_none_rounded, size: 30),
color: const Color(0xFF063B60),
tooltip: 'Notifikasi nilai kritis',
),
if (notificationCount > 0)
Positioned(
top: 6,
right: 6,
child: Container(
padding: const EdgeInsets.all(5),
constraints: const BoxConstraints(minWidth: 20),
decoration: const BoxDecoration(
color: Color(0xFFFF1D25),
shape: BoxShape.circle,
),
child: Text(
notificationCount > 99 ? '99+' : '$notificationCount',
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w800,
),
),
),
),
],
),
const SizedBox(width: 10),
InkWell(
borderRadius: BorderRadius.circular(28),
onTap: onProfile,
child: CircleAvatar(
radius: 27,
backgroundColor: const Color(0xFF12BFA5),
child: Text(
_initials(user['nama']?.toString() ?? 'SP'),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w900,
),
),
),
),
],
);
}
String _initials(String name) {
final parts = name
.trim()
.split(RegExp(r'\s+'))
.where((part) => part.isNotEmpty)
.toList();
if (parts.isEmpty) {
return 'SP';
}
return parts.take(2).map((part) => part[0].toUpperCase()).join();
}
}
class SampleGroupsScreen extends StatelessWidget {
const SampleGroupsScreen({
super.key,
required this.books,
required this.token,
required this.baseUrl,
required this.user,
required this.notificationCount,
});
final List<dynamic> books;
final String token;
final String baseUrl;
final Map<String, dynamic> user;
final int notificationCount;
@override
Widget build(BuildContext context) {
final total = books.fold<int>(
0,
(sum, item) => sum + (asMap(item)['total'] as num? ?? 0).toInt(),
);
return Scaffold(
body: ListView(
padding: const EdgeInsets.fromLTRB(18, 18, 18, 24),
children: [
_DashboardSubHeader(
title: 'Detail Sampel',
subtitle: 'Catatan & Register',
badgeText: '$total',
user: user,
notificationCount: notificationCount,
onNotifications: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) =>
CriticalValuesScreen(token: token, baseUrl: baseUrl),
),
);
},
onProfile: () => _showProfile(context, user),
),
const SizedBox(height: 28),
_SampleGroupsSummary(total: total, count: books.length),
const SizedBox(height: 18),
Text(
'Jenis Spesimen',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFF080D3D),
),
),
const SizedBox(height: 12),
if (books.isEmpty)
const EmptyPanel(text: 'Tidak ada kelompok pemeriksaan.')
else
...books.map(
(book) => _SampleGroupCard(
book: asMap(book),
onTap: () {
final mapped = asMap(book);
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => SpecimenRegisterScreen(
token: token,
baseUrl: baseUrl,
master: mapped['master']?.toString() ?? 'buku0',
title: mapped['label']?.toString() ?? 'Spesimen',
total: (mapped['total'] as num? ?? 0).toInt(),
user: user,
notificationCount: notificationCount,
),
),
);
},
),
),
],
),
);
}
void _showProfile(BuildContext context, Map<String, dynamic> user) {
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (context) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
CircleAvatar(
radius: 28,
backgroundColor: const Color(0xFF0F766E),
child: Text(
_initials(user['nama']?.toString() ?? 'SP'),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w900,
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
user['nama']?.toString() ?? '-',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w900),
),
const SizedBox(height: 2),
Text(
'${user['username'] ?? '-'} - ${user['previlage'] ?? '-'}',
style: const TextStyle(color: Colors.black54),
),
],
),
),
],
),
const SizedBox(height: 18),
FilledButton.icon(
onPressed: () async {
await SessionStore.clear();
if (!context.mounted) return;
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const LoginScreen()),
(_) => false,
);
},
icon: const Icon(Icons.logout),
label: const Text('Logout'),
),
],
),
),
),
);
}
String _initials(String name) {
final parts = name
.trim()
.split(RegExp(r'\s+'))
.where((part) => part.isNotEmpty)
.toList();
if (parts.isEmpty) {
return 'SP';
}
return parts.take(2).map((part) => part[0].toUpperCase()).join();
}
}
class _SampleGroupsSummary extends StatelessWidget {
const _SampleGroupsSummary({required this.total, required this.count});
final int total;
final int count;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: const Color(0xFFE1E8F5)),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: const Color(0xFF2563EB).withValues(alpha: 0.05),
blurRadius: 18,
offset: const Offset(0, 10),
),
],
),
child: Row(
children: [
Container(
width: 58,
height: 58,
decoration: BoxDecoration(
color: const Color(0xFFFF6B00).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(16),
),
child: const Icon(
Icons.menu_book_rounded,
color: Color(0xFFFF6B00),
size: 32,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$total sampel aktif',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: const Color(0xFF080D3D),
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 4),
Text(
'$count jenis spesimen tersedia',
style: const TextStyle(
color: Color(0xFF47517A),
fontWeight: FontWeight.w700,
),
),
],
),
),
],
),
);
}
}
class _SampleGroupCard extends StatelessWidget {
const _SampleGroupCard({required this.book, required this.onTap});
final Map<String, dynamic> book;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final total = (book['total'] as num? ?? 0).toInt();
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: const BorderSide(color: Color(0xFFE1E8F5)),
),
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
width: 54,
height: 54,
decoration: BoxDecoration(
color: const Color(0xFFFF6B00).withValues(alpha: 0.10),
shape: BoxShape.circle,
),
child: const Icon(
Icons.science_outlined,
color: Color(0xFFFF6B00),
size: 28,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
book['label']?.toString() ?? 'Spesimen',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: const Color(0xFF080D3D),
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 6),
BadgeLabel(
text: '${book['master'] ?? '-'}',
color: const Color(0xFFFF6B00),
),
],
),
),
BadgeLabel(text: '$total sampel', color: const Color(0xFF0B7BFF)),
const SizedBox(width: 8),
const Icon(Icons.chevron_right_rounded, color: Color(0xFF667095)),
],
),
),
),
);
}
}
@@ -0,0 +1,765 @@
part of '../../app/app.dart';
class SpecimenRegisterScreen extends StatefulWidget {
const SpecimenRegisterScreen({
super.key,
required this.token,
required this.baseUrl,
required this.master,
required this.title,
required this.total,
required this.user,
required this.notificationCount,
});
final String token;
final String baseUrl;
final String master;
final String title;
final int total;
final Map<String, dynamic> user;
final int notificationCount;
@override
State<SpecimenRegisterScreen> createState() => _SpecimenRegisterScreenState();
}
class _SpecimenRegisterScreenState extends State<SpecimenRegisterScreen> {
late final ApiClient _api;
late Future<Map<String, dynamic>> _future;
final _search = TextEditingController();
static const _allSpecimens = '__all_specimens__';
static const _allStatuses = '__all_statuses__';
static const int _pageSize = 10;
String _specimen = _allSpecimens;
String _status = _allStatuses;
int _page = 0;
@override
void initState() {
super.initState();
_api = ApiClient(baseUrl: widget.baseUrl, token: widget.token);
_future = _load();
_search.addListener(_onSearchChanged);
}
@override
void dispose() {
_search.removeListener(_onSearchChanged);
_search.dispose();
super.dispose();
}
Future<Map<String, dynamic>> _load() {
return _api.get('api/mobile/books/${widget.master}/examinations');
}
void _reload() {
setState(() {
_page = 0;
_future = _load();
});
}
void _onSearchChanged() {
if (!mounted) return;
setState(() {
_page = 0;
});
}
Future<void> _openCriticalValues() async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) =>
CriticalValuesScreen(token: widget.token, baseUrl: widget.baseUrl),
),
);
}
void _showProfile() {
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (context) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
CircleAvatar(
radius: 28,
backgroundColor: const Color(0xFF0F766E),
child: Text(
_initials(widget.user['nama']?.toString() ?? 'SP'),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w900,
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.user['nama']?.toString() ?? '-',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w900),
),
const SizedBox(height: 2),
Text(
'${widget.user['username'] ?? '-'}${widget.user['previlage'] ?? '-'}',
style: const TextStyle(color: Colors.black54),
),
],
),
),
],
),
const SizedBox(height: 18),
FilledButton.icon(
onPressed: () async {
await SessionStore.clear();
if (!context.mounted) return;
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const LoginScreen()),
(_) => false,
);
},
icon: const Icon(Icons.logout),
label: const Text('Logout'),
),
],
),
),
),
);
}
String _initials(String name) {
final parts = name
.trim()
.split(RegExp(r'\s+'))
.where((part) => part.isNotEmpty)
.toList();
if (parts.isEmpty) {
return 'SP';
}
return parts.take(2).map((part) => part[0].toUpperCase()).join();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: FutureBuilder<Map<String, dynamic>>(
future: _future,
builder: (context, snapshot) {
final items = asList(snapshot.data?['items']);
final filtered = _filtered(items);
final specimens = _specimenOptions(items);
final statuses = _statusOptions(items);
final maxPage = filtered.isEmpty
? 0
: (filtered.length - 1) ~/ _pageSize;
final page = _page.clamp(0, maxPage);
final start = filtered.isEmpty ? 0 : page * _pageSize;
final end = filtered.isEmpty
? 0
: (start + _pageSize).clamp(0, filtered.length);
final visible = filtered.sublist(start, end);
return ListView(
padding: const EdgeInsets.fromLTRB(18, 12, 18, 24),
children: [
_SpecimenHeader(
title: widget.title,
total: filtered.length,
user: widget.user,
notificationCount: widget.notificationCount,
onNotifications: _openCriticalValues,
onProfile: _showProfile,
),
const SizedBox(height: 24),
_FilterDropdown(
label: 'Jenis Spesimen',
value: _specimen,
icon: Icons.science_outlined,
options: specimens,
onChanged: (value) => setState(() {
_specimen = value;
_status = _allStatuses;
_page = 0;
}),
),
const SizedBox(height: 18),
_FilterDropdown(
label: 'Status Pengerjaan',
value: _status,
icon: Icons.assignment_outlined,
options: statuses,
onChanged: (value) => setState(() {
_status = value;
_page = 0;
}),
),
const SizedBox(height: 22),
Text(
'Cari Spesimen',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFF080D3D),
),
),
const SizedBox(height: 10),
_SearchBlock(search: _search),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 18),
_RegisterTitle(title: widget.title, total: filtered.length),
const SizedBox(height: 14),
if (snapshot.connectionState != ConnectionState.done)
const SizedBox(
height: 180,
child: Center(child: CircularProgressIndicator()),
)
else if (snapshot.hasError)
ErrorView(message: _message(snapshot.error), onRetry: _reload)
else if (filtered.isEmpty)
const EmptyPanel(text: 'Tidak ada sampel.')
else ...[
...visible.map(
(item) => SpecimenSampleCard(
item: asMap(item),
token: widget.token,
baseUrl: widget.baseUrl,
),
),
const SizedBox(height: 16),
_SpecimenPager(
start: start + 1,
end: end,
total: filtered.length,
canPrevious: page > 0,
canNext: page < maxPage,
onPrevious: () => setState(() => _page = page - 1),
onNext: () => setState(() => _page = page + 1),
),
],
],
);
},
),
),
bottomNavigationBar: NavigationBar(
selectedIndex: 1,
onDestinationSelected: (index) {
if (index == 0) {
Navigator.of(context).popUntil((route) => route.isFirst);
}
if (index == 2) {
Navigator.of(context).popUntil((route) => route.isFirst);
}
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
label: 'Beranda',
),
NavigationDestination(icon: Icon(Icons.search), label: 'Cari Sampel'),
NavigationDestination(
icon: Icon(Icons.person_outline),
label: 'Profil',
),
],
),
);
}
List<dynamic> _filtered(List<dynamic> items) {
final query = _search.text.trim().toLowerCase();
return items.where((raw) {
final item = asMap(raw);
final specimenValue = _specimenValue(item);
final specimenOk =
_specimen == _allSpecimens || specimenValue == _specimen;
final statusOk =
_status == _allStatuses || item['status']?.toString() == _status;
final searchOk =
query.isEmpty ||
[
item['nofoto'],
item['noregister'],
item['nmpasien'],
item['reques'],
item['kd_spesimen'],
item['nm_spesimen'],
item['status'],
].whereType<Object>().join(' ').toLowerCase().contains(query);
return specimenOk && statusOk && searchOk;
}).toList();
}
List<_FilterOption> _specimenOptions(List<dynamic> items) {
final values = <String, String>{};
for (final raw in items) {
final item = asMap(raw);
final value = _specimenValue(item);
final label = item['nm_spesimen']?.toString() ?? '';
if (value.isNotEmpty && label.isNotEmpty) {
values.putIfAbsent(value, () => label);
}
}
final options =
values.entries
.map((entry) => _FilterOption(value: entry.key, label: entry.value))
.toList()
..sort((a, b) => a.label.compareTo(b.label));
return [
const _FilterOption(value: _allSpecimens, label: 'Semua Spesimen'),
...options,
];
}
List<_FilterOption> _statusOptions(List<dynamic> items) {
final filteredBySpecimen = items.where((raw) {
final item = asMap(raw);
return _specimen == _allSpecimens || _specimenValue(item) == _specimen;
});
final values =
filteredBySpecimen
.map((item) => asMap(item)['status']?.toString() ?? '')
.where((value) => value.isNotEmpty)
.toSet()
.toList()
..sort();
return [
const _FilterOption(value: _allStatuses, label: 'Semua Status'),
...values.map((value) => _FilterOption(value: value, label: value)),
];
}
String _specimenValue(Map<String, dynamic> item) {
final code = item['kd_spesimen']?.toString() ?? '';
if (code.isNotEmpty) {
return code;
}
return item['nm_spesimen']?.toString() ?? '';
}
}
class _FilterOption {
const _FilterOption({required this.value, required this.label});
final String value;
final String label;
}
class _SpecimenHeader extends StatelessWidget {
const _SpecimenHeader({
required this.title,
required this.total,
required this.user,
required this.notificationCount,
required this.onNotifications,
required this.onProfile,
});
final String title;
final int total;
final Map<String, dynamic> user;
final int notificationCount;
final VoidCallback onNotifications;
final VoidCallback onProfile;
@override
Widget build(BuildContext context) {
return Row(
children: [
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.arrow_back),
),
const SizedBox(width: 6),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFF080D3D),
),
),
),
const SizedBox(width: 10),
BadgeLabel(text: '$total', color: const Color(0xFF6D28D9)),
],
),
const SizedBox(height: 4),
Text(
'Catatan & Register',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: const Color(0xFF47517A),
fontWeight: FontWeight.w700,
),
),
],
),
),
Stack(
clipBehavior: Clip.none,
children: [
IconButton(
onPressed: onNotifications,
icon: const Icon(Icons.notifications_none_rounded, size: 30),
color: const Color(0xFF063B60),
tooltip: 'Notifikasi nilai kritis',
),
if (notificationCount > 0)
Positioned(
top: 6,
right: 6,
child: Container(
padding: const EdgeInsets.all(5),
constraints: const BoxConstraints(minWidth: 20),
decoration: const BoxDecoration(
color: Color(0xFFFF1D25),
shape: BoxShape.circle,
),
child: Text(
notificationCount > 99 ? '99+' : '$notificationCount',
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w800,
),
),
),
),
],
),
const SizedBox(width: 14),
InkWell(
borderRadius: BorderRadius.circular(24),
onTap: onProfile,
child: CircleAvatar(
backgroundColor: const Color(0xFF12BFA5),
child: Text(
_initials(user['nama']?.toString() ?? 'SP'),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w900,
),
),
),
),
],
);
}
String _initials(String name) {
final parts = name
.trim()
.split(RegExp(r'\s+'))
.where((part) => part.isNotEmpty)
.toList();
if (parts.isEmpty) {
return 'SP';
}
return parts.take(2).map((part) => part[0].toUpperCase()).join();
}
}
class _FilterDropdown extends StatelessWidget {
const _FilterDropdown({
required this.label,
required this.value,
required this.icon,
required this.options,
required this.onChanged,
});
final String label;
final String value;
final IconData icon;
final List<_FilterOption> options;
final ValueChanged<String> onChanged;
@override
Widget build(BuildContext context) {
final values = options.map((option) => option.value).toSet();
final effectiveValue = values.contains(value) ? value : options.first.value;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: const Color(0xFF080D3D),
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 10),
DropdownButtonFormField<String>(
initialValue: effectiveValue,
isExpanded: true,
selectedItemBuilder: (context) => options
.map(
(option) => Align(
alignment: Alignment.centerLeft,
child: Text(
option.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
)
.toList(),
decoration: InputDecoration(
prefixIcon: Icon(icon, color: const Color(0xFF6D28D9)),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFFA875FF)),
),
),
items: options
.map(
(option) => DropdownMenuItem(
value: option.value,
child: Text(option.label, overflow: TextOverflow.ellipsis),
),
)
.toList(),
onChanged: (value) => onChanged(value ?? options.first.value),
),
],
);
}
}
class _SearchBlock extends StatelessWidget {
const _SearchBlock({required this.search});
final TextEditingController search;
@override
Widget build(BuildContext context) {
return Column(
children: [
TextField(
controller: search,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search),
hintText: 'Ketik No. Sampel, No. RM, atau Nama Pasien',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: BadgeLabel(
text: 'Pencarian: No. Sampel / No. RM / Nama',
color: const Color(0xFF6D28D9),
),
),
],
);
}
}
class _SpecimenPager extends StatelessWidget {
const _SpecimenPager({
required this.start,
required this.end,
required this.total,
required this.canPrevious,
required this.canNext,
required this.onPrevious,
required this.onNext,
});
final int start;
final int end;
final int total;
final bool canPrevious;
final bool canNext;
final VoidCallback onPrevious;
final VoidCallback onNext;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Text(
'$start-$end dari $total sampel',
style: const TextStyle(
color: Color(0xFF47517A),
fontWeight: FontWeight.w800,
),
),
),
const Text(
'Tampilkan',
style: TextStyle(
color: Color(0xFF47517A),
fontWeight: FontWeight.w800,
),
),
const SizedBox(width: 10),
BadgeLabel(text: '10', color: const Color(0xFF667095)),
const SizedBox(width: 8),
IconButton.filledTonal(
onPressed: canPrevious ? onPrevious : null,
icon: const Icon(Icons.chevron_left_rounded),
),
const SizedBox(width: 8),
IconButton.filledTonal(
onPressed: canNext ? onNext : null,
icon: const Icon(Icons.chevron_right_rounded),
),
],
);
}
}
class _RegisterTitle extends StatelessWidget {
const _RegisterTitle({required this.title, required this.total});
final String title;
final int total;
@override
Widget build(BuildContext context) {
return Row(
children: [
const Icon(Icons.menu_book_rounded, color: Color(0xFF6D28D9)),
const SizedBox(width: 10),
Expanded(
child: Text(
'BUKU REGISTER ${title.toUpperCase()}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: const Color(0xFF6D28D9),
fontWeight: FontWeight.w900,
),
),
),
Text(
'$total',
style: const TextStyle(
color: Color(0xFF667095),
fontWeight: FontWeight.w900,
),
),
],
);
}
}
class SpecimenSampleCard extends StatelessWidget {
const SpecimenSampleCard({
super.key,
required this.item,
required this.token,
required this.baseUrl,
});
final Map<String, dynamic> item;
final String token;
final String baseUrl;
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: Color(0xFFE1E8F5)),
),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => _openDetail(context, item, token, baseUrl),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 6,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item['nofoto']?.toString() ?? '-',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: const Color(0xFF080D3D),
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 6),
BadgeLabel(
text: item['reques']?.toString() ?? '-',
color: const Color(0xFF0891B2),
),
const SizedBox(height: 8),
Text(
item['nmpasien']?.toString() ?? '-',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFF47517A),
fontWeight: FontWeight.w700,
),
),
Text(
'No.RM ${item['noregister'] ?? '-'}',
style: const TextStyle(
color: Color(0xFF47517A),
fontWeight: FontWeight.w700,
),
),
],
),
),
Container(width: 1, height: 96, color: const Color(0xFFE1E8F5)),
const SizedBox(width: 14),
Expanded(
flex: 4,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Status :',
style: TextStyle(
color: Color(0xFF667095),
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 10),
StatusPill(status: item['status']?.toString() ?? 'NEW'),
],
),
),
],
),
),
),
);
}
}
@@ -65,16 +65,6 @@ class _ExaminationDetailScreenState extends State<ExaminationDetailScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Detail Pemeriksaan'),
actions: [
IconButton(
onPressed: _reload,
icon: const Icon(Icons.refresh),
tooltip: 'Cek status',
),
],
),
body: FutureBuilder<Map<String, dynamic>>(
future: _future,
builder: (context, snapshot) {
@@ -90,80 +80,27 @@ class _ExaminationDetailScreenState extends State<ExaminationDetailScreen> {
final data = snapshot.data!;
final item = asMap(data['item']);
return ListView(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.fromLTRB(18, 18, 18, 28),
children: [
Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
item['nmpasien']?.toString() ?? '-',
style: Theme.of(context).textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.w800),
),
),
StatusPill(
status: item['status']?.toString() ?? 'NEW',
),
],
),
const SizedBox(height: 16),
DetailRow(label: 'No. Lab', value: item['nofoto']),
DetailRow(label: 'No. RM', value: item['noregister']),
DetailRow(label: 'Order', value: item['reques']),
DetailRow(
label: 'Asal Pasien',
value: item['asalpasien'],
),
DetailRow(label: 'Ruangan', value: item['ruangan']),
DetailRow(
label: 'Dokter Pengirim',
value: item['klinisi'] ?? item['nmdokter'],
),
DetailRow(label: 'Spesimen', value: item['nm_spesimen']),
DetailRow(label: 'Tanggal Daftar', value: item['daftar']),
DetailRow(
label: 'Tanggal Sampel',
value: item['tanggalsampel'],
),
DetailRow(
label: 'Cara Pengambilan',
value: item['pengambilan'],
),
DetailRow(
label: 'Asal Pengambilan',
value: item['asalpengirim'],
),
DetailRow(label: 'Alamat', value: item['alamatpasien']),
],
),
SafeArea(
bottom: false,
child: _ExaminationScreenHeader(
title: 'Detail Pemeriksaan',
subtitle: item['nofoto']?.toString() ?? '-',
onRefresh: _reload,
),
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _openExpertise,
icon: const Icon(Icons.edit_document),
label: const Text('Expertise'),
const SizedBox(height: 22),
_ExaminationPatientCard(item: item),
const SizedBox(height: 14),
_ExaminationInfoCard(item: item),
const SizedBox(height: 14),
_ExaminationActionPanel(
onExpertise: _openExpertise,
onRefresh: _reload,
resultUrl: data['result_url']?.toString(),
onPreview: () => _launch(data['result_url']?.toString()),
),
OutlinedButton.icon(
onPressed: _reload,
icon: const Icon(Icons.fact_check_outlined),
label: const Text('Cek Status'),
),
if (data['result_url'] != null)
TextButton.icon(
onPressed: () => _launch(data['result_url']?.toString()),
icon: const Icon(Icons.description_outlined),
label: const Text('Preview Hasil'),
),
],
);
},
@@ -171,3 +108,170 @@ class _ExaminationDetailScreenState extends State<ExaminationDetailScreen> {
);
}
}
class _ExaminationPatientCard extends StatelessWidget {
const _ExaminationPatientCard({required this.item});
final Map<String, dynamic> item;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: const Color(0xFFE1E8F5)),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: const Color(0xFF2563EB).withValues(alpha: 0.05),
blurRadius: 18,
offset: const Offset(0, 10),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
item['nmpasien']?.toString() ?? '-',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: const Color(0xFF080D3D),
fontWeight: FontWeight.w900,
),
),
),
const SizedBox(width: 10),
StatusPill(status: item['status']?.toString() ?? 'NEW'),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
BadgeLabel(
text: item['nofoto']?.toString() ?? '-',
color: const Color(0xFF0B7BFF),
),
BadgeLabel(
text: 'RM ${item['noregister'] ?? '-'}',
color: const Color(0xFF667095),
),
BadgeLabel(
text: item['nm_spesimen']?.toString() ?? '-',
color: const Color(0xFFFF6B00),
),
],
),
const SizedBox(height: 12),
Text(
item['reques']?.toString() ?? '-',
style: const TextStyle(
color: Color(0xFF47517A),
fontWeight: FontWeight.w800,
height: 1.35,
),
),
],
),
);
}
}
class _ExaminationInfoCard extends StatelessWidget {
const _ExaminationInfoCard({required this.item});
final Map<String, dynamic> item;
@override
Widget build(BuildContext context) {
final rows = [
('Asal Pasien', item['asalpasien']),
('Ruangan', item['ruangan']),
('Dokter Pengirim', item['klinisi'] ?? item['nmdokter']),
('Tanggal Daftar', item['daftar']),
('Tanggal Sampel', item['tanggalsampel']),
('Cara Pengambilan', item['pengambilan']),
('Asal Pengambilan', item['asalpengirim']),
('Alamat', item['alamatpasien']),
];
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: const Color(0xFFE1E8F5)),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Informasi Pemeriksaan',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: const Color(0xFF080D3D),
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 14),
...rows.map((row) => DetailRow(label: row.$1, value: row.$2)),
],
),
);
}
}
class _ExaminationActionPanel extends StatelessWidget {
const _ExaminationActionPanel({
required this.onExpertise,
required this.onRefresh,
required this.resultUrl,
required this.onPreview,
});
final VoidCallback onExpertise;
final VoidCallback onRefresh;
final String? resultUrl;
final VoidCallback onPreview;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: const Color(0xFFE1E8F5)),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilledButton.icon(
onPressed: onExpertise,
icon: const Icon(Icons.edit_document),
label: const Text('Expertise'),
),
const SizedBox(height: 10),
OutlinedButton.icon(
onPressed: onRefresh,
icon: const Icon(Icons.fact_check_outlined),
label: const Text('Cek Status'),
),
if (resultUrl?.isNotEmpty == true) ...[
const SizedBox(height: 10),
TextButton.icon(
onPressed: onPreview,
icon: const Icon(Icons.description_outlined),
label: const Text('Preview Hasil'),
),
],
],
),
);
}
}
@@ -7,12 +7,14 @@ class ExaminationListScreen extends StatefulWidget {
required this.baseUrl,
required this.master,
required this.title,
this.initialSearch,
});
final String token;
final String baseUrl;
final String master;
final String title;
final String? initialSearch;
@override
State<ExaminationListScreen> createState() => _ExaminationListScreenState();
@@ -27,6 +29,7 @@ class _ExaminationListScreenState extends State<ExaminationListScreen> {
void initState() {
super.initState();
_api = ApiClient(baseUrl: widget.baseUrl, token: widget.token);
_search.text = widget.initialSearch ?? '';
_future = _load();
}
@@ -53,32 +56,50 @@ class _ExaminationListScreenState extends State<ExaminationListScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
actions: [
IconButton(
onPressed: _reload,
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
),
],
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 10),
child: TextField(
controller: _search,
textInputAction: TextInputAction.search,
onSubmitted: (_) => _reload(),
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search),
labelText: 'Cari no lab, RM, pasien, order',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
onPressed: _reload,
icon: const Icon(Icons.arrow_forward),
tooltip: 'Cari',
padding: const EdgeInsets.fromLTRB(18, 18, 18, 0),
child: SafeArea(
bottom: false,
child: _ExaminationScreenHeader(
title: widget.title,
subtitle: 'List Pemeriksaan',
onRefresh: _reload,
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(18, 22, 18, 14),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: const Color(0xFFCFE0FF)),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: const Color(0xFF2563EB).withValues(alpha: 0.05),
blurRadius: 18,
offset: const Offset(0, 10),
),
],
),
child: TextField(
controller: _search,
textInputAction: TextInputAction.search,
onSubmitted: (_) => _reload(),
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search),
hintText: 'Cari no lab, RM, pasien, order',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
suffixIcon: IconButton(
onPressed: _reload,
icon: const Icon(Icons.manage_search_rounded),
tooltip: 'Cari',
),
),
),
),
@@ -103,7 +124,7 @@ class _ExaminationListScreenState extends State<ExaminationListScreen> {
return RefreshIndicator(
onRefresh: () async => _reload(),
child: ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
padding: const EdgeInsets.fromLTRB(18, 0, 18, 24),
itemCount: items.length,
itemBuilder: (context, index) => ExaminationCard(
item: asMap(items[index]),
@@ -135,17 +156,35 @@ class ExaminationCompactTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(
item['nmpasien']?.toString() ?? '-',
maxLines: 1,
overflow: TextOverflow.ellipsis,
return Card(
margin: const EdgeInsets.only(bottom: 10),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: Color(0xFFE1E8F5)),
),
subtitle: Text(
'${item['nofoto'] ?? '-'} - ${item['warning_label'] ?? item['status'] ?? '-'}',
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
title: Text(
item['nmpasien']?.toString() ?? '-',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFF080D3D),
fontWeight: FontWeight.w900,
),
),
subtitle: Text(
'${item['nofoto'] ?? '-'} / ${item['warning_label'] ?? item['status'] ?? '-'}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: const Icon(
Icons.chevron_right_rounded,
color: Color(0xFF667095),
),
onTap: () => _openDetail(context, item, token, baseUrl),
),
trailing: const Icon(Icons.chevron_right),
onTap: () => _openDetail(context, item, token, baseUrl),
);
}
}
@@ -165,13 +204,17 @@ class ExaminationCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
margin: const EdgeInsets.only(bottom: 12),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: const BorderSide(color: Color(0xFFE1E8F5)),
),
child: InkWell(
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(14),
onTap: () => _openDetail(context, item, token, baseUrl),
child: Padding(
padding: const EdgeInsets.all(14),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -180,30 +223,49 @@ class ExaminationCard extends StatelessWidget {
Expanded(
child: Text(
item['nmpasien']?.toString() ?? '-',
style: const TextStyle(fontWeight: FontWeight.w800),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFF080D3D),
fontWeight: FontWeight.w900,
),
),
),
StatusPill(status: item['status']?.toString() ?? 'NEW'),
],
),
const SizedBox(height: 8),
Text(
'${item['nofoto'] ?? '-'} / RM ${item['noregister'] ?? '-'}',
Wrap(
spacing: 8,
runSpacing: 8,
children: [
BadgeLabel(
text: item['nofoto']?.toString() ?? '-',
color: const Color(0xFF0B7BFF),
),
BadgeLabel(
text: 'RM ${item['noregister'] ?? '-'}',
color: const Color(0xFF667095),
),
],
),
Text(
item['reques']?.toString() ?? '-',
maxLines: 2,
overflow: TextOverflow.ellipsis,
const SizedBox(height: 10),
BadgeLabel(
text: item['reques']?.toString() ?? '-',
color: const Color(0xFF0891B2),
),
const SizedBox(height: 10),
Row(
children: [
const Icon(Icons.schedule, size: 16, color: Colors.black54),
const SizedBox(width: 6),
Expanded(
child: Text(
item['daftar']?.toString() ?? '-',
style: const TextStyle(color: Colors.black54),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFF47517A),
fontWeight: FontWeight.w700,
),
),
),
TextButton.icon(
@@ -221,6 +283,62 @@ class ExaminationCard extends StatelessWidget {
}
}
class _ExaminationScreenHeader extends StatelessWidget {
const _ExaminationScreenHeader({
required this.title,
required this.subtitle,
required this.onRefresh,
});
final String title;
final String subtitle;
final VoidCallback onRefresh;
@override
Widget build(BuildContext context) {
return Row(
children: [
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.arrow_back_ios_new_rounded),
color: const Color(0xFF080D3D),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: const Color(0xFF080D3D),
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 4),
Text(
subtitle,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: const Color(0xFF47517A),
fontWeight: FontWeight.w700,
),
),
],
),
),
IconButton(
onPressed: onRefresh,
icon: const Icon(Icons.refresh_rounded),
color: const Color(0xFF063B60),
tooltip: 'Refresh',
),
],
);
}
}
Future<void> _openDetail(
BuildContext context,
Map<String, dynamic> item,
+54 -9
View File
@@ -33,15 +33,60 @@ class StatusPill extends StatelessWidget {
@override
Widget build(BuildContext context) {
final lower = status.toLowerCase();
final color = lower.contains('selesai')
? const Color(0xFF15803D)
: lower.contains('dibatalkan')
? const Color(0xFF6B7280)
: lower.contains('warning') || lower.contains('target')
? const Color(0xFFDC2626)
: const Color(0xFF0F766E);
return BadgeLabel(text: status, color: color);
final normalized = status.trim();
final lower = normalized.toLowerCase();
if (normalized.isEmpty || lower == 'new') {
return const BadgeLabel(text: 'NEW', color: Color(0xFFDC2626));
}
if (lower.contains('dibatalkan')) {
return const Text(
'Batal',
style: TextStyle(color: Color(0xFF6B7280), fontWeight: FontWeight.w800),
);
}
if (lower.contains('pemeriksaan sampel')) {
return const Text(
'Pemeriksaan Sampel',
style: TextStyle(color: Color(0xFF15803D), fontWeight: FontWeight.w800),
);
}
if (lower.contains('proses analisis sampel')) {
return const Text(
'Diperiksa',
style: TextStyle(color: Color(0xFF111827), fontWeight: FontWeight.w900),
);
}
if (lower.contains('draft')) {
return const Text(
'Draft',
style: TextStyle(color: Color(0xFF15803D), fontWeight: FontWeight.w800),
);
}
if (lower.contains('expertise')) {
return const Text(
'Expertise',
style: TextStyle(color: Color(0xFF15803D), fontWeight: FontWeight.w900),
);
}
if (lower.contains('decliend')) {
return const Text(
'Decliend',
style: TextStyle(color: Color(0xFFDC2626), fontWeight: FontWeight.w900),
);
}
if (lower.contains('selesai')) {
return const BadgeLabel(text: 'Selesai', color: Color(0xFF15803D));
}
if (lower.contains('arsip')) {
return const BadgeLabel(text: 'Arsip', color: Color(0xFF2563EB));
}
if (lower.contains('data vitek di terima')) {
return const BadgeLabel(
text: 'Data Vitek di Terima',
color: Color(0xFFF59E0B),
);
}
return BadgeLabel(text: normalized, color: const Color(0xFF0891B2));
}
}
@@ -5,10 +5,12 @@
import FlutterMacOS
import Foundation
import mobile_scanner
import shared_preferences_foundation
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
}
@@ -10,5 +10,7 @@
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
</dict>
</plist>
+2
View File
@@ -24,6 +24,8 @@
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSHumanReadableCopyright</key>
<string>$(PRODUCT_COPYRIGHT)</string>
<key>NSCameraUsageDescription</key>
<string>MyLIS memakai kamera untuk scan barcode sampel.</string>
<key>NSMainNibFile</key>
<string>MainMenu</string>
<key>NSPrincipalClass</key>
+2
View File
@@ -6,5 +6,7 @@
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
</dict>
</plist>
+8
View File
@@ -168,6 +168,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.18.0"
mobile_scanner:
dependency: "direct main"
description:
name: mobile_scanner
sha256: ce3f059ebd6dbfab7292bba0e893e354b46730636820d3c9ef69005ce2d55bce
url: "https://pub.dev"
source: hosted
version: "7.4.0"
path:
dependency: transitive
description:
+2 -1
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
version: 1.0.2+2
environment:
sdk: ^3.12.2
@@ -37,6 +37,7 @@ dependencies:
http: ^1.2.2
shared_preferences: ^2.3.2
url_launcher: ^6.3.1
mobile_scanner: ^7.4.0
dev_dependencies:
flutter_test:
+1 -1
View File
@@ -18,7 +18,7 @@ void main() {
await tester.pump();
expect(
find.text('Mikrobiology Laboratory Information System (MyLIS)'),
find.text('Mikrobiology Laboratory Information System'),
findsOneWidget,
);
expect(find.text('Login'), findsOneWidget);