diff --git a/htdocs/app/Http/Controllers/MobileApiController.php b/htdocs/app/Http/Controllers/MobileApiController.php index eef4496a..865368a9 100644 --- a/htdocs/app/Http/Controllers/MobileApiController.php +++ b/htdocs/app/Http/Controllers/MobileApiController.php @@ -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); diff --git a/htdocs/resources/views/dokter/pemeriksaan.blade.php b/htdocs/resources/views/dokter/pemeriksaan.blade.php index 8098f95c..4aa34a58 100644 --- a/htdocs/resources/views/dokter/pemeriksaan.blade.php +++ b/htdocs/resources/views/dokter/pemeriksaan.blade.php @@ -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(); diff --git a/htdocs/resources/views/dokter/ppds.blade.php b/htdocs/resources/views/dokter/ppds.blade.php index e1413dad..40cee9d2 100644 --- a/htdocs/resources/views/dokter/ppds.blade.php +++ b/htdocs/resources/views/dokter/ppds.blade.php @@ -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(); diff --git a/htdocs/resources/views/dokter/ppdsdeveloper.blade.php b/htdocs/resources/views/dokter/ppdsdeveloper.blade.php index c52b759e..d1615ba0 100644 --- a/htdocs/resources/views/dokter/ppdsdeveloper.blade.php +++ b/htdocs/resources/views/dokter/ppdsdeveloper.blade.php @@ -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(); diff --git a/htdocs/routes/api.php b/htdocs/routes/api.php index f2a27231..04e14e30 100644 --- a/htdocs/routes/api.php +++ b/htdocs/routes/api.php @@ -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']); diff --git a/mylis/android/app/src/main/AndroidManifest.xml b/mylis/android/app/src/main/AndroidManifest.xml index 8340d5c5..d729c66c 100644 --- a/mylis/android/app/src/main/AndroidManifest.xml +++ b/mylis/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ + $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSCameraUsageDescription + MyLIS memakai kamera untuk scan barcode sampel. UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/mylis/lib/app/app.dart b/mylis/lib/app/app.dart index d1d13da7..e344e6b6 100644 --- a/mylis/lib/app/app.dart +++ b/mylis/lib/app/app.dart @@ -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'; diff --git a/mylis/lib/screens/dashboard/barcode_scanner_screen.dart b/mylis/lib/screens/dashboard/barcode_scanner_screen.dart new file mode 100644 index 00000000..d919bf2e --- /dev/null +++ b/mylis/lib/screens/dashboard/barcode_scanner_screen.dart @@ -0,0 +1,169 @@ +part of '../../app/app.dart'; + +class BarcodeScannerScreen extends StatefulWidget { + const BarcodeScannerScreen({super.key}); + + @override + State createState() => _BarcodeScannerScreenState(); +} + +class _BarcodeScannerScreenState extends State { + 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'), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/mylis/lib/screens/dashboard/critical_values_screen.dart b/mylis/lib/screens/dashboard/critical_values_screen.dart new file mode 100644 index 00000000..79492044 --- /dev/null +++ b/mylis/lib/screens/dashboard/critical_values_screen.dart @@ -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 createState() => _CriticalValuesScreenState(); +} + +class _CriticalValuesScreenState extends State { + late final ApiClient _api; + late Future> _future; + + @override + void initState() { + super.initState(); + _api = ApiClient(baseUrl: widget.baseUrl, token: widget.token); + _future = _load(); + } + + Future> _load() { + return _api.get('api/mobile/critical-values'); + } + + void _reload() { + setState(() { + _future = _load(); + }); + } + + Future _markRead(Map item) async { + final method = ValueNotifier(''); + final recipient = TextEditingController(); + final reason = TextEditingController(); + + await showModalBottomSheet( + 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( + 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( + 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>( + 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 items; + final String emptyText; + final ValueChanged>? 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 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 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 '-'; + } +} diff --git a/mylis/lib/screens/dashboard/dashboard_screen.dart b/mylis/lib/screens/dashboard/dashboard_screen.dart index 8668d0de..22e1a8d4 100644 --- a/mylis/lib/screens/dashboard/dashboard_screen.dart +++ b/mylis/lib/screens/dashboard/dashboard_screen.dart @@ -17,6 +17,8 @@ class DashboardScreen extends StatefulWidget { class _DashboardScreenState extends State { late final ApiClient _api; late Future> _future; + final _homeSearch = TextEditingController(); + int _selectedTab = 0; @override void initState() { @@ -25,6 +27,12 @@ class _DashboardScreenState extends State { _future = _api.get('api/mobile/dashboard'); } + @override + void dispose() { + _homeSearch.dispose(); + super.dispose(); + } + void _reload() { setState(() { _future = _api.get('api/mobile/dashboard'); @@ -41,24 +49,284 @@ class _DashboardScreenState extends State { ).pushReplacement(MaterialPageRoute(builder: (_) => const LoginScreen())); } + Future _searchAndOpenDetail(String query) async { + final keyword = query.trim(); + if (keyword.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Masukkan kata kunci sampel.')), + ); + return; + } + + try { + final data = await _api.get('api/mobile/examinations/search', { + 'search': keyword, + }); + final item = asMap(data['item']); + final id = (item['id'] as num?)?.toInt(); + if (id == null) { + throw ApiException('Data pemeriksaan tidak ditemukan.'); + } + if (!mounted) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ExaminationDetailScreen( + token: widget.token, + baseUrl: widget.baseUrl, + id: id, + ), + ), + ); + } on ApiException catch (error) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error.message))); + } + } + + Future _scanAndSearch() async { + final result = await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const BarcodeScannerScreen()), + ); + if (result == null || result.trim().isEmpty) { + return; + } + _homeSearch.text = result.trim(); + await _searchAndOpenDetail(result); + } + + void _showSearchSheet() { + showModalBottomSheet( + context: context, + showDragHandle: true, + isScrollControlled: true, + builder: (context) => SafeArea( + child: Padding( + padding: EdgeInsets.fromLTRB( + 18, + 0, + 18, + MediaQuery.viewInsetsOf(context).bottom + 18, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Cari Sampel', + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w900), + ), + const SizedBox(height: 12), + TextField( + controller: _homeSearch, + autofocus: true, + textInputAction: TextInputAction.search, + onSubmitted: (value) { + Navigator.of(context).pop(); + _searchAndOpenDetail(value); + }, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.search), + labelText: 'No. Sampel / No. RM / Nama Pasien', + border: const OutlineInputBorder(), + suffixIcon: IconButton( + onPressed: () async { + Navigator.of(context).pop(); + await _scanAndSearch(); + }, + icon: const Icon(Icons.qr_code_scanner_rounded), + tooltip: 'Scan barcode', + ), + ), + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: () { + Navigator.of(context).pop(); + _searchAndOpenDetail(_homeSearch.text); + }, + icon: const Icon(Icons.manage_search_rounded), + label: const Text('Cari dan Buka Detail'), + ), + ], + ), + ), + ), + ); + } + + void _openBook(Map book) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ExaminationListScreen( + token: widget.token, + baseUrl: widget.baseUrl, + master: book['master']?.toString() ?? 'buku0', + title: book['label']?.toString() ?? 'Pemeriksaan', + ), + ), + ); + } + + void _openEws(List warnings) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => EwsScreen( + warnings: warnings, + token: widget.token, + baseUrl: widget.baseUrl, + ), + ), + ); + } + + void _openInitialWork(Map user, int notificationCount) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => InitialWorkScreen( + token: widget.token, + baseUrl: widget.baseUrl, + user: user, + notificationCount: notificationCount, + ), + ), + ); + } + + void _openSampleGroups( + List books, + Map user, + int notificationCount, + ) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SampleGroupsScreen( + books: books, + token: widget.token, + baseUrl: widget.baseUrl, + user: user, + notificationCount: notificationCount, + ), + ), + ); + } + + void _openAllSpecimens(Map user, int notificationCount) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SpecimenRegisterScreen( + token: widget.token, + baseUrl: widget.baseUrl, + master: 'ALL', + title: 'Semua Spesimen', + total: 0, + user: user, + notificationCount: notificationCount, + ), + ), + ); + } + + Future _openCriticalValues() async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + CriticalValuesScreen(token: widget.token, baseUrl: widget.baseUrl), + ), + ); + if (mounted) { + _reload(); + } + } + + void _showProfile(Map user) { + showModalBottomSheet( + 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: () { + Navigator.of(context).pop(); + _logout(); + }, + icon: const Icon(Icons.logout), + label: const Text('Logout'), + ), + ], + ), + ), + ), + ); + } + + void _handleBottomNav(int value, Map user) { + setState(() => _selectedTab = value); + if (value == 1) { + _showSearchSheet(); + } else if (value == 2) { + _showProfile(user); + setState(() => _selectedTab = 0); + } + } + + 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( - appBar: AppBar( - title: const Text(kAppShortName), - actions: [ - IconButton( - onPressed: _reload, - icon: const Icon(Icons.refresh), - tooltip: 'Refresh', - ), - IconButton( - onPressed: _logout, - icon: const Icon(Icons.logout), - tooltip: 'Logout', - ), - ], - ), body: RefreshIndicator( onRefresh: () async => _reload(), child: FutureBuilder>( @@ -78,40 +346,861 @@ class _DashboardScreenState extends State { final summary = asMap(data['summary']); final warnings = asList(data['early_warning_groups']); final books = asList(data['books']); + final notificationCount = + (summary['criticalNotificationCount'] as num? ?? 0).toInt(); return ListView( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + padding: const EdgeInsets.fromLTRB(18, 18, 18, 24), children: [ - HeaderPanel(user: user, summary: summary), - const SizedBox(height: 16), - SectionTitle( - title: 'Early Warning Sistem', - actionLabel: - '${warnings.fold(0, (total, item) => total + (asMap(item)['total'] as num? ?? 0).toInt())} kasus', + HomeTopBar( + user: user, + notificationCount: notificationCount, + onNotifications: _openCriticalValues, + onProfile: () => _showProfile(user), + ), + const SizedBox(height: 22), + SampleSearchPanel( + controller: _homeSearch, + onSearch: () => _searchAndOpenDetail(_homeSearch.text), + onScan: _scanAndSearch, + ), + const SizedBox(height: 24), + const HomeSectionTitle(title: 'Menu Utama'), + const SizedBox(height: 12), + MainMenuGrid( + books: books, + onEwsTap: () => _openEws(warnings), + onInitialWorkTap: () => + _openInitialWork(user, notificationCount), + onSpecimenTap: () => + _openAllSpecimens(user, notificationCount), + onBookTap: _openBook, + ), + const SizedBox(height: 24), + EwsSummaryPanel( + warnings: warnings, + token: widget.token, + baseUrl: widget.baseUrl, ), - if (warnings.isEmpty) - const EmptyPanel(text: 'Tidak ada data Early Warning.') - else - ...warnings.map( - (group) => EarlyWarningGroupCard( - group: asMap(group), - token: widget.token, - baseUrl: widget.baseUrl, - ), - ), const SizedBox(height: 18), - const SectionTitle(title: 'List Pemeriksaan'), - ...books.map( - (book) => BookTile( - book: asMap(book), - token: widget.token, - baseUrl: widget.baseUrl, - ), + DetailSamplePanel( + onTap: () => + _openSampleGroups(books, user, notificationCount), + total: summary['antrian_hari_ini'], ), + const SizedBox(height: 12), ], ); }, ), ), + bottomNavigationBar: FutureBuilder>( + future: _future, + builder: (context, snapshot) { + final user = asMap(snapshot.data?['user']); + return NavigationBar( + selectedIndex: _selectedTab, + onDestinationSelected: (value) => _handleBottomNav(value, user), + destinations: const [ + NavigationDestination( + icon: Icon(Icons.home_outlined), + selectedIcon: Icon(Icons.home), + label: 'Beranda', + ), + NavigationDestination( + icon: Icon(Icons.search), + label: 'Cari sampel', + ), + NavigationDestination( + icon: Icon(Icons.person_outline), + selectedIcon: Icon(Icons.person), + label: 'Profil', + ), + ], + ); + }, + ), + ); + } +} + +class HomeTopBar extends StatelessWidget { + const HomeTopBar({ + super.key, + required this.user, + required this.notificationCount, + required this.onNotifications, + required this.onProfile, + }); + + final Map user; + final int notificationCount; + final VoidCallback onNotifications; + final VoidCallback onProfile; + + @override + Widget build(BuildContext context) { + final initials = _initials(user['nama']?.toString() ?? 'SP'); + return SafeArea( + bottom: false, + child: Row( + children: [ + Container( + width: 66, + height: 66, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(18), + ), + child: Image.asset(kAppLogoAsset, fit: BoxFit.contain), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Mikrobiology Laboratory Information System', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: const Color(0xFF47517A), + ), + ), + ], + ), + ), + Stack( + clipBehavior: Clip.none, + children: [ + IconButton( + onPressed: onNotifications, + icon: const Icon(Icons.notifications_none_rounded, size: 31), + 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: 8), + InkWell( + borderRadius: BorderRadius.circular(28), + onTap: onProfile, + child: Row( + children: [ + Container( + width: 56, + height: 56, + alignment: Alignment.center, + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [Color(0xFF10B981), Color(0xFF06B6D4)], + ), + ), + child: Text( + initials, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w900, + fontSize: 20, + ), + ), + ), + const Icon(Icons.keyboard_arrow_down_rounded), + ], + ), + ), + ], + ), + ); + } + + 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 SampleSearchPanel extends StatelessWidget { + const SampleSearchPanel({ + super.key, + required this.controller, + required this.onSearch, + required this.onScan, + }); + + final TextEditingController controller; + final VoidCallback onSearch; + final VoidCallback onScan; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + 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: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.manage_search_rounded, + color: Color(0xFF0B7BFF), + size: 38, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Cari sampel dengan cepat', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w900, + color: const Color(0xFF080D3D), + ), + ), + const SizedBox(height: 3), + Text( + 'Cari dan pantau status sampel', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: const Color(0xFF47517A), + ), + ), + ], + ), + ), + const Icon( + Icons.keyboard_arrow_up_rounded, + color: Color(0xFF0B7BFF), + ), + ], + ), + const SizedBox(height: 18), + Text( + 'Masukkan kata kunci', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + color: const Color(0xFF47517A), + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextField( + controller: controller, + textInputAction: TextInputAction.search, + onSubmitted: (_) => onSearch(), + decoration: const InputDecoration( + prefixIcon: Icon(Icons.search), + hintText: 'Ketik No. Sampel, No. RM, Nama Pasien', + border: OutlineInputBorder(), + isDense: true, + ), + ), + ), + const SizedBox(width: 10), + IconButton.outlined( + onPressed: onScan, + icon: const Icon(Icons.qr_code_scanner_rounded), + tooltip: 'Scan barcode', + ), + const SizedBox(width: 10), + SizedBox( + width: 88, + child: FilledButton( + onPressed: onSearch, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text('Cari'), + ), + ), + ], + ), + ], + ), + ); + } +} + +class HomeSectionTitle extends StatelessWidget { + const HomeSectionTitle({super.key, required this.title}); + + final String title; + + @override + Widget build(BuildContext context) { + return Text( + title, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w900, + color: const Color(0xFF080D3D), + ), + ); + } +} + +class MainMenuGrid extends StatelessWidget { + const MainMenuGrid({ + super.key, + required this.books, + required this.onEwsTap, + required this.onInitialWorkTap, + required this.onSpecimenTap, + required this.onBookTap, + }); + + final List books; + final VoidCallback onEwsTap; + final VoidCallback onInitialWorkTap; + final VoidCallback onSpecimenTap; + final ValueChanged> onBookTap; + + @override + Widget build(BuildContext context) { + final items = [ + _HomeMenuItem( + title: 'EWS', + subtitle: 'Early Warning Score', + icon: Icons.track_changes_rounded, + color: const Color(0xFF0B7BFF), + onTap: onEwsTap, + ), + _HomeMenuItem( + title: 'Pengerjaan Awal', + subtitle: 'Validasi & Verifikasi', + icon: Icons.check_circle_outline_rounded, + color: const Color(0xFF11A85B), + onTap: onInitialWorkTap, + ), + _HomeMenuItem( + title: 'Spesimen', + subtitle: 'Catatan & Register', + icon: Icons.menu_book_rounded, + color: const Color(0xFFFF6B00), + onTap: onSpecimenTap, + ), + ]; + + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: items.length, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + childAspectRatio: 0.68, + ), + itemBuilder: (context, index) => _HomeMenuCard(item: items[index]), + ); + } +} + +class _HomeMenuCard extends StatelessWidget { + const _HomeMenuCard({required this.item}); + + final _HomeMenuItem item; + + @override + Widget build(BuildContext context) { + return InkWell( + borderRadius: BorderRadius.circular(14), + onTap: item.onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: const Color(0xFFE1E8F5)), + borderRadius: BorderRadius.circular(14), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 46, + height: 46, + decoration: BoxDecoration( + color: item.color.withValues(alpha: 0.13), + borderRadius: BorderRadius.circular(16), + ), + child: Icon(item.icon, color: item.color, size: 29), + ), + const SizedBox(height: 8), + Text( + item.title, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w900, + color: item.color, + height: 1.05, + ), + ), + const SizedBox(height: 6), + Text( + item.subtitle, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: const Color(0xFF47517A), + fontWeight: FontWeight.w600, + fontSize: 11, + height: 1.2, + ), + ), + ], + ), + ), + ); + } +} + +class _HomeMenuItem { + const _HomeMenuItem({ + required this.title, + required this.subtitle, + required this.icon, + required this.color, + required this.onTap, + }); + + final String title; + final String subtitle; + final IconData icon; + final Color color; + final VoidCallback onTap; +} + +class EwsSummaryPanel extends StatelessWidget { + const EwsSummaryPanel({ + super.key, + required this.warnings, + required this.token, + required this.baseUrl, + }); + + final List warnings; + final String token; + final String baseUrl; + + @override + Widget build(BuildContext context) { + final totalTypes = warnings.length; + final rows = warnings.take(3).map((item) => asMap(item)).toList(); + return Container( + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: const Color(0xFFE1E8F5)), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 12, 14), + child: Row( + children: [ + Expanded( + child: Text( + 'EWS - Early Warning System', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w900, + color: const Color(0xFF0B7BFF), + ), + ), + ), + const Icon( + Icons.keyboard_arrow_up_rounded, + color: Color(0xFF0B7BFF), + ), + ], + ), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: Text( + 'Ringkasan per Jenis Spesimen', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w900, + color: const Color(0xFF080D3D), + ), + ), + ), + Text( + 'Total $totalTypes jenis spesimen', + style: const TextStyle( + color: Color(0xFF47517A), + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + if (rows.isEmpty) + const EmptyPanel(text: 'Tidak ada data Early Warning.') + else + ...rows.map( + (row) => + EwsSummaryRow(group: row, token: token, baseUrl: baseUrl), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Wrap( + spacing: 16, + runSpacing: 8, + children: const [ + _LegendDot( + color: Color(0xFFFF1D25), + text: 'Melewati Target (TAT > Target)', + ), + _LegendDot(color: Color(0xFFFF8A00), text: 'Mendekati Target'), + ], + ), + ), + ], + ), + ); + } +} + +class EwsSummaryRow extends StatelessWidget { + const EwsSummaryRow({ + super.key, + required this.group, + required this.token, + required this.baseUrl, + }); + + final Map group; + final String token; + final String baseUrl; + + @override + Widget build(BuildContext context) { + final items = asList(group['items']); + final total = (group['total'] as num? ?? items.length).toInt(); + final late = _countByText(items, ['lewat', 'melewati', 'target']); + final near = total - late > 0 ? total - late : 0; + final title = group['subpoli']?.toString().isNotEmpty == true + ? group['subpoli'].toString() + : 'Tanpa Subpoli'; + final subtitle = items.isEmpty + ? 'Belum ada sampel' + : asMap(items.first)['reques']?.toString() ?? 'Pemeriksaan aktif'; + return InkWell( + onTap: () { + if (items.isEmpty) return; + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (context) => ListView( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + children: [ + Text( + title, + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w900), + ), + const SizedBox(height: 10), + ...items.map( + (item) => ExaminationCompactTile( + item: asMap(item), + token: token, + baseUrl: baseUrl, + ), + ), + ], + ), + ); + }, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 10), + child: Row( + children: [ + _SpecimenIcon(title: title), + const SizedBox(width: 12), + Expanded( + flex: 5, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title.toUpperCase(), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Color(0xFF080D3D), + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 5), + Text( + subtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Color(0xFF47517A), + fontWeight: FontWeight.w600, + height: 1.35, + ), + ), + ], + ), + ), + _EwsCount( + label: 'Melewati', + value: late, + color: const Color(0xFFFF1D25), + ), + _EwsCount( + label: 'Mendekati', + value: near, + color: const Color(0xFFFF7A00), + ), + _EwsCount( + label: 'Total', + value: total, + color: const Color(0xFF47517A), + ), + const Icon(Icons.chevron_right_rounded, color: Color(0xFF667095)), + ], + ), + ), + ); + } + + int _countByText(List items, List keywords) { + var count = 0; + for (final item in items) { + final text = asMap(item).values.join(' ').toLowerCase(); + if (keywords.any(text.contains)) { + count += 1; + } + } + return count == 0 && items.isNotEmpty ? items.length : count; + } +} + +class _SpecimenIcon extends StatelessWidget { + const _SpecimenIcon({required this.title}); + + final String title; + + @override + Widget build(BuildContext context) { + final lower = title.toLowerCase(); + final color = lower.contains('darah') + ? const Color(0xFF11A85B) + : lower.contains('sputum') + ? const Color(0xFF06A6D7) + : const Color(0xFF8B2BEF); + final icon = lower.contains('darah') + ? Icons.water_drop_rounded + : lower.contains('sputum') + ? Icons.science_rounded + : Icons.biotech_rounded; + return Container( + width: 58, + height: 58, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: Icon(icon, color: color, size: 30), + ); + } +} + +class _EwsCount extends StatelessWidget { + const _EwsCount({ + required this.label, + required this.value, + required this.color, + }); + + final String label; + final int value; + final Color color; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 66, + child: Column( + children: [ + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: color, + fontSize: 10, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 6), + Text( + '$value', + style: TextStyle( + color: color, + fontSize: 24, + fontWeight: FontWeight.w900, + ), + ), + ], + ), + ); + } +} + +class _LegendDot extends StatelessWidget { + const _LegendDot({required this.color, required this.text}); + + final Color color; + final String text; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 8), + Text( + text, + style: const TextStyle( + color: Color(0xFF47517A), + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } +} + +class DetailSamplePanel extends StatelessWidget { + const DetailSamplePanel({ + super.key, + required this.onTap, + required this.total, + }); + + final VoidCallback onTap; + final Object? total; + + @override + Widget build(BuildContext context) { + return InkWell( + borderRadius: BorderRadius.circular(16), + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: const Color(0xFFE1E8F5)), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Detail Sampel', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w900, + color: const Color(0xFF080D3D), + ), + ), + const SizedBox(height: 10), + Text( + 'Pilih jenis spesimen untuk melihat daftar sampel yang mendekati atau melewati target TAT.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: const Color(0xFF47517A), + height: 1.5, + ), + ), + const SizedBox(height: 8), + BadgeLabel( + text: '${total ?? 0} sampel hari ini', + color: const Color(0xFF0B7BFF), + ), + ], + ), + ), + const SizedBox(width: 12), + Container( + width: 86, + height: 86, + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(18), + ), + child: const Icon( + Icons.find_in_page_outlined, + color: Color(0xFF0B7BFF), + size: 52, + ), + ), + ], + ), + ), ); } } @@ -306,11 +1395,13 @@ class BookTile extends StatelessWidget { required this.book, required this.token, required this.baseUrl, + this.detailBuilder, }); final Map book; final String token; final String baseUrl; + final Widget Function(Map book)? detailBuilder; @override Widget build(BuildContext context) { @@ -328,12 +1419,14 @@ class BookTile extends StatelessWidget { onTap: () { Navigator.of(context).push( MaterialPageRoute( - builder: (_) => ExaminationListScreen( - token: token, - baseUrl: baseUrl, - master: book['master'].toString(), - title: book['label']?.toString() ?? 'Pemeriksaan', - ), + builder: (_) => + detailBuilder?.call(book) ?? + ExaminationListScreen( + token: token, + baseUrl: baseUrl, + master: book['master'].toString(), + title: book['label']?.toString() ?? 'Pemeriksaan', + ), ), ); }, diff --git a/mylis/lib/screens/dashboard/ews_screen.dart b/mylis/lib/screens/dashboard/ews_screen.dart new file mode 100644 index 00000000..40a8ea38 --- /dev/null +++ b/mylis/lib/screens/dashboard/ews_screen.dart @@ -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 warnings; + final String token; + final String baseUrl; + + @override + Widget build(BuildContext context) { + final total = warnings.fold( + 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, + ), + ), + ], + ), + ); + } +} diff --git a/mylis/lib/screens/dashboard/initial_work_screen.dart b/mylis/lib/screens/dashboard/initial_work_screen.dart new file mode 100644 index 00000000..e2f41081 --- /dev/null +++ b/mylis/lib/screens/dashboard/initial_work_screen.dart @@ -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 user; + final int notificationCount; + + @override + State createState() => _InitialWorkScreenState(); +} + +class _InitialWorkScreenState extends State { + late final ApiClient _api; + late Future> _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> _load() { + return _api.get('api/mobile/initial-work/samples'); + } + + void _reload() { + setState(() { + _future = _load(); + }); + } + + void _onSearchChanged() { + if (!mounted) return; + setState(() { + _page = 0; + }); + } + + Future _openCriticalValues() async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + CriticalValuesScreen(token: widget.token, baseUrl: widget.baseUrl), + ), + ); + } + + void _showProfile() { + showModalBottomSheet( + 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 _showActions(Map item) async { + await showModalBottomSheet( + 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 _showAcceptDialog(Map item) async { + await showDialog( + context: context, + builder: (context) => _AcceptInitialWorkDialog( + item: item, + onSubmit: (payload) => _accept(item, payload), + ), + ); + } + + Future _accept( + Map item, + Map 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 _confirmReject(Map item) async { + final confirmed = await showDialog( + 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 _reject(Map 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> _filtered(List 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().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>( + 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 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 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 item; + final Future Function(Map 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 _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( + 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( + 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 _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); + } + } +} diff --git a/mylis/lib/screens/dashboard/sample_groups_screen.dart b/mylis/lib/screens/dashboard/sample_groups_screen.dart new file mode 100644 index 00000000..c063d9f1 --- /dev/null +++ b/mylis/lib/screens/dashboard/sample_groups_screen.dart @@ -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 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 books; + final String token; + final String baseUrl; + final Map user; + final int notificationCount; + + @override + Widget build(BuildContext context) { + final total = books.fold( + 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 user) { + showModalBottomSheet( + 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 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)), + ], + ), + ), + ), + ); + } +} diff --git a/mylis/lib/screens/dashboard/specimen_register_screen.dart b/mylis/lib/screens/dashboard/specimen_register_screen.dart new file mode 100644 index 00000000..87f0e39b --- /dev/null +++ b/mylis/lib/screens/dashboard/specimen_register_screen.dart @@ -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 user; + final int notificationCount; + + @override + State createState() => _SpecimenRegisterScreenState(); +} + +class _SpecimenRegisterScreenState extends State { + late final ApiClient _api; + late Future> _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> _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 _openCriticalValues() async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + CriticalValuesScreen(token: widget.token, baseUrl: widget.baseUrl), + ), + ); + } + + void _showProfile() { + showModalBottomSheet( + 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>( + 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 _filtered(List 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().join(' ').toLowerCase().contains(query); + return specimenOk && statusOk && searchOk; + }).toList(); + } + + List<_FilterOption> _specimenOptions(List items) { + final values = {}; + 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 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 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 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 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( + 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 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'), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/mylis/lib/screens/examinations/examination_detail_screen.dart b/mylis/lib/screens/examinations/examination_detail_screen.dart index 8fa1575a..1b7caff0 100644 --- a/mylis/lib/screens/examinations/examination_detail_screen.dart +++ b/mylis/lib/screens/examinations/examination_detail_screen.dart @@ -65,16 +65,6 @@ class _ExaminationDetailScreenState extends State { @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>( future: _future, builder: (context, snapshot) { @@ -90,80 +80,27 @@ class _ExaminationDetailScreenState extends State { 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 { ); } } + +class _ExaminationPatientCard extends StatelessWidget { + const _ExaminationPatientCard({required this.item}); + + final Map 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 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'), + ), + ], + ], + ), + ); + } +} diff --git a/mylis/lib/screens/examinations/examination_list_screen.dart b/mylis/lib/screens/examinations/examination_list_screen.dart index dea8551e..6efc1acd 100644 --- a/mylis/lib/screens/examinations/examination_list_screen.dart +++ b/mylis/lib/screens/examinations/examination_list_screen.dart @@ -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 createState() => _ExaminationListScreenState(); @@ -27,6 +29,7 @@ class _ExaminationListScreenState extends State { 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 { @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 { 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 _openDetail( BuildContext context, Map item, diff --git a/mylis/lib/widgets/common_widgets.dart b/mylis/lib/widgets/common_widgets.dart index f504c24b..4ca73243 100644 --- a/mylis/lib/widgets/common_widgets.dart +++ b/mylis/lib/widgets/common_widgets.dart @@ -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)); } } diff --git a/mylis/macos/Flutter/GeneratedPluginRegistrant.swift b/mylis/macos/Flutter/GeneratedPluginRegistrant.swift index 997e35da..eb7589b7 100644 --- a/mylis/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/mylis/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) } diff --git a/mylis/macos/Runner/DebugProfile.entitlements b/mylis/macos/Runner/DebugProfile.entitlements index 08c3ab17..88410221 100644 --- a/mylis/macos/Runner/DebugProfile.entitlements +++ b/mylis/macos/Runner/DebugProfile.entitlements @@ -10,5 +10,7 @@ com.apple.security.network.client + com.apple.security.device.camera + diff --git a/mylis/macos/Runner/Info.plist b/mylis/macos/Runner/Info.plist index 4789daa6..2455074c 100644 --- a/mylis/macos/Runner/Info.plist +++ b/mylis/macos/Runner/Info.plist @@ -24,6 +24,8 @@ $(MACOSX_DEPLOYMENT_TARGET) NSHumanReadableCopyright $(PRODUCT_COPYRIGHT) + NSCameraUsageDescription + MyLIS memakai kamera untuk scan barcode sampel. NSMainNibFile MainMenu NSPrincipalClass diff --git a/mylis/macos/Runner/Release.entitlements b/mylis/macos/Runner/Release.entitlements index ee95ab7e..a32e4b14 100644 --- a/mylis/macos/Runner/Release.entitlements +++ b/mylis/macos/Runner/Release.entitlements @@ -6,5 +6,7 @@ com.apple.security.network.client + com.apple.security.device.camera + diff --git a/mylis/pubspec.lock b/mylis/pubspec.lock index b8e19abe..ad0735ff 100644 --- a/mylis/pubspec.lock +++ b/mylis/pubspec.lock @@ -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: diff --git a/mylis/pubspec.yaml b/mylis/pubspec.yaml index 724d8f80..0a461ad4 100644 --- a/mylis/pubspec.yaml +++ b/mylis/pubspec.yaml @@ -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: diff --git a/mylis/test/widget_test.dart b/mylis/test/widget_test.dart index 752d535c..678d022e 100644 --- a/mylis/test/widget_test.dart +++ b/mylis/test/widget_test.dart @@ -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);