diff --git a/htdocs/app/Http/Controllers/MobileApiController.php b/htdocs/app/Http/Controllers/MobileApiController.php index 4ce8cc1b..eef4496a 100644 --- a/htdocs/app/Http/Controllers/MobileApiController.php +++ b/htdocs/app/Http/Controllers/MobileApiController.php @@ -386,6 +386,7 @@ class MobileApiController extends Controller 'preliminary' => 'Preliminary Results', 'Permohonan Verifikasi' => 'Expertise Saved (Un Verified)', 'Permohonan Verifikasi Preliminary' => 'Otor Maldi (Un Verified)', + 'statuspewarnaanlsg' => $this->resolvePeriksaHierarchyStatus($row->status, 'Pewarnaan Langsung'), default => 'Pemeriksaan awal', }; @@ -424,6 +425,7 @@ class MobileApiController extends Controller 'preliminary' => 'Preliminary Results', 'Permohonan Verifikasi' => 'Kirim ke SPV', 'Permohonan Verifikasi Preliminary' => 'Kirim Preliminary ke SPV', + 'statuspewarnaanlsg' => 'Pewarnaan Langsung', default => 'Draft', }, 'verifikasi' => in_array($action, ['verifikasi', 'preliminary'], true) ? 'Accepted' : '', @@ -435,6 +437,7 @@ class MobileApiController extends Controller 'preliminary' => 'Preliminary Results Saved', 'Permohonan Verifikasi' => 'Permohonan Verifikasi Terkirim', 'Permohonan Verifikasi Preliminary' => 'Permohonan Verifikasi Preliminary Terkirim', + 'statuspewarnaanlsg' => 'Pewarnaan Langsung tersimpan', default => 'Draft Expertise Saved', }, 'item' => $this->examinationPayload($row->fresh()), @@ -958,6 +961,123 @@ class MobileApiController extends Controller }; } + protected function resolvePeriksaHierarchyStatus($currentStatus, $proposedStatus): string + { + $currentStatus = trim((string) $currentStatus); + $proposedStatus = trim((string) $proposedStatus); + + if ($proposedStatus === '') { + return $currentStatus; + } + + $currentRank = $this->periksaHierarchyStatusRank($currentStatus); + $proposedRank = $this->periksaHierarchyStatusRank($proposedStatus); + + if ($currentStatus === '') { + return $proposedStatus; + } + + if ($proposedRank === null) { + return $currentRank === null ? $proposedStatus : $currentStatus; + } + + if ($currentRank === null || $proposedRank >= $currentRank) { + return $proposedStatus; + } + + return $currentStatus; + } + + protected function periksaHierarchyStatusRank($status): ?int + { + $normalized = $this->normalizePeriksaStatus($status); + + if ($normalized === '') { + return null; + } + + if ($normalized === 'penerimaan sampel') { + return 1; + } + + if (in_array($normalized, ['pemeriksaan awal', 'pemeriksaan sampel', 'proses analisis sampel'], true)) { + return 2; + } + + if (str_starts_with($normalized, 'data bd di terima') || str_starts_with($normalized, 'data bd diterima')) { + return 3; + } + + if ($normalized === 'pewarnaan langsung') { + return 4; + } + + if ($this->isProsesingKoloniMediaStatus($normalized)) { + return 5; + } + + if (str_starts_with($normalized, 'data malditof diterima') || str_starts_with($normalized, 'data malditof di terima')) { + return 6; + } + + if (str_starts_with($normalized, 'otor maldi (un verified)')) { + return 7; + } + + if (str_starts_with($normalized, 'preliminary results')) { + return 8; + } + + if (str_starts_with($normalized, 'data vitek di terima') || str_starts_with($normalized, 'data vitek diterima')) { + return 9; + } + + if (str_starts_with($normalized, 'id/ast pending result') || $normalized === 'sedang id+ast') { + return 10; + } + + if (str_starts_with($normalized, 'expertise saved (un verified)') + || in_array($normalized, ['expertise', 'selesai', 'final result'], true)) { + return 11; + } + + return null; + } + + protected function normalizePeriksaStatus($status): string + { + $status = strtolower(trim((string) $status)); + $status = str_replace(['_', '-'], ' ', $status); + $status = preg_replace('/\s+/', ' ', $status); + + return trim($status); + } + + protected function isProsesingKoloniMediaStatus(string $normalizedStatus): bool + { + $exactStatuses = [ + 'tidak ada pertumbuhan', + 'tidak lanjut identifikasi', + 'tidak tindak lanjut identifikasi', + 'inkubasi lanjutan', + 'sub kultur', + 'menunggu kultur yg lain', + 'menunggu kultur yang lain', + 'proses identifikasi dan uji kepekaan', + 'sedang malditof', + 'pertumbuhan primer', + ]; + + if (in_array($normalizedStatus, $exactStatuses, true)) { + return true; + } + + return str_starts_with($normalizedStatus, 'prosesing koloni media') + || str_starts_with($normalizedStatus, 'processing koloni media') + || str_starts_with($normalizedStatus, 'proses identifikasi') + || str_starts_with($normalizedStatus, 'proses uji kepekaan'); + } + public function earlyWarning(Request $request) { $this->requireUser($request); diff --git a/htdocs/resources/views/cetak/ekspertisecci.blade.php b/htdocs/resources/views/cetak/ekspertisecci.blade.php index 177053ff..ed12f9e7 100644 --- a/htdocs/resources/views/cetak/ekspertisecci.blade.php +++ b/htdocs/resources/views/cetak/ekspertisecci.blade.php @@ -104,7 +104,7 @@ if (trim($rows->komponen) == 'bakteri') {$bakteri = $rows->isidata; } } } - if ($periksa->kd_spesimen == 'KULTUR JMR'){ + if ($periksa->kd_spesimen == 'KULTUR JMR' OR $periksa->kd_spesimen == 'JAMUR'){ $teksmikroorganisme = 'Jamur yang ditemukan : '; } else { $teksmikroorganisme = 'Bakteri yang ditemukan : '; diff --git a/htdocs/resources/views/cetak/ekspertisedefault.blade.php b/htdocs/resources/views/cetak/ekspertisedefault.blade.php index 57d35522..a54f4f55 100644 --- a/htdocs/resources/views/cetak/ekspertisedefault.blade.php +++ b/htdocs/resources/views/cetak/ekspertisedefault.blade.php @@ -94,7 +94,7 @@ $namakuman = ''; $namakuman1 = ''; $namakuman2 = ''; - if ($periksa->kd_spesimen == 'KULTUR JMR'){ + if ($periksa->kd_spesimen == 'KULTUR JMR' OR $periksa->kd_spesimen == 'JAMUR'){ $teksmikroorganisme = 'Jamur yang ditemukan : '; } else { $teksmikroorganisme = 'Bakteri yang ditemukan : '; diff --git a/htdocs/resources/views/cetak/ekspertisekultur.blade.php b/htdocs/resources/views/cetak/ekspertisekultur.blade.php index 54c12af5..ad4481fc 100644 --- a/htdocs/resources/views/cetak/ekspertisekultur.blade.php +++ b/htdocs/resources/views/cetak/ekspertisekultur.blade.php @@ -353,7 +353,7 @@ $namakuman = ''; $namakuman1 = ''; $namakuman2 = ''; - if ($periksa->kd_spesimen == 'KULTUR JMR'){ + if ($periksa->kd_spesimen == 'KULTUR JMR' OR $periksa->kd_spesimen == 'JAMUR'){ $teksmikroorganisme = 'Jamur yang ditemukan : '; } else { $teksmikroorganisme = 'Bakteri yang ditemukan : '; diff --git a/htdocs/resources/views/cetak/ekspertisepcrcovid.blade.php b/htdocs/resources/views/cetak/ekspertisepcrcovid.blade.php index 5e578864..3c468c35 100644 --- a/htdocs/resources/views/cetak/ekspertisepcrcovid.blade.php +++ b/htdocs/resources/views/cetak/ekspertisepcrcovid.blade.php @@ -196,7 +196,7 @@ @php $cekketerangan = explode('/table', $keterangan); - if ($periksa->kd_spesimen == 'KULTUR JMR'){ + if ($periksa->kd_spesimen == 'KULTUR JMR' OR $periksa->kd_spesimen == 'JAMUR'){ $teksmikroorganisme = 'Jamur yang ditemukan : '; } else { $teksmikroorganisme = 'Bakteri yang ditemukan : '; diff --git a/htdocs/resources/views/cetak/ekspertisepl.blade.php b/htdocs/resources/views/cetak/ekspertisepl.blade.php index b9cbf495..0d00f081 100644 --- a/htdocs/resources/views/cetak/ekspertisepl.blade.php +++ b/htdocs/resources/views/cetak/ekspertisepl.blade.php @@ -333,7 +333,7 @@ $namakuman = ''; $namakuman1 = ''; $namakuman2 = ''; - if ($periksa->kd_spesimen == 'KULTUR JMR'){ + if ($periksa->kd_spesimen == 'KULTUR JMR' OR $periksa->kd_spesimen == 'JAMUR'){ $teksmikroorganisme = 'Jamur yang ditemukan : '; } else { $teksmikroorganisme = 'Bakteri yang ditemukan : '; diff --git a/htdocs/resources/views/cetak/ekspertisevl.blade.php b/htdocs/resources/views/cetak/ekspertisevl.blade.php index e9279419..ae02dd0a 100644 --- a/htdocs/resources/views/cetak/ekspertisevl.blade.php +++ b/htdocs/resources/views/cetak/ekspertisevl.blade.php @@ -178,7 +178,7 @@ $namakuman = ''; $namakuman1 = ''; $namakuman2 = ''; - if ($periksa->kd_spesimen == 'KULTUR JMR'){ + if ($periksa->kd_spesimen == 'KULTUR JMR' OR $periksa->kd_spesimen == 'JAMUR'){ $teksmikroorganisme = 'Jamur yang ditemukan : '; } else { $teksmikroorganisme = 'Bakteri yang ditemukan : '; diff --git a/htdocs/resources/views/cetak/igmiggletospira.blade.php b/htdocs/resources/views/cetak/igmiggletospira.blade.php index 90995952..736c8b1c 100644 --- a/htdocs/resources/views/cetak/igmiggletospira.blade.php +++ b/htdocs/resources/views/cetak/igmiggletospira.blade.php @@ -100,7 +100,7 @@ if (trim($rows->komponen) == 'iggigm_interpretasi') {$iggigm_interpretasi = $rows->isidata; } } } - if ($periksa->kd_spesimen == 'KULTUR JMR'){ + if ($periksa->kd_spesimen == 'KULTUR JMR' OR $periksa->kd_spesimen == 'JAMUR'){ $teksmikroorganisme = 'Jamur yang ditemukan : '; } else { $teksmikroorganisme = 'Bakteri yang ditemukan : '; diff --git a/mylis/lib/app/app.dart b/mylis/lib/app/app.dart index 11f7ab23..80f72a5b 100644 --- a/mylis/lib/app/app.dart +++ b/mylis/lib/app/app.dart @@ -1,7 +1,10 @@ +import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; +import 'package:http/io_client.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:url_launcher/url_launcher.dart'; diff --git a/mylis/lib/main.dart b/mylis/lib/main.dart index 0c2eebd2..f41e1e40 100644 --- a/mylis/lib/main.dart +++ b/mylis/lib/main.dart @@ -1,8 +1,25 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'app/app.dart'; export 'app/app.dart'; void main() { + HttpOverrides.global = _InternalCertificateHttpOverrides(); runApp(const MyLisApp()); } + +class _InternalCertificateHttpOverrides extends HttpOverrides { + @override + HttpClient createHttpClient(SecurityContext? context) { + return super.createHttpClient(context) + ..badCertificateCallback = (certificate, host, port) { + debugPrint( + 'MyLIS menerima sertifikat internal untuk $host:$port ' + 'issuer=${certificate.issuer}', + ); + return true; + }; + } +} diff --git a/mylis/lib/screens/expertise/cci_expertise_wizard.dart b/mylis/lib/screens/expertise/cci_expertise_wizard.dart index 51811654..4529338a 100644 --- a/mylis/lib/screens/expertise/cci_expertise_wizard.dart +++ b/mylis/lib/screens/expertise/cci_expertise_wizard.dart @@ -16,6 +16,8 @@ class CciExpertiseWizard extends StatefulWidget { required this.onCriticalChanged, required this.onChanged, required this.onSave, + required this.initialStep, + required this.onStepChanged, }); final Map item; @@ -31,47 +33,42 @@ class CciExpertiseWizard extends StatefulWidget { final ValueChanged onCriticalChanged; final VoidCallback onChanged; final ValueChanged onSave; + final int initialStep; + final ValueChanged onStepChanged; @override State createState() => _CciExpertiseWizardState(); } class _CciExpertiseWizardState extends State { - int _step = 0; + late int _step; - void _next() { - setState(() => _step = (_step + 1).clamp(0, 3)); + @override + void initState() { + super.initState(); + _step = widget.initialStep.clamp(0, 3); } - void _previous() { - setState(() => _step = (_step - 1).clamp(0, 3)); + @override + void didUpdateWidget(covariant CciExpertiseWizard oldWidget) { + super.didUpdateWidget(oldWidget); + final nextStep = widget.initialStep.clamp(0, 3); + if (nextStep != _step) { + _step = nextStep; + } + } + + void _setStep(int step) { + final nextStep = step.clamp(0, 3); + setState(() => _step = nextStep); + widget.onStepChanged(nextStep); } @override Widget build(BuildContext context) { - return Stepper( + return ExpertiseWizardShell( 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'), - ), - ], - ), - ); - }, + onStepChanged: _setStep, steps: [ Step( title: const Text('Pasien & Petugas'), @@ -339,60 +336,136 @@ class PatientStaffPanel extends StatelessWidget { @override Widget build(BuildContext context) { + final status = item['status']?.toString() ?? '-'; + final genderAge = '${item['jkpasien'] ?? '-'}, ${item['usia'] ?? '-'}'; return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Card( margin: EdgeInsets.zero, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), child: Padding( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.all(14), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - DetailRow(label: 'Order', value: item['reques']), - DetailRow( - label: 'Dokter Pengirim', - value: item['klinisi'] ?? item['nmdokter'], + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + radius: 24, + backgroundColor: const Color( + 0xFF0F766E, + ).withValues(alpha: 0.12), + child: const Icon( + Icons.person_outline, + color: Color(0xFF0F766E), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item['nmpasien']?.toString() ?? '-', + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w900), + ), + const SizedBox(height: 3), + Text( + 'RM ${_textValue(item['noregister'])} ยท Lab ${_textValue(item['nofoto'])}', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: Colors.black54), + ), + ], + ), + ), + StatusPill(status: status), + ], ), - 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'] ?? '-'}', + const SizedBox(height: 14), + _InfoGrid( + items: [ + _InfoItem('Order', item['reques'], Icons.receipt_long), + _InfoItem( + 'Dokter Pengirim', + item['klinisi'] ?? item['nmdokter'], + Icons.local_hospital_outlined, + ), + _InfoItem( + 'Asal Pasien', + item['asalpasien'], + Icons.apartment_outlined, + ), + _InfoItem('Gender, Age', genderAge, Icons.badge_outlined), + _InfoItem('Phone', item['tlppasien'], Icons.call_outlined), + _InfoItem( + 'Tanggal Registrasi', + item['tanggalregis'] ?? item['daftar'], + Icons.event_available_outlined, + ), + _InfoItem( + 'Tanggal Pengambilan', + item['tanggalsampel'], + Icons.science_outlined, + ), + _InfoItem( + 'Cara Pengambilan', + item['pengambilan'], + Icons.medical_services_outlined, + ), + _InfoItem( + 'Asal Pengambilan', + item['asalpengirim'], + Icons.output_outlined, + ), + _InfoItem( + 'Spesimen', + item['kesimpulan'] ?? item['nm_spesimen'], + Icons.biotech_outlined, + ), + ], ), - DetailRow(label: 'Phone', value: item['tlppasien']), - DetailRow(label: 'Address', value: item['alamatpasien']), - DetailRow( - label: 'Tanggal Registrasi', - value: item['tanggalregis'] ?? item['daftar'], + const SizedBox(height: 8), + _InfoTile( + label: 'Address', + value: item['alamatpasien'], + icon: Icons.place_outlined, ), - 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']), + Card( + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Petugas', + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w900), + ), + const SizedBox(height: 12), + _staffSelect('analis', 'ATLM', asList(staffOptions['analis'])), + _staffSelect('ppds3', 'PPDS', asList(staffOptions['ppds'])), + _staffSelect('dokter', 'SPV', asList(staffOptions['dokters'])), + ], + ), + ), + ), + const SizedBox(height: 12), + _InfoTile( + label: 'Klinis/Diagnosis', + value: item['klinis'], + icon: Icons.assignment_outlined, + ), ], ); } @@ -409,6 +482,7 @@ class PatientStaffPanel extends StatelessWidget { decoration: InputDecoration( labelText: label, border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.account_circle_outlined), ), items: [ const DropdownMenuItem(value: '0', child: Text('Pilih')), @@ -426,6 +500,113 @@ class PatientStaffPanel extends StatelessWidget { ), ); } + + String _textValue(Object? value) { + final text = value?.toString() ?? ''; + return text.trim().isEmpty ? '-' : decodeHtmlEntities(text.trim()); + } +} + +class _InfoGrid extends StatelessWidget { + const _InfoGrid({required this.items}); + + final List<_InfoItem> items; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final columns = constraints.maxWidth >= 520 ? 2 : 1; + return Wrap( + spacing: 10, + runSpacing: 10, + children: [ + for (final item in items) + SizedBox( + width: columns == 1 + ? constraints.maxWidth + : (constraints.maxWidth - 10) / 2, + child: _InfoTile( + label: item.label, + value: item.value, + icon: item.icon, + compact: true, + ), + ), + ], + ); + }, + ); + } +} + +class _InfoTile extends StatelessWidget { + const _InfoTile({ + required this.label, + required this.value, + required this.icon, + this.compact = false, + }); + + final String label; + final Object? value; + final IconData icon; + final bool compact; + + @override + Widget build(BuildContext context) { + final cleanValue = _clean(value); + return Container( + padding: EdgeInsets.all(compact ? 10 : 12), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + border: Border.all(color: const Color(0xFFE2E8F0)), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 18, color: const Color(0xFF0F766E)), + const SizedBox(width: 9), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: Colors.black54, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 3), + Text( + cleanValue, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w800, + color: Colors.black87, + ), + ), + ], + ), + ), + ], + ), + ); + } + + String _clean(Object? value) { + final text = value?.toString() ?? ''; + return text.trim().isEmpty ? '-' : decodeHtmlEntities(text.trim()); + } +} + +class _InfoItem { + const _InfoItem(this.label, this.value, this.icon); + + final String label; + final Object? value; + final IconData icon; } const List _sirOptions = [ diff --git a/mylis/lib/screens/expertise/covid_expertise_wizard.dart b/mylis/lib/screens/expertise/covid_expertise_wizard.dart index c43e1980..0105141d 100644 --- a/mylis/lib/screens/expertise/covid_expertise_wizard.dart +++ b/mylis/lib/screens/expertise/covid_expertise_wizard.dart @@ -15,6 +15,8 @@ class CovidExpertiseWizard extends StatefulWidget { required this.onCriticalChanged, required this.onChanged, required this.onSave, + required this.initialStep, + required this.onStepChanged, }); final Map item; @@ -29,47 +31,42 @@ class CovidExpertiseWizard extends StatefulWidget { final ValueChanged onCriticalChanged; final VoidCallback onChanged; final ValueChanged onSave; + final int initialStep; + final ValueChanged onStepChanged; @override State createState() => _CovidExpertiseWizardState(); } class _CovidExpertiseWizardState extends State { - int _step = 0; + late int _step; - void _next() { - setState(() => _step = (_step + 1).clamp(0, 4)); + @override + void initState() { + super.initState(); + _step = widget.initialStep.clamp(0, 4); } - void _previous() { - setState(() => _step = (_step - 1).clamp(0, 4)); + @override + void didUpdateWidget(covariant CovidExpertiseWizard oldWidget) { + super.didUpdateWidget(oldWidget); + final nextStep = widget.initialStep.clamp(0, 4); + if (nextStep != _step) { + _step = nextStep; + } + } + + void _setStep(int step) { + final nextStep = step.clamp(0, 4); + setState(() => _step = nextStep); + widget.onStepChanged(nextStep); } @override Widget build(BuildContext context) { - return Stepper( + return ExpertiseWizardShell( 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'), - ), - ], - ), - ); - }, + onStepChanged: _setStep, steps: [ Step( title: const Text('Pasien & Petugas'), @@ -136,49 +133,10 @@ class _CovidExpertiseWizardState extends State { } 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'), - ], - ), - ), - ), - ], + return AntibioticSensitivityPanel( + textControllers: widget.textControllers, + selectValues: widget.selectValues, + onChanged: widget.onChanged, ); } diff --git a/mylis/lib/screens/expertise/expertise_form.dart b/mylis/lib/screens/expertise/expertise_form.dart index 7ca333b2..b4b55884 100644 --- a/mylis/lib/screens/expertise/expertise_form.dart +++ b/mylis/lib/screens/expertise/expertise_form.dart @@ -1,5 +1,1176 @@ part of '../../app/app.dart'; +class ExpertiseWizardShell extends StatelessWidget { + const ExpertiseWizardShell({ + super.key, + required this.currentStep, + required this.steps, + required this.onStepChanged, + }); + + final int currentStep; + final List steps; + final ValueChanged onStepChanged; + + @override + Widget build(BuildContext context) { + final safeStep = currentStep.clamp(0, steps.length - 1); + final step = steps[safeStep]; + final title = _stepTitle(step.title); + final canPrevious = safeStep > 0; + final canNext = safeStep < steps.length - 1; + + return Column( + children: [ + Expanded( + child: CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 10), + child: _WizardHeader( + currentStep: safeStep, + steps: steps, + onStepChanged: onStepChanged, + ), + ), + ), + SliverToBoxAdapter( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 180), + child: Padding( + key: ValueKey(safeStep), + padding: const EdgeInsets.fromLTRB(16, 0, 16, 18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.w800), + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.topCenter, + child: step.content, + ), + ], + ), + ), + ), + ), + ], + ), + ), + _WizardBottomToolbar( + currentStep: safeStep, + totalSteps: steps.length, + canPrevious: canPrevious, + canNext: canNext, + onPrevious: () => onStepChanged(safeStep - 1), + onNext: () => onStepChanged(safeStep + 1), + ), + ], + ); + } + + String _stepTitle(Widget title) { + if (title is Text) { + final data = title.data; + if (data != null && data.isNotEmpty) { + return data; + } + } + return 'Langkah ${currentStep + 1}'; + } +} + +class _WizardHeader extends StatelessWidget { + const _WizardHeader({ + required this.currentStep, + required this.steps, + required this.onStepChanged, + }); + + final int currentStep; + final List steps; + final ValueChanged onStepChanged; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 46, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: steps.length, + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (context, index) { + final selected = index == currentStep; + final title = _stepTitle(steps[index].title, index); + return ChoiceChip( + selected: selected, + label: Text('${index + 1}. $title'), + onSelected: (_) => onStepChanged(index), + showCheckmark: false, + visualDensity: VisualDensity.compact, + ); + }, + ), + ); + } + + String _stepTitle(Widget title, int index) { + if (title is Text) { + final data = title.data; + if (data != null && data.isNotEmpty) { + return data; + } + } + return 'Langkah ${index + 1}'; + } +} + +class _WizardBottomToolbar extends StatelessWidget { + const _WizardBottomToolbar({ + required this.currentStep, + required this.totalSteps, + required this.canPrevious, + required this.canNext, + required this.onPrevious, + required this.onNext, + }); + + final int currentStep; + final int totalSteps; + final bool canPrevious; + final bool canNext; + final VoidCallback onPrevious; + final VoidCallback onNext; + + @override + Widget build(BuildContext context) { + return SafeArea( + top: false, + child: Container( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 12), + decoration: const BoxDecoration( + color: Colors.white, + border: Border(top: BorderSide(color: Color(0xFFE2E8F0))), + ), + child: Row( + children: [ + OutlinedButton.icon( + onPressed: canPrevious ? onPrevious : null, + icon: const Icon(Icons.chevron_left), + label: const Text('Previous'), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + '${currentStep + 1}/$totalSteps', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + color: Colors.black54, + ), + ), + ), + const SizedBox(width: 12), + FilledButton.icon( + onPressed: canNext ? onNext : null, + icon: const Icon(Icons.chevron_right), + label: const Text('Next'), + ), + ], + ), + ), + ); + } +} + +class CompactMultiSelectField extends StatelessWidget { + const CompactMultiSelectField({ + super.key, + required this.label, + required this.options, + required this.selected, + required this.onChanged, + }); + + final String label; + final List options; + final Set selected; + final ValueChanged> onChanged; + + @override + Widget build(BuildContext context) { + final cleanOptions = options + .map(decodeHtmlEntities) + .where((option) => option.isNotEmpty) + .toList(); + final cleanSelected = selected.map(decodeHtmlEntities).toSet(); + final summary = _summary(cleanSelected); + + return InkWell( + borderRadius: BorderRadius.circular(4), + onTap: () => _showPicker(context, cleanOptions, cleanSelected), + child: InputDecorator( + decoration: InputDecoration( + labelText: decodeHtmlEntities(label), + border: const OutlineInputBorder(), + suffixIcon: const Icon(Icons.expand_more), + ), + child: Text( + summary, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cleanSelected.isEmpty ? Colors.black54 : Colors.black87, + fontWeight: cleanSelected.isEmpty + ? FontWeight.w500 + : FontWeight.w700, + ), + ), + ), + ); + } + + String _summary(Set values) { + if (values.isEmpty) { + return 'Belum ada pilihan'; + } + if (values.length <= 2) { + return values.join(', '); + } + return '${values.length} pilihan dipilih'; + } + + Future _showPicker( + BuildContext context, + List cleanOptions, + Set cleanSelected, + ) async { + final draft = cleanSelected.toSet(); + await showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (context) { + return StatefulBuilder( + builder: (context, setSheetState) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + decodeHtmlEntities(label), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 10), + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.58, + ), + child: ListView.separated( + shrinkWrap: true, + itemCount: cleanOptions.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) { + final option = cleanOptions[index]; + return CheckboxListTile( + value: draft.contains(option), + title: Text(option), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + onChanged: (checked) { + setSheetState(() { + if (checked == true) { + draft.add(option); + } else { + draft.remove(option); + } + }); + }, + ); + }, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + TextButton( + onPressed: () { + draft.clear(); + onChanged(draft); + Navigator.of(context).pop(); + }, + child: const Text('Kosongkan'), + ), + const Spacer(), + FilledButton.icon( + onPressed: () { + onChanged(draft); + Navigator.of(context).pop(); + }, + icon: const Icon(Icons.check), + label: const Text('Selesai'), + ), + ], + ), + ], + ), + ), + ); + }, + ); + }, + ); + } +} + +class AntibioticSensitivityPanel extends StatefulWidget { + const AntibioticSensitivityPanel({ + super.key, + required this.textControllers, + required this.selectValues, + required this.onChanged, + }); + + final Map textControllers; + final Map selectValues; + final VoidCallback onChanged; + + @override + State createState() => + _AntibioticSensitivityPanelState(); +} + +class _AntibioticSensitivityPanelState + extends State { + int _selectedMedia = 0; + + @override + Widget build(BuildContext context) { + final selected = _antibioticMedia[_selectedMedia]; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle(context, 'C. Tes Kepekaan Antibiotik'), + Text( + 'S: Sensitif, I: Intermediate, R: Resisten', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 12), + _latestSummary(), + const SizedBox(height: 12), + _mediaTabs(), + const SizedBox(height: 12), + _actionToolbar(selected), + const SizedBox(height: 12), + _mediaTable(selected), + ], + ); + } + + Widget _sectionTitle(BuildContext context, String title) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w800, + color: const Color(0xFF0F766E), + ), + ), + ); + } + + Widget _latestSummary() { + return Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Ringkasan Hasil Terakhir', + style: Theme.of( + context, + ).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w800), + ), + const Spacer(), + const Icon(Icons.update, size: 15, color: Colors.black45), + ], + ), + const SizedBox(height: 10), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: _antibioticMedia.take(4).map((media) { + final latest = _rows(media).isNotEmpty + ? _rows(media).last + : {}; + final mediaText = + latest['media']?.toString().trim().isNotEmpty == true + ? latest['media'].toString() + : '-'; + final status = + widget.selectValues[media.statusField] ?? + latest['status']?.toString() ?? + '-'; + return Container( + width: 172, + margin: const EdgeInsets.only(right: 10), + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFE2E8F0)), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + media.label, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 8), + Text( + mediaText, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: media.color, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 8), + BadgeLabel(text: 'Status: $status', color: media.color), + ], + ), + ); + }).toList(), + ), + ), + ], + ), + ), + ); + } + + Widget _mediaTabs() { + return SizedBox( + height: 82, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: _antibioticMedia.length, + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (context, index) { + final media = _antibioticMedia[index]; + final selected = index == _selectedMedia; + return InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => setState(() => _selectedMedia = index), + child: Container( + width: 158, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: selected + ? media.color.withValues(alpha: 0.10) + : Colors.white, + border: Border.all( + color: selected ? media.color : const Color(0xFFE2E8F0), + ), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + media.icon, + size: 16, + color: selected ? media.color : Colors.black54, + ), + const SizedBox(width: 5), + Flexible( + child: Text( + media.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w800, + color: selected ? media.color : Colors.black87, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + BadgeLabel( + text: '${_rows(media).length} data', + color: selected ? media.color : const Color(0xFF64748B), + ), + ], + ), + ), + ); + }, + ), + ); + } + + Widget _actionToolbar(_AntibioticMedia media) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(8), + ), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + FilledButton.icon( + onPressed: () => _openEditor(media), + icon: const Icon(Icons.add), + label: const Text('Ada Pertumbuhan'), + ), + OutlinedButton.icon( + onPressed: () => _quickStatus(media, 'Tidak Ada Pertumbuhan'), + icon: const Icon(Icons.event_busy_outlined), + label: const Text('Tidak Ada Pertumbuhan'), + ), + OutlinedButton.icon( + onPressed: () => _quickStatus(media, 'Pertumbuhan Primer'), + icon: const Icon(Icons.calendar_month_outlined), + label: const Text('Pertumbuhan Primer'), + ), + ], + ), + ); + } + + Widget _mediaTable(_AntibioticMedia media) { + final rows = _rows(media); + if (rows.isEmpty) { + return EmptyPanel(text: 'Belum ada data ${media.label}.'); + } + return Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: DataTable( + headingRowHeight: 40, + dataRowMinHeight: 46, + dataRowMaxHeight: 58, + columns: [ + const DataColumn(label: Text('#')), + const DataColumn(label: Text('Tanggal')), + const DataColumn(label: Text('Petugas')), + for (final column in media.tableColumns) + DataColumn(label: Text(column.label)), + const DataColumn(label: Text('Edit')), + const DataColumn(label: Text('Delete')), + const DataColumn(label: Text('Next')), + ], + rows: [ + for (var i = 0; i < rows.length; i += 1) + DataRow( + cells: [ + DataCell(Text('${i + 1}')), + DataCell(Text(_cell(rows[i], 'tanggal'))), + DataCell(Text(_cell(rows[i], 'petugas'))), + for (final column in media.tableColumns) + DataCell(Text(_cell(rows[i], column.key))), + DataCell( + IconButton( + tooltip: 'Edit', + icon: const Icon(Icons.edit_outlined), + onPressed: () => _openEditor(media, index: i), + ), + ), + DataCell( + IconButton( + tooltip: 'Delete', + color: const Color(0xFFDC2626), + icon: const Icon(Icons.delete_outline), + onPressed: () => _deleteRow(media, i), + ), + ), + DataCell( + OutlinedButton( + onPressed: () => _nextRow(media, i), + child: const Text('Next'), + ), + ), + ], + ), + ], + ), + ), + ); + } + + String _cell(Map row, String key) { + final value = row[key]?.toString() ?? ''; + return value.isEmpty ? '-' : decodeHtmlEntities(value); + } + + List> _rows(_AntibioticMedia media) { + final raw = widget.textControllers[media.rowsField]?.text ?? ''; + if (raw.trim().isEmpty) { + return >[]; + } + try { + final decoded = jsonDecode(raw); + if (decoded is List) { + return decoded.map((item) => asMap(item)).toList(); + } + } catch (_) { + return >[]; + } + return >[]; + } + + void _saveRows(_AntibioticMedia media, List> rows) { + widget.textControllers[media.rowsField] ??= TextEditingController(); + widget.textControllers[media.rowsField]!.text = jsonEncode(rows); + widget.onChanged(); + } + + void _quickStatus(_AntibioticMedia media, String status) { + widget.selectValues[media.statusField] = status; + final rows = _rows(media); + rows.add({ + 'tanggal': _today(), + 'petugas': 'Petugas', + 'kuman': '', + 'media': status, + 'status': status, + }); + _saveRows(media, rows); + setState(() {}); + } + + Future _deleteRow(_AntibioticMedia media, int index) async { + final rows = _rows(media); + if (index < 0 || index >= rows.length) { + return; + } + rows.removeAt(index); + _saveRows(media, rows); + setState(() {}); + } + + Future _nextRow(_AntibioticMedia media, int index) async { + final rows = _rows(media); + if (index < 0 || index >= rows.length) { + return; + } + rows[index]['status'] = 'Sub Kultur'; + widget.selectValues[media.statusField] = 'Sub Kultur'; + _saveRows(media, rows); + setState(() {}); + } + + Future _openEditor(_AntibioticMedia media, {int? index}) async { + final rows = _rows(media); + final current = index == null ? {} : rows[index]; + final controllers = { + 'tanggal': TextEditingController( + text: _fieldValue(current, 'tanggal').isEmpty + ? _today() + : _fieldValue(current, 'tanggal'), + ), + 'petugas': TextEditingController( + text: _fieldValue(current, 'petugas').isEmpty + ? 'Petugas' + : _fieldValue(current, 'petugas'), + ), + for (final field in media.modalFields) + field.key: TextEditingController(text: _fieldValue(current, field.key)), + }; + + await showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (context) { + return SafeArea( + child: Padding( + padding: EdgeInsets.fromLTRB( + 16, + 0, + 16, + MediaQuery.viewInsetsOf(context).bottom + 16, + ), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + '${index == null ? 'Tambah' : 'Edit'} ${media.label}', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + Text( + media.modalId, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: Colors.black54), + ), + const SizedBox(height: 12), + _modalText(controllers['tanggal']!, 'Tanggal'), + _modalText(controllers['petugas']!, 'Petugas'), + for (final field in media.modalFields) + _modalField(controllers[field.key]!, field), + const SizedBox(height: 8), + FilledButton.icon( + onPressed: () { + final row = { + 'tanggal': controllers['tanggal']!.text.trim(), + 'petugas': controllers['petugas']!.text.trim(), + for (final field in media.modalFields) + field.key: controllers[field.key]!.text.trim(), + }; + if (index == null) { + rows.add(row); + } else { + rows[index] = row; + } + widget.selectValues[media.statusField] = + (row['status'] ?? '').toString().isEmpty + ? 'Ada Pertumbuhan' + : row['status'].toString(); + _saveRows(media, rows); + setState(() {}); + Navigator.of(context).pop(); + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ), + ), + ); + }, + ); + + // Bottom sheet close/keyboard animations can still read these controllers + // for a frame after `showModalBottomSheet` completes. + } + + String _fieldValue(Map row, String key) { + final value = row[key]?.toString() ?? ''; + return decodeHtmlEntities(value); + } + + Widget _modalText(TextEditingController controller, String label) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: TextField( + controller: controller, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + ), + ), + ); + } + + Widget _modalField(TextEditingController controller, _AntibioticField field) { + if (field.options.isNotEmpty) { + final current = field.options.contains(controller.text) + ? controller.text + : null; + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: DropdownButtonFormField( + initialValue: current, + isExpanded: true, + decoration: InputDecoration( + labelText: field.label, + border: const OutlineInputBorder(), + ), + items: field.options + .map( + (option) => + DropdownMenuItem(value: option, child: Text(option)), + ) + .toList(), + onChanged: (value) => controller.text = value ?? '', + ), + ); + } + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: TextField( + controller: controller, + minLines: field.multiline ? 3 : 1, + maxLines: field.multiline ? 5 : 1, + decoration: InputDecoration( + labelText: field.label, + border: const OutlineInputBorder(), + ), + ), + ); + } + + String _today() { + final now = DateTime.now(); + return '${now.year.toString().padLeft(4, '0')}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; + } +} + +class _AntibioticMedia { + const _AntibioticMedia({ + required this.label, + required this.modalId, + required this.statusField, + required this.rowsField, + required this.color, + required this.icon, + required this.modalFields, + required this.tableColumns, + }); + + final String label; + final String modalId; + final String statusField; + final String rowsField; + final Color color; + final IconData icon; + final List<_AntibioticField> modalFields; + final List<_AntibioticColumn> tableColumns; +} + +class _AntibioticField { + const _AntibioticField( + this.key, + this.label, { + this.options = const [], + this.multiline = false, + }); + + final String key; + final String label; + final List options; + final bool multiline; +} + +class _AntibioticColumn { + const _AntibioticColumn(this.key, this.label); + + final String key; + final String label; +} + +const List _kumanOptions = [ + 'Kuman 1', + 'Kuman 2', + 'Kuman 3', + 'Kuman 4', + 'Kuman 5', +]; + +const List _fungalKumanOptions = [ + 'Bakteri Kuman 1', + 'Yeast Kuman 1', + 'Mold Kuman 1', + 'Bakteri Kuman 2', + 'Yeast Kuman 2', + 'Mold Kuman 2', + 'Bakteri Kuman 3', + 'Yeast Kuman 3', + 'Mold Kuman 3', + 'Bakteri Kuman 4', + 'Yeast Kuman 4', + 'Mold Kuman 4', + 'Bakteri Kuman 5', + 'Yeast Kuman 5', + 'Mold Kuman 5', +]; + +const List _hemolisaOptions = ['Alpha', 'Beta', 'Gamma']; +const List _posNegOptions = ['POS', 'NEG']; +const List _yesNoOptions = ['YA', 'TIDAK']; + +const List _mediaStatusOptions = [ + 'Inkubasi Lanjutan', + 'Sub Kultur', + 'Proses identifikasi dan uji kepekaan vitek', + 'Proses identifikasi dan uji kepekaan manual', + 'Proses identifikasi malditof', + 'Proses uji kepekaan vitek', + 'Prosesuji kepekaan manual', + 'ID/AST pending result', + 'Tidak Lanjut Identifikasi', + 'Menunggu kultur yg lain', +]; + +const List _fungalStatusOptions = [ + 'Tidak Lanjut Identifikasi', + 'PATOGEN (Lanjut Identifikasi Vitek)', + 'PATOGEN (Lanjut Identifikasi Manual)', + 'Subkultur', +]; + +const List _colonyCountOptions = [ + '>= 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', +]; + +const List<_AntibioticColumn> _standardColumns = [ + _AntibioticColumn('kuman', 'Kuman'), + _AntibioticColumn('media', 'Media'), + _AntibioticColumn('hemolisa', 'Hemolisa'), + _AntibioticColumn('katalase', 'Katalase'), + _AntibioticColumn('koagulase', 'Koagulase'), + _AntibioticColumn('oksidase', 'Oksidase'), + _AntibioticColumn('lainnya', 'Lainnya'), + _AntibioticColumn('status', 'Status'), +]; + +const List<_AntibioticColumn> _bapColumns = [ + _AntibioticColumn('kuman', 'Kuman'), + _AntibioticColumn('media', 'Media'), + _AntibioticColumn('hemolisa', 'Hemolisa'), + _AntibioticColumn('katalase', 'Katalase'), + _AntibioticColumn('koagulase', 'Koagulase'), + _AntibioticColumn('lainnya', 'Lainnya'), + _AntibioticColumn('hitungkoloni', 'Hitung Koloni'), + _AntibioticColumn('status', 'Status'), +]; + +const List<_AntibioticColumn> _capColumns = [ + _AntibioticColumn('kuman', 'Kuman'), + _AntibioticColumn('media', 'Media'), + _AntibioticColumn('katalase', 'Katalase'), + _AntibioticColumn('koagulase', 'Koagulase'), + _AntibioticColumn('lainnya', 'Lainnya'), + _AntibioticColumn('status', 'Status'), +]; + +const List<_AntibioticColumn> _mcConkeyColumns = [ + _AntibioticColumn('kuman', 'Kuman'), + _AntibioticColumn('media', 'Media'), + _AntibioticColumn('oksidase', 'Oksidase'), + _AntibioticColumn('lainnya', 'Lainnya'), + _AntibioticColumn('status', 'Status'), +]; + +const List<_AntibioticColumn> _fungalColumns = [ + _AntibioticColumn('kuman', 'Kuman'), + _AntibioticColumn('media', 'Media'), + _AntibioticColumn('hemolisa', 'Tumbuh'), + _AntibioticColumn('status', 'Status'), +]; + +const List<_AntibioticMedia> _antibioticMedia = [ + _AntibioticMedia( + label: 'Media BAP', + modalId: 'modalgridmediabap', + statusField: 'media_bap_status', + rowsField: 'media_bap_rows', + color: Color(0xFF15803D), + icon: Icons.monitor_outlined, + modalFields: [ + _AntibioticField('kuman', 'Kuman', options: _kumanOptions), + _AntibioticField('media', 'Media BAP'), + _AntibioticField('hemolisa', 'Hemolisa', options: _hemolisaOptions), + _AntibioticField('katalase', 'Katalase', options: _posNegOptions), + _AntibioticField('koagulase', 'Koagulase', options: _posNegOptions), + _AntibioticField('lainnya', 'Uji Lainnya'), + _AntibioticField( + 'hitungkoloni', + 'Hitung Koloni', + options: _colonyCountOptions, + ), + _AntibioticField('hitungkoloniteks', 'Hitung Koloni Lainnya'), + _AntibioticField('status', 'Status', options: _mediaStatusOptions), + _AntibioticField('keterangan', 'Keterangan', multiline: true), + ], + tableColumns: _bapColumns, + ), + _AntibioticMedia( + label: 'Media CAP', + modalId: 'modalgridmediacap', + statusField: 'media_cap_status', + rowsField: 'media_cap_rows', + color: Color(0xFF2563EB), + icon: Icons.monitor_outlined, + modalFields: [ + _AntibioticField('kuman', 'Kuman', options: _kumanOptions), + _AntibioticField('media', 'Media CAP'), + _AntibioticField('katalase', 'Katalase', options: _posNegOptions), + _AntibioticField('koagulase', 'Koagulase', options: _posNegOptions), + _AntibioticField('lainnya', 'Uji Lainnya'), + _AntibioticField('status', 'Status', options: _mediaStatusOptions), + ], + tableColumns: _capColumns, + ), + _AntibioticMedia( + label: 'Media Mc Conkey', + modalId: 'modalgridmediamcconkey', + statusField: 'media_mcconkey_status', + rowsField: 'media_mcconkey_rows', + color: Color(0xFF7C3AED), + icon: Icons.monitor_outlined, + modalFields: [ + _AntibioticField('kuman', 'Kuman', options: _kumanOptions), + _AntibioticField('media', 'Media Mc Conkey'), + _AntibioticField('oksidase', 'Oksidase', options: _posNegOptions), + _AntibioticField('lainnya', 'Uji Lainnya'), + _AntibioticField('status', 'Status', options: _mediaStatusOptions), + ], + tableColumns: _mcConkeyColumns, + ), + _AntibioticMedia( + label: 'Kultur Jamur R1', + modalId: 'modalgridmediasdar1', + statusField: 'media_sdar1_status', + rowsField: 'media_sdar1_rows', + color: Color(0xFFD97706), + icon: Icons.eco_outlined, + modalFields: [ + _AntibioticField('kuman', 'Kuman', options: _fungalKumanOptions), + _AntibioticField('media', 'Nama Bakteri/Yeast/Mold'), + _AntibioticField( + 'hemolisa', + 'Tumbuh di Area Inokulasi?', + options: _yesNoOptions, + ), + _AntibioticField('status', 'Status', options: _fungalStatusOptions), + ], + tableColumns: _fungalColumns, + ), + _AntibioticMedia( + label: 'Kultur Jamur R2', + modalId: 'modalgridmediasdar2', + statusField: 'media_sdar2_status', + rowsField: 'media_sdar2_rows', + color: Color(0xFFD97706), + icon: Icons.eco_outlined, + modalFields: [ + _AntibioticField('kuman', 'Kuman', options: _fungalKumanOptions), + _AntibioticField('media', 'Nama Bakteri/Yeast/Mold'), + _AntibioticField( + 'hemolisa', + 'Tumbuh di Area Inokulasi?', + options: _yesNoOptions, + ), + _AntibioticField('status', 'Status', options: _fungalStatusOptions), + ], + tableColumns: _fungalColumns, + ), + _AntibioticMedia( + label: 'Kultur Jamur I1', + modalId: 'modalgridmediasdai1', + statusField: 'media_sdai1_status', + rowsField: 'media_sdai1_rows', + color: Color(0xFFD97706), + icon: Icons.eco_outlined, + modalFields: [ + _AntibioticField('kuman', 'Kuman', options: _fungalKumanOptions), + _AntibioticField('media', 'Nama Bakteri/Yeast/Mold'), + _AntibioticField( + 'hemolisa', + 'Tumbuh di Area Inokulasi?', + options: _yesNoOptions, + ), + _AntibioticField('status', 'Status', options: _fungalStatusOptions), + ], + tableColumns: _fungalColumns, + ), + _AntibioticMedia( + label: 'Kultur Jamur I2', + modalId: 'modalgridmediasdai2', + statusField: 'media_sdai2_status', + rowsField: 'media_sdai2_rows', + color: Color(0xFFD97706), + icon: Icons.eco_outlined, + modalFields: [ + _AntibioticField('kuman', 'Kuman', options: _fungalKumanOptions), + _AntibioticField('media', 'Nama Bakteri/Yeast/Mold'), + _AntibioticField( + 'hemolisa', + 'Tumbuh di Area Inokulasi?', + options: _yesNoOptions, + ), + _AntibioticField('status', 'Status', options: _fungalStatusOptions), + ], + tableColumns: _fungalColumns, + ), + _AntibioticMedia( + label: 'Media Selektif lainnya', + modalId: 'modalgridmediaselektif', + statusField: 'media_sellainnya_status', + rowsField: 'media_sellainnya_rows', + color: Color(0xFF0F766E), + icon: Icons.science_outlined, + modalFields: [ + _AntibioticField('media', 'Media Selektif lainnya'), + _AntibioticField('hemolisa', 'Hemolisa', options: _hemolisaOptions), + _AntibioticField('katalase', 'Katalase', options: _posNegOptions), + _AntibioticField('koagulase', 'Koagulase', options: _posNegOptions), + _AntibioticField('oksidase', 'Oksidase'), + _AntibioticField('lainnya', 'Uji Lainnya'), + _AntibioticField('status', 'Status', options: _mediaStatusOptions), + ], + tableColumns: _standardColumns, + ), + _AntibioticMedia( + label: 'Pemeriksaan Tambahan Lainnya', + modalId: 'modalgridmediatamlainnya', + statusField: 'media_tamlainnya_status', + rowsField: 'media_tamlainnya_rows', + color: Color(0xFF475569), + icon: Icons.assignment_outlined, + modalFields: [ + _AntibioticField('media', 'Pemeriksaan Tambahan'), + _AntibioticField('hemolisa', 'Hemolisa', options: _hemolisaOptions), + _AntibioticField('katalase', 'Katalase', options: _posNegOptions), + _AntibioticField('koagulase', 'Koagulase', options: _posNegOptions), + _AntibioticField('oksidase', 'Oksidase'), + _AntibioticField('lainnya', 'Uji Lainnya'), + _AntibioticField('status', 'Status', options: _mediaStatusOptions), + ], + tableColumns: _standardColumns, + ), +]; + +const List kAntibioticMediaRowFields = [ + 'media_bap_rows', + 'media_cap_rows', + 'media_mcconkey_rows', + 'media_sdar1_rows', + 'media_sdar2_rows', + 'media_sdai1_rows', + 'media_sdai2_rows', + 'media_sellainnya_rows', + 'media_tamlainnya_rows', +]; + class ExpertiseForm extends StatelessWidget { const ExpertiseForm({ super.key, @@ -80,32 +1251,14 @@ class ExpertiseForm extends StatelessWidget { final selected = multiValues[name] ?? {}; return Padding( padding: const EdgeInsets.only(bottom: 12), - child: InputDecorator( - decoration: InputDecoration( - labelText: label, - border: const OutlineInputBorder(), - ), - child: Wrap( - spacing: 8, - runSpacing: 4, - children: options - .map( - (option) => FilterChip( - label: Text(option), - selected: selected.contains(option), - onSelected: (checked) { - if (checked) { - selected.add(option); - } else { - selected.remove(option); - } - multiValues[name] = selected; - onChanged(); - }, - ), - ) - .toList(), - ), + child: CompactMultiSelectField( + label: label, + options: options, + selected: selected, + onChanged: (value) { + multiValues[name] = value; + onChanged(); + }, ), ); } diff --git a/mylis/lib/screens/expertise/expertise_screen.dart b/mylis/lib/screens/expertise/expertise_screen.dart index 61a6791a..fcd01461 100644 --- a/mylis/lib/screens/expertise/expertise_screen.dart +++ b/mylis/lib/screens/expertise/expertise_screen.dart @@ -23,6 +23,7 @@ class _ExpertiseScreenState extends State { final Map _selectValues = {}; final Map> _multiValues = {}; final Map _staffValues = {}; + final Map _wizardSteps = {}; String _currentDlp = ''; bool _criticalValue = false; bool _saving = false; @@ -89,6 +90,11 @@ class _ExpertiseScreenState extends State { ); } } + for (final name in kAntibioticMediaRowFields) { + _textControllers[name] = TextEditingController( + text: components[name]?.toString() ?? '', + ); + } } Future _setTemplate(String dlp) async { @@ -113,6 +119,9 @@ class _ExpertiseScreenState extends State { } Future _save(String action) async { + if (action == 'statuspewarnaanlsg') { + _wizardSteps[_currentDlp] = 1; + } setState(() => _saving = true); final fields = {}; for (final entry in _textControllers.entries) { @@ -151,6 +160,12 @@ class _ExpertiseScreenState extends State { } } + int _wizardStep(String dlp) => _wizardSteps[dlp] ?? 0; + + void _setWizardStep(String dlp, int step) { + _wizardSteps[dlp] = step; + } + void _showMessage(String message) { ScaffoldMessenger.of( context, @@ -188,201 +203,233 @@ class _ExpertiseScreenState extends State { _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, + final summaryCard = 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: [ - 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), - ), - ], - ), + 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( + ), + ); + + final expertiseContent = _currentDlp.isEmpty + ? TemplatePicker( templates: asList(data['templates']), saving: _saving, onUse: _setTemplate, ) - else - _currentDlp == 'CCI' - ? CciExpertiseWizard( + : _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, + initialStep: _wizardStep(_currentDlp), + onStepChanged: (step) => _setWizardStep(_currentDlp, step), + ) + : _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, + initialStep: _wizardStep(_currentDlp), + onStepChanged: (step) => _setWizardStep(_currentDlp, step), + ) + : _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, + initialStep: _wizardStep(_currentDlp), + onStepChanged: (step) => _setWizardStep(_currentDlp, step), + ) + : _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, + initialStep: _wizardStep(_currentDlp), + onStepChanged: (step) => _setWizardStep(_currentDlp, step), + ) + : _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, + initialStep: _wizardStep(_currentDlp), + onStepChanged: (step) => _setWizardStep(_currentDlp, step), + ) + : _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, + initialStep: _wizardStep(_currentDlp), + onStepChanged: (step) => _setWizardStep(_currentDlp, step), + ) + : _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, + initialStep: _wizardStep(_currentDlp), + onStepChanged: (step) => _setWizardStep(_currentDlp, step), + ) + : SingleChildScrollView( + padding: const EdgeInsets.only(bottom: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + PatientStaffPanel( 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, - ), - ], ), - ], + 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, + ), + ], + ), + ); + + if (_currentDlp.isEmpty) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + summaryCard, + const SizedBox(height: 14), + expertiseContent, + ], + ); + } + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: Column( + children: [ + summaryCard, + const SizedBox(height: 14), + Expanded(child: expertiseContent), + ], + ), ); }, ), diff --git a/mylis/lib/screens/expertise/kultur_expertise_wizard.dart b/mylis/lib/screens/expertise/kultur_expertise_wizard.dart index aa5860cc..64deca12 100644 --- a/mylis/lib/screens/expertise/kultur_expertise_wizard.dart +++ b/mylis/lib/screens/expertise/kultur_expertise_wizard.dart @@ -17,6 +17,8 @@ class KulturExpertiseWizard extends StatefulWidget { required this.onCriticalChanged, required this.onChanged, required this.onSave, + required this.initialStep, + required this.onStepChanged, }); final Map item; @@ -33,47 +35,42 @@ class KulturExpertiseWizard extends StatefulWidget { final ValueChanged onCriticalChanged; final VoidCallback onChanged; final ValueChanged onSave; + final int initialStep; + final ValueChanged onStepChanged; @override State createState() => _KulturExpertiseWizardState(); } class _KulturExpertiseWizardState extends State { - int _step = 0; + late int _step; - void _next() { - setState(() => _step = (_step + 1).clamp(0, 5)); + @override + void initState() { + super.initState(); + _step = widget.initialStep.clamp(0, 5); } - void _previous() { - setState(() => _step = (_step - 1).clamp(0, 5)); + @override + void didUpdateWidget(covariant KulturExpertiseWizard oldWidget) { + super.didUpdateWidget(oldWidget); + final nextStep = widget.initialStep.clamp(0, 5); + if (nextStep != _step) { + _step = nextStep; + } + } + + void _setStep(int step) { + final nextStep = step.clamp(0, 5); + setState(() => _step = nextStep); + widget.onStepChanged(nextStep); } @override Widget build(BuildContext context) { - return Stepper( + return ExpertiseWizardShell( 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'), - ), - ], - ), - ); - }, + onStepChanged: _setStep, steps: [ Step( title: const Text('Pasien & Petugas'), @@ -172,10 +169,32 @@ class _KulturExpertiseWizardState extends State { _options('jsonpewarnaangiemsa', _giemsaFallback), ), _text('id_pewarnaangiesmaoptional', 'Pewarnaan Giemsa Manual'), + _saveDirectSmearButton(), ], ); } + Widget _saveDirectSmearButton() { + return Padding( + padding: const EdgeInsets.only(top: 4, bottom: 12), + child: SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: widget.saving + ? null + : () => widget.onSave('statuspewarnaanlsg'), + icon: widget.saving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: const Text('SIMPAN PEWARNAAN LANGSUNG'), + ), + ), + ); + } + Widget _nugentScore() { return Card( margin: const EdgeInsets.only(bottom: 12), @@ -276,53 +295,10 @@ class _KulturExpertiseWizardState extends State { } 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)), - ], + return AntibioticSensitivityPanel( + textControllers: widget.textControllers, + selectValues: widget.selectValues, + onChanged: widget.onChanged, ); } @@ -512,32 +488,14 @@ class _KulturExpertiseWizardState extends State { final selected = widget.multiValues[name] ?? {}; return Padding( padding: const EdgeInsets.only(bottom: 12), - child: InputDecorator( - decoration: InputDecoration( - labelText: label, - border: const OutlineInputBorder(), - ), - child: Wrap( - spacing: 8, - runSpacing: 4, - children: options - .map( - (option) => FilterChip( - label: Text(option), - selected: selected.contains(option), - onSelected: (checked) { - if (checked) { - selected.add(option); - } else { - selected.remove(option); - } - widget.multiValues[name] = selected; - widget.onChanged(); - }, - ), - ) - .toList(), - ), + child: CompactMultiSelectField( + label: label, + options: options, + selected: selected, + onChanged: (value) { + widget.multiValues[name] = value; + widget.onChanged(); + }, ), ); } @@ -572,24 +530,10 @@ class _KirbyAntibiotic { final String sirField; } -class _CultureMedia { - const _CultureMedia(this.label, this.statusField, this.noteField); - - final String label; - final String statusField; - final String noteField; -} - const List _scoreOptions = ['0', '1', '2', '3', '4']; const List _antibioticSirOptions = ['S', 'I', 'R']; -const List _growthOptions = [ - 'Ada Pertumbuhan', - 'Tidak Ada Pertumbuhan', - 'Pertumbuhan Primer', -]; - const List _kirbyZoneOptions = [ 'Tidak dilakukan', '1', @@ -643,30 +587,6 @@ const List<_KirbyAntibiotic> _kirbyAntibiotics = [ _KirbyAntibiotic('SCF', 'id_kbscf', 'id_sirkbscf'), ]; -const List<_CultureMedia> _cultureMedia = [ - _CultureMedia('Media BAP', 'media_bap_status', 'media_bap_note'), - _CultureMedia('Media CAP', 'media_cap_status', 'media_cap_note'), - _CultureMedia( - 'Media Mc Conkey', - 'media_mcconkey_status', - 'media_mcconkey_note', - ), - _CultureMedia('Kultur Jamur R1', 'media_sdar1_status', 'media_sdar1_note'), - _CultureMedia('Kultur Jamur R2', 'media_sdar2_status', 'media_sdar2_note'), - _CultureMedia('Kultur Jamur I1', 'media_sdai1_status', 'media_sdai1_note'), - _CultureMedia('Kultur Jamur I2', 'media_sdai2_status', 'media_sdai2_note'), - _CultureMedia( - 'Media Selektif Lainnya', - 'media_sellainnya_status', - 'media_sellainnya_note', - ), - _CultureMedia( - 'Pemeriksaan Tambahan Lainnya', - 'media_tamlainnya_status', - 'media_tamlainnya_note', - ), -]; - const List _kohFallback = [ 'Ditemukan morfologi Hifa', 'Ditemukan morfologi Budding Cell', diff --git a/mylis/lib/screens/expertise/leptospira_expertise_wizard.dart b/mylis/lib/screens/expertise/leptospira_expertise_wizard.dart index 86693682..9a0361f7 100644 --- a/mylis/lib/screens/expertise/leptospira_expertise_wizard.dart +++ b/mylis/lib/screens/expertise/leptospira_expertise_wizard.dart @@ -15,6 +15,8 @@ class LeptospiraExpertiseWizard extends StatefulWidget { required this.onCriticalChanged, required this.onChanged, required this.onSave, + required this.initialStep, + required this.onStepChanged, }); final Map item; @@ -29,6 +31,8 @@ class LeptospiraExpertiseWizard extends StatefulWidget { final ValueChanged onCriticalChanged; final VoidCallback onChanged; final ValueChanged onSave; + final int initialStep; + final ValueChanged onStepChanged; @override State createState() => @@ -36,41 +40,34 @@ class LeptospiraExpertiseWizard extends StatefulWidget { } class _LeptospiraExpertiseWizardState extends State { - int _step = 0; + late int _step; - void _next() { - setState(() => _step = (_step + 1).clamp(0, 4)); + @override + void initState() { + super.initState(); + _step = widget.initialStep.clamp(0, 4); } - void _previous() { - setState(() => _step = (_step - 1).clamp(0, 4)); + @override + void didUpdateWidget(covariant LeptospiraExpertiseWizard oldWidget) { + super.didUpdateWidget(oldWidget); + final nextStep = widget.initialStep.clamp(0, 4); + if (nextStep != _step) { + _step = nextStep; + } + } + + void _setStep(int step) { + final nextStep = step.clamp(0, 4); + setState(() => _step = nextStep); + widget.onStepChanged(nextStep); } @override Widget build(BuildContext context) { - return Stepper( + return ExpertiseWizardShell( 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'), - ), - ], - ), - ); - }, + onStepChanged: _setStep, steps: [ Step( title: const Text('Pasien & Petugas'), @@ -126,49 +123,10 @@ class _LeptospiraExpertiseWizardState extends State { } 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'), - ], - ), - ), - ), - ], + return AntibioticSensitivityPanel( + textControllers: widget.textControllers, + selectValues: widget.selectValues, + onChanged: widget.onChanged, ); } diff --git a/mylis/lib/screens/expertise/pewarnaan_langsung_expertise_wizard.dart b/mylis/lib/screens/expertise/pewarnaan_langsung_expertise_wizard.dart index fb51073f..d84bca72 100644 --- a/mylis/lib/screens/expertise/pewarnaan_langsung_expertise_wizard.dart +++ b/mylis/lib/screens/expertise/pewarnaan_langsung_expertise_wizard.dart @@ -17,6 +17,8 @@ class PewarnaanLangsungExpertiseWizard extends StatefulWidget { required this.onCriticalChanged, required this.onChanged, required this.onSave, + required this.initialStep, + required this.onStepChanged, }); final Map item; @@ -33,6 +35,8 @@ class PewarnaanLangsungExpertiseWizard extends StatefulWidget { final ValueChanged onCriticalChanged; final VoidCallback onChanged; final ValueChanged onSave; + final int initialStep; + final ValueChanged onStepChanged; @override State createState() => @@ -41,41 +45,34 @@ class PewarnaanLangsungExpertiseWizard extends StatefulWidget { class _PewarnaanLangsungExpertiseWizardState extends State { - int _step = 0; + late int _step; - void _next() { - setState(() => _step = (_step + 1).clamp(0, 4)); + @override + void initState() { + super.initState(); + _step = widget.initialStep.clamp(0, 4); } - void _previous() { - setState(() => _step = (_step - 1).clamp(0, 4)); + @override + void didUpdateWidget(covariant PewarnaanLangsungExpertiseWizard oldWidget) { + super.didUpdateWidget(oldWidget); + final nextStep = widget.initialStep.clamp(0, 4); + if (nextStep != _step) { + _step = nextStep; + } + } + + void _setStep(int step) { + final nextStep = step.clamp(0, 4); + setState(() => _step = nextStep); + widget.onStepChanged(nextStep); } @override Widget build(BuildContext context) { - return Stepper( + return ExpertiseWizardShell( 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'), - ), - ], - ), - ); - }, + onStepChanged: _setStep, steps: [ Step( title: const Text('Pasien & Petugas'), @@ -175,10 +172,32 @@ class _PewarnaanLangsungExpertiseWizardState maxLines: 8, ), _nugentScore(), + _saveDirectSmearButton(), ], ); } + Widget _saveDirectSmearButton() { + return Padding( + padding: const EdgeInsets.only(top: 4, bottom: 12), + child: SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: widget.saving + ? null + : () => widget.onSave('statuspewarnaanlsg'), + icon: widget.saving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: const Text('SIMPAN PEWARNAAN LANGSUNG'), + ), + ), + ); + } + Widget _nugentScore() { return Card( margin: const EdgeInsets.only(bottom: 12), @@ -214,49 +233,10 @@ class _PewarnaanLangsungExpertiseWizardState } 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'), - ], - ), - ), - ), - ], + return AntibioticSensitivityPanel( + textControllers: widget.textControllers, + selectValues: widget.selectValues, + onChanged: widget.onChanged, ); } @@ -440,32 +420,14 @@ class _PewarnaanLangsungExpertiseWizardState final selected = widget.multiValues[name] ?? {}; return Padding( padding: const EdgeInsets.only(bottom: 12), - child: InputDecorator( - decoration: InputDecoration( - labelText: label, - border: const OutlineInputBorder(), - ), - child: Wrap( - spacing: 8, - runSpacing: 4, - children: options - .map( - (option) => FilterChip( - label: Text(option), - selected: selected.contains(option), - onSelected: (checked) { - if (checked) { - selected.add(option); - } else { - selected.remove(option); - } - widget.multiValues[name] = selected; - widget.onChanged(); - }, - ), - ) - .toList(), - ), + child: CompactMultiSelectField( + label: label, + options: options, + selected: selected, + onChanged: (value) { + widget.multiValues[name] = value; + widget.onChanged(); + }, ), ); } diff --git a/mylis/lib/screens/expertise/tbc_expertise_wizard.dart b/mylis/lib/screens/expertise/tbc_expertise_wizard.dart index 784ece6c..5071493c 100644 --- a/mylis/lib/screens/expertise/tbc_expertise_wizard.dart +++ b/mylis/lib/screens/expertise/tbc_expertise_wizard.dart @@ -16,6 +16,8 @@ class TbcExpertiseWizard extends StatefulWidget { required this.onCriticalChanged, required this.onChanged, required this.onSave, + required this.initialStep, + required this.onStepChanged, }); final Map item; @@ -31,47 +33,42 @@ class TbcExpertiseWizard extends StatefulWidget { final ValueChanged onCriticalChanged; final VoidCallback onChanged; final ValueChanged onSave; + final int initialStep; + final ValueChanged onStepChanged; @override State createState() => _TbcExpertiseWizardState(); } class _TbcExpertiseWizardState extends State { - int _step = 0; + late int _step; - void _next() { - setState(() => _step = (_step + 1).clamp(0, 6)); + @override + void initState() { + super.initState(); + _step = widget.initialStep.clamp(0, 6); } - void _previous() { - setState(() => _step = (_step - 1).clamp(0, 6)); + @override + void didUpdateWidget(covariant TbcExpertiseWizard oldWidget) { + super.didUpdateWidget(oldWidget); + final nextStep = widget.initialStep.clamp(0, 6); + if (nextStep != _step) { + _step = nextStep; + } + } + + void _setStep(int step) { + final nextStep = step.clamp(0, 6); + setState(() => _step = nextStep); + widget.onStepChanged(nextStep); } @override Widget build(BuildContext context) { - return Stepper( + return ExpertiseWizardShell( 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'), - ), - ], - ), - ); - }, + onStepChanged: _setStep, steps: [ Step( title: const Text('Pasien & Petugas'), @@ -362,38 +359,10 @@ class _TbcExpertiseWizardState extends State { } 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'), - ], - ), - ), - ), - ], + return AntibioticSensitivityPanel( + textControllers: widget.textControllers, + selectValues: widget.selectValues, + onChanged: widget.onChanged, ); } @@ -567,32 +536,14 @@ class _TbcExpertiseWizardState extends State { final selected = widget.multiValues[name] ?? {}; return Padding( padding: const EdgeInsets.only(bottom: 12), - child: InputDecorator( - decoration: InputDecoration( - labelText: label, - border: const OutlineInputBorder(), - ), - child: Wrap( - spacing: 8, - runSpacing: 4, - children: options - .map( - (option) => FilterChip( - label: Text(option), - selected: selected.contains(option), - onSelected: (checked) { - if (checked) { - selected.add(option); - } else { - selected.remove(option); - } - widget.multiValues[name] = selected; - widget.onChanged(); - }, - ), - ) - .toList(), - ), + child: CompactMultiSelectField( + label: label, + options: options, + selected: selected, + onChanged: (value) { + widget.multiValues[name] = value; + widget.onChanged(); + }, ), ); } diff --git a/mylis/lib/screens/expertise/viral_load_expertise_wizard.dart b/mylis/lib/screens/expertise/viral_load_expertise_wizard.dart index 80761820..1645cdbb 100644 --- a/mylis/lib/screens/expertise/viral_load_expertise_wizard.dart +++ b/mylis/lib/screens/expertise/viral_load_expertise_wizard.dart @@ -16,6 +16,8 @@ class ViralLoadExpertiseWizard extends StatefulWidget { required this.onCriticalChanged, required this.onChanged, required this.onSave, + required this.initialStep, + required this.onStepChanged, }); final Map item; @@ -31,6 +33,8 @@ class ViralLoadExpertiseWizard extends StatefulWidget { final ValueChanged onCriticalChanged; final VoidCallback onChanged; final ValueChanged onSave; + final int initialStep; + final ValueChanged onStepChanged; @override State createState() => @@ -38,41 +42,34 @@ class ViralLoadExpertiseWizard extends StatefulWidget { } class _ViralLoadExpertiseWizardState extends State { - int _step = 0; + late int _step; - void _next() { - setState(() => _step = (_step + 1).clamp(0, 4)); + @override + void initState() { + super.initState(); + _step = widget.initialStep.clamp(0, 4); } - void _previous() { - setState(() => _step = (_step - 1).clamp(0, 4)); + @override + void didUpdateWidget(covariant ViralLoadExpertiseWizard oldWidget) { + super.didUpdateWidget(oldWidget); + final nextStep = widget.initialStep.clamp(0, 4); + if (nextStep != _step) { + _step = nextStep; + } + } + + void _setStep(int step) { + final nextStep = step.clamp(0, 4); + setState(() => _step = nextStep); + widget.onStepChanged(nextStep); } @override Widget build(BuildContext context) { - return Stepper( + return ExpertiseWizardShell( 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'), - ), - ], - ), - ); - }, + onStepChanged: _setStep, steps: [ Step( title: const Text('Pasien & Petugas'), @@ -139,49 +136,10 @@ class _ViralLoadExpertiseWizardState extends State { } 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'), - ], - ), - ), - ), - ], + return AntibioticSensitivityPanel( + textControllers: widget.textControllers, + selectValues: widget.selectValues, + onChanged: widget.onChanged, ); } diff --git a/mylis/lib/screens/login/login_screen.dart b/mylis/lib/screens/login/login_screen.dart index be0fdce2..d509e30f 100644 --- a/mylis/lib/screens/login/login_screen.dart +++ b/mylis/lib/screens/login/login_screen.dart @@ -64,8 +64,10 @@ class _LoginScreenState extends State { ); } on ApiException catch (error) { _showMessage(error.message); - } catch (_) { - _showMessage('Tidak bisa terhubung ke server MyLIS.'); + } catch (error, stackTrace) { + debugPrint('Login MyLIS gagal tidak terduga: $error'); + debugPrintStack(stackTrace: stackTrace); + _showMessage('Tidak bisa terhubung ke server MyLIS. Penyebab: $error'); } finally { if (mounted) setState(() => _loading = false); } @@ -93,8 +95,10 @@ class _LoginScreenState extends State { }); } on ApiException catch (error) { setState(() => _pingStatus = error.message); - } catch (_) { - setState(() => _pingStatus = 'Koneksi server gagal.'); + } catch (error, stackTrace) { + debugPrint('Ping MyLIS gagal tidak terduga: $error'); + debugPrintStack(stackTrace: stackTrace); + setState(() => _pingStatus = 'Koneksi server gagal. Penyebab: $error'); } finally { if (mounted) setState(() => _pinging = false); } @@ -120,29 +124,34 @@ class _LoginScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Center( - child: Image.asset( - kAppLogoAsset, - height: 96, - fit: BoxFit.contain, - ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Flexible( + child: Image.asset( + kAppLogoAsset, + height: 78, + fit: BoxFit.contain, + ), + ), + const SizedBox(width: 18), + Container( + width: 1, + height: 58, + color: const Color(0xFFE2E8F0), + ), + const SizedBox(width: 18), + Flexible( + child: Image.asset( + kHospitalLogoAsset, + height: 78, + fit: BoxFit.contain, + ), + ), + ], ), - const SizedBox(height: 16), - Text( - kAppLongName, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.headlineMedium - ?.copyWith(fontWeight: FontWeight.w700), - ), - const SizedBox(height: 12), - Center( - child: Image.asset( - kHospitalLogoAsset, - height: 54, - fit: BoxFit.contain, - ), - ), - const SizedBox(height: 8), + const SizedBox(height: 18), Text( kHospitalName, textAlign: TextAlign.center, @@ -152,6 +161,13 @@ class _LoginScreenState extends State { ), ), const SizedBox(height: 8), + Text( + kAppLongName, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 8), Text( 'Login petugas', textAlign: TextAlign.center, @@ -261,6 +277,15 @@ class _LoginScreenState extends State { context, ).textTheme.bodySmall?.copyWith(color: Colors.black45), ), + const SizedBox(height: 18), + Text( + 'Pembuat aplikasi: Duidev Software House | CV Swandhana Copyright @ 2026', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.black54, + fontWeight: FontWeight.w600, + ), + ), ], ), ), diff --git a/mylis/lib/services/api_client.dart b/mylis/lib/services/api_client.dart index 28611833..0bb3b8ed 100644 --- a/mylis/lib/services/api_client.dart +++ b/mylis/lib/services/api_client.dart @@ -25,31 +25,171 @@ class ApiClient { String path, Map body, ) async { - final response = await http.post( - _uri(path), - headers: _headers, - body: jsonEncode(body), - ); - return _decode(response); + final uri = _uri(path); + final client = _clientFor(uri); + try { + final response = await client + .post(uri, headers: _headers, body: jsonEncode(body)) + .timeout(const Duration(seconds: 15)); + return _decode(response, uri); + } catch (error, stackTrace) { + throw _connectionException(uri, error, stackTrace); + } finally { + client.close(); + } } Future> get( String path, [ Map? query, ]) async { - final response = await http.get(_uri(path, query), headers: _headers); - return _decode(response); + final uri = _uri(path, query); + final client = _clientFor(uri); + try { + final response = await client + .get(uri, headers: _headers) + .timeout(const Duration(seconds: 15)); + return _decode(response, uri); + } catch (error, stackTrace) { + throw _connectionException(uri, error, stackTrace); + } finally { + client.close(); + } } - Map _decode(http.Response response) { - final decoded = response.body.isEmpty - ? {} - : jsonDecode(response.body); - if (response.statusCode >= 200 && response.statusCode < 300) { - return decoded is Map ? decoded : {'data': decoded}; + http.Client _clientFor(Uri uri) { + final client = HttpClient() + ..badCertificateCallback = (certificate, host, port) { + debugPrint( + 'MyLIS menerima sertifikat internal untuk $host:$port ' + 'issuer=${certificate.issuer}', + ); + return true; + }; + return IOClient(client); + } + + Map _decode(http.Response response, Uri uri) { + final bodyPreview = response.body.length > 240 + ? '${response.body.substring(0, 240)}...' + : response.body; + + if (response.statusCode < 200 || response.statusCode >= 300) { + Object? decoded; + if (_looksLikeJson(response)) { + try { + decoded = response.body.isEmpty + ? {} + : jsonDecode(response.body); + } catch (_) { + decoded = null; + } + } + final message = decoded is Map ? decoded['message'] : null; + debugPrint( + 'MyLIS API gagal: ${response.request?.method ?? '-'} $uri ' + 'HTTP ${response.statusCode}; body=$bodyPreview', + ); + throw ApiException( + message?.toString() ?? + 'Server mengembalikan HTTP ${response.statusCode}. ' + 'URL: $uri. Kemungkinan request masuk ke virtual host/proxy yang salah. ' + 'Detail awal respons: $bodyPreview', + ); } - final message = decoded is Map ? decoded['message'] : null; - throw ApiException(message?.toString() ?? 'Koneksi ke server gagal.'); + + final Object decoded; + try { + decoded = response.body.isEmpty + ? {} + : jsonDecode(response.body); + } catch (error, stackTrace) { + debugPrint( + 'MyLIS API decode gagal: ${response.request?.method ?? '-'} $uri ' + 'HTTP ${response.statusCode}; error=$error', + ); + debugPrintStack(stackTrace: stackTrace); + throw ApiException( + 'Respons server tidak bisa dibaca. ' + 'URL: $uri. Status HTTP: ${response.statusCode}.', + ); + } + + if (response.statusCode >= 200 && response.statusCode < 300) { + final cleanDecoded = _decodeHtmlEntitiesIn(decoded); + return cleanDecoded is Map + ? cleanDecoded + : {'data': cleanDecoded}; + } + return {'data': _decodeHtmlEntitiesIn(decoded)}; + } + + bool _looksLikeJson(http.Response response) { + final contentType = response.headers['content-type']?.toLowerCase() ?? ''; + final body = response.body.trimLeft(); + return contentType.contains('json') || + body.startsWith('{') || + body.startsWith('['); + } + + ApiException _connectionException( + Uri uri, + Object error, + StackTrace stackTrace, + ) { + if (error is ApiException) { + return error; + } + + final reason = _connectionReason(error); + debugPrint('MyLIS koneksi gagal: $uri; $reason; error=$error'); + debugPrintStack(stackTrace: stackTrace); + return ApiException('Koneksi server gagal. URL: $uri. Penyebab: $reason'); + } + + String _connectionReason(Object error) { + if (error is TimeoutException) { + return 'timeout lebih dari 15 detik. Periksa jaringan, DNS lokal, atau server Laravel belum aktif.'; + } + if (error is http.ClientException) { + final message = error.message.toLowerCase(); + if (message.contains('certificate') || + message.contains('handshake') || + message.contains('tls')) { + return 'sertifikat HTTPS lokal belum dipercaya oleh device/simulator.'; + } + if (message.contains('failed host lookup') || + message.contains('nodename') || + message.contains('name or service')) { + return 'host tidak ditemukan. Periksa URL atau DNS/hosts lokal.'; + } + if (message.contains('connection refused')) { + return 'server menolak koneksi. Periksa apakah Laravel/web server aktif.'; + } + if (message.contains('connection closed')) { + return 'koneksi ditutup oleh server.'; + } + return 'client HTTP gagal: ${error.message}'; + } + if (error is FormatException) { + return 'format URL atau respons tidak valid: ${error.message}'; + } + return error.toString(); + } + + Object? _decodeHtmlEntitiesIn(Object? value) { + if (value is String) { + return decodeHtmlEntities(value); + } + if (value is List) { + return value.map(_decodeHtmlEntitiesIn).toList(); + } + if (value is Map) { + return value.map( + (key, item) => MapEntry(key.toString(), _decodeHtmlEntitiesIn(item)), + ); + } + return value; } } diff --git a/mylis/lib/widgets/common_widgets.dart b/mylis/lib/widgets/common_widgets.dart index 88d46f0c..f504c24b 100644 --- a/mylis/lib/widgets/common_widgets.dart +++ b/mylis/lib/widgets/common_widgets.dart @@ -138,3 +138,32 @@ List asList(Object? value) => value is List ? value : []; String _message(Object? error) => error is ApiException ? error.message : 'Terjadi kesalahan saat memuat data.'; + +String decodeHtmlEntities(String value) { + var result = value; + for (var i = 0; i < 3; i += 1) { + final previous = result; + result = result + .replaceAll(RegExp(r'<\s*br\s*/?\s*>', caseSensitive: false), '\n') + .replaceAll(RegExp(r'', caseSensitive: false), '\n') + .replaceAll(RegExp(r'<[^>]*>'), '') + .replaceAll('>', '>') + .replaceAll('<', '<') + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll(''', "'"); + result = result.replaceAllMapped(RegExp(r'&#(\d+);'), (match) { + final code = int.tryParse(match.group(1) ?? ''); + return code == null ? match.group(0)! : String.fromCharCode(code); + }); + result = result.replaceAllMapped(RegExp(r'&#x([0-9a-fA-F]+);'), (match) { + final code = int.tryParse(match.group(1) ?? '', radix: 16); + return code == null ? match.group(0)! : String.fromCharCode(code); + }); + if (result == previous) { + break; + } + } + return result; +}