This commit is contained in:
Dwi Swandhana
2026-07-21 19:08:27 +07:00
parent d944647129
commit 6d0f9a8bf6
26 changed files with 6405 additions and 1312 deletions
@@ -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);
@@ -772,7 +772,7 @@
</select>
</div>
<div class="form-group account-btn text-center m-t-10">
<span class="badge badge-primary">C. Tes Kepekaan Antibiotika: (S: Sensitif ; I : Intermediate ; R: Resisten)</span>
<span class="badge badge-primary">C. Tes Kepekaan Antibiotik: (S: Sensitif ; I : Intermediate ; R: Resisten)</span>
</div>
<div class="form-group col-lg-12">
<div class="form-row">
+3
View File
@@ -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([
+31 -32
View File
@@ -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 = [
+206 -11
View File
@@ -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.
+52
View File
@@ -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(),
);
}
}
+29
View File
@@ -0,0 +1,29 @@
part of '../app/app.dart';
const Set<String> _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';
}
+2 -1268
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
part of '../app/app.dart';
class SessionGate extends StatefulWidget {
const SessionGate({super.key});
@override
State<SessionGate> createState() => _SessionGateState();
}
class _SessionGateState extends State<SessionGate> {
late final Future<SessionData> _sessionFuture;
@override
void initState() {
super.initState();
_sessionFuture = SessionStore.session();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<SessionData>(
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);
},
);
}
}
@@ -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<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends State<DashboardScreen> {
late final ApiClient _api;
late Future<Map<String, dynamic>> _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<void> _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<Map<String, dynamic>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const LoadingScreen();
}
if (snapshot.hasError) {
return ErrorView(
message: _message(snapshot.error),
onRetry: _reload,
);
}
final data = snapshot.data!;
final 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<int>(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<String, dynamic> user;
final Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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',
),
),
);
},
),
);
}
}
@@ -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<ExaminationDetailScreen> createState() =>
_ExaminationDetailScreenState();
}
class _ExaminationDetailScreenState extends State<ExaminationDetailScreen> {
late final ApiClient _api;
late Future<Map<String, dynamic>> _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<void> _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<void> _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<Map<String, dynamic>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const LoadingScreen();
}
if (snapshot.hasError) {
return ErrorView(
message: _message(snapshot.error),
onRetry: _reload,
);
}
final data = snapshot.data!;
final 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'),
),
],
);
},
),
);
}
}
@@ -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<ExaminationListScreen> createState() => _ExaminationListScreenState();
}
class _ExaminationListScreenState extends State<ExaminationListScreen> {
late final ApiClient _api;
late Future<Map<String, dynamic>> _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<Map<String, dynamic>> _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<Map<String, dynamic>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const LoadingScreen();
}
if (snapshot.hasError) {
return ErrorView(
message: _message(snapshot.error),
onRetry: _reload,
);
}
final 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<String, dynamic> 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<String, dynamic> 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<void> _openDetail(
BuildContext context,
Map<String, dynamic> item,
String token,
String baseUrl,
) async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ExaminationDetailScreen(
token: token,
baseUrl: baseUrl,
id: (item['id'] as num).toInt(),
),
),
);
}
@@ -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<String, dynamic> item;
final Map<String, dynamic> user;
final Map<String, dynamic> staffOptions;
final Map<String, dynamic> optionSets;
final Map<String, TextEditingController> textControllers;
final Map<String, String> selectValues;
final Map<String, String> staffValues;
final bool criticalValue;
final bool isSupervisor;
final bool saving;
final ValueChanged<bool> onCriticalChanged;
final VoidCallback onChanged;
final ValueChanged<String> onSave;
@override
State<CciExpertiseWizard> createState() => _CciExpertiseWizardState();
}
class _CciExpertiseWizardState extends State<CciExpertiseWizard> {
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<String> _options(String key, List<String> 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<String> 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<String>(
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<String, dynamic> item;
final Map<String, dynamic> user;
final Map<String, dynamic> staffOptions;
final Map<String, String> 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<dynamic> 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<String>(
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<String> _sirOptions = [
'ESBL',
'MRSA',
'MDR',
'XDR',
'PDR',
'Carbapenem Resistant',
'MDR Carbapenem Resistant',
'XDR Carbapenem Resistant',
];
const List<String> _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',
];
@@ -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<String, dynamic> item;
final Map<String, dynamic> user;
final Map<String, dynamic> staffOptions;
final Map<String, TextEditingController> textControllers;
final Map<String, String> selectValues;
final Map<String, String> staffValues;
final bool criticalValue;
final bool isSupervisor;
final bool saving;
final ValueChanged<bool> onCriticalChanged;
final VoidCallback onChanged;
final ValueChanged<String> onSave;
@override
State<CovidExpertiseWizard> createState() => _CovidExpertiseWizardState();
}
class _CovidExpertiseWizardState extends State<CovidExpertiseWizard> {
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<String> 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<String>(
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<String> _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',
),
];
@@ -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<dynamic> fields;
final Map<String, TextEditingController> textControllers;
final Map<String, String> selectValues;
final Map<String, Set<String>> 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<String, dynamic> 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<String>(
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] ?? <String>{};
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<String> 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'),
),
],
],
),
),
);
}
}
@@ -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<ExpertiseScreen> createState() => _ExpertiseScreenState();
}
class _ExpertiseScreenState extends State<ExpertiseScreen> {
late final ApiClient _api;
late Future<Map<String, dynamic>> _future;
final Map<String, TextEditingController> _textControllers = {};
final Map<String, String> _selectValues = {};
final Map<String, Set<String>> _multiValues = {};
final Map<String, String> _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<Map<String, dynamic>> _load() {
return _api.get('api/mobile/examinations/${widget.id}/expertise');
}
void _reload() {
setState(() => _future = _load());
}
void _prepareFields(Map<String, dynamic> 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()
: <String>{};
} else {
_textControllers[name] = TextEditingController(
text: value?.toString() ?? '',
);
}
}
}
Future<void> _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<void> _save(String action) async {
setState(() => _saving = true);
final fields = <String, dynamic>{};
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<Map<String, dynamic>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const LoadingScreen();
}
if (snapshot.hasError) {
return ErrorView(
message: _message(snapshot.error),
onRetry: _reload,
);
}
final data = snapshot.data!;
final 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,
),
],
),
],
);
},
),
);
}
}
@@ -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<String, dynamic> item;
final Map<String, dynamic> user;
final Map<String, dynamic> staffOptions;
final Map<String, dynamic> optionSets;
final Map<String, TextEditingController> textControllers;
final Map<String, String> selectValues;
final Map<String, Set<String>> multiValues;
final Map<String, String> staffValues;
final bool criticalValue;
final bool isSupervisor;
final bool saving;
final ValueChanged<bool> onCriticalChanged;
final VoidCallback onChanged;
final ValueChanged<String> onSave;
@override
State<KulturExpertiseWizard> createState() => _KulturExpertiseWizardState();
}
class _KulturExpertiseWizardState extends State<KulturExpertiseWizard> {
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<String> _options(
String key, [
List<String> 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<String> 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<String>(
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<String> options) {
final selected = widget.multiValues[name] ?? <String>{};
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<String> _scoreOptions = ['0', '1', '2', '3', '4'];
const List<String> _antibioticSirOptions = ['S', 'I', 'R'];
const List<String> _growthOptions = [
'Ada Pertumbuhan',
'Tidak Ada Pertumbuhan',
'Pertumbuhan Primer',
];
const List<String> _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<String> _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<String> _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',
];
@@ -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<String, dynamic> item;
final Map<String, dynamic> user;
final Map<String, dynamic> staffOptions;
final Map<String, TextEditingController> textControllers;
final Map<String, String> selectValues;
final Map<String, String> staffValues;
final bool criticalValue;
final bool isSupervisor;
final bool saving;
final ValueChanged<bool> onCriticalChanged;
final VoidCallback onChanged;
final ValueChanged<String> onSave;
@override
State<LeptospiraExpertiseWizard> createState() =>
_LeptospiraExpertiseWizardState();
}
class _LeptospiraExpertiseWizardState extends State<LeptospiraExpertiseWizard> {
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<String> 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<String>(
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(),
),
),
);
}
}
@@ -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<String, dynamic> item;
final Map<String, dynamic> user;
final Map<String, dynamic> staffOptions;
final Map<String, dynamic> optionSets;
final Map<String, TextEditingController> textControllers;
final Map<String, String> selectValues;
final Map<String, Set<String>> multiValues;
final Map<String, String> staffValues;
final bool criticalValue;
final bool isSupervisor;
final bool saving;
final ValueChanged<bool> onCriticalChanged;
final VoidCallback onChanged;
final ValueChanged<String> onSave;
@override
State<PewarnaanLangsungExpertiseWizard> createState() =>
_PewarnaanLangsungExpertiseWizardState();
}
class _PewarnaanLangsungExpertiseWizardState
extends State<PewarnaanLangsungExpertiseWizard> {
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<String> _options(
String key, [
List<String> 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<String> 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<String>(
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<String> options) {
final selected = widget.multiValues[name] ?? <String>{};
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(),
),
),
);
}
}
@@ -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<String, dynamic> item;
final Map<String, dynamic> user;
final Map<String, dynamic> staffOptions;
final Map<String, TextEditingController> textControllers;
final Map<String, String> selectValues;
final Map<String, Set<String>> multiValues;
final Map<String, String> staffValues;
final bool criticalValue;
final bool isSupervisor;
final bool saving;
final ValueChanged<bool> onCriticalChanged;
final VoidCallback onChanged;
final ValueChanged<String> onSave;
@override
State<TbcExpertiseWizard> createState() => _TbcExpertiseWizardState();
}
class _TbcExpertiseWizardState extends State<TbcExpertiseWizard> {
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<String> 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<String>(
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<String> options) {
final selected = widget.multiValues[name] ?? <String>{};
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<String> _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<String> _tcmRifTypes = {
'TCM MTB Rif (Xpert)',
'TCM MTB Rif (Truenat)',
'TCM MTB Rif INH (BDMAX)',
'LPA lini 1',
'LPA lini 2',
};
const List<String> _tbMicroscopeResults = ['+++', '++', '+', '1-9', 'Neg'];
const List<String> _tcmMtbRifResults = [
'Neg',
'Rif Sen',
'Rif Res',
'Rif Indet',
'Invalid',
'Error',
'No result',
];
const List<String> _xdrValidityResults = [
'Valid',
'Invalid',
'No Result',
'Error',
];
const List<String> _xdrSirResults = [
'S',
'I',
'R',
'Invalid',
'Error',
'No Result',
];
@@ -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<dynamic> templates;
final bool saving;
final ValueChanged<String> 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'),
),
),
),
),
],
);
}
}
@@ -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<String, dynamic> item;
final Map<String, dynamic> user;
final Map<String, dynamic> staffOptions;
final Map<String, dynamic> optionSets;
final Map<String, TextEditingController> textControllers;
final Map<String, String> selectValues;
final Map<String, String> staffValues;
final bool criticalValue;
final bool isSupervisor;
final bool saving;
final ValueChanged<bool> onCriticalChanged;
final VoidCallback onChanged;
final ValueChanged<String> onSave;
@override
State<ViralLoadExpertiseWizard> createState() =>
_ViralLoadExpertiseWizardState();
}
class _ViralLoadExpertiseWizardState extends State<ViralLoadExpertiseWizard> {
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<String> _options(
String key, [
List<String> 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<String> 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<String>(
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<String> _viralLoadResultOptions = [
'NOT DETECTED',
'<40',
'<1.60',
'40',
'41',
'44',
'45',
'145',
'<10',
'20',
'322',
'511',
'144',
'211',
'lainnya',
];
const List<String> _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',
];
+254
View File
@@ -0,0 +1,254 @@
part of '../../app/app.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
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<void> _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<void> _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),
),
],
),
),
),
),
),
),
);
}
}
+59
View File
@@ -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<String, String>? query]) {
final normalizedBase = normalizeBaseUrl(baseUrl);
final base = Uri.parse(normalizedBase);
return base.replace(
path: '${base.path.replaceAll(RegExp(r'/$'), '')}/$path',
queryParameters: query,
);
}
Map<String, String> get _headers => {
'Accept': 'application/json',
'Content-Type': 'application/json',
if (token != null) 'Authorization': 'Bearer $token',
};
Future<Map<String, dynamic>> post(
String path,
Map<String, dynamic> body,
) async {
final response = await http.post(
_uri(path),
headers: _headers,
body: jsonEncode(body),
);
return _decode(response);
}
Future<Map<String, dynamic>> get(
String path, [
Map<String, String>? query,
]) async {
final response = await http.get(_uri(path, query), headers: _headers);
return _decode(response);
}
Map<String, dynamic> _decode(http.Response response) {
final decoded = response.body.isEmpty
? <String, dynamic>{}
: jsonDecode(response.body);
if (response.statusCode >= 200 && response.statusCode < 300) {
return decoded is Map<String, dynamic> ? 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;
}
+57
View File
@@ -0,0 +1,57 @@
part of '../app/app.dart';
class SessionStore {
static const _tokenKey = 'mylis_token';
static const _baseUrlKey = 'mylis_base_url';
static Future<String?> token() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_tokenKey);
}
static Future<String> baseUrl() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_baseUrlKey) ?? kDefaultBaseUrl;
}
static Future<SessionData> session() async {
final prefs = await SharedPreferences.getInstance();
return SessionData(
token: prefs.getString(_tokenKey),
baseUrl: prefs.getString(_baseUrlKey) ?? kDefaultBaseUrl,
);
}
static Future<void> saveToken(String token) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_tokenKey, token);
}
static Future<void> saveBaseUrl(String baseUrl) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_baseUrlKey, normalizeBaseUrl(baseUrl));
}
static Future<void> 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'/+$'), '');
}
+140
View File
@@ -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<String, dynamic> asMap(Object? value) {
if (value is Map<String, dynamic>) {
return value;
}
if (value is Map) {
return value.map((key, item) => MapEntry(key.toString(), item));
}
return <String, dynamic>{};
}
List<dynamic> asList(Object? value) => value is List ? value : <dynamic>[];
String _message(Object? error) => error is ApiException
? error.message
: 'Terjadi kesalahan saat memuat data.';