From 6d0f9a8bf6ce459def764ec235898dd535c881dc Mon Sep 17 00:00:00 2001 From: Dwi Swandhana Date: Tue, 21 Jul 2026 19:08:27 +0700 Subject: [PATCH] update --- .../Http/Controllers/MobileApiController.php | 688 +++++++++ .../views/dokter/ppdsdeveloper.blade.php | 2 +- htdocs/routes/api.php | 3 + listener/geneexpert.py | 63 +- mylis/README.md | 217 ++- mylis/lib/app/app.dart | 52 + mylis/lib/data/expertise_data.dart | 29 + mylis/lib/main.dart | 1270 +---------------- mylis/lib/providers/session_provider.dart | 35 + .../screens/dashboard/dashboard_screen.dart | 306 ++++ .../examination_detail_screen.dart | 171 +++ .../examinations/examination_list_screen.dart | 237 +++ .../expertise/cci_expertise_wizard.dart | 448 ++++++ .../expertise/covid_expertise_wizard.dart | 418 ++++++ .../lib/screens/expertise/expertise_form.dart | 192 +++ .../screens/expertise/expertise_screen.dart | 391 +++++ .../expertise/kultur_expertise_wizard.dart | 688 +++++++++ .../leptospira_expertise_wizard.dart | 361 +++++ .../pewarnaan_langsung_expertise_wizard.dart | 493 +++++++ .../expertise/tbc_expertise_wizard.dart | 669 +++++++++ .../screens/expertise/template_picker.dart | 55 + .../viral_load_expertise_wizard.dart | 419 ++++++ mylis/lib/screens/login/login_screen.dart | 254 ++++ mylis/lib/services/api_client.dart | 59 + mylis/lib/services/session_store.dart | 57 + mylis/lib/widgets/common_widgets.dart | 140 ++ 26 files changed, 6405 insertions(+), 1312 deletions(-) create mode 100644 mylis/lib/app/app.dart create mode 100644 mylis/lib/data/expertise_data.dart create mode 100644 mylis/lib/providers/session_provider.dart create mode 100644 mylis/lib/screens/dashboard/dashboard_screen.dart create mode 100644 mylis/lib/screens/examinations/examination_detail_screen.dart create mode 100644 mylis/lib/screens/examinations/examination_list_screen.dart create mode 100644 mylis/lib/screens/expertise/cci_expertise_wizard.dart create mode 100644 mylis/lib/screens/expertise/covid_expertise_wizard.dart create mode 100644 mylis/lib/screens/expertise/expertise_form.dart create mode 100644 mylis/lib/screens/expertise/expertise_screen.dart create mode 100644 mylis/lib/screens/expertise/kultur_expertise_wizard.dart create mode 100644 mylis/lib/screens/expertise/leptospira_expertise_wizard.dart create mode 100644 mylis/lib/screens/expertise/pewarnaan_langsung_expertise_wizard.dart create mode 100644 mylis/lib/screens/expertise/tbc_expertise_wizard.dart create mode 100644 mylis/lib/screens/expertise/template_picker.dart create mode 100644 mylis/lib/screens/expertise/viral_load_expertise_wizard.dart create mode 100644 mylis/lib/screens/login/login_screen.dart create mode 100644 mylis/lib/services/api_client.dart create mode 100644 mylis/lib/services/session_store.dart create mode 100644 mylis/lib/widgets/common_widgets.dart diff --git a/htdocs/app/Http/Controllers/MobileApiController.php b/htdocs/app/Http/Controllers/MobileApiController.php index 3ff63093..4ce8cc1b 100644 --- a/htdocs/app/Http/Controllers/MobileApiController.php +++ b/htdocs/app/Http/Controllers/MobileApiController.php @@ -2,8 +2,13 @@ namespace App\Http\Controllers; +use App\KomponenJawaban; +use App\CriticalValueSample; +use App\Organisms; use App\Periksa; use App\PeriksaSYNC; +use App\Riwayat; +use App\Subjawaban; use App\User; use Carbon\Carbon; use Illuminate\Http\Request; @@ -143,9 +148,16 @@ class MobileApiController extends Controller 'reques' => $row->reques, 'asalpasien' => $row->asalpasien, 'ruangan' => $row->ruangan, + 'analis' => $row->analis, + 'ppds3' => $row->ppdssenior, + 'dokter_id' => $row->dokter_id, 'klinisi' => $row->klinisi, + 'klinis' => $row->klinis, 'nmdokter' => $row->nmdokter, 'nm_spesimen' => $row->nm_spesimen, + 'kesimpulan' => $row->kesimpulan, + 'dlp' => $row->dlp, + 'tanggalregis' => $row->tanggalregis, 'tanggalsampel' => $row->tanggalsampel, 'pengambilan' => $row->pengambilan, 'asalpengirim' => $row->asalpengirim, @@ -270,6 +282,682 @@ class MobileApiController extends Controller ]); } + public function expertise(Request $request, int $id) + { + $user = $this->requireUser($request); + $row = Periksa::find($id); + abort_if(!$row, 404, 'Data pemeriksaan tidak ditemukan.'); + + $dlp = trim((string) ($row->dlp ?? '')); + return response()->json([ + 'item' => $this->examinationPayload($row), + 'user' => [ + 'id' => $user->id, + 'nama' => $user->nama, + 'previlage' => $user->previlage, + ], + 'dlp' => $dlp, + 'templates' => $this->templateList(), + 'staff_options' => $this->staffOptions(), + 'option_sets' => $this->expertiseOptionSets(), + 'is_critical' => CriticalValueSample::where('periksa_id', $row->id)->exists(), + 'components' => $dlp === '' ? (object) [] : $this->componentMap($row->nofoto, $dlp), + 'fields' => $dlp === '' ? [] : $this->expertiseFields($dlp), + ]); + } + + public function setExpertiseTemplate(Request $request, int $id) + { + $user = $this->requireUser($request); + $request->validate(['dlp' => ['required', 'string']]); + $row = Periksa::find($id); + abort_if(!$row, 404, 'Data pemeriksaan tidak ditemukan.'); + + $dlp = trim((string) $request->input('dlp')); + $row->update([ + 'dlp' => $dlp, + 'nmdrafter' => $user->nama, + 'nmexcutor' => $user->nama, + 'excutor' => $user->id, + 'status' => $row->status ?: 'Pemeriksaan awal', + ]); + + return response()->json([ + 'message' => 'Template expertise dipilih.', + 'item' => $this->examinationPayload($row->fresh()), + 'dlp' => $dlp, + 'components' => $this->componentMap($row->nofoto, $dlp), + 'fields' => $this->expertiseFields($dlp), + ]); + } + + public function saveExpertise(Request $request, int $id) + { + $user = $this->requireUser($request); + $request->validate([ + 'dlp' => ['required', 'string'], + 'action' => ['nullable', 'string'], + 'fields' => ['nullable', 'array'], + 'keterangan' => ['nullable', 'string'], + 'analis' => ['nullable'], + 'ppds3' => ['nullable'], + 'dokter' => ['nullable'], + 'nilai_kritis' => ['nullable'], + ]); + + $row = Periksa::find($id); + abort_if(!$row, 404, 'Data pemeriksaan tidak ditemukan.'); + + $dlp = trim((string) $request->input('dlp')); + $fields = $request->input('fields', []); + $keterangan = (string) $request->input('keterangan', ''); + $action = (string) $request->input('action', 'Draft'); + $analis = $request->input('analis'); + $ppds3 = $request->input('ppds3'); + $dokter = $request->input('dokter'); + $atlmUser = User::find($analis); + $ppdsUser = User::find($ppds3); + $spvUser = User::find($dokter); + + foreach ($fields as $key => $value) { + if ($value === '' || $value === null || $value === 'Pilih Salah Satu') { + KomponenJawaban::where('accnumber', $row->nofoto) + ->where('template', $dlp) + ->where('komponen', $key) + ->delete(); + continue; + } + + KomponenJawaban::updateOrCreate( + [ + 'accnumber' => $row->nofoto, + 'komponen' => $key, + ], + [ + 'isidata' => is_array($value) ? json_encode($value) : $value, + 'template' => $dlp, + 'created_by' => $user->nama, + ] + ); + } + + $status = match ($action) { + 'verifikasi' => 'Selesai', + 'preliminary' => 'Preliminary Results', + 'Permohonan Verifikasi' => 'Expertise Saved (Un Verified)', + 'Permohonan Verifikasi Preliminary' => 'Otor Maldi (Un Verified)', + default => 'Pemeriksaan awal', + }; + + $updateData = [ + 'keterangan' => $keterangan, + 'dlp' => $dlp, + 'analis' => $analis, + 'nmanalis' => $atlmUser->nama ?? null, + 'dokter_id' => $action === 'verifikasi' || $action === 'preliminary' ? $user->id : $dokter, + 'nmdokter' => $action === 'verifikasi' || $action === 'preliminary' ? $user->nama : ($spvUser->nama ?? null), + 'ppdssenior' => $ppds3, + 'nmppdssenior' => $ppdsUser->nama ?? null, + 'nmdrafter' => $user->nama, + 'nmexcutor' => $user->nama, + 'excutor' => $user->id, + 'baca' => in_array($action, ['Permohonan Verifikasi', 'Permohonan Verifikasi Preliminary'], true) ? now() : $row->baca, + 'nmpembaca' => in_array($action, ['Permohonan Verifikasi', 'Permohonan Verifikasi Preliminary'], true) ? $user->nama : $row->nmpembaca, + 'status' => $status, + ]; + if (in_array($action, ['verifikasi', 'preliminary'], true)) { + $updateData['verifikasi'] = now(); + $updateData['akhir'] = $action === 'verifikasi' ? now() : $row->akhir; + } + $row->update($updateData); + + if ($this->shouldMarkAsCritical($request)) { + $this->upsertCriticalValueSample($row->fresh(), $user); + } + + Riwayat::create([ + 'nofoto' => $row->nofoto, + 'jawaban' => $keterangan !== '' ? $keterangan : json_encode($fields), + 'inputor' => $user->nama, + 'keterangan' => match ($action) { + 'verifikasi' => 'Expertise', + 'preliminary' => 'Preliminary Results', + 'Permohonan Verifikasi' => 'Kirim ke SPV', + 'Permohonan Verifikasi Preliminary' => 'Kirim Preliminary ke SPV', + default => 'Draft', + }, + 'verifikasi' => in_array($action, ['verifikasi', 'preliminary'], true) ? 'Accepted' : '', + ]); + + return response()->json([ + 'message' => match ($action) { + 'verifikasi' => 'Expertise Saved', + 'preliminary' => 'Preliminary Results Saved', + 'Permohonan Verifikasi' => 'Permohonan Verifikasi Terkirim', + 'Permohonan Verifikasi Preliminary' => 'Permohonan Verifikasi Preliminary Terkirim', + default => 'Draft Expertise Saved', + }, + 'item' => $this->examinationPayload($row->fresh()), + 'components' => $this->componentMap($row->nofoto, $dlp), + ]); + } + + protected function templateList(): array + { + return Subjawaban::orderBy('kategori') + ->orderBy('judul') + ->get(['id', 'kategori', 'judul', 'subjawaban', 'kesimpulan']) + ->map(fn ($row) => [ + 'id' => $row->id, + 'kategori' => (string) $row->kategori, + 'judul' => (string) $row->judul, + 'isine' => trim((string) $row->subjawaban.' '.(string) $row->kesimpulan), + ]) + ->values() + ->toArray(); + } + + protected function componentMap($nofoto, string $dlp): array + { + return KomponenJawaban::where('accnumber', $nofoto) + ->where('template', $dlp) + ->get(['komponen', 'isidata']) + ->mapWithKeys(fn ($row) => [$row->komponen => $this->decodeComponentValue($row->isidata)]) + ->toArray(); + } + + protected function decodeComponentValue($value) + { + if (!is_string($value)) { + return $value; + } + $decoded = json_decode($value, true); + return json_last_error() === JSON_ERROR_NONE ? $decoded : $value; + } + + protected function staffOptions(): array + { + $mapUser = fn ($row) => [ + 'id' => $row->id, + 'nama' => $row->nama, + 'username' => $row->username, + 'previlage' => $row->previlage, + ]; + + return [ + 'analis' => User::where('previlage', 'analis')->orderBy('nama')->get()->map($mapUser)->values()->toArray(), + 'ppds' => User::where('previlage', 'ppds')->orderBy('nama')->get()->map($mapUser)->values()->toArray(), + 'dokters' => User::where('previlage', 'supervisor')->orderBy('nama')->get()->map($mapUser)->values()->toArray(), + ]; + } + + protected function expertiseOptionSets(): array + { + $organismNames = fn ($kelompok) => Organisms::where('kelompok', $kelompok) + ->orderBy('category') + ->orderBy('name') + ->pluck('name') + ->filter() + ->values() + ->toArray(); + + return [ + 'jsonsputum' => $organismNames('Sputum'), + 'jsonswabtenggorok' => $organismNames('Swab Tenggorok'), + 'jsonurine' => $organismNames('Urine'), + 'jsonswabperineum' => $organismNames('Swab perineum'), + 'jsonselepitel' => $organismNames('Sel Epitel'), + 'jsonselradang' => $organismNames('Sel Radang'), + 'jsonmikroorganisme' => $organismNames('Mikroorganisme'), + 'jsonmikroorganismeoptional' => Organisms::orderBy('category') + ->orderBy('name') + ->pluck('name') + ->filter() + ->values() + ->toArray(), + 'jsonjumlahlactobacillus' => $organismNames('Jumlah Lactobacillus'), + 'jsonjumlahgardnerella' => $organismNames('Jumlah Gardnerella'), + 'jsonjumlahmobiluncus' => $organismNames('Jumlah Mobiluncus'), + 'jsonpewarnaanziehlnielsen' => $organismNames('Pewarnaan Ziehl Neelsen'), + 'jsonpewarnaankoh' => $organismNames('Pewarnaan KOH'), + 'jsonpewarnaanneisser' => $organismNames('Pewarnaan Neisser'), + 'jsonpewarnaannegatif' => $organismNames('Pewarnaan Negatif'), + 'jsonpewarnaanspora' => $organismNames('Pewarnaan Spora'), + 'jsonpewarnaangiemsa' => $organismNames('Pewarnaan Giemsa'), + 'jsonbiakankultur' => $organismNames('Biakan Kultur'), + 'jsonvlparameter' => $organismNames('Viral Load Parameter'), + ]; + } + + protected function shouldMarkAsCritical(Request $request): bool + { + $flag = strtolower((string) $request->input('nilai_kritis', '0')); + return in_array($flag, ['1', 'true', 'yes', 'on'], true); + } + + protected function upsertCriticalValueSample(Periksa $periksa, User $user): void + { + $criticalSample = CriticalValueSample::firstOrNew(['periksa_id' => $periksa->id]); + $criticalSample->fill([ + 'nofoto' => $periksa->nofoto, + 'noregister' => $periksa->noregister, + 'nmpasien' => $periksa->nmpasien, + 'nm_spesimen' => $periksa->nm_spesimen, + 'asalpasien' => $periksa->asalpasien, + 'critical_set_at' => now(), + 'critical_set_by_user_id' => $user->id, + 'critical_set_by_name' => $user->nama, + ]); + $criticalSample->save(); + } + + protected function expertiseFields(string $dlp): array + { + $select = fn ($name, $label, $options) => ['name' => $name, 'label' => $label, 'type' => 'select', 'options' => $options]; + $text = fn ($name, $label) => ['name' => $name, 'label' => $label, 'type' => 'text']; + $area = fn ($name, $label) => ['name' => $name, 'label' => $label, 'type' => 'textarea']; + $multi = fn ($name, $label, $options) => ['name' => $name, 'label' => $label, 'type' => 'multiselect', 'options' => $options]; + $yn = ['Positif', 'Negatif']; + $tbResult = ['+++', '++', '+', '1-9', 'Neg']; + $organismOptions = ['Tidak ditemukan', 'Sedikit', 'Sedang', 'Banyak', 'lainnya']; + $sirOptions = ['ESBL', 'MRSA', 'MDR', 'XDR', 'PDR', 'Carbapenem Resistant', 'MDR Carbapenem Resistant', 'XDR Carbapenem Resistant']; + $colonyOptions = ['>= 10^5 CFU/ml Urine (Bakteriuria bermakna)', '>= 10^5 CFU/ml Urine (Candiduria bermakna)', '>= 10^3 CFU/ml Urine (Bakteriuria bermakna)', '8 x 10^3 CFU/ml Urine (Bakteriuria bermakna)', 'lainnya']; + $printOptions = ['YA', 'TIDAK']; + + return match ($dlp) { + 'CCI' => [ + $select('id_sputum', 'Sputum', $organismOptions), + $select('id_swabtenggorok', 'Swab Tenggorok', $organismOptions), + $select('id_urine', 'Urine', $organismOptions), + $select('id_swabperineum', 'Swab Perineum', $organismOptions), + $text('bakteri', 'Vitek - Bakteri'), + $select('bakterisir', 'Vitek - SIR', $sirOptions), + $select('id_bakterihitungkol', 'Vitek - Hitung Koloni', $colonyOptions), + $text('id_bakterihitungkolteks', 'Vitek - Hitung Koloni Lainnya'), + $select('id_baktericetak', 'Vitek - Cetak', $printOptions), + $text('id_bakteri01', 'MALDITOF - Bakteri'), + $text('id_antibiotikmanual1', 'MALDITOF - Set Antibiotik'), + $select('id_bakterisir01', 'MALDITOF - SIR', $sirOptions), + $select('id_bakterihitungkol01', 'MALDITOF - Hitung Koloni', $colonyOptions), + $text('id_bakterihitungkolteks01', 'MALDITOF - Hitung Koloni Lainnya'), + $select('id_bakteri01cetak', 'MALDITOF - Cetak', $printOptions), + $text('id_bakteri02', 'Bakteri Manual I - Bakteri'), + $text('id_antibiotikmanual2', 'Bakteri Manual I - Set Antibiotik'), + $select('id_bakterisir02', 'Bakteri Manual I - SIR', $sirOptions), + $select('id_bakterihitungkol02', 'Bakteri Manual I - Hitung Koloni', $colonyOptions), + $text('id_bakterihitungkolteks02', 'Bakteri Manual I - Hitung Koloni Lainnya'), + $select('id_bakteri02cetak', 'Bakteri Manual I - Cetak', $printOptions), + $area('keterangan', 'Keterangan Expertise'), + ], + 'Kultur' => [ + $select('id_selepitel', 'Sel Epitel', $organismOptions), + $select('id_selradang', 'Sel Radang', $organismOptions), + $select('id_mikroorganisme', 'Mikroorganisme', $organismOptions), + $multi('id_mikroorganismeoptional', 'Mikroorganisme Tambahan', ['Gram positif', 'Gram negatif', 'Yeast cell', 'Basil', 'Kokus']), + $select('id_jumlahlactobacillus', 'Lactobacillus - Jumlah Per Lapang', $organismOptions), + $select('id_lactobacillus', 'Lactobacillus - Skor', ['0', '1', '2', '3', '4']), + $select('id_jumlahgardnerella', 'Gardnerella - Jumlah Per Lapang', $organismOptions), + $select('id_gardnerella', 'Gardnerella - Skor', ['0', '1', '2', '3', '4']), + $select('id_jumlahmobiluncus', 'Mobiluncus - Jumlah Per Lapang', $organismOptions), + $select('id_mobiluncus', 'Mobiluncus - Skor', ['0', '1', '2', '3', '4']), + $select('id_pewarnaanziehlnielsen', 'Pewarnaan Ziehl Neelsen Pagi', $organismOptions), + $select('id_pewarnaanziehlnielsensewaktu', 'Pewarnaan Ziehl Neelsen Sewaktu', $organismOptions), + $select('id_pewarnaangiesma', 'Pewarnaan Giemsa', ['Tidak ditemukan', 'Ditemukan', 'lainnya']), + $text('id_pewarnaangiesmaoptional', 'Pewarnaan Giemsa Lainnya'), + $select('id_pewarnaankoh', 'Pewarnaan KOH', ['Tidak ditemukan', 'Ditemukan', 'lainnya']), + $text('id_pewarnaankohoptional', 'Pewarnaan KOH Lainnya'), + $select('id_pewarnaanneisser', 'Pewarnaan Neisser', $organismOptions), + $select('id_pewarnaannegatif', 'Pewarnaan Negatif', $organismOptions), + $select('id_pewarnaanspora', 'Pewarnaan Spora', $organismOptions), + $text('bd_result', 'BD Result'), + $text('bd_result_date', 'BD Result Date'), + $select('id_kbamc', 'Kirby Bauer AMC', array_merge(['Tidak dilakukan'], range(1, 42))), + $select('id_sirkbamc', 'Kirby Bauer AMC SIR', ['S', 'I', 'R']), + $select('id_kbak', 'Kirby Bauer AK', array_merge(['Tidak dilakukan'], range(1, 42))), + $select('id_sirkbak', 'Kirby Bauer AK SIR', ['S', 'I', 'R']), + $select('id_kbfos', 'Kirby Bauer FOS', array_merge(['Tidak dilakukan'], range(1, 42))), + $select('id_sirkbfos', 'Kirby Bauer FOS SIR', ['S', 'I', 'R']), + $select('id_kbscf', 'Kirby Bauer SCF', array_merge(['Tidak dilakukan'], range(1, 42))), + $select('id_sirkbscf', 'Kirby Bauer SCF SIR', ['S', 'I', 'R']), + $select('id_biakankultur', 'Biakan Kultur (Aerob / Anaerob)', $organismOptions), + $select('media_bap_status', 'Media BAP Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_bap_note', 'Media BAP Catatan'), + $select('media_cap_status', 'Media CAP Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_cap_note', 'Media CAP Catatan'), + $select('media_mcconkey_status', 'Media Mc Conkey Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_mcconkey_note', 'Media Mc Conkey Catatan'), + $select('media_sdar1_status', 'Kultur Jamur R1 Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_sdar1_note', 'Kultur Jamur R1 Catatan'), + $select('media_sdar2_status', 'Kultur Jamur R2 Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_sdar2_note', 'Kultur Jamur R2 Catatan'), + $select('media_sdai1_status', 'Kultur Jamur I1 Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_sdai1_note', 'Kultur Jamur I1 Catatan'), + $select('media_sdai2_status', 'Kultur Jamur I2 Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_sdai2_note', 'Kultur Jamur I2 Catatan'), + $select('media_sellainnya_status', 'Media Selektif Lainnya Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_sellainnya_note', 'Media Selektif Lainnya Catatan'), + $select('media_tamlainnya_status', 'Pemeriksaan Tambahan Lainnya Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_tamlainnya_note', 'Pemeriksaan Tambahan Lainnya Catatan'), + $text('bakteri', 'Vitek - Bakteri'), + $select('bakterisir', 'Vitek - SIR', $sirOptions), + $select('id_bakterihitungkol', 'Vitek - Hitung Koloni', $colonyOptions), + $text('id_bakterihitungkolteks', 'Vitek - Hitung Koloni Lainnya'), + $select('id_baktericetak', 'Vitek - Cetak', $printOptions), + $text('id_bakteri01', 'MALDITOF - Bakteri'), + $text('id_antibiotikmanual1', 'MALDITOF - Set Antibiotik'), + $select('id_bakterisir01', 'MALDITOF - SIR', $sirOptions), + $select('id_bakterihitungkol01', 'MALDITOF - Hitung Koloni', $colonyOptions), + $text('id_bakterihitungkolteks01', 'MALDITOF - Hitung Koloni Lainnya'), + $select('id_bakteri01cetak', 'MALDITOF - Cetak', $printOptions), + $text('id_bakteri02', 'Bakteri Manual I - Bakteri'), + $text('id_antibiotikmanual2', 'Bakteri Manual I - Set Antibiotik'), + $select('id_bakterisir02', 'Bakteri Manual I - SIR', $sirOptions), + $select('id_bakterihitungkol02', 'Bakteri Manual I - Hitung Koloni', $colonyOptions), + $text('id_bakterihitungkolteks02', 'Bakteri Manual I - Hitung Koloni Lainnya'), + $select('id_bakteri02cetak', 'Bakteri Manual I - Cetak', $printOptions), + $area('keterangan', 'Keterangan Expertise'), + ], + 'Pewarna Langsung' => [ + $select('lsg_selepitel', 'Sel Epitel', $organismOptions), + $select('lsg_selradang', 'Sel Radang', $organismOptions), + $select('lsg_mikroorganisme', 'Mikroorganisme', $organismOptions), + $multi('lsg_mikroorganismeoptional', 'Mikroorganisme Tambahan', ['Gram positif', 'Gram negatif', 'Yeast cell', 'Basil', 'Kokus']), + $select('lsg_pewarnaanziehlnielsen', 'Pewarnaan Ziehl Neelsen Pagi', $organismOptions), + $select('lsg_pewarnaanziehlnielsensewaktu', 'Pewarnaan Ziehl Neelsen Sewaktu', $organismOptions), + $select('lsg_pewarnaankoh', 'Pewarnaan KOH', ['Tidak ditemukan', 'Ditemukan', 'lainnya']), + $text('lsg_pewarnaankohoptional', 'Pewarnaan KOH Lainnya'), + $select('lsg_pewarnaanneisser', 'Pewarnaan Neisser', $organismOptions), + $select('lsg_pewarnaannegatif', 'Pewarnaan Negatif', $organismOptions), + $select('lsg_pewarnaangiesma', 'Pewarnaan Giemsa', ['Tidak ditemukan', 'Ditemukan', 'lainnya']), + $text('lsg_pewarnaangiesmaoptional', 'Pewarnaan Giemsa Lainnya'), + $select('lsg_pewarnaanspora', 'Pewarnaan Spora', $organismOptions), + $area('lsg_pewarnaanlain', 'Pewarnaan Lain'), + $select('lsg_jumlahlactobacillus', 'Lactobacillus - Jumlah Per Lapang', $organismOptions), + $select('lsg_lactobacillus', 'Lactobacillus - Skor', ['0', '1', '2', '3', '4']), + $select('lsg_jumlahgardnerella', 'Gardnerella - Jumlah Per Lapang', $organismOptions), + $select('lsg_gardnerella', 'Gardnerella - Skor', ['0', '1', '2', '3', '4']), + $select('lsg_jumlahmobiluncus', 'Mobiluncus - Jumlah Per Lapang', $organismOptions), + $select('lsg_mobiluncus', 'Mobiluncus - Skor', ['0', '1', '2', '3', '4']), + $select('media_bap_status', 'Media BAP Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_bap_note', 'Media BAP Catatan'), + $select('media_cap_status', 'Media CAP Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_cap_note', 'Media CAP Catatan'), + $select('media_mcconkey_status', 'Media Mc Conkey Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_mcconkey_note', 'Media Mc Conkey Catatan'), + $select('media_sdar1_status', 'Kultur Jamur R1 Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_sdar1_note', 'Kultur Jamur R1 Catatan'), + $select('media_sdar2_status', 'Kultur Jamur R2 Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_sdar2_note', 'Kultur Jamur R2 Catatan'), + $select('media_sdai1_status', 'Kultur Jamur I1 Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_sdai1_note', 'Kultur Jamur I1 Catatan'), + $select('media_sdai2_status', 'Kultur Jamur I2 Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_sdai2_note', 'Kultur Jamur I2 Catatan'), + $select('media_sellainnya_status', 'Media Selektif Lainnya Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_sellainnya_note', 'Media Selektif Lainnya Catatan'), + $select('media_tamlainnya_status', 'Pemeriksaan Tambahan Lainnya Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_tamlainnya_note', 'Pemeriksaan Tambahan Lainnya Catatan'), + $text('bakteri', 'Vitek - Bakteri'), + $select('bakterisir', 'Vitek - SIR', $sirOptions), + $select('id_bakterihitungkol', 'Vitek - Hitung Koloni', $colonyOptions), + $text('id_bakterihitungkolteks', 'Vitek - Hitung Koloni Lainnya'), + $select('id_baktericetak', 'Vitek - Cetak', $printOptions), + $text('id_bakteri01', 'MALDITOF - Bakteri'), + $text('id_antibiotikmanual1', 'MALDITOF - Set Antibiotik'), + $select('id_bakterisir01', 'MALDITOF - SIR', $sirOptions), + $select('id_bakterihitungkol01', 'MALDITOF - Hitung Koloni', $colonyOptions), + $text('id_bakterihitungkolteks01', 'MALDITOF - Hitung Koloni Lainnya'), + $select('id_bakteri01cetak', 'MALDITOF - Cetak', $printOptions), + $text('id_bakteri02', 'Bakteri Manual I - Bakteri'), + $text('id_antibiotikmanual2', 'Bakteri Manual I - Set Antibiotik'), + $select('id_bakterisir02', 'Bakteri Manual I - SIR', $sirOptions), + $select('id_bakterihitungkol02', 'Bakteri Manual I - Hitung Koloni', $colonyOptions), + $text('id_bakterihitungkolteks02', 'Bakteri Manual I - Hitung Koloni Lainnya'), + $select('id_bakteri02cetak', 'Bakteri Manual I - Cetak', $printOptions), + $area('keterangan', 'Keterangan Expertise'), + ], + 'TBC' => [ + $multi('jenis_pasien_tbc', 'Jenis Terduga / Pasien TBC', ['TBC SO', 'TBC RO', 'Anak', 'HIV', 'DM']), + $text('id_noidsediaan', 'No. Identitas Sediaan'), + $text('id_tanggalpengambilancontohuji', 'Tanggal Pengambilan Contoh Uji'), + $text('id_tanggalpengirimancontohuji', 'Tanggal Pengiriman Contoh Uji'), + $select('id_lamariwayatpengobatantb', 'Lama Riwayat Pengobatan TBC', ['Riwayat Pengobatan >= 5 tahun', 'Riwayat Pengobatan < 5 tahun']), + $select('id_jenispemeriksaantb', 'Jenis Pemeriksaan', ['Mikroskopis', 'TCM MTB Rif (Xpert)', 'TCM XDR (Xpert)', 'TCM MTB Rif (Truenat)', 'TCM MTB Rif INH (BDMAX)', 'PCR Open System', 'LPA lini 1', 'LPA lini 2', 'Biakan', 'Paket standar uji kepekaan']), + $select('id_lokasianatomi', 'Lokasi Anatomi', ['Paru', 'Ekstraparu']), + $text('id_ekstraparu', 'Ekstraparu'), + $select('id_alasanpemeriksaan', 'Alasan Pemeriksaan', ['Diagnosis TBC', 'Diagnosis Baseline TBC', 'Akhir Pengobatan']), + $text('id_bulankemajuantbfollow', 'Pemantauan kemajuan pengobatan'), + $text('id_bulanpemeriksaanulang', 'Pemeriksaan ulang'), + $text('id_bulanpemeriksaansetelahselesai', 'Pemeriksaan setelah selesai pengobatan'), + $text('id_noregfasyankes', 'No.Reg.TBC/TBC RO Fasyankes'), + $text('id_noregkota', 'No.Reg.TBC/TBC RO Kab/Kota'), + $select('id_contohuji', 'Contoh Uji', ['Dahak', 'Lainnya']), + $text('id_contohujilainnya', 'Contoh Uji Lainnya'), + $multi('visual_dahak_sewaktu', 'Visual dahak Sewaktu', ['Nanah Lendir', 'Bercak darah', 'Air liur']), + $multi('visual_dahak_pagi', 'Visual dahak Pagi', ['Nanah Lendir', 'Bercak darah', 'Air liur']), + $text('hasilpemeriksaantmikroskopis_tglsewaktu', 'Tanggal Mikroskopis Sewaktu'), + $select('hasilpemeriksaantmikroskopis_hasilsewaktu', 'Mikroskopis Sewaktu', $tbResult), + $text('hasilpemeriksaantmikroskopis_tglpagi', 'Tanggal Mikroskopis Pagi'), + $select('hasilpemeriksaantmikroskopis_hasilpagi', 'Mikroskopis Pagi', $tbResult), + $text('hasilpemeriksaanttcmmtbrifxpert_tglsewaktu', 'Tanggal TCM MTB Rif Sewaktu'), + $text('hasilpemeriksaanttcmmtbrifxpert_kodeunik', 'Kode Unik TCM MTB Rif Sewaktu'), + $select('hasilpemeriksaanttcmmtbrifxpert_hasilsewaktu', 'TCM MTB Rif Sewaktu', ['Neg', 'Rif Sen', 'Rif Res', 'Rif Indet', 'Invalid', 'Error', 'No result']), + $text('hasilpemeriksaanttcmmtbrifxpert_tglpagi', 'Tanggal TCM MTB Rif Pagi'), + $text('hasilpemeriksaanttcmmtbrifxpert_kodeunikpagi', 'Kode Unik TCM MTB Rif Pagi'), + $select('hasilpemeriksaanttcmmtbrifxpert_hasilpagi', 'TCM MTB Rif Pagi', ['Neg', 'Rif Sen', 'Rif Res', 'Rif Indet', 'Invalid', 'Error', 'No result']), + $text('hasilpemeriksaantbbiakan_tgl', 'Tanggal Mikroskopis Biakan'), + $select('hasilpemeriksaantbbiakan_pagi', 'Biakan Mikroskopis Pagi', $tbResult), + $select('hasilpemeriksaantbbiakan_sewaktu', 'Biakan Mikroskopis Sewaktu', $tbResult), + $text('hasilpemeriksaantbbiakan_tglbiakan', 'Tanggal Biakan'), + $select('hasilpemeriksaantbbiakan_hasilbiakanmgit', 'Hasil Biakan MGIT', ['Pos', 'Neg', 'NTM', 'KTM', 'TDL']), + $select('hasilpemeriksaantbbiakan_hasilbiakanlj', 'Hasil Biakan LJ', ['3+', '2+', '1+', '1-9', 'Neg', 'NTM', 'KTM', 'TDL']), + $text('hasilpemeriksaanttcmmtbxdrxpert_tglpagi', 'Tanggal TCM XDR Pagi'), + $text('hasilpemeriksaanttcmmtbxdrxpert_kodeunikpagi', 'Kode Unik TCM XDR Pagi'), + $text('hasilpemeriksaanttcmmtbxdrxpert_tglsewaktu', 'Tanggal TCM XDR Sewaktu'), + $text('hasilpemeriksaanttcmmtbxdrxpert_kodeunik', 'Kode Unik TCM XDR Sewaktu'), + $select('hasilpemeriksaanttcmmtbxdrxpert_pagi', 'TCM XDR Pagi', ['Valid', 'Invalid', 'No Result', 'Error']), + $select('hasilpemeriksaanttcmmtbxdrxpert_pagimtb', 'MTB Pagi', ['Detected', 'Not Detected']), + $select('hasilpemeriksaanttcmmtbxdrxpert_paginhlow', 'H Low Pagi', ['S', 'I', 'R', 'Invalid', 'Error', 'No Result']), + $select('hasilpemeriksaanttcmmtbxdrxpert_pagih', 'H Pagi', ['S', 'I', 'R', 'Invalid', 'Error', 'No Result']), + $select('hasilpemeriksaanttcmmtbxdrxpert_pagifqlow', 'FQ Low Pagi', ['S', 'I', 'R', 'Invalid', 'Error', 'No Result']), + $select('hasilpemeriksaanttcmmtbxdrxpert_pagifq', 'FQ Pagi', ['S', 'I', 'R', 'Invalid', 'Error', 'No Result']), + $select('hasilpemeriksaanttcmmtbxdrxpert_pagiamk', 'Amk Pagi', ['S', 'I', 'R', 'Invalid', 'Error', 'No Result']), + $select('hasilpemeriksaanttcmmtbxdrxpert_pagikm', 'Km Pagi', ['S', 'I', 'R', 'Invalid', 'Error', 'No Result']), + $select('hasilpemeriksaanttcmmtbxdrxpert_pagicm', 'Cm Pagi', ['S', 'I', 'R', 'Invalid', 'Error', 'No Result']), + $select('hasilpemeriksaanttcmmtbxdrxpert_pagieto', 'Eto Pagi', ['S', 'I', 'R', 'Invalid', 'Error', 'No Result']), + $select('hasilpemeriksaanttcmmtbxdrxpert_sewaktu', 'TCM XDR Sewaktu', ['Valid', 'Invalid', 'No Result', 'Error']), + $select('hasilpemeriksaanttcmmtbxdrxpert_sewaktumtb', 'MTB Sewaktu', ['Detected', 'Not Detected']), + $select('hasilpemeriksaanttcmmtbxdrxpert_sewaktunhlow', 'H Low Sewaktu', ['S', 'I', 'R', 'Invalid', 'Error', 'No Result']), + $select('hasilpemeriksaanttcmmtbxdrxpert_sewaktuh', 'H Sewaktu', ['S', 'I', 'R', 'Invalid', 'Error', 'No Result']), + $select('hasilpemeriksaanttcmmtbxdrxpert_sewaktufqlow', 'FQ Low Sewaktu', ['S', 'I', 'R', 'Invalid', 'Error', 'No Result']), + $select('hasilpemeriksaanttcmmtbxdrxpert_sewaktufq', 'FQ Sewaktu', ['S', 'I', 'R', 'Invalid', 'Error', 'No Result']), + $select('hasilpemeriksaanttcmmtbxdrxpert_sewaktuamk', 'Amk Sewaktu', ['S', 'I', 'R', 'Invalid', 'Error', 'No Result']), + $select('hasilpemeriksaanttcmmtbxdrxpert_sewaktukm', 'Km Sewaktu', ['S', 'I', 'R', 'Invalid', 'Error', 'No Result']), + $select('hasilpemeriksaanttcmmtbxdrxpert_sewaktucm', 'Cm Sewaktu', ['S', 'I', 'R', 'Invalid', 'Error', 'No Result']), + $select('hasilpemeriksaanttcmmtbxdrxpert_sewaktueto', 'Eto Sewaktu', ['S', 'I', 'R', 'Invalid', 'Error', 'No Result']), + $select('media_bap_status', 'Media BAP Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_bap_note', 'Media BAP Catatan'), + $select('media_cap_status', 'Media CAP Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_cap_note', 'Media CAP Catatan'), + $select('media_mcconkey_status', 'Media Mc Conkey Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_mcconkey_note', 'Media Mc Conkey Catatan'), + $select('media_sdar1_status', 'Kultur Jamur R1 Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_sdar1_note', 'Kultur Jamur R1 Catatan'), + $select('media_sdar2_status', 'Kultur Jamur R2 Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_sdar2_note', 'Kultur Jamur R2 Catatan'), + $select('media_sdai1_status', 'Kultur Jamur I1 Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_sdai1_note', 'Kultur Jamur I1 Catatan'), + $select('media_sdai2_status', 'Kultur Jamur I2 Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_sdai2_note', 'Kultur Jamur I2 Catatan'), + $select('media_sellainnya_status', 'Media Selektif Lainnya Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_sellainnya_note', 'Media Selektif Lainnya Catatan'), + $select('media_tamlainnya_status', 'Pemeriksaan Tambahan Lainnya Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_tamlainnya_note', 'Pemeriksaan Tambahan Lainnya Catatan'), + $text('bakteri', 'Vitek - Bakteri'), + $select('bakterisir', 'Vitek - SIR', $sirOptions), + $select('id_bakterihitungkol', 'Vitek - Hitung Koloni', $colonyOptions), + $text('id_bakterihitungkolteks', 'Vitek - Hitung Koloni Lainnya'), + $select('id_baktericetak', 'Vitek - Cetak', $printOptions), + $text('id_bakteri01', 'MALDITOF - Bakteri'), + $text('id_antibiotikmanual1', 'MALDITOF - Set Antibiotik'), + $select('id_bakterisir01', 'MALDITOF - SIR', $sirOptions), + $select('id_bakterihitungkol01', 'MALDITOF - Hitung Koloni', $colonyOptions), + $text('id_bakterihitungkolteks01', 'MALDITOF - Hitung Koloni Lainnya'), + $select('id_bakteri01cetak', 'MALDITOF - Cetak', $printOptions), + $text('id_bakteri02', 'Bakteri Manual I - Bakteri'), + $text('id_antibiotikmanual2', 'Bakteri Manual I - Set Antibiotik'), + $select('id_bakterisir02', 'Bakteri Manual I - SIR', $sirOptions), + $select('id_bakterihitungkol02', 'Bakteri Manual I - Hitung Koloni', $colonyOptions), + $text('id_bakterihitungkolteks02', 'Bakteri Manual I - Hitung Koloni Lainnya'), + $select('id_bakteri02cetak', 'Bakteri Manual I - Cetak', $printOptions), + $area('keterangan', 'Keterangan Expertise'), + ], + 'Viral Load' => [ + $select('vl_parameter01', 'Parameter', ['HIV RNA', 'HCV RNA', 'HBV DNA']), + $select('vl_hasil01', 'Hasil', ['NOT DETECTED', '<40', '<1.60', '40', '41', '44', '45', '145', '<10', '20', '322', '511', '144', '211', 'lainnya']), + $text('vl_hasil01teks', 'Hasil Lainnya'), + $select('vl_satuan01', 'Satuan', ['copies/mL', 'IU/mL']), + $select('vl_rujukan01', 'Nilai Rujukan', ['40-10.000.000', '10-100.000.000']), + $select('vl_parameter02', 'Parameter 02', ['LOG']), + $select('vl_hasil02', 'Hasil 02', ['NOT DETECTED', '<1,60', '<1,85', '1,60', '1,61', '1,64', '1,65', '2,16', '1,00', '1,30', '2,51', '2,71', '2,34', 'lainnya']), + $text('vl_hasil02teks', 'Hasil 02 Lainnya'), + $select('vl_satuan02', 'Satuan 02', ['LOG']), + $select('vl_rujukan02', 'Nilai Rujukan 02', ['1,6-7', ' ']), + $area('viralload', 'Catatan Viral Load'), + $select('media_bap_status', 'Media BAP Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_bap_note', 'Media BAP Catatan'), + $select('media_cap_status', 'Media CAP Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_cap_note', 'Media CAP Catatan'), + $select('media_mcconkey_status', 'Media Mc Conkey Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_mcconkey_note', 'Media Mc Conkey Catatan'), + $select('media_sdar1_status', 'Kultur Jamur R1 Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_sdar1_note', 'Kultur Jamur R1 Catatan'), + $select('media_sdar2_status', 'Kultur Jamur R2 Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_sdar2_note', 'Kultur Jamur R2 Catatan'), + $select('media_sdai1_status', 'Kultur Jamur I1 Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_sdai1_note', 'Kultur Jamur I1 Catatan'), + $select('media_sdai2_status', 'Kultur Jamur I2 Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_sdai2_note', 'Kultur Jamur I2 Catatan'), + $select('media_sellainnya_status', 'Media Selektif Lainnya Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_sellainnya_note', 'Media Selektif Lainnya Catatan'), + $select('media_tamlainnya_status', 'Pemeriksaan Tambahan Lainnya Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_tamlainnya_note', 'Pemeriksaan Tambahan Lainnya Catatan'), + $text('bakteri', 'Vitek - Bakteri'), + $select('bakterisir', 'Vitek - SIR', $sirOptions), + $select('id_bakterihitungkol', 'Vitek - Hitung Koloni', $colonyOptions), + $text('id_bakterihitungkolteks', 'Vitek - Hitung Koloni Lainnya'), + $select('id_baktericetak', 'Vitek - Cetak', $printOptions), + $text('id_bakteri01', 'MALDITOF - Bakteri'), + $text('id_antibiotikmanual1', 'MALDITOF - Set Antibiotik'), + $select('id_bakterisir01', 'MALDITOF - SIR', $sirOptions), + $select('id_bakterihitungkol01', 'MALDITOF - Hitung Koloni', $colonyOptions), + $text('id_bakterihitungkolteks01', 'MALDITOF - Hitung Koloni Lainnya'), + $select('id_bakteri01cetak', 'MALDITOF - Cetak', $printOptions), + $text('id_bakteri02', 'Bakteri Manual I - Bakteri'), + $text('id_antibiotikmanual2', 'Bakteri Manual I - Set Antibiotik'), + $select('id_bakterisir02', 'Bakteri Manual I - SIR', $sirOptions), + $select('id_bakterihitungkol02', 'Bakteri Manual I - Hitung Koloni', $colonyOptions), + $text('id_bakterihitungkolteks02', 'Bakteri Manual I - Hitung Koloni Lainnya'), + $select('id_bakteri02cetak', 'Bakteri Manual I - Cetak', $printOptions), + $area('keterangan', 'Keterangan Expertise'), + ], + 'IgM IgG Leptospira' => [ + $select('igg_parameter', 'IgG Leptospira', $yn), + $select('igm_parameter', 'IgM Leptospira', $yn), + $area('iggigm_interpretasi', 'Interpretasi'), + $select('media_bap_status', 'Media BAP Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_bap_note', 'Media BAP Catatan'), + $select('media_cap_status', 'Media CAP Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_cap_note', 'Media CAP Catatan'), + $select('media_mcconkey_status', 'Media Mc Conkey Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_mcconkey_note', 'Media Mc Conkey Catatan'), + $select('media_sdar1_status', 'Kultur Jamur R1 Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_sdar1_note', 'Kultur Jamur R1 Catatan'), + $select('media_sdar2_status', 'Kultur Jamur R2 Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_sdar2_note', 'Kultur Jamur R2 Catatan'), + $select('media_sdai1_status', 'Kultur Jamur I1 Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_sdai1_note', 'Kultur Jamur I1 Catatan'), + $select('media_sdai2_status', 'Kultur Jamur I2 Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_sdai2_note', 'Kultur Jamur I2 Catatan'), + $select('media_sellainnya_status', 'Media Selektif Lainnya Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_sellainnya_note', 'Media Selektif Lainnya Catatan'), + $select('media_tamlainnya_status', 'Pemeriksaan Tambahan Lainnya Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_tamlainnya_note', 'Pemeriksaan Tambahan Lainnya Catatan'), + $text('bakteri', 'Vitek - Bakteri'), + $select('bakterisir', 'Vitek - SIR', $sirOptions), + $select('id_bakterihitungkol', 'Vitek - Hitung Koloni', $colonyOptions), + $text('id_bakterihitungkolteks', 'Vitek - Hitung Koloni Lainnya'), + $select('id_baktericetak', 'Vitek - Cetak', $printOptions), + $text('id_bakteri01', 'MALDITOF - Bakteri'), + $text('id_antibiotikmanual1', 'MALDITOF - Set Antibiotik'), + $select('id_bakterisir01', 'MALDITOF - SIR', $sirOptions), + $select('id_bakterihitungkol01', 'MALDITOF - Hitung Koloni', $colonyOptions), + $text('id_bakterihitungkolteks01', 'MALDITOF - Hitung Koloni Lainnya'), + $select('id_bakteri01cetak', 'MALDITOF - Cetak', $printOptions), + $text('id_bakteri02', 'Bakteri Manual I - Bakteri'), + $text('id_antibiotikmanual2', 'Bakteri Manual I - Set Antibiotik'), + $select('id_bakterisir02', 'Bakteri Manual I - SIR', $sirOptions), + $select('id_bakterihitungkol02', 'Bakteri Manual I - Hitung Koloni', $colonyOptions), + $text('id_bakterihitungkolteks02', 'Bakteri Manual I - Hitung Koloni Lainnya'), + $select('id_bakteri02cetak', 'Bakteri Manual I - Cetak', $printOptions), + $area('keterangan', 'Keterangan Expertise'), + ], + 'PCR COVID' => [ + $select('covid_virus01', 'Virus SARS-Cov-2', ['Positif', 'Negatif', 'Tidak Diperiksa']), + $text('covid_satuanvirus01', 'Satuan SARS-Cov-2'), + $text('covid_rujukanvirus01', 'Nilai Rujukan SARS-Cov-2'), + $select('covid_virus02', 'Influenza A', ['Positif', 'Negatif', 'Tidak Diperiksa']), + $text('covid_satuanvirus02', 'Satuan Influenza A'), + $text('covid_rujukanvirus02', 'Nilai Rujukan Influenza A'), + $select('covid_virus03', 'Influenza B', ['Positif', 'Negatif', 'Tidak Diperiksa']), + $text('covid_satuanvirus03', 'Satuan Influenza B'), + $text('covid_rujukanvirus03', 'Nilai Rujukan Influenza B'), + $select('covid_virus04', 'RSV', ['Positif', 'Negatif', 'Tidak Diperiksa']), + $text('covid_satuanvirus04', 'Satuan RSV'), + $text('covid_rujukanvirus04', 'Nilai Rujukan RSV'), + $area('covid_interpretasi', 'Interpretasi'), + $select('media_bap_status', 'Media BAP Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_bap_note', 'Media BAP Catatan'), + $select('media_cap_status', 'Media CAP Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_cap_note', 'Media CAP Catatan'), + $select('media_mcconkey_status', 'Media Mc Conkey Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_mcconkey_note', 'Media Mc Conkey Catatan'), + $select('media_sdar1_status', 'Kultur Jamur R1 Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_sdar1_note', 'Kultur Jamur R1 Catatan'), + $select('media_sdar2_status', 'Kultur Jamur R2 Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_sdar2_note', 'Kultur Jamur R2 Catatan'), + $select('media_sdai1_status', 'Kultur Jamur I1 Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_sdai1_note', 'Kultur Jamur I1 Catatan'), + $select('media_sdai2_status', 'Kultur Jamur I2 Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_sdai2_note', 'Kultur Jamur I2 Catatan'), + $select('media_sellainnya_status', 'Media Selektif Lainnya Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_sellainnya_note', 'Media Selektif Lainnya Catatan'), + $select('media_tamlainnya_status', 'Pemeriksaan Tambahan Lainnya Status', ['Ada Pertumbuhan', 'Tidak Ada Pertumbuhan', 'Pertumbuhan Primer']), + $text('media_tamlainnya_note', 'Pemeriksaan Tambahan Lainnya Catatan'), + $text('bakteri', 'Vitek - Bakteri'), + $select('bakterisir', 'Vitek - SIR', $sirOptions), + $select('id_bakterihitungkol', 'Vitek - Hitung Koloni', $colonyOptions), + $text('id_bakterihitungkolteks', 'Vitek - Hitung Koloni Lainnya'), + $select('id_baktericetak', 'Vitek - Cetak', $printOptions), + $text('id_bakteri01', 'MALDITOF - Bakteri'), + $text('id_antibiotikmanual1', 'MALDITOF - Set Antibiotik'), + $select('id_bakterisir01', 'MALDITOF - SIR', $sirOptions), + $select('id_bakterihitungkol01', 'MALDITOF - Hitung Koloni', $colonyOptions), + $text('id_bakterihitungkolteks01', 'MALDITOF - Hitung Koloni Lainnya'), + $select('id_bakteri01cetak', 'MALDITOF - Cetak', $printOptions), + $text('id_bakteri02', 'Bakteri Manual I - Bakteri'), + $text('id_antibiotikmanual2', 'Bakteri Manual I - Set Antibiotik'), + $select('id_bakterisir02', 'Bakteri Manual I - SIR', $sirOptions), + $select('id_bakterihitungkol02', 'Bakteri Manual I - Hitung Koloni', $colonyOptions), + $text('id_bakterihitungkolteks02', 'Bakteri Manual I - Hitung Koloni Lainnya'), + $select('id_bakteri02cetak', 'Bakteri Manual I - Cetak', $printOptions), + $area('keterangan', 'Keterangan Expertise'), + ], + default => [ + $area('keterangan', 'Keterangan Expertise'), + ], + }; + } + public function earlyWarning(Request $request) { $this->requireUser($request); diff --git a/htdocs/resources/views/dokter/ppdsdeveloper.blade.php b/htdocs/resources/views/dokter/ppdsdeveloper.blade.php index fc342260..ea0dc7b5 100644 --- a/htdocs/resources/views/dokter/ppdsdeveloper.blade.php +++ b/htdocs/resources/views/dokter/ppdsdeveloper.blade.php @@ -772,7 +772,7 @@
- C. Tes Kepekaan Antibiotika: (S: Sensitif ; I : Intermediate ; R: Resisten) + C. Tes Kepekaan Antibiotik: (S: Sensitif ; I : Intermediate ; R: Resisten)
diff --git a/htdocs/routes/api.php b/htdocs/routes/api.php index e01ad440..f2a27231 100644 --- a/htdocs/routes/api.php +++ b/htdocs/routes/api.php @@ -26,6 +26,9 @@ Route::prefix('mobile')->group(function () { Route::get('early-warning', [MobileApiController::class, 'earlyWarning']); Route::get('books/{master}/examinations', [MobileApiController::class, 'examinations']); Route::get('examinations/{id}', [MobileApiController::class, 'examination']); + Route::get('examinations/{id}/expertise', [MobileApiController::class, 'expertise']); + Route::post('examinations/{id}/expertise/template', [MobileApiController::class, 'setExpertiseTemplate']); + Route::post('examinations/{id}/expertise', [MobileApiController::class, 'saveExpertise']); }); Route::group([ diff --git a/listener/geneexpert.py b/listener/geneexpert.py index fd54029c..431e81e9 100644 --- a/listener/geneexpert.py +++ b/listener/geneexpert.py @@ -135,40 +135,40 @@ TARGET_MAPPING = { # Kanan: 'Host Test Code' dari Dokumen Word Anda GENEXPERT_TEST_MAPPING = { # Mapping untuk IP 10.10.120.75 (Multi-Assay) - "HIV": "HIV1-VL", # Xpert HIV-1 Viral Load XC Version 3 - "TCM TB": "MTBRIF", # Xpert MTBRIF Assay G4 Version 6 - "TCM TB ULTRA": "MTBRIF", # Xpert MTBRIF Ultra Version 4 - "TCM TB XDR": "MTB-XDR", # Xpert MTB-XDR Version 1 - "HCV VL": "HCV", # Xpert HCV Viral Load Version 1 - "COVID-19": "SARSCOV2FLURSV", # Xpert Xpress SARS-CoV-2 Version 2 - "17.3.1 TCM COVID-19": "SARSCOV2FLURSV", # Xpert Xpress SARS-CoV-2 Version 2 - "17.3.2 PCR COVID-19": "SARSCOV2FLURSV", # Xpert Xpress SARS-CoV-2 Version 2 + "HIV": "HIV1-VL", + "TCM TB": "MTBRIF", + "TCM TB ULTRA": "MTBRIF", + "TCM TB XDR": "MTB-XDR 2", + "HCV VL": "HCV", + "COVID-19": "SARSCOV2FLURSV", + "17.3.1 TCM COVID-19": "SARSCOV2FLURSV", + "17.3.2 PCR COVID-19": "SARSCOV2FLURSV", "E.2.5 HCV TCM": "HCV", "18.1.1 TCM HCV": "HCV", "18.1.2 TCM HIV VIRAL LOAD": "HIV1-VL", "18.1.4 TCM HPV": "HCV", "7.3.7 KULTUR TBC MGIT (AUTOMATIC)": "MTBRIF", "5.3.8 KULTUR TBC MGIT (AUTOMATIC)": "MTBRIF", - "5.3.7 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR", - "3.3.6 KULTUR TBC MEDIA LJ (KONVENSIONAL) ": "MTB-XDR", - "2.3.7 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR", - "1.3.6 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR", - "1.3.7 KULTUR TBC MGIT (AUTOMATIC)": "MTB-XDR", - "15.2.1 KULTUR TB MEDIA LJ": "MTB-XDR", - "8.3.7 KULTUR TBC MGIT (AUTOMATIC)": "MTB-XDR", - "9.3.5 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR", - "9.3.6 KULTUR TBC MGIT (AUTOMATIC)": "MTB-XDR", - "12.3.7 KULTUR TBC MGIT (AUTOMATIC)": "MTB-XDR", - "H.2.5 PEMERIKSAAN KULTUR MYCROBACTERIUM TBC": "MTB-XDR", - "12.3.6 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR", - "11.3.6 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR", - "10.3.7 KULTUR TBC MGIT (AUTOMATIC)": "MTB-XDR", - "10.3.6 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR", - "3.3.7 KULTUR TBC MGIT (AUTOMATIC)": "MTB-XDR", - "15.2.2 KULTUR TB MEDIA MGIT (AUTOMATIC)": "MTB-XDR", - "11.3.7 KULTUR TBC MGIT (AUTOMATIC)": "MTB-XDR", - "8.3.6 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR", - "7.3.6 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR", + "5.3.7 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR 2", + "3.3.6 KULTUR TBC MEDIA LJ (KONVENSIONAL) ": "MTB-XDR 2", + "2.3.7 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR 2", + "1.3.6 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR 2", + "1.3.7 KULTUR TBC MGIT (AUTOMATIC)": "MTB-XDR 2", + "15.2.1 KULTUR TB MEDIA LJ": "MTB-XDR 2", + "8.3.7 KULTUR TBC MGIT (AUTOMATIC)": "MTB-XDR 2", + "9.3.5 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR 2", + "9.3.6 KULTUR TBC MGIT (AUTOMATIC)": "MTB-XDR 2", + "12.3.7 KULTUR TBC MGIT (AUTOMATIC)": "MTB-XDR 2", + "H.2.5 PEMERIKSAAN KULTUR MYCROBACTERIUM TBC": "MTB-XDR 2", + "12.3.6 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR 2", + "11.3.6 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR 2", + "10.3.7 KULTUR TBC MGIT (AUTOMATIC)": "MTB-XDR 2", + "10.3.6 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR 2", + "3.3.7 KULTUR TBC MGIT (AUTOMATIC)": "MTB-XDR 2", + "15.2.2 KULTUR TB MEDIA MGIT (AUTOMATIC)": "MTB-XDR 2", + "11.3.7 KULTUR TBC MGIT (AUTOMATIC)": "MTB-XDR 2", + "8.3.6 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR 2", + "7.3.6 KULTUR TBC MEDIA LJ (KONVENSIONAL)": "MTB-XDR 2", "2.3.10 TCM CLAMIDIA TRACHOMATIS / NEISSERIA GONORRHOE": "MTBRIF", "12.3.8 TCM TB (GENE EXPERT)": "MTBRIF", "15.2.3 TCM TB (GENE EXPERT)": "MTBRIF", @@ -183,11 +183,10 @@ GENEXPERT_TEST_MAPPING = { } GENEXPERT_IP_CAPABILITIES = { - "10.10.120.75": ["MTBRIF", "HBVVL", "HIV1-VL", "MTB-XDR", "HCV VL", "SARSCOV2FLURSV"], - "10.10.120.108": ["HCV", "HIV", "HBV"], - "10.10.120.73": ["MTBRIF"] + "10.10.120.75": ["MTBRIF", "HBVVL", "HIV1-VL", "MTB-XDR 2", "HCV", "SARSCOV2FLURSV"], + "10.10.120.108": ["HCV", "HIV1-VL", "HBVVL"], + "10.10.120.73": ["MTBRIF", "HBVVL", "HIV1-VL", "HCV", "SARSCOV2FLURSV"], } -# Default code jika nama tes di database tidak dikenali DEFAULT_GXP_CODE = "MTBRIF" DEVICE_CONFIGS = [ diff --git a/mylis/README.md b/mylis/README.md index d9003aeb..8bca9095 100644 --- a/mylis/README.md +++ b/mylis/README.md @@ -1,17 +1,212 @@ -# mylis +# MyLIS Flutter -A new Flutter project. +MyLIS Flutter adalah aplikasi pendamping untuk Laravel LIS lokal. Aplikasi ini fokus pada akses mobile/desktop untuk login, dashboard Early Warning Sistem, daftar pemeriksaan, detail pemeriksaan, dan pengisian expertise per template DLP. -## Getting Started +Default URL Laravel lokal: -This project is a starting point for a Flutter application. +```text +https://lis.swandhana.test/ +``` -A few resources to get you started if this is your first Flutter project: +URL tersebut bisa diganti dari halaman sebelum login. Tersedia tombol `Ping` agar pengguna dapat memastikan server Laravel sudah dapat diakses sebelum login. -- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) -- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) +## Fitur Utama -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. +- Login tanpa register. +- Pengaturan URL Laravel dari layar login. +- Ping koneksi ke Laravel. +- Dashboard dengan menu utama `Early Warning Sistem`. +- Daftar pemeriksaan per buku/kategori. +- Detail pemeriksaan dengan status dan tombol `Expertise`. +- Screen expertise Flutter native, tidak membuka Blade Laravel. +- Pemilihan template saat `periksa.dlp` masih kosong. +- Penyimpanan draft, preliminary, final, dan kirim ke SPV sesuai role pengguna. +- Dukungan build Android, iOS, dan macOS. + +## Alur Expertise + +Jika `periksa.dlp` kosong, aplikasi menampilkan pilihan template. Jika sudah terisi, aplikasi langsung membuka wizard sesuai DLP. + +Screen DLP yang sudah dibuat: + +- `CCI` +- `Kultur` +- `Pewarna Langsung` +- `TBC` +- `Viral Load` +- `IgM IgG Leptospira` +- `PCR COVID` + +Semua wizard memakai pola umum: + +1. Data pasien dan petugas. +2. Isian parameter/template sesuai DLP. +3. Tes Kepekaan Antibiotik jika berlaku. +4. Data alat jika berlaku. +5. Isian expertise akhir. + +Navigasi wizard tidak memakai validasi wajib, sehingga pengguna bebas `Next` dan `Previous`. + +## Role dan Tombol Simpan + +Untuk role supervisor/SPV: + +- `Save as Draft` +- `Save Preliminary result` +- `Save Final Result` + +Untuk role selain supervisor/SPV: + +- `Save as Draft` +- `Save and Send To SPV` +- `Kirim SPV Preliminary` + +Checkbox `Nilai Kritis` tersedia di step akhir dan dikirim ke API saat menyimpan. + +## Struktur Folder + +```text +lib/ + app/ + app.dart + data/ + expertise_data.dart + providers/ + session_provider.dart + screens/ + dashboard/ + examinations/ + expertise/ + login/ + services/ + api_client.dart + session_store.dart + widgets/ + common_widgets.dart + main.dart +``` + +Ringkasan: + +- `main.dart`: entry point aplikasi. +- `app/app.dart`: root aplikasi dan registrasi part screen. +- `services/api_client.dart`: komunikasi HTTP ke Laravel. +- `services/session_store.dart`: penyimpanan token dan base URL. +- `providers/session_provider.dart`: pengecekan session awal. +- `screens/login`: halaman login, URL Laravel, dan ping. +- `screens/dashboard`: dashboard dan Early Warning Sistem. +- `screens/examinations`: daftar dan detail pemeriksaan. +- `screens/expertise`: semua screen wizard DLP. +- `widgets/common_widgets.dart`: komponen UI umum. +- `data/expertise_data.dart`: daftar DLP dan label template. + +## API Laravel + +Aplikasi memakai endpoint mobile Laravel dengan prefix: + +```text +/api/mobile +``` + +Endpoint utama: + +- `GET /api/mobile/ping` +- `POST /api/mobile/login` +- `GET /api/mobile/dashboard` +- `GET /api/mobile/early-warning` +- `GET /api/mobile/books/{master}/examinations` +- `GET /api/mobile/examinations/{id}` +- `GET /api/mobile/examinations/{id}/expertise` +- `POST /api/mobile/examinations/{id}/expertise/template` +- `POST /api/mobile/examinations/{id}/expertise` + +Controller Laravel berada di: + +```text +/Users/duidev/htdocs/lis/htdocs/app/Http/Controllers/MobileApiController.php +``` + +Route API berada di: + +```text +/Users/duidev/htdocs/lis/htdocs/routes/api.php +``` + +## Menjalankan Aplikasi + +Ambil dependency: + +```bash +flutter pub get +``` + +Jalankan di device/simulator: + +```bash +flutter run +``` + +Jalankan dengan default URL lain: + +```bash +flutter run --dart-define=LIS_BASE_URL=https://lis.swandhana.test/ +``` + +## Verifikasi + +Analisis kode Flutter: + +```bash +flutter analyze +``` + +Test Flutter: + +```bash +flutter test +``` + +Cek route Laravel mobile: + +```bash +php artisan route:list --path=mobile +``` + +Cek sintaks controller Laravel: + +```bash +php -l app/Http/Controllers/MobileApiController.php +``` + +## Build + +Android debug: + +```bash +flutter build apk --debug +``` + +iOS debug tanpa code signing: + +```bash +flutter build ios --debug --no-codesign +``` + +macOS debug: + +```bash +flutter build macos --debug +``` + +Output build yang umum: + +- Android: `build/app/outputs/flutter-apk/app-debug.apk` +- iOS: `build/ios/iphoneos/Runner.app` +- macOS: `build/macos/Build/Products/Debug/mylis.app` + +## Catatan Lokal + +- Pastikan Laravel lokal `https://lis.swandhana.test/` aktif. +- Pastikan sertifikat lokal dipercaya oleh device/simulator yang dipakai. +- Untuk iOS device fisik, build `--no-codesign` tetap perlu signing manual sebelum deploy. +- Jangan menjalankan beberapa command Flutter berat secara bersamaan karena bisa berebut file sementara pada folder platform. diff --git a/mylis/lib/app/app.dart b/mylis/lib/app/app.dart new file mode 100644 index 00000000..8773a8ef --- /dev/null +++ b/mylis/lib/app/app.dart @@ -0,0 +1,52 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:url_launcher/url_launcher.dart'; + +part '../providers/session_provider.dart'; +part '../services/api_client.dart'; +part '../services/session_store.dart'; +part '../screens/login/login_screen.dart'; +part '../screens/dashboard/dashboard_screen.dart'; +part '../screens/examinations/examination_list_screen.dart'; +part '../screens/examinations/examination_detail_screen.dart'; +part '../screens/expertise/expertise_screen.dart'; +part '../screens/expertise/template_picker.dart'; +part '../screens/expertise/cci_expertise_wizard.dart'; +part '../screens/expertise/kultur_expertise_wizard.dart'; +part '../screens/expertise/pewarnaan_langsung_expertise_wizard.dart'; +part '../screens/expertise/viral_load_expertise_wizard.dart'; +part '../screens/expertise/leptospira_expertise_wizard.dart'; +part '../screens/expertise/covid_expertise_wizard.dart'; +part '../screens/expertise/tbc_expertise_wizard.dart'; +part '../screens/expertise/expertise_form.dart'; +part '../data/expertise_data.dart'; +part '../widgets/common_widgets.dart'; + +const String kDefaultBaseUrl = String.fromEnvironment( + 'LIS_BASE_URL', + defaultValue: 'https://lis.swandhana.test/', +); + +class MyLisApp extends StatelessWidget { + const MyLisApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'MyLIS Mobile', + debugShowCheckedModeBanner: false, + theme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF0F766E), + brightness: Brightness.light, + ), + scaffoldBackgroundColor: const Color(0xFFF7FAF9), + useMaterial3: true, + ), + home: const SessionGate(), + ); + } +} diff --git a/mylis/lib/data/expertise_data.dart b/mylis/lib/data/expertise_data.dart new file mode 100644 index 00000000..7ba1b7df --- /dev/null +++ b/mylis/lib/data/expertise_data.dart @@ -0,0 +1,29 @@ +part of '../app/app.dart'; + +const Set _knownDlp = { + 'CCI', + 'Kultur', + 'Pewarna Langsung', + 'TBC', + 'Viral Load', + 'IgM IgG Leptospira', + 'PCR COVID', +}; + +String _templateTitle(String dlp) { + return switch (dlp) { + 'CCI' => 'Pemeriksaan Candida Colonization Index', + 'Kultur' => 'Kultur', + 'Pewarna Langsung' => 'Pewarna Langsung', + 'TBC' => 'TBC', + 'Viral Load' => 'Viral Load', + 'IgM IgG Leptospira' => 'IgM IgG Leptospira', + 'PCR COVID' => 'PCR COVID', + _ => dlp, + }; +} + +bool _isSupervisor(String? role) { + final normalized = role?.toLowerCase().trim() ?? ''; + return normalized.contains('supervisor') || normalized == 'spv'; +} diff --git a/mylis/lib/main.dart b/mylis/lib/main.dart index 845499b7..0c2eebd2 100644 --- a/mylis/lib/main.dart +++ b/mylis/lib/main.dart @@ -1,1274 +1,8 @@ -import 'dart:convert'; - import 'package:flutter/material.dart'; -import 'package:http/http.dart' as http; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:url_launcher/url_launcher.dart'; -const String kDefaultBaseUrl = String.fromEnvironment( - 'LIS_BASE_URL', - defaultValue: 'https://lis.swandhana.test/', -); +import 'app/app.dart'; +export 'app/app.dart'; void main() { runApp(const MyLisApp()); } - -class MyLisApp extends StatelessWidget { - const MyLisApp({super.key}); - - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'MyLIS Mobile', - debugShowCheckedModeBanner: false, - theme: ThemeData( - colorScheme: ColorScheme.fromSeed( - seedColor: const Color(0xFF0F766E), - brightness: Brightness.light, - ), - scaffoldBackgroundColor: const Color(0xFFF7FAF9), - useMaterial3: true, - ), - home: const SessionGate(), - ); - } -} - -class SessionGate extends StatefulWidget { - const SessionGate({super.key}); - - @override - State createState() => _SessionGateState(); -} - -class _SessionGateState extends State { - late final Future _sessionFuture; - - @override - void initState() { - super.initState(); - _sessionFuture = SessionStore.session(); - } - - @override - Widget build(BuildContext context) { - return FutureBuilder( - future: _sessionFuture, - builder: (context, snapshot) { - if (!snapshot.hasData && - snapshot.connectionState != ConnectionState.done) { - return const LoadingScreen(); - } - final session = snapshot.data ?? const SessionData(); - return session.token == null - ? const LoginScreen() - : DashboardScreen(token: session.token!, baseUrl: session.baseUrl); - }, - ); - } -} - -class ApiClient { - ApiClient({required this.baseUrl, this.token}); - - final String baseUrl; - final String? token; - - Uri _uri(String path, [Map? query]) { - final normalizedBase = normalizeBaseUrl(baseUrl); - final base = Uri.parse(normalizedBase); - return base.replace( - path: '${base.path.replaceAll(RegExp(r'/$'), '')}/$path', - queryParameters: query, - ); - } - - Map get _headers => { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - if (token != null) 'Authorization': 'Bearer $token', - }; - - Future> post( - String path, - Map body, - ) async { - final response = await http.post( - _uri(path), - headers: _headers, - body: jsonEncode(body), - ); - return _decode(response); - } - - Future> get( - String path, [ - Map? query, - ]) async { - final response = await http.get(_uri(path, query), headers: _headers); - return _decode(response); - } - - Map _decode(http.Response response) { - final decoded = response.body.isEmpty - ? {} - : jsonDecode(response.body); - if (response.statusCode >= 200 && response.statusCode < 300) { - return decoded is Map ? decoded : {'data': decoded}; - } - final message = decoded is Map ? decoded['message'] : null; - throw ApiException(message?.toString() ?? 'Koneksi ke server gagal.'); - } -} - -class ApiException implements Exception { - ApiException(this.message); - final String message; -} - -class SessionStore { - static const _tokenKey = 'mylis_token'; - static const _baseUrlKey = 'mylis_base_url'; - - static Future token() async { - final prefs = await SharedPreferences.getInstance(); - return prefs.getString(_tokenKey); - } - - static Future baseUrl() async { - final prefs = await SharedPreferences.getInstance(); - return prefs.getString(_baseUrlKey) ?? kDefaultBaseUrl; - } - - static Future session() async { - final prefs = await SharedPreferences.getInstance(); - return SessionData( - token: prefs.getString(_tokenKey), - baseUrl: prefs.getString(_baseUrlKey) ?? kDefaultBaseUrl, - ); - } - - static Future saveToken(String token) async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_tokenKey, token); - } - - static Future saveBaseUrl(String baseUrl) async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_baseUrlKey, normalizeBaseUrl(baseUrl)); - } - - static Future clear() async { - final prefs = await SharedPreferences.getInstance(); - await prefs.remove(_tokenKey); - } -} - -class SessionData { - const SessionData({this.token, this.baseUrl = kDefaultBaseUrl}); - - final String? token; - final String baseUrl; -} - -String normalizeBaseUrl(String value) { - var url = value.trim(); - if (url.isEmpty) { - return kDefaultBaseUrl; - } - if (!url.startsWith('http://') && !url.startsWith('https://')) { - url = 'https://$url'; - } - return url.replaceAll(RegExp(r'/+$'), ''); -} - -class LoginScreen extends StatefulWidget { - const LoginScreen({super.key}); - - @override - State createState() => _LoginScreenState(); -} - -class _LoginScreenState extends State { - final _formKey = GlobalKey(); - final _baseUrl = TextEditingController(text: kDefaultBaseUrl); - final _username = TextEditingController(); - final _password = TextEditingController(); - bool _obscure = true; - bool _loading = false; - bool _pinging = false; - String? _pingStatus; - - @override - void initState() { - super.initState(); - SessionStore.baseUrl().then((value) { - if (!mounted) { - return; - } - _baseUrl.text = value; - }); - } - - @override - void dispose() { - _baseUrl.dispose(); - _username.dispose(); - _password.dispose(); - super.dispose(); - } - - Future _login() async { - if (!_formKey.currentState!.validate()) { - return; - } - setState(() => _loading = true); - try { - final serverUrl = normalizeBaseUrl(_baseUrl.text); - await SessionStore.saveBaseUrl(serverUrl); - final api = ApiClient(baseUrl: serverUrl); - final data = await api.post('api/mobile/login', { - 'username': _username.text.trim(), - 'password': _password.text, - }); - final token = data['token']?.toString(); - if (token == null || token.isEmpty) { - throw ApiException('Token login tidak diterima dari server.'); - } - await SessionStore.saveToken(token); - if (!mounted) { - return; - } - Navigator.of(context).pushReplacement( - MaterialPageRoute( - builder: (_) => DashboardScreen(token: token, baseUrl: serverUrl), - ), - ); - } on ApiException catch (error) { - _showMessage(error.message); - } catch (_) { - _showMessage('Tidak bisa terhubung ke server MyLIS.'); - } finally { - if (mounted) setState(() => _loading = false); - } - } - - Future _ping() async { - final serverUrl = normalizeBaseUrl(_baseUrl.text); - if (serverUrl.isEmpty) { - return; - } - setState(() { - _pinging = true; - _pingStatus = null; - }); - - try { - await SessionStore.saveBaseUrl(serverUrl); - final data = await ApiClient(baseUrl: serverUrl).get('api/mobile/ping'); - final appName = data['application']?.toString(); - setState(() { - _baseUrl.text = serverUrl; - _pingStatus = appName == null || appName.isEmpty - ? 'Server bisa diakses.' - : 'Server bisa diakses: $appName'; - }); - } on ApiException catch (error) { - setState(() => _pingStatus = error.message); - } catch (_) { - setState(() => _pingStatus = 'Koneksi server gagal.'); - } finally { - if (mounted) setState(() => _pinging = false); - } - } - - void _showMessage(String message) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(message))); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Icon( - Icons.biotech_outlined, - size: 72, - color: Color(0xFF0F766E), - ), - const SizedBox(height: 16), - Text( - 'MyLIS Mobile', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.headlineMedium - ?.copyWith(fontWeight: FontWeight.w700), - ), - const SizedBox(height: 6), - Text( - 'Login petugas', - textAlign: TextAlign.center, - style: Theme.of( - context, - ).textTheme.bodyLarge?.copyWith(color: Colors.black54), - ), - const SizedBox(height: 32), - TextFormField( - controller: _baseUrl, - keyboardType: TextInputType.url, - textInputAction: TextInputAction.next, - decoration: InputDecoration( - prefixIcon: const Icon(Icons.link), - labelText: 'URL Laravel', - border: const OutlineInputBorder(), - suffixIcon: IconButton( - tooltip: 'Ping server', - onPressed: _pinging ? null : _ping, - icon: _pinging - ? const SizedBox.square( - dimension: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ) - : const Icon(Icons.network_ping), - ), - ), - validator: (value) { - final url = normalizeBaseUrl(value ?? ''); - final uri = Uri.tryParse(url); - return uri == null || - !uri.hasScheme || - !uri.hasAuthority - ? 'URL Laravel tidak valid' - : null; - }, - ), - if (_pingStatus != null) ...[ - const SizedBox(height: 8), - Text( - _pingStatus!, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: _pingStatus!.contains('bisa diakses') - ? const Color(0xFF15803D) - : const Color(0xFFDC2626), - ), - ), - ], - const SizedBox(height: 14), - TextFormField( - controller: _username, - textInputAction: TextInputAction.next, - decoration: const InputDecoration( - prefixIcon: Icon(Icons.person_outline), - labelText: 'Username', - border: OutlineInputBorder(), - ), - validator: (value) => - value == null || value.trim().isEmpty - ? 'Username wajib diisi' - : null, - ), - const SizedBox(height: 14), - TextFormField( - controller: _password, - obscureText: _obscure, - onFieldSubmitted: (_) => _login(), - decoration: InputDecoration( - prefixIcon: const Icon(Icons.lock_outline), - labelText: 'Password', - border: const OutlineInputBorder(), - suffixIcon: IconButton( - tooltip: _obscure - ? 'Tampilkan password' - : 'Sembunyikan password', - onPressed: () => setState(() => _obscure = !_obscure), - icon: Icon( - _obscure - ? Icons.visibility_outlined - : Icons.visibility_off_outlined, - ), - ), - ), - validator: (value) => value == null || value.isEmpty - ? 'Password wajib diisi' - : null, - ), - const SizedBox(height: 22), - FilledButton.icon( - onPressed: _loading ? null : _login, - icon: _loading - ? const SizedBox.square( - dimension: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.login), - label: const Text('Login'), - ), - const SizedBox(height: 18), - Text( - 'Default: $kDefaultBaseUrl', - textAlign: TextAlign.center, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: Colors.black45), - ), - ], - ), - ), - ), - ), - ), - ), - ); - } -} - -class DashboardScreen extends StatefulWidget { - const DashboardScreen({ - super.key, - required this.token, - required this.baseUrl, - }); - - final String token; - final String baseUrl; - - @override - State createState() => _DashboardScreenState(); -} - -class _DashboardScreenState extends State { - late final ApiClient _api; - late Future> _future; - - @override - void initState() { - super.initState(); - _api = ApiClient(baseUrl: widget.baseUrl, token: widget.token); - _future = _api.get('api/mobile/dashboard'); - } - - void _reload() { - setState(() => _future = _api.get('api/mobile/dashboard')); - } - - Future _logout() async { - await SessionStore.clear(); - if (!mounted) { - return; - } - Navigator.of( - context, - ).pushReplacement(MaterialPageRoute(builder: (_) => const LoginScreen())); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Dashboard'), - 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>( - 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 user = asMap(data['user']); - final summary = asMap(data['summary']); - final warnings = asList(data['early_warning_groups']); - final books = asList(data['books']); - return ListView( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 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', - ), - 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, - ), - ), - ], - ); - }, - ), - ), - ); - } -} - -class HeaderPanel extends StatelessWidget { - const HeaderPanel({super.key, required this.user, required this.summary}); - - final Map user; - final Map summary; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFF0F766E), - borderRadius: BorderRadius.circular(8), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - user['nama']?.toString() ?? '-', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: Colors.white, - fontWeight: FontWeight.w700, - ), - ), - Text( - user['previlage']?.toString() ?? '-', - style: const TextStyle(color: Colors.white70), - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: SummaryItem(label: 'PPDS', value: summary['total_ppds']), - ), - Expanded( - child: SummaryItem( - label: 'Verifikasi', - value: summary['butuh_verifikasi'], - ), - ), - Expanded( - child: SummaryItem( - label: 'Hari Ini', - value: summary['antrian_hari_ini'], - ), - ), - ], - ), - ], - ), - ); - } -} - -class SummaryItem extends StatelessWidget { - const SummaryItem({super.key, required this.label, required this.value}); - final String label; - final Object? value; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '${value ?? 0}', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: Colors.white, - fontWeight: FontWeight.w800, - ), - ), - Text( - label, - style: const TextStyle(color: Colors.white70, fontSize: 12), - ), - ], - ); - } -} - -class SectionTitle extends StatelessWidget { - const SectionTitle({super.key, required this.title, this.actionLabel}); - final String title; - final String? actionLabel; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Row( - children: [ - Expanded( - child: Text( - title, - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w800), - ), - ), - if (actionLabel != null) - BadgeLabel(text: actionLabel!, color: const Color(0xFFDC2626)), - ], - ), - ); - } -} - -class EarlyWarningGroupCard extends StatelessWidget { - const EarlyWarningGroupCard({ - 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']); - return Card( - margin: const EdgeInsets.only(bottom: 10), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - child: ExpansionTile( - leading: const Icon( - Icons.warning_amber_rounded, - color: Color(0xFFDC2626), - ), - title: Text( - group['subpoli']?.toString() ?? 'Tanpa Subpoli', - style: const TextStyle(fontWeight: FontWeight.w700), - ), - subtitle: Text('${group['total'] ?? items.length} kasus warning'), - children: items - .map( - (item) => ExaminationCompactTile( - item: asMap(item), - token: token, - baseUrl: baseUrl, - ), - ) - .toList(), - ), - ); - } -} - -class BookTile extends StatelessWidget { - const BookTile({ - super.key, - required this.book, - required this.token, - required this.baseUrl, - }); - - final Map book; - final String token; - final String baseUrl; - - @override - Widget build(BuildContext context) { - return Card( - margin: const EdgeInsets.only(bottom: 8), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - child: ListTile( - leading: const CircleAvatar( - backgroundColor: Color(0xFFE0F2F1), - child: Icon(Icons.science_outlined, color: Color(0xFF0F766E)), - ), - title: Text(book['label']?.toString() ?? '-'), - subtitle: Text('${book['total'] ?? 0} pemeriksaan aktif'), - trailing: const Icon(Icons.chevron_right), - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ExaminationListScreen( - token: token, - baseUrl: baseUrl, - master: book['master'].toString(), - title: book['label']?.toString() ?? 'Pemeriksaan', - ), - ), - ); - }, - ), - ); - } -} - -class ExaminationListScreen extends StatefulWidget { - const ExaminationListScreen({ - super.key, - required this.token, - required this.baseUrl, - required this.master, - required this.title, - }); - - final String token; - final String baseUrl; - final String master; - final String title; - - @override - State createState() => _ExaminationListScreenState(); -} - -class _ExaminationListScreenState extends State { - late final ApiClient _api; - late Future> _future; - final _search = TextEditingController(); - - @override - void initState() { - super.initState(); - _api = ApiClient(baseUrl: widget.baseUrl, token: widget.token); - _future = _load(); - } - - @override - void dispose() { - _search.dispose(); - super.dispose(); - } - - Future> _load() { - final query = _search.text.trim(); - return _api.get( - 'api/mobile/books/${widget.master}/examinations', - query.isEmpty ? null : {'search': query}, - ); - } - - void _reload() { - setState(() => _future = _load()); - } - - @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', - ), - ), - ), - ), - Expanded( - child: 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 items = asList(snapshot.data!['items']); - if (items.isEmpty) { - return const EmptyPanel(text: 'Tidak ada pemeriksaan aktif.'); - } - return RefreshIndicator( - onRefresh: () async => _reload(), - child: ListView.builder( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), - itemCount: items.length, - itemBuilder: (context, index) => ExaminationCard( - item: asMap(items[index]), - token: widget.token, - baseUrl: widget.baseUrl, - ), - ), - ); - }, - ), - ), - ], - ), - ); - } -} - -class ExaminationCompactTile extends StatelessWidget { - const ExaminationCompactTile({ - 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 ListTile( - title: Text( - item['nmpasien']?.toString() ?? '-', - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text( - '${item['nofoto'] ?? '-'} - ${item['warning_label'] ?? item['status'] ?? '-'}', - ), - trailing: const Icon(Icons.chevron_right), - onTap: () => _openDetail(context, item, token, baseUrl), - ); - } -} - -class ExaminationCard extends StatelessWidget { - const ExaminationCard({ - 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: 10), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - child: InkWell( - borderRadius: BorderRadius.circular(8), - onTap: () => _openDetail(context, item, token, baseUrl), - child: Padding( - padding: const EdgeInsets.all(14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - item['nmpasien']?.toString() ?? '-', - style: const TextStyle(fontWeight: FontWeight.w800), - ), - ), - StatusPill(status: item['status']?.toString() ?? 'NEW'), - ], - ), - const SizedBox(height: 8), - Text( - '${item['nofoto'] ?? '-'} / RM ${item['noregister'] ?? '-'}', - ), - Text( - item['reques']?.toString() ?? '-', - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - 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), - ), - ), - TextButton.icon( - onPressed: () => _openDetail(context, item, token, baseUrl), - icon: const Icon(Icons.edit_note), - label: const Text('Expertise'), - ), - ], - ), - ], - ), - ), - ), - ); - } -} - -Future _openDetail( - BuildContext context, - Map item, - String token, - String baseUrl, -) async { - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ExaminationDetailScreen( - token: token, - baseUrl: baseUrl, - id: (item['id'] as num).toInt(), - ), - ), - ); -} - -class ExaminationDetailScreen extends StatefulWidget { - const ExaminationDetailScreen({ - super.key, - required this.token, - required this.baseUrl, - required this.id, - }); - - final String token; - final String baseUrl; - final int id; - - @override - State createState() => - _ExaminationDetailScreenState(); -} - -class _ExaminationDetailScreenState extends State { - late final ApiClient _api; - late Future> _future; - - @override - void initState() { - super.initState(); - _api = ApiClient(baseUrl: widget.baseUrl, token: widget.token); - _future = _api.get('api/mobile/examinations/${widget.id}'); - } - - void _reload() { - setState(() => _future = _api.get('api/mobile/examinations/${widget.id}')); - } - - Future _launch(String? url) async { - if (url == null || url.isEmpty) { - return; - } - final uri = Uri.parse(url); - if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) { - if (!mounted) { - return; - } - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Halaman tidak bisa dibuka.')), - ); - } - } - - @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) { - if (snapshot.connectionState != ConnectionState.done) { - return const LoadingScreen(); - } - if (snapshot.hasError) { - return ErrorView( - message: _message(snapshot.error), - onRetry: _reload, - ); - } - final data = snapshot.data!; - final item = asMap(data['item']); - return ListView( - padding: const EdgeInsets.all(16), - 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']), - ], - ), - ), - ), - const SizedBox(height: 12), - FilledButton.icon( - onPressed: () => _launch(data['expertise_url']?.toString()), - icon: const Icon(Icons.edit_document), - label: const Text('Expertise'), - ), - 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'), - ), - ], - ); - }, - ), - ); - } -} - -class DetailRow extends StatelessWidget { - const DetailRow({super.key, required this.label, required this.value}); - final String label; - final Object? value; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: const TextStyle(color: Colors.black54, fontSize: 12), - ), - const SizedBox(height: 2), - Text( - value?.toString().isNotEmpty == true ? value.toString() : '-', - style: const TextStyle(fontWeight: FontWeight.w600), - ), - ], - ), - ); - } -} - -class StatusPill extends StatelessWidget { - const StatusPill({super.key, required this.status}); - final String status; - - @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); - } -} - -class BadgeLabel extends StatelessWidget { - const BadgeLabel({super.key, required this.text, required this.color}); - final String text; - final Color color; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(6), - ), - child: Text( - text, - style: TextStyle( - color: color, - fontWeight: FontWeight.w700, - fontSize: 12, - ), - ), - ); - } -} - -class EmptyPanel extends StatelessWidget { - const EmptyPanel({super.key, required this.text}); - final String text; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 24), - child: Center( - child: Text(text, style: const TextStyle(color: Colors.black54)), - ), - ); - } -} - -class ErrorView extends StatelessWidget { - const ErrorView({super.key, required this.message, required this.onRetry}); - final String message; - final VoidCallback onRetry; - - @override - Widget build(BuildContext context) { - return Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.error_outline, size: 42, color: Color(0xFFDC2626)), - const SizedBox(height: 10), - Text(message, textAlign: TextAlign.center), - const SizedBox(height: 12), - OutlinedButton.icon( - onPressed: onRetry, - icon: const Icon(Icons.refresh), - label: const Text('Coba Lagi'), - ), - ], - ), - ), - ); - } -} - -class LoadingScreen extends StatelessWidget { - const LoadingScreen({super.key}); - - @override - Widget build(BuildContext context) { - return const Scaffold(body: Center(child: CircularProgressIndicator())); - } -} - -Map asMap(Object? value) { - if (value is Map) { - return value; - } - if (value is Map) { - return value.map((key, item) => MapEntry(key.toString(), item)); - } - return {}; -} - -List asList(Object? value) => value is List ? value : []; - -String _message(Object? error) => error is ApiException - ? error.message - : 'Terjadi kesalahan saat memuat data.'; diff --git a/mylis/lib/providers/session_provider.dart b/mylis/lib/providers/session_provider.dart new file mode 100644 index 00000000..f3ca547b --- /dev/null +++ b/mylis/lib/providers/session_provider.dart @@ -0,0 +1,35 @@ +part of '../app/app.dart'; + +class SessionGate extends StatefulWidget { + const SessionGate({super.key}); + + @override + State createState() => _SessionGateState(); +} + +class _SessionGateState extends State { + late final Future _sessionFuture; + + @override + void initState() { + super.initState(); + _sessionFuture = SessionStore.session(); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _sessionFuture, + builder: (context, snapshot) { + if (!snapshot.hasData && + snapshot.connectionState != ConnectionState.done) { + return const LoadingScreen(); + } + final session = snapshot.data ?? const SessionData(); + return session.token == null + ? const LoginScreen() + : DashboardScreen(token: session.token!, baseUrl: session.baseUrl); + }, + ); + } +} diff --git a/mylis/lib/screens/dashboard/dashboard_screen.dart b/mylis/lib/screens/dashboard/dashboard_screen.dart new file mode 100644 index 00000000..a982e41c --- /dev/null +++ b/mylis/lib/screens/dashboard/dashboard_screen.dart @@ -0,0 +1,306 @@ +part of '../../app/app.dart'; + +class DashboardScreen extends StatefulWidget { + const DashboardScreen({ + super.key, + required this.token, + required this.baseUrl, + }); + + final String token; + final String baseUrl; + + @override + State createState() => _DashboardScreenState(); +} + +class _DashboardScreenState extends State { + late final ApiClient _api; + late Future> _future; + + @override + void initState() { + super.initState(); + _api = ApiClient(baseUrl: widget.baseUrl, token: widget.token); + _future = _api.get('api/mobile/dashboard'); + } + + void _reload() { + setState(() => _future = _api.get('api/mobile/dashboard')); + } + + Future _logout() async { + await SessionStore.clear(); + if (!mounted) { + return; + } + Navigator.of( + context, + ).pushReplacement(MaterialPageRoute(builder: (_) => const LoginScreen())); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Dashboard'), + 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>( + 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 user = asMap(data['user']); + final summary = asMap(data['summary']); + final warnings = asList(data['early_warning_groups']); + final books = asList(data['books']); + return ListView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 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', + ), + 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, + ), + ), + ], + ); + }, + ), + ), + ); + } +} + +class HeaderPanel extends StatelessWidget { + const HeaderPanel({super.key, required this.user, required this.summary}); + + final Map user; + final Map summary; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFF0F766E), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + user['nama']?.toString() ?? '-', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + Text( + user['previlage']?.toString() ?? '-', + style: const TextStyle(color: Colors.white70), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: SummaryItem(label: 'PPDS', value: summary['total_ppds']), + ), + Expanded( + child: SummaryItem( + label: 'Verifikasi', + value: summary['butuh_verifikasi'], + ), + ), + Expanded( + child: SummaryItem( + label: 'Hari Ini', + value: summary['antrian_hari_ini'], + ), + ), + ], + ), + ], + ), + ); + } +} + +class SummaryItem extends StatelessWidget { + const SummaryItem({super.key, required this.label, required this.value}); + final String label; + final Object? value; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${value ?? 0}', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w800, + ), + ), + Text( + label, + style: const TextStyle(color: Colors.white70, fontSize: 12), + ), + ], + ); + } +} + +class SectionTitle extends StatelessWidget { + const SectionTitle({super.key, required this.title, this.actionLabel}); + final String title; + final String? actionLabel; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Expanded( + child: Text( + title, + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w800), + ), + ), + if (actionLabel != null) + BadgeLabel(text: actionLabel!, color: const Color(0xFFDC2626)), + ], + ), + ); + } +} + +class EarlyWarningGroupCard extends StatelessWidget { + const EarlyWarningGroupCard({ + 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']); + return Card( + margin: const EdgeInsets.only(bottom: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + child: ExpansionTile( + leading: const Icon( + Icons.warning_amber_rounded, + color: Color(0xFFDC2626), + ), + title: Text( + group['subpoli']?.toString() ?? 'Tanpa Subpoli', + style: const TextStyle(fontWeight: FontWeight.w700), + ), + subtitle: Text('${group['total'] ?? items.length} kasus warning'), + children: items + .map( + (item) => ExaminationCompactTile( + item: asMap(item), + token: token, + baseUrl: baseUrl, + ), + ) + .toList(), + ), + ); + } +} + +class BookTile extends StatelessWidget { + const BookTile({ + super.key, + required this.book, + required this.token, + required this.baseUrl, + }); + + final Map book; + final String token; + final String baseUrl; + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.only(bottom: 8), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + child: ListTile( + leading: const CircleAvatar( + backgroundColor: Color(0xFFE0F2F1), + child: Icon(Icons.science_outlined, color: Color(0xFF0F766E)), + ), + title: Text(book['label']?.toString() ?? '-'), + subtitle: Text('${book['total'] ?? 0} pemeriksaan aktif'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ExaminationListScreen( + token: token, + baseUrl: baseUrl, + master: book['master'].toString(), + title: book['label']?.toString() ?? 'Pemeriksaan', + ), + ), + ); + }, + ), + ); + } +} diff --git a/mylis/lib/screens/examinations/examination_detail_screen.dart b/mylis/lib/screens/examinations/examination_detail_screen.dart new file mode 100644 index 00000000..ec7c35b3 --- /dev/null +++ b/mylis/lib/screens/examinations/examination_detail_screen.dart @@ -0,0 +1,171 @@ +part of '../../app/app.dart'; + +class ExaminationDetailScreen extends StatefulWidget { + const ExaminationDetailScreen({ + super.key, + required this.token, + required this.baseUrl, + required this.id, + }); + + final String token; + final String baseUrl; + final int id; + + @override + State createState() => + _ExaminationDetailScreenState(); +} + +class _ExaminationDetailScreenState extends State { + late final ApiClient _api; + late Future> _future; + + @override + void initState() { + super.initState(); + _api = ApiClient(baseUrl: widget.baseUrl, token: widget.token); + _future = _api.get('api/mobile/examinations/${widget.id}'); + } + + void _reload() { + setState(() => _future = _api.get('api/mobile/examinations/${widget.id}')); + } + + Future _launch(String? url) async { + if (url == null || url.isEmpty) { + return; + } + final uri = Uri.parse(url); + if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) { + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Halaman tidak bisa dibuka.')), + ); + } + } + + Future _openExpertise() async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ExpertiseScreen( + token: widget.token, + baseUrl: widget.baseUrl, + id: widget.id, + ), + ), + ); + _reload(); + } + + @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) { + if (snapshot.connectionState != ConnectionState.done) { + return const LoadingScreen(); + } + if (snapshot.hasError) { + return ErrorView( + message: _message(snapshot.error), + onRetry: _reload, + ); + } + final data = snapshot.data!; + final item = asMap(data['item']); + return ListView( + padding: const EdgeInsets.all(16), + 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']), + ], + ), + ), + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _openExpertise, + icon: const Icon(Icons.edit_document), + label: const Text('Expertise'), + ), + 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'), + ), + ], + ); + }, + ), + ); + } +} diff --git a/mylis/lib/screens/examinations/examination_list_screen.dart b/mylis/lib/screens/examinations/examination_list_screen.dart new file mode 100644 index 00000000..c1677308 --- /dev/null +++ b/mylis/lib/screens/examinations/examination_list_screen.dart @@ -0,0 +1,237 @@ +part of '../../app/app.dart'; + +class ExaminationListScreen extends StatefulWidget { + const ExaminationListScreen({ + super.key, + required this.token, + required this.baseUrl, + required this.master, + required this.title, + }); + + final String token; + final String baseUrl; + final String master; + final String title; + + @override + State createState() => _ExaminationListScreenState(); +} + +class _ExaminationListScreenState extends State { + late final ApiClient _api; + late Future> _future; + final _search = TextEditingController(); + + @override + void initState() { + super.initState(); + _api = ApiClient(baseUrl: widget.baseUrl, token: widget.token); + _future = _load(); + } + + @override + void dispose() { + _search.dispose(); + super.dispose(); + } + + Future> _load() { + final query = _search.text.trim(); + return _api.get( + 'api/mobile/books/${widget.master}/examinations', + query.isEmpty ? null : {'search': query}, + ); + } + + void _reload() { + setState(() => _future = _load()); + } + + @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', + ), + ), + ), + ), + Expanded( + child: 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 items = asList(snapshot.data!['items']); + if (items.isEmpty) { + return const EmptyPanel(text: 'Tidak ada pemeriksaan aktif.'); + } + return RefreshIndicator( + onRefresh: () async => _reload(), + child: ListView.builder( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), + itemCount: items.length, + itemBuilder: (context, index) => ExaminationCard( + item: asMap(items[index]), + token: widget.token, + baseUrl: widget.baseUrl, + ), + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +class ExaminationCompactTile extends StatelessWidget { + const ExaminationCompactTile({ + 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 ListTile( + title: Text( + item['nmpasien']?.toString() ?? '-', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + '${item['nofoto'] ?? '-'} - ${item['warning_label'] ?? item['status'] ?? '-'}', + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => _openDetail(context, item, token, baseUrl), + ); + } +} + +class ExaminationCard extends StatelessWidget { + const ExaminationCard({ + 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: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => _openDetail(context, item, token, baseUrl), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + item['nmpasien']?.toString() ?? '-', + style: const TextStyle(fontWeight: FontWeight.w800), + ), + ), + StatusPill(status: item['status']?.toString() ?? 'NEW'), + ], + ), + const SizedBox(height: 8), + Text( + '${item['nofoto'] ?? '-'} / RM ${item['noregister'] ?? '-'}', + ), + Text( + item['reques']?.toString() ?? '-', + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + 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), + ), + ), + TextButton.icon( + onPressed: () => _openDetail(context, item, token, baseUrl), + icon: const Icon(Icons.edit_note), + label: const Text('Expertise'), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +Future _openDetail( + BuildContext context, + Map item, + String token, + String baseUrl, +) async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ExaminationDetailScreen( + token: token, + baseUrl: baseUrl, + id: (item['id'] as num).toInt(), + ), + ), + ); +} diff --git a/mylis/lib/screens/expertise/cci_expertise_wizard.dart b/mylis/lib/screens/expertise/cci_expertise_wizard.dart new file mode 100644 index 00000000..51811654 --- /dev/null +++ b/mylis/lib/screens/expertise/cci_expertise_wizard.dart @@ -0,0 +1,448 @@ +part of '../../app/app.dart'; + +class CciExpertiseWizard extends StatefulWidget { + const CciExpertiseWizard({ + super.key, + required this.item, + required this.user, + required this.staffOptions, + required this.optionSets, + required this.textControllers, + required this.selectValues, + required this.staffValues, + required this.criticalValue, + required this.isSupervisor, + required this.saving, + required this.onCriticalChanged, + required this.onChanged, + required this.onSave, + }); + + final Map item; + final Map user; + final Map staffOptions; + final Map optionSets; + final Map textControllers; + final Map selectValues; + final Map staffValues; + final bool criticalValue; + final bool isSupervisor; + final bool saving; + final ValueChanged onCriticalChanged; + final VoidCallback onChanged; + final ValueChanged onSave; + + @override + State createState() => _CciExpertiseWizardState(); +} + +class _CciExpertiseWizardState extends State { + int _step = 0; + + void _next() { + setState(() => _step = (_step + 1).clamp(0, 3)); + } + + void _previous() { + setState(() => _step = (_step - 1).clamp(0, 3)); + } + + @override + Widget build(BuildContext context) { + return Stepper( + currentStep: _step, + onStepTapped: (step) => setState(() => _step = step), + controlsBuilder: (context, details) { + return Padding( + padding: const EdgeInsets.only(top: 12), + child: Row( + children: [ + OutlinedButton.icon( + onPressed: _step == 0 ? null : _previous, + icon: const Icon(Icons.chevron_left), + label: const Text('Previous'), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: _step == 3 ? null : _next, + icon: const Icon(Icons.chevron_right), + label: const Text('Next'), + ), + ], + ), + ); + }, + steps: [ + Step( + title: const Text('Pasien & Petugas'), + isActive: _step == 0, + content: Column( + children: [ + PatientStaffPanel( + item: widget.item, + user: widget.user, + staffOptions: widget.staffOptions, + staffValues: widget.staffValues, + onChanged: widget.onChanged, + ), + ], + ), + ), + Step( + title: const Text('CCI'), + isActive: _step == 1, + content: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Pemeriksaan Candida Colonization Index', + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w800), + ), + const SizedBox(height: 12), + _select( + 'id_sputum', + 'Sputum', + _options('jsonsputum', ['Pilih Salah Satu']), + ), + _select( + 'id_swabtenggorok', + 'Swab Tenggorok', + _options('jsonswabtenggorok', ['Pilih Salah Satu']), + ), + _select( + 'id_urine', + 'Urine', + _options('jsonurine', ['Pilih Salah Satu']), + ), + _select( + 'id_swabperineum', + 'Swab Perineum', + _options('jsonswabperineum', ['Pilih Salah Satu']), + ), + ], + ), + ), + Step( + title: const Text('Data Alat'), + isActive: _step == 2, + content: Column( + children: [ + _toolSection( + title: 'Vitek', + bacteriaField: 'bakteri', + sirField: 'bakterisir', + colonyField: 'id_bakterihitungkol', + colonyTextField: 'id_bakterihitungkolteks', + printField: 'id_baktericetak', + ), + _toolSection( + title: 'MALDITOF', + bacteriaField: 'id_bakteri01', + antibioticField: 'id_antibiotikmanual1', + sirField: 'id_bakterisir01', + colonyField: 'id_bakterihitungkol01', + colonyTextField: 'id_bakterihitungkolteks01', + printField: 'id_bakteri01cetak', + ), + _toolSection( + title: 'Bakteri Manual I', + bacteriaField: 'id_bakteri02', + antibioticField: 'id_antibiotikmanual2', + sirField: 'id_bakterisir02', + colonyField: 'id_bakterihitungkol02', + colonyTextField: 'id_bakterihitungkolteks02', + printField: 'id_bakteri02cetak', + ), + ], + ), + ), + Step( + title: const Text('Final'), + isActive: _step == 3, + content: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _text('keterangan', 'Expertise', minLines: 8, maxLines: 12), + CheckboxListTile( + value: widget.criticalValue, + onChanged: (value) => widget.onCriticalChanged(value ?? false), + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + title: const Text('Nilai Kritis'), + ), + FilledButton.icon( + onPressed: widget.saving ? null : () => widget.onSave('Draft'), + icon: widget.saving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: const Text('Save as Draft'), + ), + if (widget.isSupervisor) ...[ + OutlinedButton.icon( + onPressed: widget.saving + ? null + : () => widget.onSave('preliminary'), + icon: const Icon(Icons.done_all_outlined), + label: const Text('Save Preliminary result'), + ), + OutlinedButton.icon( + onPressed: widget.saving + ? null + : () => widget.onSave('verifikasi'), + icon: const Icon(Icons.verified_outlined), + label: const Text('Save Final Result'), + ), + ] else ...[ + OutlinedButton.icon( + onPressed: widget.saving + ? null + : () => widget.onSave('Permohonan Verifikasi'), + icon: const Icon(Icons.send_outlined), + label: const Text('Save and Send To SPV'), + ), + OutlinedButton.icon( + onPressed: widget.saving + ? null + : () => + widget.onSave('Permohonan Verifikasi Preliminary'), + icon: const Icon(Icons.outgoing_mail), + label: const Text('Kirim SPV Preliminary'), + ), + ], + ], + ), + ), + ], + ); + } + + List _options(String key, List fallback) { + final values = asList( + widget.optionSets[key], + ).map((item) => item.toString()).where((item) => item.isNotEmpty).toList(); + return values.isEmpty ? fallback : values; + } + + Widget _toolSection({ + required String title, + required String bacteriaField, + String? antibioticField, + required String sirField, + required String colonyField, + required String colonyTextField, + required String printField, + }) { + return Card( + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w800), + ), + const SizedBox(height: 10), + _text(bacteriaField, 'Bakteri'), + if (antibioticField != null) + _text(antibioticField, 'Set Antibiotik'), + _select(sirField, 'SIR', _sirOptions), + _select(colonyField, 'Hitung Koloni', _colonyOptions), + _text(colonyTextField, 'Hitung Koloni Lainnya'), + _select(printField, 'Cetak', const ['YA', 'TIDAK']), + ], + ), + ), + ); + } + + Widget _select(String name, String label, List options) { + final current = widget.selectValues[name]; + final effectiveOptions = [ + if (current != null && current.isNotEmpty && !options.contains(current)) + current, + ...options, + ]; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: DropdownButtonFormField( + initialValue: + current != null && + current.isNotEmpty && + effectiveOptions.contains(current) + ? current + : null, + isExpanded: true, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + ), + items: effectiveOptions + .map( + (option) => DropdownMenuItem(value: option, child: Text(option)), + ) + .toList(), + onChanged: (value) { + widget.selectValues[name] = value ?? ''; + widget.onChanged(); + }, + ), + ); + } + + Widget _text( + String name, + String label, { + int minLines = 1, + int maxLines = 1, + }) { + final controller = widget.textControllers[name] ??= TextEditingController(); + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: TextField( + controller: controller, + minLines: minLines, + maxLines: maxLines, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + ), + ), + ); + } +} + +class PatientStaffPanel extends StatelessWidget { + const PatientStaffPanel({ + super.key, + required this.item, + required this.user, + required this.staffOptions, + required this.staffValues, + required this.onChanged, + }); + + final Map item; + final Map user; + final Map staffOptions; + final Map staffValues; + final VoidCallback onChanged; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Card( + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + children: [ + DetailRow(label: 'Order', value: item['reques']), + DetailRow( + label: 'Dokter Pengirim', + value: item['klinisi'] ?? item['nmdokter'], + ), + DetailRow(label: 'Asal Pasien', value: item['asalpasien']), + DetailRow(label: 'No. Lokal Lab', value: item['nofoto']), + DetailRow(label: 'Register Number', value: item['noregister']), + DetailRow(label: 'Name', value: item['nmpasien']), + DetailRow( + label: 'Gender, Age', + value: '${item['jkpasien'] ?? '-'}, ${item['usia'] ?? '-'}', + ), + DetailRow(label: 'Phone', value: item['tlppasien']), + DetailRow(label: 'Address', value: item['alamatpasien']), + DetailRow( + label: 'Tanggal Registrasi', + value: item['tanggalregis'] ?? item['daftar'], + ), + DetailRow( + label: 'Tanggal Pengambilan', + value: item['tanggalsampel'], + ), + DetailRow( + label: 'Cara Pengambilan', + value: item['pengambilan'], + ), + DetailRow( + label: 'Asal Pengambilan', + value: item['asalpengirim'], + ), + DetailRow( + label: 'Spesimen', + value: item['kesimpulan'] ?? item['nm_spesimen'], + ), + DetailRow(label: 'Status', value: item['status']), + ], + ), + ), + ), + const SizedBox(height: 12), + _staffSelect('analis', 'ATLM', asList(staffOptions['analis'])), + _staffSelect('ppds3', 'PPDS', asList(staffOptions['ppds'])), + _staffSelect('dokter', 'SPV', asList(staffOptions['dokters'])), + DetailRow(label: 'Klinis/Diagnosis', value: item['klinis']), + ], + ); + } + + Widget _staffSelect(String name, String label, List users) { + final current = staffValues[name]; + final items = users.map(asMap).toList(); + final hasCurrent = items.any((item) => item['id'].toString() == current); + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: DropdownButtonFormField( + initialValue: hasCurrent ? current : null, + isExpanded: true, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + ), + items: [ + const DropdownMenuItem(value: '0', child: Text('Pilih')), + ...items.map( + (item) => DropdownMenuItem( + value: item['id'].toString(), + child: Text(item['nama']?.toString() ?? '-'), + ), + ), + ], + onChanged: (value) { + staffValues[name] = value ?? '0'; + onChanged(); + }, + ), + ); + } +} + +const List _sirOptions = [ + 'ESBL', + 'MRSA', + 'MDR', + 'XDR', + 'PDR', + 'Carbapenem Resistant', + 'MDR Carbapenem Resistant', + 'XDR Carbapenem Resistant', +]; + +const List _colonyOptions = [ + '>= 10^5 CFU/ml Urine (Bakteriuria bermakna)', + '>= 10^5 CFU/ml Urine (Candiduria bermakna)', + '>= 10^3 CFU/ml Urine (Bakteriuria bermakna)', + '8 x 10^3 CFU/ml Urine (Bakteriuria bermakna)', + 'lainnya', +]; diff --git a/mylis/lib/screens/expertise/covid_expertise_wizard.dart b/mylis/lib/screens/expertise/covid_expertise_wizard.dart new file mode 100644 index 00000000..c43e1980 --- /dev/null +++ b/mylis/lib/screens/expertise/covid_expertise_wizard.dart @@ -0,0 +1,418 @@ +part of '../../app/app.dart'; + +class CovidExpertiseWizard extends StatefulWidget { + const CovidExpertiseWizard({ + super.key, + required this.item, + required this.user, + required this.staffOptions, + required this.textControllers, + required this.selectValues, + required this.staffValues, + required this.criticalValue, + required this.isSupervisor, + required this.saving, + required this.onCriticalChanged, + required this.onChanged, + required this.onSave, + }); + + final Map item; + final Map user; + final Map staffOptions; + final Map textControllers; + final Map selectValues; + final Map staffValues; + final bool criticalValue; + final bool isSupervisor; + final bool saving; + final ValueChanged onCriticalChanged; + final VoidCallback onChanged; + final ValueChanged onSave; + + @override + State createState() => _CovidExpertiseWizardState(); +} + +class _CovidExpertiseWizardState extends State { + int _step = 0; + + void _next() { + setState(() => _step = (_step + 1).clamp(0, 4)); + } + + void _previous() { + setState(() => _step = (_step - 1).clamp(0, 4)); + } + + @override + Widget build(BuildContext context) { + return Stepper( + currentStep: _step, + onStepTapped: (step) => setState(() => _step = step), + controlsBuilder: (context, details) { + return Padding( + padding: const EdgeInsets.only(top: 12), + child: Row( + children: [ + OutlinedButton.icon( + onPressed: _step == 0 ? null : _previous, + icon: const Icon(Icons.chevron_left), + label: const Text('Previous'), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: _step == 4 ? null : _next, + icon: const Icon(Icons.chevron_right), + label: const Text('Next'), + ), + ], + ), + ); + }, + steps: [ + Step( + title: const Text('Pasien & Petugas'), + isActive: _step == 0, + content: PatientStaffPanel( + item: widget.item, + user: widget.user, + staffOptions: widget.staffOptions, + staffValues: widget.staffValues, + onChanged: widget.onChanged, + ), + ), + Step( + title: const Text('Parameter'), + isActive: _step == 1, + content: _parameters(), + ), + Step( + title: const Text('Tes Kepekaan Antibiotik'), + isActive: _step == 2, + content: _antibioticSensitivity(), + ), + Step( + title: const Text('Data Alat'), + isActive: _step == 3, + content: _toolData(), + ), + Step( + title: const Text('Expertise'), + isActive: _step == 4, + content: _finalExpertise(), + ), + ], + ); + } + + Widget _parameters() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle('PCR COVID'), + for (final virus in _covidVirusFields) + Card( + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle(virus.label, compact: true), + _select(virus.resultField, virus.label, _covidResultOptions), + _text(virus.unitField, 'Satuan'), + _text(virus.referenceField, 'Nilai Rujukan'), + ], + ), + ), + ), + _text('covid_interpretasi', 'Interpretasi', minLines: 6, maxLines: 9), + ], + ); + } + + Widget _antibioticSensitivity() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle('Tes Kepekaan Antibiotik'), + Text( + 'S: Sensitif, I: Intermediate, R: Resisten', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: const [ + BadgeLabel(text: 'Media BAP', color: Color(0xFF2563EB)), + BadgeLabel(text: 'Media CAP', color: Color(0xFFDC2626)), + BadgeLabel(text: 'Mc Conkey', color: Color(0xFF0891B2)), + BadgeLabel(text: 'Media Jamur', color: Color(0xFFD97706)), + ], + ), + const SizedBox(height: 12), + for (final media in _cultureMedia) + Card( + margin: const EdgeInsets.only(bottom: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle(media.label, compact: true), + _select( + media.statusField, + 'Status Pertumbuhan', + _growthOptions, + ), + _text(media.noteField, 'Catatan'), + ], + ), + ), + ), + ], + ); + } + + Widget _toolData() { + return Column( + children: [ + _toolSection( + title: 'Vitek', + bacteriaField: 'bakteri', + sirField: 'bakterisir', + colonyField: 'id_bakterihitungkol', + colonyTextField: 'id_bakterihitungkolteks', + printField: 'id_baktericetak', + ), + _toolSection( + title: 'MALDITOF', + bacteriaField: 'id_bakteri01', + antibioticField: 'id_antibiotikmanual1', + sirField: 'id_bakterisir01', + colonyField: 'id_bakterihitungkol01', + colonyTextField: 'id_bakterihitungkolteks01', + printField: 'id_bakteri01cetak', + ), + _toolSection( + title: 'Bakteri Manual I', + bacteriaField: 'id_bakteri02', + antibioticField: 'id_antibiotikmanual2', + sirField: 'id_bakterisir02', + colonyField: 'id_bakterihitungkol02', + colonyTextField: 'id_bakterihitungkolteks02', + printField: 'id_bakteri02cetak', + ), + ], + ); + } + + Widget _finalExpertise() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _text('keterangan', 'Expertise', minLines: 8, maxLines: 12), + CheckboxListTile( + value: widget.criticalValue, + onChanged: (value) => widget.onCriticalChanged(value ?? false), + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + title: const Text('Nilai Kritis'), + ), + FilledButton.icon( + onPressed: widget.saving ? null : () => widget.onSave('Draft'), + icon: widget.saving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: const Text('Save as Draft'), + ), + if (widget.isSupervisor) ...[ + OutlinedButton.icon( + onPressed: widget.saving + ? null + : () => widget.onSave('preliminary'), + icon: const Icon(Icons.done_all_outlined), + label: const Text('Save Preliminary result'), + ), + OutlinedButton.icon( + onPressed: widget.saving ? null : () => widget.onSave('verifikasi'), + icon: const Icon(Icons.verified_outlined), + label: const Text('Save Final Result'), + ), + ] else ...[ + OutlinedButton.icon( + onPressed: widget.saving + ? null + : () => widget.onSave('Permohonan Verifikasi'), + icon: const Icon(Icons.send_outlined), + label: const Text('Save and Send To SPV'), + ), + OutlinedButton.icon( + onPressed: widget.saving + ? null + : () => widget.onSave('Permohonan Verifikasi Preliminary'), + icon: const Icon(Icons.outgoing_mail), + label: const Text('Kirim SPV Preliminary'), + ), + ], + ], + ); + } + + Widget _sectionTitle(String title, {bool compact = false}) { + return Padding( + padding: EdgeInsets.only(bottom: compact ? 8 : 12), + child: Text( + title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w800, + color: const Color(0xFF0F766E), + ), + ), + ); + } + + Widget _toolSection({ + required String title, + required String bacteriaField, + String? antibioticField, + required String sirField, + required String colonyField, + required String colonyTextField, + required String printField, + }) { + return Card( + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle(title, compact: true), + _text(bacteriaField, 'Bakteri'), + if (antibioticField != null) + _text(antibioticField, 'Set Antibiotik'), + _select(sirField, 'SIR', _sirOptions), + _select(colonyField, 'Hitung Koloni', _colonyOptions), + _text(colonyTextField, 'Hitung Koloni Lainnya'), + _select(printField, 'Cetak', const ['YA', 'TIDAK']), + ], + ), + ), + ); + } + + Widget _select(String name, String label, List options) { + final current = widget.selectValues[name]; + final effectiveOptions = [ + if (current != null && current.isNotEmpty && !options.contains(current)) + current, + ...options, + ]; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: DropdownButtonFormField( + initialValue: + current != null && + current.isNotEmpty && + effectiveOptions.contains(current) + ? current + : null, + isExpanded: true, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + ), + items: effectiveOptions + .map( + (option) => DropdownMenuItem(value: option, child: Text(option)), + ) + .toList(), + onChanged: (value) { + widget.selectValues[name] = value ?? ''; + widget.onChanged(); + }, + ), + ); + } + + Widget _text( + String name, + String label, { + int minLines = 1, + int maxLines = 1, + }) { + final controller = widget.textControllers[name] ??= TextEditingController(); + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: TextField( + controller: controller, + minLines: minLines, + maxLines: maxLines, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + ), + ), + ); + } +} + +class _CovidVirusField { + const _CovidVirusField( + this.label, + this.resultField, + this.unitField, + this.referenceField, + ); + + final String label; + final String resultField; + final String unitField; + final String referenceField; +} + +const List _covidResultOptions = [ + 'Positif', + 'Negatif', + 'Tidak Diperiksa', +]; + +const List<_CovidVirusField> _covidVirusFields = [ + _CovidVirusField( + 'Virus SARS-Cov-2', + 'covid_virus01', + 'covid_satuanvirus01', + 'covid_rujukanvirus01', + ), + _CovidVirusField( + 'Influenza A', + 'covid_virus02', + 'covid_satuanvirus02', + 'covid_rujukanvirus02', + ), + _CovidVirusField( + 'Influenza B', + 'covid_virus03', + 'covid_satuanvirus03', + 'covid_rujukanvirus03', + ), + _CovidVirusField( + 'RSV', + 'covid_virus04', + 'covid_satuanvirus04', + 'covid_rujukanvirus04', + ), +]; diff --git a/mylis/lib/screens/expertise/expertise_form.dart b/mylis/lib/screens/expertise/expertise_form.dart new file mode 100644 index 00000000..7ca333b2 --- /dev/null +++ b/mylis/lib/screens/expertise/expertise_form.dart @@ -0,0 +1,192 @@ +part of '../../app/app.dart'; + +class ExpertiseForm extends StatelessWidget { + const ExpertiseForm({ + super.key, + required this.dlp, + required this.fields, + required this.textControllers, + required this.selectValues, + required this.multiValues, + required this.onChanged, + }); + + final String dlp; + final List fields; + final Map textControllers; + final Map selectValues; + final Map> multiValues; + final VoidCallback onChanged; + + @override + Widget build(BuildContext context) { + return Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _templateTitle(dlp), + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w800), + ), + const SizedBox(height: 12), + ...fields.map((rawField) => _buildField(context, asMap(rawField))), + ], + ), + ), + ); + } + + Widget _buildField(BuildContext context, Map field) { + final name = field['name']?.toString() ?? ''; + final label = field['label']?.toString() ?? name; + final type = field['type']?.toString() ?? 'text'; + final options = asList( + field['options'], + ).map((item) => item.toString()).toList(); + + if (type == 'select') { + final currentValue = options.contains(selectValues[name]) + ? selectValues[name] + : null; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: DropdownButtonFormField( + initialValue: currentValue, + isExpanded: true, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + ), + items: options + .map( + (option) => + DropdownMenuItem(value: option, child: Text(option)), + ) + .toList(), + onChanged: (value) { + selectValues[name] = value ?? ''; + onChanged(); + }, + ), + ); + } + + if (type == 'multiselect') { + final selected = multiValues[name] ?? {}; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: InputDecorator( + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + ), + child: Wrap( + spacing: 8, + runSpacing: 4, + children: options + .map( + (option) => FilterChip( + label: Text(option), + selected: selected.contains(option), + onSelected: (checked) { + if (checked) { + selected.add(option); + } else { + selected.remove(option); + } + multiValues[name] = selected; + onChanged(); + }, + ), + ) + .toList(), + ), + ), + ); + } + + final controller = textControllers[name] ??= TextEditingController(); + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: TextField( + controller: controller, + minLines: type == 'textarea' ? 4 : 1, + maxLines: type == 'textarea' ? 8 : 1, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + ), + ), + ); + } +} + +class ExpertiseActions extends StatelessWidget { + const ExpertiseActions({ + super.key, + required this.isSupervisor, + required this.saving, + required this.onSave, + }); + + final bool isSupervisor; + final bool saving; + final ValueChanged onSave; + + @override + Widget build(BuildContext context) { + return Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FilledButton.icon( + onPressed: saving ? null : () => onSave('Draft'), + icon: saving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: const Text('Save as Draft'), + ), + if (isSupervisor) ...[ + OutlinedButton.icon( + onPressed: saving ? null : () => onSave('preliminary'), + icon: const Icon(Icons.done_all_outlined), + label: const Text('Save Preliminary result'), + ), + OutlinedButton.icon( + onPressed: saving ? null : () => onSave('verifikasi'), + icon: const Icon(Icons.verified_outlined), + label: const Text('Save Final Result'), + ), + ] else ...[ + OutlinedButton.icon( + onPressed: saving + ? null + : () => onSave('Permohonan Verifikasi'), + icon: const Icon(Icons.send_outlined), + label: const Text('Save and Send To SPV'), + ), + OutlinedButton.icon( + onPressed: saving + ? null + : () => onSave('Permohonan Verifikasi Preliminary'), + icon: const Icon(Icons.outgoing_mail), + label: const Text('Kirim SPV Preliminary'), + ), + ], + ], + ), + ), + ); + } +} diff --git a/mylis/lib/screens/expertise/expertise_screen.dart b/mylis/lib/screens/expertise/expertise_screen.dart new file mode 100644 index 00000000..61a6791a --- /dev/null +++ b/mylis/lib/screens/expertise/expertise_screen.dart @@ -0,0 +1,391 @@ +part of '../../app/app.dart'; + +class ExpertiseScreen extends StatefulWidget { + const ExpertiseScreen({ + super.key, + required this.token, + required this.baseUrl, + required this.id, + }); + + final String token; + final String baseUrl; + final int id; + + @override + State createState() => _ExpertiseScreenState(); +} + +class _ExpertiseScreenState extends State { + late final ApiClient _api; + late Future> _future; + final Map _textControllers = {}; + final Map _selectValues = {}; + final Map> _multiValues = {}; + final Map _staffValues = {}; + String _currentDlp = ''; + bool _criticalValue = false; + bool _saving = false; + + @override + void initState() { + super.initState(); + _api = ApiClient(baseUrl: widget.baseUrl, token: widget.token); + _future = _load(); + } + + @override + void dispose() { + for (final controller in _textControllers.values) { + controller.dispose(); + } + super.dispose(); + } + + Future> _load() { + return _api.get('api/mobile/examinations/${widget.id}/expertise'); + } + + void _reload() { + setState(() => _future = _load()); + } + + void _prepareFields(Map data) { + final dlp = data['dlp']?.toString() ?? ''; + if (_currentDlp == dlp && _textControllers.isNotEmpty) { + return; + } + _currentDlp = dlp; + for (final controller in _textControllers.values) { + controller.dispose(); + } + _textControllers.clear(); + _selectValues.clear(); + _multiValues.clear(); + _staffValues.clear(); + + final components = asMap(data['components']); + final item = asMap(data['item']); + final user = asMap(data['user']); + _staffValues['analis'] = (item['analis'] ?? user['id'] ?? '0').toString(); + _staffValues['ppds3'] = (item['ppds3'] ?? user['id'] ?? '0').toString(); + _staffValues['dokter'] = (item['dokter_id'] ?? user['id'] ?? '0') + .toString(); + _criticalValue = data['is_critical'] == true; + for (final rawField in asList(data['fields'])) { + final field = asMap(rawField); + final name = field['name']?.toString() ?? ''; + final type = field['type']?.toString() ?? 'text'; + final value = components[name]; + if (type == 'select') { + _selectValues[name] = value?.toString() ?? ''; + } else if (type == 'multiselect') { + _multiValues[name] = value is List + ? value.map((item) => item.toString()).toSet() + : {}; + } else { + _textControllers[name] = TextEditingController( + text: value?.toString() ?? '', + ); + } + } + } + + Future _setTemplate(String dlp) async { + setState(() => _saving = true); + try { + await _api.post( + 'api/mobile/examinations/${widget.id}/expertise/template', + {'dlp': dlp}, + ); + if (!mounted) return; + _showMessage('Template $dlp dipilih.'); + setState(() => _future = _load()); + } on ApiException catch (error) { + _showMessage(error.message); + } catch (_) { + _showMessage('Template tidak bisa dipilih.'); + } finally { + if (mounted) { + setState(() => _saving = false); + } + } + } + + Future _save(String action) async { + setState(() => _saving = true); + final fields = {}; + for (final entry in _textControllers.entries) { + fields[entry.key] = entry.value.text.trim(); + } + for (final entry in _selectValues.entries) { + fields[entry.key] = entry.value; + } + for (final entry in _multiValues.entries) { + fields[entry.key] = entry.value.toList(); + } + + try { + final data = await _api + .post('api/mobile/examinations/${widget.id}/expertise', { + 'dlp': _currentDlp, + 'action': action, + 'fields': fields, + 'keterangan': fields['keterangan']?.toString() ?? '', + 'analis': _staffValues['analis'] ?? '0', + 'ppds3': _staffValues['ppds3'] ?? '0', + 'dokter': _staffValues['dokter'] ?? '0', + 'nilai_kritis': _criticalValue ? '1' : '0', + }); + if (!mounted) return; + _showMessage(data['message']?.toString() ?? 'Expertise tersimpan.'); + setState(() => _future = _load()); + } on ApiException catch (error) { + _showMessage(error.message); + } catch (_) { + _showMessage('Expertise tidak bisa disimpan.'); + } finally { + if (mounted) { + setState(() => _saving = false); + } + } + } + + void _showMessage(String message) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Expertise'), + actions: [ + IconButton( + onPressed: _saving ? null : _reload, + icon: const Icon(Icons.refresh), + tooltip: 'Refresh', + ), + ], + ), + 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 item = asMap(data['item']); + final user = asMap(data['user']); + _prepareFields(data); + final isSupervisor = _isSupervisor(user['previlage']?.toString()); + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item['nmpasien']?.toString() ?? '-', + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w800), + ), + const SizedBox(height: 6), + Text( + '${item['nofoto'] ?? '-'} / RM ${item['noregister'] ?? '-'}', + ), + Text(item['reques']?.toString() ?? '-'), + const SizedBox(height: 8), + Row( + children: [ + StatusPill( + status: item['status']?.toString() ?? 'NEW', + ), + const SizedBox(width: 8), + if (_currentDlp.isNotEmpty) + BadgeLabel( + text: _currentDlp, + color: const Color(0xFF0F766E), + ), + ], + ), + ], + ), + ), + ), + const SizedBox(height: 14), + if (_currentDlp.isEmpty) + TemplatePicker( + templates: asList(data['templates']), + saving: _saving, + onUse: _setTemplate, + ) + else + _currentDlp == 'CCI' + ? CciExpertiseWizard( + item: item, + user: user, + staffOptions: asMap(data['staff_options']), + optionSets: asMap(data['option_sets']), + textControllers: _textControllers, + selectValues: _selectValues, + staffValues: _staffValues, + criticalValue: _criticalValue, + isSupervisor: isSupervisor, + saving: _saving, + onCriticalChanged: (value) => + setState(() => _criticalValue = value), + onChanged: () => setState(() {}), + onSave: _save, + ) + : _currentDlp == 'Kultur' + ? KulturExpertiseWizard( + item: item, + user: user, + staffOptions: asMap(data['staff_options']), + optionSets: asMap(data['option_sets']), + textControllers: _textControllers, + selectValues: _selectValues, + multiValues: _multiValues, + staffValues: _staffValues, + criticalValue: _criticalValue, + isSupervisor: isSupervisor, + saving: _saving, + onCriticalChanged: (value) => + setState(() => _criticalValue = value), + onChanged: () => setState(() {}), + onSave: _save, + ) + : _currentDlp == 'Pewarna Langsung' + ? PewarnaanLangsungExpertiseWizard( + item: item, + user: user, + staffOptions: asMap(data['staff_options']), + optionSets: asMap(data['option_sets']), + textControllers: _textControllers, + selectValues: _selectValues, + multiValues: _multiValues, + staffValues: _staffValues, + criticalValue: _criticalValue, + isSupervisor: isSupervisor, + saving: _saving, + onCriticalChanged: (value) => + setState(() => _criticalValue = value), + onChanged: () => setState(() {}), + onSave: _save, + ) + : _currentDlp == 'Viral Load' + ? ViralLoadExpertiseWizard( + item: item, + user: user, + staffOptions: asMap(data['staff_options']), + optionSets: asMap(data['option_sets']), + textControllers: _textControllers, + selectValues: _selectValues, + staffValues: _staffValues, + criticalValue: _criticalValue, + isSupervisor: isSupervisor, + saving: _saving, + onCriticalChanged: (value) => + setState(() => _criticalValue = value), + onChanged: () => setState(() {}), + onSave: _save, + ) + : _currentDlp == 'IgM IgG Leptospira' + ? LeptospiraExpertiseWizard( + item: item, + user: user, + staffOptions: asMap(data['staff_options']), + textControllers: _textControllers, + selectValues: _selectValues, + staffValues: _staffValues, + criticalValue: _criticalValue, + isSupervisor: isSupervisor, + saving: _saving, + onCriticalChanged: (value) => + setState(() => _criticalValue = value), + onChanged: () => setState(() {}), + onSave: _save, + ) + : _currentDlp == 'PCR COVID' + ? CovidExpertiseWizard( + item: item, + user: user, + staffOptions: asMap(data['staff_options']), + textControllers: _textControllers, + selectValues: _selectValues, + staffValues: _staffValues, + criticalValue: _criticalValue, + isSupervisor: isSupervisor, + saving: _saving, + onCriticalChanged: (value) => + setState(() => _criticalValue = value), + onChanged: () => setState(() {}), + onSave: _save, + ) + : _currentDlp == 'TBC' + ? TbcExpertiseWizard( + item: item, + user: user, + staffOptions: asMap(data['staff_options']), + textControllers: _textControllers, + selectValues: _selectValues, + multiValues: _multiValues, + staffValues: _staffValues, + criticalValue: _criticalValue, + isSupervisor: isSupervisor, + saving: _saving, + onCriticalChanged: (value) => + setState(() => _criticalValue = value), + onChanged: () => setState(() {}), + onSave: _save, + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + PatientStaffPanel( + item: item, + user: user, + staffOptions: asMap(data['staff_options']), + staffValues: _staffValues, + onChanged: () => setState(() {}), + ), + const SizedBox(height: 12), + ExpertiseForm( + dlp: _currentDlp, + fields: asList(data['fields']), + textControllers: _textControllers, + selectValues: _selectValues, + multiValues: _multiValues, + onChanged: () => setState(() {}), + ), + const SizedBox(height: 12), + ExpertiseActions( + isSupervisor: isSupervisor, + saving: _saving, + onSave: _save, + ), + ], + ), + ], + ); + }, + ), + ); + } +} diff --git a/mylis/lib/screens/expertise/kultur_expertise_wizard.dart b/mylis/lib/screens/expertise/kultur_expertise_wizard.dart new file mode 100644 index 00000000..aa5860cc --- /dev/null +++ b/mylis/lib/screens/expertise/kultur_expertise_wizard.dart @@ -0,0 +1,688 @@ +part of '../../app/app.dart'; + +class KulturExpertiseWizard extends StatefulWidget { + const KulturExpertiseWizard({ + super.key, + required this.item, + required this.user, + required this.staffOptions, + required this.optionSets, + required this.textControllers, + required this.selectValues, + required this.multiValues, + required this.staffValues, + required this.criticalValue, + required this.isSupervisor, + required this.saving, + required this.onCriticalChanged, + required this.onChanged, + required this.onSave, + }); + + final Map item; + final Map user; + final Map staffOptions; + final Map optionSets; + final Map textControllers; + final Map selectValues; + final Map> multiValues; + final Map staffValues; + final bool criticalValue; + final bool isSupervisor; + final bool saving; + final ValueChanged onCriticalChanged; + final VoidCallback onChanged; + final ValueChanged onSave; + + @override + State createState() => _KulturExpertiseWizardState(); +} + +class _KulturExpertiseWizardState extends State { + int _step = 0; + + void _next() { + setState(() => _step = (_step + 1).clamp(0, 5)); + } + + void _previous() { + setState(() => _step = (_step - 1).clamp(0, 5)); + } + + @override + Widget build(BuildContext context) { + return Stepper( + currentStep: _step, + onStepTapped: (step) => setState(() => _step = step), + controlsBuilder: (context, details) { + return Padding( + padding: const EdgeInsets.only(top: 12), + child: Row( + children: [ + OutlinedButton.icon( + onPressed: _step == 0 ? null : _previous, + icon: const Icon(Icons.chevron_left), + label: const Text('Previous'), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: _step == 5 ? null : _next, + icon: const Icon(Icons.chevron_right), + label: const Text('Next'), + ), + ], + ), + ); + }, + steps: [ + Step( + title: const Text('Pasien & Petugas'), + isActive: _step == 0, + content: PatientStaffPanel( + item: widget.item, + user: widget.user, + staffOptions: widget.staffOptions, + staffValues: widget.staffValues, + onChanged: widget.onChanged, + ), + ), + Step( + title: const Text('Sediaan Langsung'), + isActive: _step == 1, + content: _directSmear(), + ), + Step( + title: const Text('Biakan Kultur'), + isActive: _step == 2, + content: _cultureGrowth(), + ), + Step( + title: const Text('Tes Kepekaan Antibiotik'), + isActive: _step == 3, + content: _antibioticSensitivity(), + ), + Step( + title: const Text('Data Alat'), + isActive: _step == 4, + content: _toolData(), + ), + Step( + title: const Text('Expertise'), + isActive: _step == 5, + content: _finalExpertise(), + ), + ], + ); + } + + Widget _directSmear() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle('A. Sediaan Langsung'), + _sectionTitle('1. Pewarna Gram', compact: true), + _select('id_selepitel', 'Sel Epitel', _options('jsonselepitel')), + _select('id_selradang', 'Sel Radang', _options('jsonselradang')), + _select( + 'id_mikroorganisme', + 'Mikroorganisme', + _options('jsonmikroorganisme'), + ), + _multiSelect( + 'id_mikroorganismeoptional', + 'Bisa memilih lebih dari satu', + _options('jsonmikroorganismeoptional'), + ), + _nugentScore(), + _sectionTitle('2. Pewarnaan Ziehl Neelsen', compact: true), + _select( + 'id_pewarnaanziehlnielsen', + 'Pagi', + _options('jsonpewarnaanziehlnielsen'), + ), + _select( + 'id_pewarnaanziehlnielsensewaktu', + 'Sewaktu', + _options('jsonpewarnaanziehlnielsen'), + ), + _select( + 'id_pewarnaankoh', + '3. Pewarnaan KOH', + _options('jsonpewarnaankoh', _kohFallback), + ), + _text('id_pewarnaankohoptional', 'Pewarnaan KOH Manual'), + _select( + 'id_pewarnaanneisser', + '4. Pewarnaan Neisser', + _options('jsonpewarnaanneisser'), + ), + _select( + 'id_pewarnaannegatif', + '5. Pewarnaan Negatif', + _options('jsonpewarnaannegatif'), + ), + _select( + 'id_pewarnaanspora', + '6. Pewarnaan Spora', + _options('jsonpewarnaanspora'), + ), + _select( + 'id_pewarnaangiesma', + '7. Pewarnaan Giemsa', + _options('jsonpewarnaangiemsa', _giemsaFallback), + ), + _text('id_pewarnaangiesmaoptional', 'Pewarnaan Giemsa Manual'), + ], + ); + } + + Widget _nugentScore() { + return Card( + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle('Nugent Score', compact: true), + _select( + 'id_jumlahlactobacillus', + 'Lactobacillus - Jumlah Per Lapang', + _options('jsonjumlahlactobacillus'), + ), + _select('id_lactobacillus', 'Lactobacillus - Skor', _scoreOptions), + _select( + 'id_jumlahgardnerella', + 'Gardnerella - Jumlah Per Lapang', + _options('jsonjumlahgardnerella'), + ), + _select('id_gardnerella', 'Gardnerella - Skor', _scoreOptions), + _select( + 'id_jumlahmobiluncus', + 'Mobiluncus - Jumlah Per Lapang', + _options('jsonjumlahmobiluncus'), + ), + _select('id_mobiluncus', 'Mobiluncus - Skor', _scoreOptions), + ], + ), + ), + ); + } + + Widget _cultureGrowth() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle('B. Biakan Kultur'), + const BadgeLabel( + text: 'Khusus Kultur Darah dan Cairan Steril', + color: Color(0xFFDC2626), + ), + const SizedBox(height: 12), + _text('bd_result', 'BD Result'), + _text('bd_result_date', 'BD Result Date'), + _sectionTitle('Pertumbuhan Koloni (Kultur Primer)', compact: true), + _kirbyBauer(), + _select( + 'id_biakankultur', + 'Biakan Kultur (Aerob / Anaerob)', + _options('jsonbiakankultur'), + ), + ], + ); + } + + Widget _kirbyBauer() { + return Card( + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle('Kirby Bauer', compact: true), + for (final antibiotic in _kirbyAntibiotics) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Expanded( + child: _select( + antibiotic.zoneField, + antibiotic.label, + _kirbyZoneOptions, + dense: true, + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 120, + child: _select( + antibiotic.sirField, + 'SIR', + _antibioticSirOptions, + dense: true, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _antibioticSensitivity() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle('C. Tes Kepekaan Antibiotik'), + Text( + 'S: Sensitif, I: Intermediate, R: Resisten', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 12), + _mediaStatusSummary(), + const SizedBox(height: 12), + for (final media in _cultureMedia) + Card( + margin: const EdgeInsets.only(bottom: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle(media.label, compact: true), + _select( + media.statusField, + 'Status Pertumbuhan', + _growthOptions, + ), + _text(media.noteField, 'Catatan'), + ], + ), + ), + ), + ], + ); + } + + Widget _mediaStatusSummary() { + return Wrap( + spacing: 8, + runSpacing: 8, + children: const [ + BadgeLabel(text: 'Media BAP', color: Color(0xFF2563EB)), + BadgeLabel(text: 'Media CAP', color: Color(0xFFDC2626)), + BadgeLabel(text: 'Mc Conkey', color: Color(0xFF0891B2)), + BadgeLabel(text: 'Media Jamur', color: Color(0xFFD97706)), + ], + ); + } + + Widget _toolData() { + return Column( + children: [ + _toolSection( + title: 'Vitek', + bacteriaField: 'bakteri', + sirField: 'bakterisir', + colonyField: 'id_bakterihitungkol', + colonyTextField: 'id_bakterihitungkolteks', + printField: 'id_baktericetak', + ), + _toolSection( + title: 'MALDITOF', + bacteriaField: 'id_bakteri01', + antibioticField: 'id_antibiotikmanual1', + sirField: 'id_bakterisir01', + colonyField: 'id_bakterihitungkol01', + colonyTextField: 'id_bakterihitungkolteks01', + printField: 'id_bakteri01cetak', + ), + _toolSection( + title: 'Bakteri Manual I', + bacteriaField: 'id_bakteri02', + antibioticField: 'id_antibiotikmanual2', + sirField: 'id_bakterisir02', + colonyField: 'id_bakterihitungkol02', + colonyTextField: 'id_bakterihitungkolteks02', + printField: 'id_bakteri02cetak', + ), + ], + ); + } + + Widget _finalExpertise() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _text('keterangan', 'Expertise', minLines: 8, maxLines: 12), + CheckboxListTile( + value: widget.criticalValue, + onChanged: (value) => widget.onCriticalChanged(value ?? false), + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + title: const Text('Nilai Kritis'), + ), + FilledButton.icon( + onPressed: widget.saving ? null : () => widget.onSave('Draft'), + icon: widget.saving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: const Text('Save as Draft'), + ), + if (widget.isSupervisor) ...[ + OutlinedButton.icon( + onPressed: widget.saving + ? null + : () => widget.onSave('preliminary'), + icon: const Icon(Icons.done_all_outlined), + label: const Text('Save Preliminary result'), + ), + OutlinedButton.icon( + onPressed: widget.saving ? null : () => widget.onSave('verifikasi'), + icon: const Icon(Icons.verified_outlined), + label: const Text('Save Final Result'), + ), + ] else ...[ + OutlinedButton.icon( + onPressed: widget.saving + ? null + : () => widget.onSave('Permohonan Verifikasi'), + icon: const Icon(Icons.send_outlined), + label: const Text('Save and Send To SPV'), + ), + OutlinedButton.icon( + onPressed: widget.saving + ? null + : () => widget.onSave('Permohonan Verifikasi Preliminary'), + icon: const Icon(Icons.outgoing_mail), + label: const Text('Kirim SPV Preliminary'), + ), + ], + ], + ); + } + + List _options( + String key, [ + List fallback = const ['Pilih Salah Satu'], + ]) { + final values = asList( + widget.optionSets[key], + ).map((item) => item.toString()).where((item) => item.isNotEmpty).toList(); + return values.isEmpty ? fallback : values; + } + + Widget _sectionTitle(String title, {bool compact = false}) { + return Padding( + padding: EdgeInsets.only(bottom: compact ? 8 : 12), + child: Text( + title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w800, + color: const Color(0xFF0F766E), + ), + ), + ); + } + + Widget _toolSection({ + required String title, + required String bacteriaField, + String? antibioticField, + required String sirField, + required String colonyField, + required String colonyTextField, + required String printField, + }) { + return Card( + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle(title, compact: true), + _text(bacteriaField, 'Bakteri'), + if (antibioticField != null) + _text(antibioticField, 'Set Antibiotik'), + _select(sirField, 'SIR', _sirOptions), + _select(colonyField, 'Hitung Koloni', _colonyOptions), + _text(colonyTextField, 'Hitung Koloni Lainnya'), + _select(printField, 'Cetak', const ['YA', 'TIDAK']), + ], + ), + ), + ); + } + + Widget _select( + String name, + String label, + List options, { + bool dense = false, + }) { + final current = widget.selectValues[name]; + final effectiveOptions = [ + if (current != null && current.isNotEmpty && !options.contains(current)) + current, + ...options, + ]; + return Padding( + padding: EdgeInsets.only(bottom: dense ? 0 : 12), + child: DropdownButtonFormField( + initialValue: + current != null && + current.isNotEmpty && + effectiveOptions.contains(current) + ? current + : null, + isExpanded: true, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + isDense: dense, + ), + items: effectiveOptions + .map( + (option) => DropdownMenuItem(value: option, child: Text(option)), + ) + .toList(), + onChanged: (value) { + widget.selectValues[name] = value ?? ''; + widget.onChanged(); + }, + ), + ); + } + + Widget _multiSelect(String name, String label, List options) { + final selected = widget.multiValues[name] ?? {}; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: InputDecorator( + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + ), + child: Wrap( + spacing: 8, + runSpacing: 4, + children: options + .map( + (option) => FilterChip( + label: Text(option), + selected: selected.contains(option), + onSelected: (checked) { + if (checked) { + selected.add(option); + } else { + selected.remove(option); + } + widget.multiValues[name] = selected; + widget.onChanged(); + }, + ), + ) + .toList(), + ), + ), + ); + } + + Widget _text( + String name, + String label, { + int minLines = 1, + int maxLines = 1, + }) { + final controller = widget.textControllers[name] ??= TextEditingController(); + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: TextField( + controller: controller, + minLines: minLines, + maxLines: maxLines, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + ), + ), + ); + } +} + +class _KirbyAntibiotic { + const _KirbyAntibiotic(this.label, this.zoneField, this.sirField); + + final String label; + final String zoneField; + final String sirField; +} + +class _CultureMedia { + const _CultureMedia(this.label, this.statusField, this.noteField); + + final String label; + final String statusField; + final String noteField; +} + +const List _scoreOptions = ['0', '1', '2', '3', '4']; + +const List _antibioticSirOptions = ['S', 'I', 'R']; + +const List _growthOptions = [ + 'Ada Pertumbuhan', + 'Tidak Ada Pertumbuhan', + 'Pertumbuhan Primer', +]; + +const List _kirbyZoneOptions = [ + 'Tidak dilakukan', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '13', + '14', + '15', + '16', + '17', + '18', + '19', + '20', + '21', + '22', + '23', + '24', + '25', + '26', + '27', + '28', + '29', + '30', + '31', + '32', + '33', + '34', + '35', + '36', + '37', + '38', + '39', + '40', + '41', + '42', +]; + +const List<_KirbyAntibiotic> _kirbyAntibiotics = [ + _KirbyAntibiotic('AMC', 'id_kbamc', 'id_sirkbamc'), + _KirbyAntibiotic('AK', 'id_kbak', 'id_sirkbak'), + _KirbyAntibiotic('FOS', 'id_kbfos', 'id_sirkbfos'), + _KirbyAntibiotic('SCF', 'id_kbscf', 'id_sirkbscf'), +]; + +const List<_CultureMedia> _cultureMedia = [ + _CultureMedia('Media BAP', 'media_bap_status', 'media_bap_note'), + _CultureMedia('Media CAP', 'media_cap_status', 'media_cap_note'), + _CultureMedia( + 'Media Mc Conkey', + 'media_mcconkey_status', + 'media_mcconkey_note', + ), + _CultureMedia('Kultur Jamur R1', 'media_sdar1_status', 'media_sdar1_note'), + _CultureMedia('Kultur Jamur R2', 'media_sdar2_status', 'media_sdar2_note'), + _CultureMedia('Kultur Jamur I1', 'media_sdai1_status', 'media_sdai1_note'), + _CultureMedia('Kultur Jamur I2', 'media_sdai2_status', 'media_sdai2_note'), + _CultureMedia( + 'Media Selektif Lainnya', + 'media_sellainnya_status', + 'media_sellainnya_note', + ), + _CultureMedia( + 'Pemeriksaan Tambahan Lainnya', + 'media_tamlainnya_status', + 'media_tamlainnya_note', + ), +]; + +const List _kohFallback = [ + 'Ditemukan morfologi Hifa', + 'Ditemukan morfologi Budding Cell', + 'Ditemukan morfologi Conidia', + 'Ditemukan morfologi Hifa dan Budding Cell', + 'Ditemukan morfologi Hifa dan Conidia', + 'Ditemukan morfologi Budding Cell dan Conidia', + 'Tidak ditemukan morfologi Budding Cell, Hifa dan Conidia', + 'Tidak ditemukan morfologi Budding Cell dan Hifa', + 'lainnya', +]; + +const List _giemsaFallback = [ + 'Ditemukan morfologi Inclusion Bodies dan Reticulate Bodies', + 'Tidak ditemukan morfologi Inclusion Bodies dan Reticulate Bodies', + 'Ditemukan morfologi kista tropozoid', + 'Tidak ditemukan morfologi kista tropozoid', + 'lainnya', +]; diff --git a/mylis/lib/screens/expertise/leptospira_expertise_wizard.dart b/mylis/lib/screens/expertise/leptospira_expertise_wizard.dart new file mode 100644 index 00000000..86693682 --- /dev/null +++ b/mylis/lib/screens/expertise/leptospira_expertise_wizard.dart @@ -0,0 +1,361 @@ +part of '../../app/app.dart'; + +class LeptospiraExpertiseWizard extends StatefulWidget { + const LeptospiraExpertiseWizard({ + super.key, + required this.item, + required this.user, + required this.staffOptions, + required this.textControllers, + required this.selectValues, + required this.staffValues, + required this.criticalValue, + required this.isSupervisor, + required this.saving, + required this.onCriticalChanged, + required this.onChanged, + required this.onSave, + }); + + final Map item; + final Map user; + final Map staffOptions; + final Map textControllers; + final Map selectValues; + final Map staffValues; + final bool criticalValue; + final bool isSupervisor; + final bool saving; + final ValueChanged onCriticalChanged; + final VoidCallback onChanged; + final ValueChanged onSave; + + @override + State createState() => + _LeptospiraExpertiseWizardState(); +} + +class _LeptospiraExpertiseWizardState extends State { + int _step = 0; + + void _next() { + setState(() => _step = (_step + 1).clamp(0, 4)); + } + + void _previous() { + setState(() => _step = (_step - 1).clamp(0, 4)); + } + + @override + Widget build(BuildContext context) { + return Stepper( + currentStep: _step, + onStepTapped: (step) => setState(() => _step = step), + controlsBuilder: (context, details) { + return Padding( + padding: const EdgeInsets.only(top: 12), + child: Row( + children: [ + OutlinedButton.icon( + onPressed: _step == 0 ? null : _previous, + icon: const Icon(Icons.chevron_left), + label: const Text('Previous'), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: _step == 4 ? null : _next, + icon: const Icon(Icons.chevron_right), + label: const Text('Next'), + ), + ], + ), + ); + }, + steps: [ + Step( + title: const Text('Pasien & Petugas'), + isActive: _step == 0, + content: PatientStaffPanel( + item: widget.item, + user: widget.user, + staffOptions: widget.staffOptions, + staffValues: widget.staffValues, + onChanged: widget.onChanged, + ), + ), + Step( + title: const Text('Parameter'), + isActive: _step == 1, + content: _parameters(), + ), + Step( + title: const Text('Tes Kepekaan Antibiotik'), + isActive: _step == 2, + content: _antibioticSensitivity(), + ), + Step( + title: const Text('Data Alat'), + isActive: _step == 3, + content: _toolData(), + ), + Step( + title: const Text('Expertise'), + isActive: _step == 4, + content: _finalExpertise(), + ), + ], + ); + } + + Widget _parameters() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle('IgM IgG Leptospira'), + _select('igg_parameter', 'IgG Leptospira', const [ + 'Positif', + 'Negatif', + ]), + _select('igm_parameter', 'IgM Leptospira', const [ + 'Positif', + 'Negatif', + ]), + _text('iggigm_interpretasi', 'Interpretasi', minLines: 6, maxLines: 9), + ], + ); + } + + Widget _antibioticSensitivity() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle('Tes Kepekaan Antibiotik'), + Text( + 'S: Sensitif, I: Intermediate, R: Resisten', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: const [ + BadgeLabel(text: 'Media BAP', color: Color(0xFF2563EB)), + BadgeLabel(text: 'Media CAP', color: Color(0xFFDC2626)), + BadgeLabel(text: 'Mc Conkey', color: Color(0xFF0891B2)), + BadgeLabel(text: 'Media Jamur', color: Color(0xFFD97706)), + ], + ), + const SizedBox(height: 12), + for (final media in _cultureMedia) + Card( + margin: const EdgeInsets.only(bottom: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle(media.label, compact: true), + _select( + media.statusField, + 'Status Pertumbuhan', + _growthOptions, + ), + _text(media.noteField, 'Catatan'), + ], + ), + ), + ), + ], + ); + } + + Widget _toolData() { + return Column( + children: [ + _toolSection( + title: 'Vitek', + bacteriaField: 'bakteri', + sirField: 'bakterisir', + colonyField: 'id_bakterihitungkol', + colonyTextField: 'id_bakterihitungkolteks', + printField: 'id_baktericetak', + ), + _toolSection( + title: 'MALDITOF', + bacteriaField: 'id_bakteri01', + antibioticField: 'id_antibiotikmanual1', + sirField: 'id_bakterisir01', + colonyField: 'id_bakterihitungkol01', + colonyTextField: 'id_bakterihitungkolteks01', + printField: 'id_bakteri01cetak', + ), + _toolSection( + title: 'Bakteri Manual I', + bacteriaField: 'id_bakteri02', + antibioticField: 'id_antibiotikmanual2', + sirField: 'id_bakterisir02', + colonyField: 'id_bakterihitungkol02', + colonyTextField: 'id_bakterihitungkolteks02', + printField: 'id_bakteri02cetak', + ), + ], + ); + } + + Widget _finalExpertise() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _text('keterangan', 'Expertise', minLines: 8, maxLines: 12), + CheckboxListTile( + value: widget.criticalValue, + onChanged: (value) => widget.onCriticalChanged(value ?? false), + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + title: const Text('Nilai Kritis'), + ), + FilledButton.icon( + onPressed: widget.saving ? null : () => widget.onSave('Draft'), + icon: widget.saving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: const Text('Save as Draft'), + ), + if (widget.isSupervisor) ...[ + OutlinedButton.icon( + onPressed: widget.saving + ? null + : () => widget.onSave('preliminary'), + icon: const Icon(Icons.done_all_outlined), + label: const Text('Save Preliminary result'), + ), + OutlinedButton.icon( + onPressed: widget.saving ? null : () => widget.onSave('verifikasi'), + icon: const Icon(Icons.verified_outlined), + label: const Text('Save Final Result'), + ), + ] else ...[ + OutlinedButton.icon( + onPressed: widget.saving + ? null + : () => widget.onSave('Permohonan Verifikasi'), + icon: const Icon(Icons.send_outlined), + label: const Text('Save and Send To SPV'), + ), + OutlinedButton.icon( + onPressed: widget.saving + ? null + : () => widget.onSave('Permohonan Verifikasi Preliminary'), + icon: const Icon(Icons.outgoing_mail), + label: const Text('Kirim SPV Preliminary'), + ), + ], + ], + ); + } + + Widget _sectionTitle(String title, {bool compact = false}) { + return Padding( + padding: EdgeInsets.only(bottom: compact ? 8 : 12), + child: Text( + title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w800, + color: const Color(0xFF0F766E), + ), + ), + ); + } + + Widget _toolSection({ + required String title, + required String bacteriaField, + String? antibioticField, + required String sirField, + required String colonyField, + required String colonyTextField, + required String printField, + }) { + return Card( + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle(title, compact: true), + _text(bacteriaField, 'Bakteri'), + if (antibioticField != null) + _text(antibioticField, 'Set Antibiotik'), + _select(sirField, 'SIR', _sirOptions), + _select(colonyField, 'Hitung Koloni', _colonyOptions), + _text(colonyTextField, 'Hitung Koloni Lainnya'), + _select(printField, 'Cetak', const ['YA', 'TIDAK']), + ], + ), + ), + ); + } + + Widget _select(String name, String label, List options) { + final current = widget.selectValues[name]; + final effectiveOptions = [ + if (current != null && current.isNotEmpty && !options.contains(current)) + current, + ...options, + ]; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: DropdownButtonFormField( + initialValue: + current != null && + current.isNotEmpty && + effectiveOptions.contains(current) + ? current + : null, + isExpanded: true, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + ), + items: effectiveOptions + .map( + (option) => DropdownMenuItem(value: option, child: Text(option)), + ) + .toList(), + onChanged: (value) { + widget.selectValues[name] = value ?? ''; + widget.onChanged(); + }, + ), + ); + } + + Widget _text( + String name, + String label, { + int minLines = 1, + int maxLines = 1, + }) { + final controller = widget.textControllers[name] ??= TextEditingController(); + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: TextField( + controller: controller, + minLines: minLines, + maxLines: maxLines, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + ), + ), + ); + } +} diff --git a/mylis/lib/screens/expertise/pewarnaan_langsung_expertise_wizard.dart b/mylis/lib/screens/expertise/pewarnaan_langsung_expertise_wizard.dart new file mode 100644 index 00000000..fb51073f --- /dev/null +++ b/mylis/lib/screens/expertise/pewarnaan_langsung_expertise_wizard.dart @@ -0,0 +1,493 @@ +part of '../../app/app.dart'; + +class PewarnaanLangsungExpertiseWizard extends StatefulWidget { + const PewarnaanLangsungExpertiseWizard({ + super.key, + required this.item, + required this.user, + required this.staffOptions, + required this.optionSets, + required this.textControllers, + required this.selectValues, + required this.multiValues, + required this.staffValues, + required this.criticalValue, + required this.isSupervisor, + required this.saving, + required this.onCriticalChanged, + required this.onChanged, + required this.onSave, + }); + + final Map item; + final Map user; + final Map staffOptions; + final Map optionSets; + final Map textControllers; + final Map selectValues; + final Map> multiValues; + final Map staffValues; + final bool criticalValue; + final bool isSupervisor; + final bool saving; + final ValueChanged onCriticalChanged; + final VoidCallback onChanged; + final ValueChanged onSave; + + @override + State createState() => + _PewarnaanLangsungExpertiseWizardState(); +} + +class _PewarnaanLangsungExpertiseWizardState + extends State { + int _step = 0; + + void _next() { + setState(() => _step = (_step + 1).clamp(0, 4)); + } + + void _previous() { + setState(() => _step = (_step - 1).clamp(0, 4)); + } + + @override + Widget build(BuildContext context) { + return Stepper( + currentStep: _step, + onStepTapped: (step) => setState(() => _step = step), + controlsBuilder: (context, details) { + return Padding( + padding: const EdgeInsets.only(top: 12), + child: Row( + children: [ + OutlinedButton.icon( + onPressed: _step == 0 ? null : _previous, + icon: const Icon(Icons.chevron_left), + label: const Text('Previous'), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: _step == 4 ? null : _next, + icon: const Icon(Icons.chevron_right), + label: const Text('Next'), + ), + ], + ), + ); + }, + steps: [ + Step( + title: const Text('Pasien & Petugas'), + isActive: _step == 0, + content: PatientStaffPanel( + item: widget.item, + user: widget.user, + staffOptions: widget.staffOptions, + staffValues: widget.staffValues, + onChanged: widget.onChanged, + ), + ), + Step( + title: const Text('Sediaan Langsung'), + isActive: _step == 1, + content: _directSmear(), + ), + Step( + title: const Text('Tes Kepekaan Antibiotik'), + isActive: _step == 2, + content: _antibioticSensitivity(), + ), + Step( + title: const Text('Data Alat'), + isActive: _step == 3, + content: _toolData(), + ), + Step( + title: const Text('Expertise'), + isActive: _step == 4, + content: _finalExpertise(), + ), + ], + ); + } + + Widget _directSmear() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle('A. Sediaan Langsung'), + _sectionTitle('1. Pewarnaan Gram', compact: true), + _select('lsg_selepitel', 'Sel Epitel', _options('jsonselepitel')), + _select('lsg_selradang', 'Sel Radang', _options('jsonselradang')), + _select( + 'lsg_mikroorganisme', + 'Mikroorganisme', + _options('jsonmikroorganisme'), + ), + _multiSelect( + 'lsg_mikroorganismeoptional', + 'Bisa memilih lebih dari satu', + _options('jsonmikroorganismeoptional'), + ), + _sectionTitle('2. Pewarnaan Ziehl Neelsen', compact: true), + _select( + 'lsg_pewarnaanziehlnielsen', + 'Pagi', + _options('jsonpewarnaanziehlnielsen'), + ), + _select( + 'lsg_pewarnaanziehlnielsensewaktu', + 'Sewaktu', + _options('jsonpewarnaanziehlnielsen'), + ), + _select( + 'lsg_pewarnaankoh', + '3. Pewarnaan KOH', + _options('jsonpewarnaankoh', _kohFallback), + ), + _text('lsg_pewarnaankohoptional', 'Pewarnaan KOH Manual'), + _select( + 'lsg_pewarnaanneisser', + '4. Pewarnaan Neisser', + _options('jsonpewarnaanneisser'), + ), + _select( + 'lsg_pewarnaannegatif', + '5. Pewarnaan Negatif', + _options('jsonpewarnaannegatif'), + ), + _select( + 'lsg_pewarnaangiesma', + '6. Pewarnaan Giemsa', + _options('jsonpewarnaangiemsa', _giemsaFallback), + ), + _text('lsg_pewarnaangiesmaoptional', 'Pewarnaan Giemsa Manual'), + _select( + 'lsg_pewarnaanspora', + '7. Pewarnaan Spora', + _options('jsonpewarnaanspora'), + ), + _text( + 'lsg_pewarnaanlain', + '8. Pewarnaan Lain-Lain', + minLines: 5, + maxLines: 8, + ), + _nugentScore(), + ], + ); + } + + Widget _nugentScore() { + return Card( + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle('Nugent Score', compact: true), + _select( + 'lsg_jumlahlactobacillus', + 'Lactobacillus - Jumlah Per Lapang', + _options('jsonjumlahlactobacillus'), + ), + _select('lsg_lactobacillus', 'Lactobacillus - Skor', _scoreOptions), + _select( + 'lsg_jumlahgardnerella', + 'Gardnerella - Jumlah Per Lapang', + _options('jsonjumlahgardnerella'), + ), + _select('lsg_gardnerella', 'Gardnerella - Skor', _scoreOptions), + _select( + 'lsg_jumlahmobiluncus', + 'Mobiluncus - Jumlah Per Lapang', + _options('jsonjumlahmobiluncus'), + ), + _select('lsg_mobiluncus', 'Mobiluncus - Skor', _scoreOptions), + ], + ), + ), + ); + } + + Widget _antibioticSensitivity() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle('Tes Kepekaan Antibiotik'), + Text( + 'S: Sensitif, I: Intermediate, R: Resisten', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: const [ + BadgeLabel(text: 'Media BAP', color: Color(0xFF2563EB)), + BadgeLabel(text: 'Media CAP', color: Color(0xFFDC2626)), + BadgeLabel(text: 'Mc Conkey', color: Color(0xFF0891B2)), + BadgeLabel(text: 'Media Jamur', color: Color(0xFFD97706)), + ], + ), + const SizedBox(height: 12), + for (final media in _cultureMedia) + Card( + margin: const EdgeInsets.only(bottom: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle(media.label, compact: true), + _select( + media.statusField, + 'Status Pertumbuhan', + _growthOptions, + ), + _text(media.noteField, 'Catatan'), + ], + ), + ), + ), + ], + ); + } + + Widget _toolData() { + return Column( + children: [ + _toolSection( + title: 'Vitek', + bacteriaField: 'bakteri', + sirField: 'bakterisir', + colonyField: 'id_bakterihitungkol', + colonyTextField: 'id_bakterihitungkolteks', + printField: 'id_baktericetak', + ), + _toolSection( + title: 'MALDITOF', + bacteriaField: 'id_bakteri01', + antibioticField: 'id_antibiotikmanual1', + sirField: 'id_bakterisir01', + colonyField: 'id_bakterihitungkol01', + colonyTextField: 'id_bakterihitungkolteks01', + printField: 'id_bakteri01cetak', + ), + _toolSection( + title: 'Bakteri Manual I', + bacteriaField: 'id_bakteri02', + antibioticField: 'id_antibiotikmanual2', + sirField: 'id_bakterisir02', + colonyField: 'id_bakterihitungkol02', + colonyTextField: 'id_bakterihitungkolteks02', + printField: 'id_bakteri02cetak', + ), + ], + ); + } + + Widget _finalExpertise() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _text('keterangan', 'Expertise', minLines: 8, maxLines: 12), + CheckboxListTile( + value: widget.criticalValue, + onChanged: (value) => widget.onCriticalChanged(value ?? false), + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + title: const Text('Nilai Kritis'), + ), + FilledButton.icon( + onPressed: widget.saving ? null : () => widget.onSave('Draft'), + icon: widget.saving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: const Text('Save as Draft'), + ), + if (widget.isSupervisor) ...[ + OutlinedButton.icon( + onPressed: widget.saving + ? null + : () => widget.onSave('preliminary'), + icon: const Icon(Icons.done_all_outlined), + label: const Text('Save Preliminary result'), + ), + OutlinedButton.icon( + onPressed: widget.saving ? null : () => widget.onSave('verifikasi'), + icon: const Icon(Icons.verified_outlined), + label: const Text('Save Final Result'), + ), + ] else ...[ + OutlinedButton.icon( + onPressed: widget.saving + ? null + : () => widget.onSave('Permohonan Verifikasi'), + icon: const Icon(Icons.send_outlined), + label: const Text('Save and Send To SPV'), + ), + OutlinedButton.icon( + onPressed: widget.saving + ? null + : () => widget.onSave('Permohonan Verifikasi Preliminary'), + icon: const Icon(Icons.outgoing_mail), + label: const Text('Kirim SPV Preliminary'), + ), + ], + ], + ); + } + + List _options( + String key, [ + List fallback = const ['Pilih Salah Satu'], + ]) { + final values = asList( + widget.optionSets[key], + ).map((item) => item.toString()).where((item) => item.isNotEmpty).toList(); + return values.isEmpty ? fallback : values; + } + + Widget _sectionTitle(String title, {bool compact = false}) { + return Padding( + padding: EdgeInsets.only(bottom: compact ? 8 : 12), + child: Text( + title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w800, + color: const Color(0xFF0F766E), + ), + ), + ); + } + + Widget _toolSection({ + required String title, + required String bacteriaField, + String? antibioticField, + required String sirField, + required String colonyField, + required String colonyTextField, + required String printField, + }) { + return Card( + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle(title, compact: true), + _text(bacteriaField, 'Bakteri'), + if (antibioticField != null) + _text(antibioticField, 'Set Antibiotik'), + _select(sirField, 'SIR', _sirOptions), + _select(colonyField, 'Hitung Koloni', _colonyOptions), + _text(colonyTextField, 'Hitung Koloni Lainnya'), + _select(printField, 'Cetak', const ['YA', 'TIDAK']), + ], + ), + ), + ); + } + + Widget _select(String name, String label, List options) { + final current = widget.selectValues[name]; + final effectiveOptions = [ + if (current != null && current.isNotEmpty && !options.contains(current)) + current, + ...options, + ]; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: DropdownButtonFormField( + initialValue: + current != null && + current.isNotEmpty && + effectiveOptions.contains(current) + ? current + : null, + isExpanded: true, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + ), + items: effectiveOptions + .map( + (option) => DropdownMenuItem(value: option, child: Text(option)), + ) + .toList(), + onChanged: (value) { + widget.selectValues[name] = value ?? ''; + widget.onChanged(); + }, + ), + ); + } + + Widget _multiSelect(String name, String label, List options) { + final selected = widget.multiValues[name] ?? {}; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: InputDecorator( + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + ), + child: Wrap( + spacing: 8, + runSpacing: 4, + children: options + .map( + (option) => FilterChip( + label: Text(option), + selected: selected.contains(option), + onSelected: (checked) { + if (checked) { + selected.add(option); + } else { + selected.remove(option); + } + widget.multiValues[name] = selected; + widget.onChanged(); + }, + ), + ) + .toList(), + ), + ), + ); + } + + Widget _text( + String name, + String label, { + int minLines = 1, + int maxLines = 1, + }) { + final controller = widget.textControllers[name] ??= TextEditingController(); + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: TextField( + controller: controller, + minLines: minLines, + maxLines: maxLines, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + ), + ), + ); + } +} diff --git a/mylis/lib/screens/expertise/tbc_expertise_wizard.dart b/mylis/lib/screens/expertise/tbc_expertise_wizard.dart new file mode 100644 index 00000000..784ece6c --- /dev/null +++ b/mylis/lib/screens/expertise/tbc_expertise_wizard.dart @@ -0,0 +1,669 @@ +part of '../../app/app.dart'; + +class TbcExpertiseWizard extends StatefulWidget { + const TbcExpertiseWizard({ + super.key, + required this.item, + required this.user, + required this.staffOptions, + required this.textControllers, + required this.selectValues, + required this.multiValues, + required this.staffValues, + required this.criticalValue, + required this.isSupervisor, + required this.saving, + required this.onCriticalChanged, + required this.onChanged, + required this.onSave, + }); + + final Map item; + final Map user; + final Map staffOptions; + final Map textControllers; + final Map selectValues; + final Map> multiValues; + final Map staffValues; + final bool criticalValue; + final bool isSupervisor; + final bool saving; + final ValueChanged onCriticalChanged; + final VoidCallback onChanged; + final ValueChanged onSave; + + @override + State createState() => _TbcExpertiseWizardState(); +} + +class _TbcExpertiseWizardState extends State { + int _step = 0; + + void _next() { + setState(() => _step = (_step + 1).clamp(0, 6)); + } + + void _previous() { + setState(() => _step = (_step - 1).clamp(0, 6)); + } + + @override + Widget build(BuildContext context) { + return Stepper( + currentStep: _step, + onStepTapped: (step) => setState(() => _step = step), + controlsBuilder: (context, details) { + return Padding( + padding: const EdgeInsets.only(top: 12), + child: Row( + children: [ + OutlinedButton.icon( + onPressed: _step == 0 ? null : _previous, + icon: const Icon(Icons.chevron_left), + label: const Text('Previous'), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: _step == 6 ? null : _next, + icon: const Icon(Icons.chevron_right), + label: const Text('Next'), + ), + ], + ), + ); + }, + steps: [ + Step( + title: const Text('Pasien & Petugas'), + isActive: _step == 0, + content: PatientStaffPanel( + item: widget.item, + user: widget.user, + staffOptions: widget.staffOptions, + staffValues: widget.staffValues, + onChanged: widget.onChanged, + ), + ), + Step( + title: const Text('Riwayat & Identitas Nasional'), + isActive: _step == 1, + content: _historyAndIdentity(), + ), + Step( + title: const Text('Jenis Pemeriksaan'), + isActive: _step == 2, + content: _testType(), + ), + Step( + title: const Text('Hasil Pemeriksaan'), + isActive: _step == 3, + content: _examinationResultByType(), + ), + Step( + title: const Text('Tes Kepekaan Antibiotik'), + isActive: _step == 4, + content: _antibioticSensitivity(), + ), + Step( + title: const Text('Data Alat'), + isActive: _step == 5, + content: _toolData(), + ), + Step( + title: const Text('Expertise'), + isActive: _step == 6, + content: _finalExpertise(), + ), + ], + ); + } + + Widget _historyAndIdentity() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle('Jenis Terduga / Pasien TBC'), + _multiSelect('jenis_pasien_tbc', 'Jenis Terduga / Pasien TBC', const [ + 'TBC SO', + 'TBC RO', + 'Anak', + 'HIV', + 'DM', + ]), + _text('id_noidsediaan', 'No. Identitas Sediaan'), + _text( + 'id_tanggalpengambilancontohuji', + 'Tanggal Pengambilan Contoh Uji', + ), + _text('id_tanggalpengirimancontohuji', 'Tanggal Pengiriman Contoh Uji'), + _select( + 'id_lamariwayatpengobatantb', + 'Lama Riwayat Pengobatan Terduga/Pasien TBC', + const [ + 'Riwayat Pengobatan >= 5 tahun', + 'Riwayat Pengobatan < 5 tahun', + ], + ), + _select('id_lokasianatomi', 'Lokasi Anatomi', const [ + 'Paru', + 'Ekstraparu', + ]), + _text('id_ekstraparu', 'Ekstraparu'), + _select('id_alasanpemeriksaan', 'Alasan Pemeriksaan', const [ + 'Diagnosis TBC', + 'Diagnosis Baseline TBC', + 'Akhir Pengobatan', + ]), + _text( + 'id_bulankemajuantbfollow', + 'Pemantauan kemajuan pengobatan (Bulan ke)', + ), + _text('id_bulanpemeriksaanulang', 'Pemeriksaan ulang (Bulan ke)'), + _text( + 'id_bulanpemeriksaansetelahselesai', + 'Pemeriksaan setelah selesai pengobatan (Bulan ke)', + ), + _text('id_noregfasyankes', 'No.Reg.TBC/TBC RO Fasyankes'), + _text('id_noregkota', 'No.Reg.TBC/TBC RO Kab/Kota'), + _select('id_contohuji', 'Contoh Uji', const ['Dahak', 'Lainnya']), + _text('id_contohujilainnya', 'Contoh Uji Lainnya'), + _multiSelect('visual_dahak_sewaktu', 'Visual dahak Sewaktu', const [ + 'Nanah Lendir', + 'Bercak darah', + 'Air liur', + ]), + _multiSelect('visual_dahak_pagi', 'Visual dahak Pagi', const [ + 'Nanah Lendir', + 'Bercak darah', + 'Air liur', + ]), + ], + ); + } + + Widget _testType() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle('Jenis Pemeriksaan'), + _select('id_jenispemeriksaantb', 'Jenis Pemeriksaan', _tbcTestTypes), + ], + ); + } + + Widget _examinationResultByType() { + final type = widget.selectValues['id_jenispemeriksaantb'] ?? ''; + if (type == 'TCM XDR (Xpert)') { + return _xdrResult(); + } + if (type == 'Biakan') { + return _cultureResult(); + } + if (_tcmRifTypes.contains(type)) { + return _tcmMtbRifResult(); + } + return _microscopicResult(); + } + + Widget _microscopicResult() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle('Hasil Pemeriksaan Mikroskopis'), + _text('hasilpemeriksaantmikroskopis_tglsewaktu', 'Tanggal Sewaktu'), + _select( + 'hasilpemeriksaantmikroskopis_hasilsewaktu', + 'Hasil Sewaktu', + _tbMicroscopeResults, + ), + _text('hasilpemeriksaantmikroskopis_tglpagi', 'Tanggal Pagi'), + _select( + 'hasilpemeriksaantmikroskopis_hasilpagi', + 'Hasil Pagi', + _tbMicroscopeResults, + ), + ], + ); + } + + Widget _tcmMtbRifResult() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle('Hasil Pemeriksaan TCM MTB Rif'), + _text('hasilpemeriksaanttcmmtbrifxpert_tglsewaktu', 'Tanggal Sewaktu'), + _text('hasilpemeriksaanttcmmtbrifxpert_kodeunik', 'Kode Unik Sewaktu'), + _select( + 'hasilpemeriksaanttcmmtbrifxpert_hasilsewaktu', + 'Hasil Sewaktu', + _tcmMtbRifResults, + ), + _text('hasilpemeriksaanttcmmtbrifxpert_tglpagi', 'Tanggal Pagi'), + _text('hasilpemeriksaanttcmmtbrifxpert_kodeunikpagi', 'Kode Unik Pagi'), + _select( + 'hasilpemeriksaanttcmmtbrifxpert_hasilpagi', + 'Hasil Pagi', + _tcmMtbRifResults, + ), + ], + ); + } + + Widget _cultureResult() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle('Hasil Biakan'), + _text('hasilpemeriksaantbbiakan_tgl', 'Tanggal Mikroskopis'), + _select( + 'hasilpemeriksaantbbiakan_pagi', + 'Mikroskopis Pagi', + _tbMicroscopeResults, + ), + _select( + 'hasilpemeriksaantbbiakan_sewaktu', + 'Mikroskopis Sewaktu', + _tbMicroscopeResults, + ), + _text('hasilpemeriksaantbbiakan_tglbiakan', 'Tanggal Biakan'), + _select( + 'hasilpemeriksaantbbiakan_hasilbiakanmgit', + 'Hasil Biakan (MGIT)', + const ['Pos', 'Neg', 'NTM', 'KTM', 'TDL'], + ), + _select( + 'hasilpemeriksaantbbiakan_hasilbiakanlj', + 'Hasil Biakan (LJ)', + const ['3+', '2+', '1+', '1-9', 'Neg', 'NTM', 'KTM', 'TDL'], + ), + ], + ); + } + + Widget _xdrResult() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle('TCM XDR (Xpert) - Pagi'), + _text('hasilpemeriksaanttcmmtbxdrxpert_tglpagi', 'Tanggal Pagi'), + _text('hasilpemeriksaanttcmmtbxdrxpert_kodeunikpagi', 'Kode Unik Pagi'), + _xdrPanel('pagi'), + const SizedBox(height: 12), + _sectionTitle('TCM XDR (Xpert) - Sewaktu'), + _text('hasilpemeriksaanttcmmtbxdrxpert_tglsewaktu', 'Tanggal Sewaktu'), + _text('hasilpemeriksaanttcmmtbxdrxpert_kodeunik', 'Kode Unik Sewaktu'), + _xdrPanel('sewaktu'), + ], + ); + } + + Widget _xdrPanel(String prefix) { + final normalized = prefix == 'pagi' ? 'pagi' : 'sewaktu'; + final inhLowField = prefix == 'pagi' + ? 'hasilpemeriksaanttcmmtbxdrxpert_paginhlow' + : 'hasilpemeriksaanttcmmtbxdrxpert_sewaktunhlow'; + return Card( + margin: const EdgeInsets.only(bottom: 8), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + children: [ + _select( + 'hasilpemeriksaanttcmmtbxdrxpert_$normalized', + 'TCM XDR', + _xdrValidityResults, + ), + _select( + 'hasilpemeriksaanttcmmtbxdrxpert_${normalized}mtb', + 'MTB', + const ['Detected', 'Not Detected'], + ), + _select(inhLowField, 'H Low', _xdrSirResults), + _select( + 'hasilpemeriksaanttcmmtbxdrxpert_${normalized}h', + 'H', + _xdrSirResults, + ), + _select( + 'hasilpemeriksaanttcmmtbxdrxpert_${normalized}fqlow', + 'FQ Low', + _xdrSirResults, + ), + _select( + 'hasilpemeriksaanttcmmtbxdrxpert_${normalized}fq', + 'FQ', + _xdrSirResults, + ), + _select( + 'hasilpemeriksaanttcmmtbxdrxpert_${normalized}amk', + 'Amk', + _xdrSirResults, + ), + _select( + 'hasilpemeriksaanttcmmtbxdrxpert_${normalized}km', + 'Km', + _xdrSirResults, + ), + _select( + 'hasilpemeriksaanttcmmtbxdrxpert_${normalized}cm', + 'Cm', + _xdrSirResults, + ), + _select( + 'hasilpemeriksaanttcmmtbxdrxpert_${normalized}eto', + 'Eto', + _xdrSirResults, + ), + ], + ), + ), + ); + } + + Widget _antibioticSensitivity() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle('Tes Kepekaan Antibiotik'), + Text( + 'S: Sensitif, I: Intermediate, R: Resisten', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 12), + for (final media in _cultureMedia) + Card( + margin: const EdgeInsets.only(bottom: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle(media.label, compact: true), + _select( + media.statusField, + 'Status Pertumbuhan', + _growthOptions, + ), + _text(media.noteField, 'Catatan'), + ], + ), + ), + ), + ], + ); + } + + Widget _toolData() { + return Column( + children: [ + _toolSection( + title: 'Vitek', + bacteriaField: 'bakteri', + sirField: 'bakterisir', + colonyField: 'id_bakterihitungkol', + colonyTextField: 'id_bakterihitungkolteks', + printField: 'id_baktericetak', + ), + _toolSection( + title: 'MALDITOF', + bacteriaField: 'id_bakteri01', + antibioticField: 'id_antibiotikmanual1', + sirField: 'id_bakterisir01', + colonyField: 'id_bakterihitungkol01', + colonyTextField: 'id_bakterihitungkolteks01', + printField: 'id_bakteri01cetak', + ), + _toolSection( + title: 'Bakteri Manual I', + bacteriaField: 'id_bakteri02', + antibioticField: 'id_antibiotikmanual2', + sirField: 'id_bakterisir02', + colonyField: 'id_bakterihitungkol02', + colonyTextField: 'id_bakterihitungkolteks02', + printField: 'id_bakteri02cetak', + ), + ], + ); + } + + Widget _finalExpertise() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _text('keterangan', 'Expertise', minLines: 8, maxLines: 12), + CheckboxListTile( + value: widget.criticalValue, + onChanged: (value) => widget.onCriticalChanged(value ?? false), + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + title: const Text('Nilai Kritis'), + ), + FilledButton.icon( + onPressed: widget.saving ? null : () => widget.onSave('Draft'), + icon: widget.saving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: const Text('Save as Draft'), + ), + if (widget.isSupervisor) ...[ + OutlinedButton.icon( + onPressed: widget.saving + ? null + : () => widget.onSave('preliminary'), + icon: const Icon(Icons.done_all_outlined), + label: const Text('Save Preliminary result'), + ), + OutlinedButton.icon( + onPressed: widget.saving ? null : () => widget.onSave('verifikasi'), + icon: const Icon(Icons.verified_outlined), + label: const Text('Save Final Result'), + ), + ] else ...[ + OutlinedButton.icon( + onPressed: widget.saving + ? null + : () => widget.onSave('Permohonan Verifikasi'), + icon: const Icon(Icons.send_outlined), + label: const Text('Save and Send To SPV'), + ), + OutlinedButton.icon( + onPressed: widget.saving + ? null + : () => widget.onSave('Permohonan Verifikasi Preliminary'), + icon: const Icon(Icons.outgoing_mail), + label: const Text('Kirim SPV Preliminary'), + ), + ], + ], + ); + } + + Widget _sectionTitle(String title, {bool compact = false}) { + return Padding( + padding: EdgeInsets.only(bottom: compact ? 8 : 12), + child: Text( + title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w800, + color: const Color(0xFF0F766E), + ), + ), + ); + } + + Widget _toolSection({ + required String title, + required String bacteriaField, + String? antibioticField, + required String sirField, + required String colonyField, + required String colonyTextField, + required String printField, + }) { + return Card( + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle(title, compact: true), + _text(bacteriaField, 'Bakteri'), + if (antibioticField != null) + _text(antibioticField, 'Set Antibiotik'), + _select(sirField, 'SIR', _sirOptions), + _select(colonyField, 'Hitung Koloni', _colonyOptions), + _text(colonyTextField, 'Hitung Koloni Lainnya'), + _select(printField, 'Cetak', const ['YA', 'TIDAK']), + ], + ), + ), + ); + } + + Widget _select(String name, String label, List options) { + final current = widget.selectValues[name]; + final effectiveOptions = [ + if (current != null && current.isNotEmpty && !options.contains(current)) + current, + ...options, + ]; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: DropdownButtonFormField( + initialValue: + current != null && + current.isNotEmpty && + effectiveOptions.contains(current) + ? current + : null, + isExpanded: true, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + ), + items: effectiveOptions + .map( + (option) => DropdownMenuItem(value: option, child: Text(option)), + ) + .toList(), + onChanged: (value) { + widget.selectValues[name] = value ?? ''; + widget.onChanged(); + }, + ), + ); + } + + Widget _multiSelect(String name, String label, List options) { + final selected = widget.multiValues[name] ?? {}; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: InputDecorator( + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + ), + child: Wrap( + spacing: 8, + runSpacing: 4, + children: options + .map( + (option) => FilterChip( + label: Text(option), + selected: selected.contains(option), + onSelected: (checked) { + if (checked) { + selected.add(option); + } else { + selected.remove(option); + } + widget.multiValues[name] = selected; + widget.onChanged(); + }, + ), + ) + .toList(), + ), + ), + ); + } + + Widget _text( + String name, + String label, { + int minLines = 1, + int maxLines = 1, + }) { + final controller = widget.textControllers[name] ??= TextEditingController(); + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: TextField( + controller: controller, + minLines: minLines, + maxLines: maxLines, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + ), + ), + ); + } +} + +const List _tbcTestTypes = [ + 'Mikroskopis', + 'TCM MTB Rif (Xpert)', + 'TCM XDR (Xpert)', + 'TCM MTB Rif (Truenat)', + 'TCM MTB Rif INH (BDMAX)', + 'PCR Open System', + 'LPA lini 1', + 'LPA lini 2', + 'Biakan', + 'Paket standar uji kepekaan', +]; + +const Set _tcmRifTypes = { + 'TCM MTB Rif (Xpert)', + 'TCM MTB Rif (Truenat)', + 'TCM MTB Rif INH (BDMAX)', + 'LPA lini 1', + 'LPA lini 2', +}; + +const List _tbMicroscopeResults = ['+++', '++', '+', '1-9', 'Neg']; + +const List _tcmMtbRifResults = [ + 'Neg', + 'Rif Sen', + 'Rif Res', + 'Rif Indet', + 'Invalid', + 'Error', + 'No result', +]; + +const List _xdrValidityResults = [ + 'Valid', + 'Invalid', + 'No Result', + 'Error', +]; + +const List _xdrSirResults = [ + 'S', + 'I', + 'R', + 'Invalid', + 'Error', + 'No Result', +]; diff --git a/mylis/lib/screens/expertise/template_picker.dart b/mylis/lib/screens/expertise/template_picker.dart new file mode 100644 index 00000000..7ed8c925 --- /dev/null +++ b/mylis/lib/screens/expertise/template_picker.dart @@ -0,0 +1,55 @@ +part of '../../app/app.dart'; + +class TemplatePicker extends StatelessWidget { + const TemplatePicker({ + super.key, + required this.templates, + required this.saving, + required this.onUse, + }); + + final List templates; + final bool saving; + final ValueChanged onUse; + + @override + Widget build(BuildContext context) { + final knownTemplates = templates + .map(asMap) + .where((template) => _knownDlp.contains(template['judul']?.toString())) + .toList(); + + if (knownTemplates.isEmpty) { + return const EmptyPanel(text: 'Template expertise belum tersedia.'); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionTitle(title: 'Pilih Template'), + ...knownTemplates.map( + (template) => Card( + margin: const EdgeInsets.only(bottom: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + child: ListTile( + leading: const Icon( + Icons.article_outlined, + color: Color(0xFF0F766E), + ), + title: Text(template['judul']?.toString() ?? '-'), + subtitle: Text('Kategori ${template['kategori'] ?? '-'}'), + trailing: FilledButton( + onPressed: saving + ? null + : () => onUse(template['judul'].toString()), + child: const Text('Pakai'), + ), + ), + ), + ), + ], + ); + } +} diff --git a/mylis/lib/screens/expertise/viral_load_expertise_wizard.dart b/mylis/lib/screens/expertise/viral_load_expertise_wizard.dart new file mode 100644 index 00000000..80761820 --- /dev/null +++ b/mylis/lib/screens/expertise/viral_load_expertise_wizard.dart @@ -0,0 +1,419 @@ +part of '../../app/app.dart'; + +class ViralLoadExpertiseWizard extends StatefulWidget { + const ViralLoadExpertiseWizard({ + super.key, + required this.item, + required this.user, + required this.staffOptions, + required this.optionSets, + required this.textControllers, + required this.selectValues, + required this.staffValues, + required this.criticalValue, + required this.isSupervisor, + required this.saving, + required this.onCriticalChanged, + required this.onChanged, + required this.onSave, + }); + + final Map item; + final Map user; + final Map staffOptions; + final Map optionSets; + final Map textControllers; + final Map selectValues; + final Map staffValues; + final bool criticalValue; + final bool isSupervisor; + final bool saving; + final ValueChanged onCriticalChanged; + final VoidCallback onChanged; + final ValueChanged onSave; + + @override + State createState() => + _ViralLoadExpertiseWizardState(); +} + +class _ViralLoadExpertiseWizardState extends State { + int _step = 0; + + void _next() { + setState(() => _step = (_step + 1).clamp(0, 4)); + } + + void _previous() { + setState(() => _step = (_step - 1).clamp(0, 4)); + } + + @override + Widget build(BuildContext context) { + return Stepper( + currentStep: _step, + onStepTapped: (step) => setState(() => _step = step), + controlsBuilder: (context, details) { + return Padding( + padding: const EdgeInsets.only(top: 12), + child: Row( + children: [ + OutlinedButton.icon( + onPressed: _step == 0 ? null : _previous, + icon: const Icon(Icons.chevron_left), + label: const Text('Previous'), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: _step == 4 ? null : _next, + icon: const Icon(Icons.chevron_right), + label: const Text('Next'), + ), + ], + ), + ); + }, + steps: [ + Step( + title: const Text('Pasien & Petugas'), + isActive: _step == 0, + content: PatientStaffPanel( + item: widget.item, + user: widget.user, + staffOptions: widget.staffOptions, + staffValues: widget.staffValues, + onChanged: widget.onChanged, + ), + ), + Step( + title: const Text('Parameter'), + isActive: _step == 1, + content: _parameters(), + ), + Step( + title: const Text('Tes Kepekaan Antibiotik'), + isActive: _step == 2, + content: _antibioticSensitivity(), + ), + Step( + title: const Text('Data Alat'), + isActive: _step == 3, + content: _toolData(), + ), + Step( + title: const Text('Expertise'), + isActive: _step == 4, + content: _finalExpertise(), + ), + ], + ); + } + + Widget _parameters() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle('Parameter Viral Load'), + _select( + 'vl_parameter01', + 'Parameter', + _options('jsonvlparameter', const ['HIV RNA', 'HCV RNA', 'HBV DNA']), + ), + _select('vl_hasil01', 'Hasil', _viralLoadResultOptions), + _text('vl_hasil01teks', 'Hasil Manual'), + _select('vl_satuan01', 'Satuan', const ['copies/mL', 'IU/mL']), + _select('vl_rujukan01', 'Nilai Rujukan', const [ + '40-10.000.000', + '10-100.000.000', + ]), + const SizedBox(height: 8), + _sectionTitle('Parameter 02', compact: true), + _select('vl_parameter02', 'Parameter 02', const ['LOG']), + _select('vl_hasil02', 'Hasil 02', _viralLoadLogResultOptions), + _text('vl_hasil02teks', 'Hasil 02 Manual'), + _select('vl_satuan02', 'Satuan 02', const ['LOG']), + _select('vl_rujukan02', 'Nilai Rujukan 02', const ['1,6-7', ' ']), + _text('viralload', 'Catatan Viral Load', minLines: 6, maxLines: 9), + ], + ); + } + + Widget _antibioticSensitivity() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle('Tes Kepekaan Antibiotik'), + Text( + 'S: Sensitif, I: Intermediate, R: Resisten', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: const [ + BadgeLabel(text: 'Media BAP', color: Color(0xFF2563EB)), + BadgeLabel(text: 'Media CAP', color: Color(0xFFDC2626)), + BadgeLabel(text: 'Mc Conkey', color: Color(0xFF0891B2)), + BadgeLabel(text: 'Media Jamur', color: Color(0xFFD97706)), + ], + ), + const SizedBox(height: 12), + for (final media in _cultureMedia) + Card( + margin: const EdgeInsets.only(bottom: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle(media.label, compact: true), + _select( + media.statusField, + 'Status Pertumbuhan', + _growthOptions, + ), + _text(media.noteField, 'Catatan'), + ], + ), + ), + ), + ], + ); + } + + Widget _toolData() { + return Column( + children: [ + _toolSection( + title: 'Vitek', + bacteriaField: 'bakteri', + sirField: 'bakterisir', + colonyField: 'id_bakterihitungkol', + colonyTextField: 'id_bakterihitungkolteks', + printField: 'id_baktericetak', + ), + _toolSection( + title: 'MALDITOF', + bacteriaField: 'id_bakteri01', + antibioticField: 'id_antibiotikmanual1', + sirField: 'id_bakterisir01', + colonyField: 'id_bakterihitungkol01', + colonyTextField: 'id_bakterihitungkolteks01', + printField: 'id_bakteri01cetak', + ), + _toolSection( + title: 'Bakteri Manual I', + bacteriaField: 'id_bakteri02', + antibioticField: 'id_antibiotikmanual2', + sirField: 'id_bakterisir02', + colonyField: 'id_bakterihitungkol02', + colonyTextField: 'id_bakterihitungkolteks02', + printField: 'id_bakteri02cetak', + ), + ], + ); + } + + Widget _finalExpertise() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _text('keterangan', 'Expertise', minLines: 8, maxLines: 12), + CheckboxListTile( + value: widget.criticalValue, + onChanged: (value) => widget.onCriticalChanged(value ?? false), + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + title: const Text('Nilai Kritis'), + ), + FilledButton.icon( + onPressed: widget.saving ? null : () => widget.onSave('Draft'), + icon: widget.saving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: const Text('Save as Draft'), + ), + if (widget.isSupervisor) ...[ + OutlinedButton.icon( + onPressed: widget.saving + ? null + : () => widget.onSave('preliminary'), + icon: const Icon(Icons.done_all_outlined), + label: const Text('Save Preliminary result'), + ), + OutlinedButton.icon( + onPressed: widget.saving ? null : () => widget.onSave('verifikasi'), + icon: const Icon(Icons.verified_outlined), + label: const Text('Save Final Result'), + ), + ] else ...[ + OutlinedButton.icon( + onPressed: widget.saving + ? null + : () => widget.onSave('Permohonan Verifikasi'), + icon: const Icon(Icons.send_outlined), + label: const Text('Save and Send To SPV'), + ), + OutlinedButton.icon( + onPressed: widget.saving + ? null + : () => widget.onSave('Permohonan Verifikasi Preliminary'), + icon: const Icon(Icons.outgoing_mail), + label: const Text('Kirim SPV Preliminary'), + ), + ], + ], + ); + } + + List _options( + String key, [ + List fallback = const ['Pilih Salah Satu'], + ]) { + final values = asList( + widget.optionSets[key], + ).map((item) => item.toString()).where((item) => item.isNotEmpty).toList(); + return values.isEmpty ? fallback : values; + } + + Widget _sectionTitle(String title, {bool compact = false}) { + return Padding( + padding: EdgeInsets.only(bottom: compact ? 8 : 12), + child: Text( + title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w800, + color: const Color(0xFF0F766E), + ), + ), + ); + } + + Widget _toolSection({ + required String title, + required String bacteriaField, + String? antibioticField, + required String sirField, + required String colonyField, + required String colonyTextField, + required String printField, + }) { + return Card( + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle(title, compact: true), + _text(bacteriaField, 'Bakteri'), + if (antibioticField != null) + _text(antibioticField, 'Set Antibiotik'), + _select(sirField, 'SIR', _sirOptions), + _select(colonyField, 'Hitung Koloni', _colonyOptions), + _text(colonyTextField, 'Hitung Koloni Lainnya'), + _select(printField, 'Cetak', const ['YA', 'TIDAK']), + ], + ), + ), + ); + } + + Widget _select(String name, String label, List options) { + final current = widget.selectValues[name]; + final effectiveOptions = [ + if (current != null && current.isNotEmpty && !options.contains(current)) + current, + ...options, + ]; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: DropdownButtonFormField( + initialValue: + current != null && + current.isNotEmpty && + effectiveOptions.contains(current) + ? current + : null, + isExpanded: true, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + ), + items: effectiveOptions + .map( + (option) => DropdownMenuItem(value: option, child: Text(option)), + ) + .toList(), + onChanged: (value) { + widget.selectValues[name] = value ?? ''; + widget.onChanged(); + }, + ), + ); + } + + Widget _text( + String name, + String label, { + int minLines = 1, + int maxLines = 1, + }) { + final controller = widget.textControllers[name] ??= TextEditingController(); + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: TextField( + controller: controller, + minLines: minLines, + maxLines: maxLines, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + ), + ), + ); + } +} + +const List _viralLoadResultOptions = [ + 'NOT DETECTED', + '<40', + '<1.60', + '40', + '41', + '44', + '45', + '145', + '<10', + '20', + '322', + '511', + '144', + '211', + 'lainnya', +]; + +const List _viralLoadLogResultOptions = [ + 'NOT DETECTED', + '<1,60', + '<1,85', + '1,60', + '1,61', + '1,64', + '1,65', + '2,16', + '1,00', + '1,30', + '2,51', + '2,71', + '2,34', + 'lainnya', +]; diff --git a/mylis/lib/screens/login/login_screen.dart b/mylis/lib/screens/login/login_screen.dart new file mode 100644 index 00000000..5b90bf6f --- /dev/null +++ b/mylis/lib/screens/login/login_screen.dart @@ -0,0 +1,254 @@ +part of '../../app/app.dart'; + +class LoginScreen extends StatefulWidget { + const LoginScreen({super.key}); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final _formKey = GlobalKey(); + final _baseUrl = TextEditingController(text: kDefaultBaseUrl); + final _username = TextEditingController(); + final _password = TextEditingController(); + bool _obscure = true; + bool _loading = false; + bool _pinging = false; + String? _pingStatus; + + @override + void initState() { + super.initState(); + SessionStore.baseUrl().then((value) { + if (!mounted) { + return; + } + _baseUrl.text = value; + }); + } + + @override + void dispose() { + _baseUrl.dispose(); + _username.dispose(); + _password.dispose(); + super.dispose(); + } + + Future _login() async { + if (!_formKey.currentState!.validate()) { + return; + } + setState(() => _loading = true); + try { + final serverUrl = normalizeBaseUrl(_baseUrl.text); + await SessionStore.saveBaseUrl(serverUrl); + final api = ApiClient(baseUrl: serverUrl); + final data = await api.post('api/mobile/login', { + 'username': _username.text.trim(), + 'password': _password.text, + }); + final token = data['token']?.toString(); + if (token == null || token.isEmpty) { + throw ApiException('Token login tidak diterima dari server.'); + } + await SessionStore.saveToken(token); + if (!mounted) { + return; + } + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (_) => DashboardScreen(token: token, baseUrl: serverUrl), + ), + ); + } on ApiException catch (error) { + _showMessage(error.message); + } catch (_) { + _showMessage('Tidak bisa terhubung ke server MyLIS.'); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _ping() async { + final serverUrl = normalizeBaseUrl(_baseUrl.text); + if (serverUrl.isEmpty) { + return; + } + setState(() { + _pinging = true; + _pingStatus = null; + }); + + try { + await SessionStore.saveBaseUrl(serverUrl); + final data = await ApiClient(baseUrl: serverUrl).get('api/mobile/ping'); + final appName = data['application']?.toString(); + setState(() { + _baseUrl.text = serverUrl; + _pingStatus = appName == null || appName.isEmpty + ? 'Server bisa diakses.' + : 'Server bisa diakses: $appName'; + }); + } on ApiException catch (error) { + setState(() => _pingStatus = error.message); + } catch (_) { + setState(() => _pingStatus = 'Koneksi server gagal.'); + } finally { + if (mounted) setState(() => _pinging = false); + } + } + + void _showMessage(String message) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Icon( + Icons.biotech_outlined, + size: 72, + color: Color(0xFF0F766E), + ), + const SizedBox(height: 16), + Text( + 'MyLIS Mobile', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 6), + Text( + 'Login petugas', + textAlign: TextAlign.center, + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: Colors.black54), + ), + const SizedBox(height: 32), + TextFormField( + controller: _baseUrl, + keyboardType: TextInputType.url, + textInputAction: TextInputAction.next, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.link), + labelText: 'URL Laravel', + border: const OutlineInputBorder(), + suffixIcon: IconButton( + tooltip: 'Ping server', + onPressed: _pinging ? null : _ping, + icon: _pinging + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.network_ping), + ), + ), + validator: (value) { + final url = normalizeBaseUrl(value ?? ''); + final uri = Uri.tryParse(url); + return uri == null || + !uri.hasScheme || + !uri.hasAuthority + ? 'URL Laravel tidak valid' + : null; + }, + ), + if (_pingStatus != null) ...[ + const SizedBox(height: 8), + Text( + _pingStatus!, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: _pingStatus!.contains('bisa diakses') + ? const Color(0xFF15803D) + : const Color(0xFFDC2626), + ), + ), + ], + const SizedBox(height: 14), + TextFormField( + controller: _username, + textInputAction: TextInputAction.next, + decoration: const InputDecoration( + prefixIcon: Icon(Icons.person_outline), + labelText: 'Username', + border: OutlineInputBorder(), + ), + validator: (value) => + value == null || value.trim().isEmpty + ? 'Username wajib diisi' + : null, + ), + const SizedBox(height: 14), + TextFormField( + controller: _password, + obscureText: _obscure, + onFieldSubmitted: (_) => _login(), + decoration: InputDecoration( + prefixIcon: const Icon(Icons.lock_outline), + labelText: 'Password', + border: const OutlineInputBorder(), + suffixIcon: IconButton( + tooltip: _obscure + ? 'Tampilkan password' + : 'Sembunyikan password', + onPressed: () => setState(() => _obscure = !_obscure), + icon: Icon( + _obscure + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + ), + ), + validator: (value) => value == null || value.isEmpty + ? 'Password wajib diisi' + : null, + ), + const SizedBox(height: 22), + FilledButton.icon( + onPressed: _loading ? null : _login, + icon: _loading + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.login), + label: const Text('Login'), + ), + const SizedBox(height: 18), + Text( + 'Default: $kDefaultBaseUrl', + textAlign: TextAlign.center, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: Colors.black45), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/mylis/lib/services/api_client.dart b/mylis/lib/services/api_client.dart new file mode 100644 index 00000000..28611833 --- /dev/null +++ b/mylis/lib/services/api_client.dart @@ -0,0 +1,59 @@ +part of '../app/app.dart'; + +class ApiClient { + ApiClient({required this.baseUrl, this.token}); + + final String baseUrl; + final String? token; + + Uri _uri(String path, [Map? query]) { + final normalizedBase = normalizeBaseUrl(baseUrl); + final base = Uri.parse(normalizedBase); + return base.replace( + path: '${base.path.replaceAll(RegExp(r'/$'), '')}/$path', + queryParameters: query, + ); + } + + Map get _headers => { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + if (token != null) 'Authorization': 'Bearer $token', + }; + + Future> post( + String path, + Map body, + ) async { + final response = await http.post( + _uri(path), + headers: _headers, + body: jsonEncode(body), + ); + return _decode(response); + } + + Future> get( + String path, [ + Map? query, + ]) async { + final response = await http.get(_uri(path, query), headers: _headers); + return _decode(response); + } + + Map _decode(http.Response response) { + final decoded = response.body.isEmpty + ? {} + : jsonDecode(response.body); + if (response.statusCode >= 200 && response.statusCode < 300) { + return decoded is Map ? decoded : {'data': decoded}; + } + final message = decoded is Map ? decoded['message'] : null; + throw ApiException(message?.toString() ?? 'Koneksi ke server gagal.'); + } +} + +class ApiException implements Exception { + ApiException(this.message); + final String message; +} diff --git a/mylis/lib/services/session_store.dart b/mylis/lib/services/session_store.dart new file mode 100644 index 00000000..2e77a9a9 --- /dev/null +++ b/mylis/lib/services/session_store.dart @@ -0,0 +1,57 @@ +part of '../app/app.dart'; + +class SessionStore { + static const _tokenKey = 'mylis_token'; + static const _baseUrlKey = 'mylis_base_url'; + + static Future token() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_tokenKey); + } + + static Future baseUrl() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_baseUrlKey) ?? kDefaultBaseUrl; + } + + static Future session() async { + final prefs = await SharedPreferences.getInstance(); + return SessionData( + token: prefs.getString(_tokenKey), + baseUrl: prefs.getString(_baseUrlKey) ?? kDefaultBaseUrl, + ); + } + + static Future saveToken(String token) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_tokenKey, token); + } + + static Future saveBaseUrl(String baseUrl) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_baseUrlKey, normalizeBaseUrl(baseUrl)); + } + + static Future clear() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_tokenKey); + } +} + +class SessionData { + const SessionData({this.token, this.baseUrl = kDefaultBaseUrl}); + + final String? token; + final String baseUrl; +} + +String normalizeBaseUrl(String value) { + var url = value.trim(); + if (url.isEmpty) { + return kDefaultBaseUrl; + } + if (!url.startsWith('http://') && !url.startsWith('https://')) { + url = 'https://$url'; + } + return url.replaceAll(RegExp(r'/+$'), ''); +} diff --git a/mylis/lib/widgets/common_widgets.dart b/mylis/lib/widgets/common_widgets.dart new file mode 100644 index 00000000..88d46f0c --- /dev/null +++ b/mylis/lib/widgets/common_widgets.dart @@ -0,0 +1,140 @@ +part of '../app/app.dart'; + +class DetailRow extends StatelessWidget { + const DetailRow({super.key, required this.label, required this.value}); + final String label; + final Object? value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle(color: Colors.black54, fontSize: 12), + ), + const SizedBox(height: 2), + Text( + value?.toString().isNotEmpty == true ? value.toString() : '-', + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ], + ), + ); + } +} + +class StatusPill extends StatelessWidget { + const StatusPill({super.key, required this.status}); + final String status; + + @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); + } +} + +class BadgeLabel extends StatelessWidget { + const BadgeLabel({super.key, required this.text, required this.color}); + final String text; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + text, + style: TextStyle( + color: color, + fontWeight: FontWeight.w700, + fontSize: 12, + ), + ), + ); + } +} + +class EmptyPanel extends StatelessWidget { + const EmptyPanel({super.key, required this.text}); + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Center( + child: Text(text, style: const TextStyle(color: Colors.black54)), + ), + ); + } +} + +class ErrorView extends StatelessWidget { + const ErrorView({super.key, required this.message, required this.onRetry}); + final String message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 42, color: Color(0xFFDC2626)), + const SizedBox(height: 10), + Text(message, textAlign: TextAlign.center), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } +} + +class LoadingScreen extends StatelessWidget { + const LoadingScreen({super.key}); + + @override + Widget build(BuildContext context) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } +} + +Map asMap(Object? value) { + if (value is Map) { + return value; + } + if (value is Map) { + return value.map((key, item) => MapEntry(key.toString(), item)); + } + return {}; +} + +List asList(Object? value) => value is List ? value : []; + +String _message(Object? error) => error is ApiException + ? error.message + : 'Terjadi kesalahan saat memuat data.';