610 lines
19 KiB
Dart
610 lines
19 KiB
Dart
part of '../../app/app.dart';
|
|
|
|
class CciExpertiseWizard extends StatefulWidget {
|
|
const CciExpertiseWizard({
|
|
super.key,
|
|
required this.item,
|
|
required this.user,
|
|
required this.staffOptions,
|
|
required this.optionSets,
|
|
required this.textControllers,
|
|
required this.selectValues,
|
|
required this.staffValues,
|
|
required this.criticalValue,
|
|
required this.isSupervisor,
|
|
required this.saving,
|
|
required this.onCriticalChanged,
|
|
required this.onChanged,
|
|
required this.onSave,
|
|
required this.initialStep,
|
|
required this.onStepChanged,
|
|
});
|
|
|
|
final Map<String, dynamic> item;
|
|
final Map<String, dynamic> user;
|
|
final Map<String, dynamic> staffOptions;
|
|
final Map<String, dynamic> optionSets;
|
|
final Map<String, TextEditingController> textControllers;
|
|
final Map<String, String> selectValues;
|
|
final Map<String, String> staffValues;
|
|
final bool criticalValue;
|
|
final bool isSupervisor;
|
|
final bool saving;
|
|
final ValueChanged<bool> onCriticalChanged;
|
|
final VoidCallback onChanged;
|
|
final ValueChanged<String> onSave;
|
|
final int initialStep;
|
|
final ValueChanged<int> onStepChanged;
|
|
|
|
@override
|
|
State<CciExpertiseWizard> createState() => _CciExpertiseWizardState();
|
|
}
|
|
|
|
class _CciExpertiseWizardState extends State<CciExpertiseWizard> {
|
|
late int _step;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_step = widget.initialStep.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 ExpertiseWizardShell(
|
|
currentStep: _step,
|
|
onStepChanged: _setStep,
|
|
steps: [
|
|
Step(
|
|
title: const Text('Pasien & Petugas'),
|
|
isActive: _step == 0,
|
|
content: Column(
|
|
children: [
|
|
PatientStaffPanel(
|
|
item: widget.item,
|
|
user: widget.user,
|
|
staffOptions: widget.staffOptions,
|
|
staffValues: widget.staffValues,
|
|
onChanged: widget.onChanged,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Step(
|
|
title: const Text('CCI'),
|
|
isActive: _step == 1,
|
|
content: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Pemeriksaan Candida Colonization Index',
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w800),
|
|
),
|
|
const SizedBox(height: 12),
|
|
_select(
|
|
'id_sputum',
|
|
'Sputum',
|
|
_options('jsonsputum', ['Pilih Salah Satu']),
|
|
),
|
|
_select(
|
|
'id_swabtenggorok',
|
|
'Swab Tenggorok',
|
|
_options('jsonswabtenggorok', ['Pilih Salah Satu']),
|
|
),
|
|
_select(
|
|
'id_urine',
|
|
'Urine',
|
|
_options('jsonurine', ['Pilih Salah Satu']),
|
|
),
|
|
_select(
|
|
'id_swabperineum',
|
|
'Swab Perineum',
|
|
_options('jsonswabperineum', ['Pilih Salah Satu']),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Step(
|
|
title: const Text('Data Alat'),
|
|
isActive: _step == 2,
|
|
content: ToolDataPanel(
|
|
textControllers: widget.textControllers,
|
|
selectValues: widget.selectValues,
|
|
onChanged: widget.onChanged,
|
|
),
|
|
),
|
|
Step(
|
|
title: const Text('Final'),
|
|
isActive: _step == 3,
|
|
content: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
ExpertiseHtmlEditor(
|
|
controller: widget.textControllers['keterangan'] ??=
|
|
TextEditingController(),
|
|
label: 'Expertise',
|
|
),
|
|
CheckboxListTile(
|
|
value: widget.criticalValue,
|
|
onChanged: (value) => widget.onCriticalChanged(value ?? false),
|
|
contentPadding: EdgeInsets.zero,
|
|
controlAffinity: ListTileControlAffinity.leading,
|
|
title: const Text('Nilai Kritis'),
|
|
),
|
|
FilledButton.icon(
|
|
onPressed: widget.saving ? null : () => widget.onSave('Draft'),
|
|
icon: widget.saving
|
|
? const SizedBox.square(
|
|
dimension: 18,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.save_outlined),
|
|
label: const Text('Save as Draft'),
|
|
),
|
|
if (widget.isSupervisor) ...[
|
|
OutlinedButton.icon(
|
|
onPressed: widget.saving
|
|
? null
|
|
: () => widget.onSave('preliminary'),
|
|
icon: const Icon(Icons.done_all_outlined),
|
|
label: const Text('Save Preliminary result'),
|
|
),
|
|
OutlinedButton.icon(
|
|
onPressed: widget.saving
|
|
? null
|
|
: () => widget.onSave('verifikasi'),
|
|
icon: const Icon(Icons.verified_outlined),
|
|
label: const Text('Save Final Result'),
|
|
),
|
|
] else ...[
|
|
OutlinedButton.icon(
|
|
onPressed: widget.saving
|
|
? null
|
|
: () => widget.onSave('Permohonan Verifikasi'),
|
|
icon: const Icon(Icons.send_outlined),
|
|
label: const Text('Save and Send To SPV'),
|
|
),
|
|
OutlinedButton.icon(
|
|
onPressed: widget.saving
|
|
? null
|
|
: () =>
|
|
widget.onSave('Permohonan Verifikasi Preliminary'),
|
|
icon: const Icon(Icons.outgoing_mail),
|
|
label: const Text('Kirim SPV Preliminary'),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
List<String> _options(String key, List<String> fallback) {
|
|
final values = asList(
|
|
widget.optionSets[key],
|
|
).map((item) => item.toString()).where((item) => item.isNotEmpty).toList();
|
|
return values.isEmpty ? fallback : values;
|
|
}
|
|
|
|
// ignore: unused_element
|
|
Widget _toolSection({
|
|
required String title,
|
|
required String bacteriaField,
|
|
String? antibioticField,
|
|
required String sirField,
|
|
required String colonyField,
|
|
required String colonyTextField,
|
|
required String printField,
|
|
}) {
|
|
return Card(
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
title,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w800),
|
|
),
|
|
const SizedBox(height: 10),
|
|
_text(bacteriaField, 'Bakteri'),
|
|
if (antibioticField != null)
|
|
_text(antibioticField, 'Set Antibiotik'),
|
|
_select(sirField, 'SIR', _sirOptions),
|
|
_select(colonyField, 'Hitung Koloni', _colonyOptions),
|
|
_text(colonyTextField, 'Hitung Koloni Lainnya'),
|
|
_select(printField, 'Cetak', const ['YA', 'TIDAK']),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _select(String name, String label, List<String> options) {
|
|
final current = widget.selectValues[name];
|
|
final effectiveOptions = [
|
|
if (current != null && current.isNotEmpty && !options.contains(current))
|
|
current,
|
|
...options,
|
|
];
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: DropdownButtonFormField<String>(
|
|
initialValue:
|
|
current != null &&
|
|
current.isNotEmpty &&
|
|
effectiveOptions.contains(current)
|
|
? current
|
|
: null,
|
|
isExpanded: true,
|
|
decoration: InputDecoration(
|
|
labelText: label,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
items: effectiveOptions
|
|
.map(
|
|
(option) => DropdownMenuItem(value: option, child: Text(option)),
|
|
)
|
|
.toList(),
|
|
onChanged: (value) {
|
|
widget.selectValues[name] = value ?? '';
|
|
widget.onChanged();
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _text(
|
|
String name,
|
|
String label, {
|
|
int minLines = 1,
|
|
int maxLines = 1,
|
|
}) {
|
|
final controller = widget.textControllers[name] ??= TextEditingController();
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: TextField(
|
|
controller: controller,
|
|
minLines: minLines,
|
|
maxLines: maxLines,
|
|
decoration: InputDecoration(
|
|
labelText: label,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class PatientStaffPanel extends StatelessWidget {
|
|
const PatientStaffPanel({
|
|
super.key,
|
|
required this.item,
|
|
required this.user,
|
|
required this.staffOptions,
|
|
required this.staffValues,
|
|
required this.onChanged,
|
|
});
|
|
|
|
final Map<String, dynamic> item;
|
|
final Map<String, dynamic> user;
|
|
final Map<String, dynamic> staffOptions;
|
|
final Map<String, String> staffValues;
|
|
final VoidCallback onChanged;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
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(14),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
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),
|
|
],
|
|
),
|
|
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,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
_InfoTile(
|
|
label: 'Address',
|
|
value: item['alamatpasien'],
|
|
icon: Icons.place_outlined,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
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,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _staffSelect(String name, String label, List<dynamic> users) {
|
|
final current = staffValues[name];
|
|
final items = users.map(asMap).toList();
|
|
final hasCurrent = items.any((item) => item['id'].toString() == current);
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: DropdownButtonFormField<String>(
|
|
initialValue: hasCurrent ? current : null,
|
|
isExpanded: true,
|
|
decoration: InputDecoration(
|
|
labelText: label,
|
|
border: const OutlineInputBorder(),
|
|
prefixIcon: const Icon(Icons.account_circle_outlined),
|
|
),
|
|
items: [
|
|
const DropdownMenuItem(value: '0', child: Text('Pilih')),
|
|
...items.map(
|
|
(item) => DropdownMenuItem(
|
|
value: item['id'].toString(),
|
|
child: Text(item['nama']?.toString() ?? '-'),
|
|
),
|
|
),
|
|
],
|
|
onChanged: (value) {
|
|
staffValues[name] = value ?? '0';
|
|
onChanged();
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
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<String> _sirOptions = [
|
|
'ESBL',
|
|
'MRSA',
|
|
'MDR',
|
|
'XDR',
|
|
'PDR',
|
|
'Carbapenem Resistant',
|
|
'MDR Carbapenem Resistant',
|
|
'XDR Carbapenem Resistant',
|
|
];
|
|
|
|
const List<String> _colonyOptions = [
|
|
'>= 10^5 CFU/ml Urine (Bakteriuria bermakna)',
|
|
'>= 10^5 CFU/ml Urine (Candiduria bermakna)',
|
|
'>= 10^3 CFU/ml Urine (Bakteriuria bermakna)',
|
|
'8 x 10^3 CFU/ml Urine (Bakteriuria bermakna)',
|
|
'lainnya',
|
|
];
|