Files
lis/mylis/lib/screens/expertise/cci_expertise_wizard.dart
T
2026-07-22 14:06:01 +07:00

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',
];