update
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||
];
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'/+$'), '');
|
||||
}
|
||||
@@ -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.';
|
||||
Reference in New Issue
Block a user