2303 lines
72 KiB
Dart
2303 lines
72 KiB
Dart
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<Step> steps;
|
|
final ValueChanged<int> 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<int>(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<Step> steps;
|
|
final ValueChanged<int> 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<String> options;
|
|
final Set<String> selected;
|
|
final ValueChanged<Set<String>> 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<String> values) {
|
|
if (values.isEmpty) {
|
|
return 'Belum ada pilihan';
|
|
}
|
|
if (values.length <= 2) {
|
|
return values.join(', ');
|
|
}
|
|
return '${values.length} pilihan dipilih';
|
|
}
|
|
|
|
Future<void> _showPicker(
|
|
BuildContext context,
|
|
List<String> cleanOptions,
|
|
Set<String> cleanSelected,
|
|
) async {
|
|
final draft = cleanSelected.toSet();
|
|
await showModalBottomSheet<void>(
|
|
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 ExpertiseHtmlEditor extends StatefulWidget {
|
|
const ExpertiseHtmlEditor({
|
|
super.key,
|
|
required this.controller,
|
|
this.label = 'Expertise',
|
|
});
|
|
|
|
final TextEditingController controller;
|
|
final String label;
|
|
|
|
@override
|
|
State<ExpertiseHtmlEditor> createState() => _ExpertiseHtmlEditorState();
|
|
}
|
|
|
|
class _ExpertiseHtmlEditorState extends State<ExpertiseHtmlEditor> {
|
|
bool _preview = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
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.stretch,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Text(
|
|
widget.label,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w900),
|
|
),
|
|
const Spacer(),
|
|
SegmentedButton<bool>(
|
|
segments: const [
|
|
ButtonSegment(value: false, label: Text('Edit')),
|
|
ButtonSegment(value: true, label: Text('Preview')),
|
|
],
|
|
selected: {_preview},
|
|
onSelectionChanged: (value) =>
|
|
setState(() => _preview = value.first),
|
|
showSelectedIcon: false,
|
|
style: const ButtonStyle(
|
|
visualDensity: VisualDensity.compact,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 10),
|
|
_EditorToolbar(
|
|
onBold: () => _wrapSelection('<b>', '</b>'),
|
|
onItalic: () => _wrapSelection('<i>', '</i>'),
|
|
onUnderline: () => _wrapSelection('<u>', '</u>'),
|
|
onParagraph: () => _wrapSelection('<p>', '</p>'),
|
|
onBullet: _insertBulletList,
|
|
onBreak: () => _insertText('<br>'),
|
|
onClear: _clearTags,
|
|
),
|
|
const SizedBox(height: 10),
|
|
if (_preview)
|
|
Container(
|
|
constraints: const BoxConstraints(minHeight: 210),
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFF8FAFC),
|
|
border: Border.all(color: const Color(0xFFE2E8F0)),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Text(
|
|
_plainPreview(widget.controller.text),
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
height: 1.45,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
)
|
|
else
|
|
TextField(
|
|
controller: widget.controller,
|
|
minLines: 10,
|
|
maxLines: 16,
|
|
keyboardType: TextInputType.multiline,
|
|
decoration: const InputDecoration(
|
|
hintText: 'Tulis expertise...',
|
|
border: OutlineInputBorder(),
|
|
alignLabelWithHint: true,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _wrapSelection(String open, String close) {
|
|
final selection = widget.controller.selection;
|
|
final text = widget.controller.text;
|
|
if (!selection.isValid || selection.isCollapsed) {
|
|
_insertText('$open$close', cursorOffset: open.length);
|
|
return;
|
|
}
|
|
|
|
final selectedText = text.substring(selection.start, selection.end);
|
|
final replacement = '$open$selectedText$close';
|
|
widget.controller.value = TextEditingValue(
|
|
text: text.replaceRange(selection.start, selection.end, replacement),
|
|
selection: TextSelection.collapsed(
|
|
offset: selection.start + replacement.length,
|
|
),
|
|
);
|
|
}
|
|
|
|
void _insertText(String value, {int? cursorOffset}) {
|
|
final selection = widget.controller.selection;
|
|
final text = widget.controller.text;
|
|
final start = selection.isValid ? selection.start : text.length;
|
|
final end = selection.isValid ? selection.end : text.length;
|
|
widget.controller.value = TextEditingValue(
|
|
text: text.replaceRange(start, end, value),
|
|
selection: TextSelection.collapsed(
|
|
offset: start + (cursorOffset ?? value.length),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _insertBulletList() {
|
|
final selection = widget.controller.selection;
|
|
final text = widget.controller.text;
|
|
if (selection.isValid && !selection.isCollapsed) {
|
|
final selectedText = text.substring(selection.start, selection.end);
|
|
final items = selectedText
|
|
.split('\n')
|
|
.where((line) => line.trim().isNotEmpty)
|
|
.map((line) => '<li>${line.trim()}</li>')
|
|
.join();
|
|
final replacement = '<ul>$items</ul>';
|
|
widget.controller.value = TextEditingValue(
|
|
text: text.replaceRange(selection.start, selection.end, replacement),
|
|
selection: TextSelection.collapsed(
|
|
offset: selection.start + replacement.length,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
_insertText('<ul><li></li></ul>', cursorOffset: '<ul><li>'.length);
|
|
}
|
|
|
|
void _clearTags() {
|
|
final clean = _plainPreview(widget.controller.text);
|
|
widget.controller.value = TextEditingValue(
|
|
text: clean,
|
|
selection: TextSelection.collapsed(offset: clean.length),
|
|
);
|
|
}
|
|
|
|
String _plainPreview(String value) {
|
|
return decodeHtmlEntities(
|
|
value
|
|
.replaceAll(RegExp(r'<\s*br\s*/?\s*>', caseSensitive: false), '\n')
|
|
.replaceAll(RegExp(r'</\s*p\s*>', caseSensitive: false), '\n\n')
|
|
.replaceAll(RegExp(r'<\s*li\s*>', caseSensitive: false), '• ')
|
|
.replaceAll(RegExp(r'</\s*li\s*>', caseSensitive: false), '\n')
|
|
.replaceAll(RegExp(r'<[^>]*>'), ''),
|
|
).trim();
|
|
}
|
|
}
|
|
|
|
class _EditorToolbar extends StatelessWidget {
|
|
const _EditorToolbar({
|
|
required this.onBold,
|
|
required this.onItalic,
|
|
required this.onUnderline,
|
|
required this.onParagraph,
|
|
required this.onBullet,
|
|
required this.onBreak,
|
|
required this.onClear,
|
|
});
|
|
|
|
final VoidCallback onBold;
|
|
final VoidCallback onItalic;
|
|
final VoidCallback onUnderline;
|
|
final VoidCallback onParagraph;
|
|
final VoidCallback onBullet;
|
|
final VoidCallback onBreak;
|
|
final VoidCallback onClear;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Wrap(
|
|
spacing: 6,
|
|
runSpacing: 6,
|
|
children: [
|
|
_tool(Icons.format_bold, 'Bold', onBold),
|
|
_tool(Icons.format_italic, 'Italic', onItalic),
|
|
_tool(Icons.format_underlined, 'Underline', onUnderline),
|
|
_tool(Icons.notes, 'Paragraph', onParagraph),
|
|
_tool(Icons.format_list_bulleted, 'Bullet', onBullet),
|
|
_tool(Icons.keyboard_return, 'Line break', onBreak),
|
|
_tool(Icons.format_clear, 'Clear', onClear),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _tool(IconData icon, String tooltip, VoidCallback onPressed) {
|
|
return IconButton.outlined(
|
|
tooltip: tooltip,
|
|
onPressed: onPressed,
|
|
icon: Icon(icon),
|
|
visualDensity: VisualDensity.compact,
|
|
);
|
|
}
|
|
}
|
|
|
|
class AntibioticSensitivityPanel extends StatefulWidget {
|
|
const AntibioticSensitivityPanel({
|
|
super.key,
|
|
required this.textControllers,
|
|
required this.selectValues,
|
|
required this.onChanged,
|
|
});
|
|
|
|
final Map<String, TextEditingController> textControllers;
|
|
final Map<String, String> selectValues;
|
|
final VoidCallback onChanged;
|
|
|
|
@override
|
|
State<AntibioticSensitivityPanel> createState() =>
|
|
_AntibioticSensitivityPanelState();
|
|
}
|
|
|
|
class _AntibioticSensitivityPanelState
|
|
extends State<AntibioticSensitivityPanel> {
|
|
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
|
|
: <String, dynamic>{};
|
|
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<String, dynamic> row, String key) {
|
|
final value = row[key]?.toString() ?? '';
|
|
return value.isEmpty ? '-' : decodeHtmlEntities(value);
|
|
}
|
|
|
|
List<Map<String, dynamic>> _rows(_AntibioticMedia media) {
|
|
final raw = widget.textControllers[media.rowsField]?.text ?? '';
|
|
if (raw.trim().isEmpty) {
|
|
return <Map<String, dynamic>>[];
|
|
}
|
|
try {
|
|
final decoded = jsonDecode(raw);
|
|
if (decoded is List) {
|
|
return decoded.map((item) => asMap(item)).toList();
|
|
}
|
|
} catch (_) {
|
|
return <Map<String, dynamic>>[];
|
|
}
|
|
return <Map<String, dynamic>>[];
|
|
}
|
|
|
|
void _saveRows(_AntibioticMedia media, List<Map<String, dynamic>> 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<void> _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<void> _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<void> _openEditor(_AntibioticMedia media, {int? index}) async {
|
|
final rows = _rows(media);
|
|
final current = index == null ? <String, dynamic>{} : rows[index];
|
|
final controllers = <String, TextEditingController>{
|
|
'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<void>(
|
|
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<String, dynamic> 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<String>(
|
|
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 ToolDataPanel extends StatefulWidget {
|
|
const ToolDataPanel({
|
|
super.key,
|
|
required this.textControllers,
|
|
required this.selectValues,
|
|
required this.onChanged,
|
|
});
|
|
|
|
final Map<String, TextEditingController> textControllers;
|
|
final Map<String, String> selectValues;
|
|
final VoidCallback onChanged;
|
|
|
|
@override
|
|
State<ToolDataPanel> createState() => _ToolDataPanelState();
|
|
}
|
|
|
|
class _ToolDataPanelState extends State<ToolDataPanel> {
|
|
int _selectedTab = 0;
|
|
|
|
static const List<_ToolTabConfig> _tabs = [
|
|
_ToolTabConfig(
|
|
label: 'Vitek',
|
|
tableId: 'tblvitek',
|
|
rowField: 'vitek_antibiotic_rows',
|
|
bacteriaField: 'bakteri',
|
|
sirField: 'bakterisir',
|
|
colonyField: 'id_bakterihitungkol',
|
|
colonyTextField: 'id_bakterihitungkolteks',
|
|
printField: 'id_baktericetak',
|
|
automatic: true,
|
|
),
|
|
_ToolTabConfig(
|
|
label: 'Malditof',
|
|
tableId: 'tblkumanmanual1',
|
|
rowField: 'malditof_antibiotic_rows',
|
|
bacteriaField: 'id_bakteri01',
|
|
antibioticSetField: 'id_antibiotikmanual1',
|
|
sirField: 'id_bakterisir01',
|
|
colonyField: 'id_bakterihitungkol01',
|
|
colonyTextField: 'id_bakterihitungkolteks01',
|
|
printField: 'id_bakteri01cetak',
|
|
),
|
|
_ToolTabConfig(
|
|
label: 'Manual',
|
|
tableId: 'tblkumanmanual2',
|
|
rowField: 'manual_antibiotic_rows',
|
|
bacteriaField: 'id_bakteri02',
|
|
antibioticSetField: 'id_antibiotikmanual2',
|
|
sirField: 'id_bakterisir02',
|
|
colonyField: 'id_bakterihitungkol02',
|
|
colonyTextField: 'id_bakterihitungkolteks02',
|
|
printField: 'id_bakteri02cetak',
|
|
),
|
|
];
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final tab = _tabs[_selectedTab];
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Data Alat',
|
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
fontWeight: FontWeight.w800,
|
|
color: const Color(0xFF0F766E),
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
_tabBar(),
|
|
const SizedBox(height: 12),
|
|
_toolForm(tab),
|
|
const SizedBox(height: 12),
|
|
_toolButtons(tab),
|
|
const SizedBox(height: 12),
|
|
_antibioticTable(tab),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _tabBar() {
|
|
return SizedBox(
|
|
height: 54,
|
|
child: ListView.separated(
|
|
scrollDirection: Axis.horizontal,
|
|
itemCount: _tabs.length,
|
|
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
|
itemBuilder: (context, index) {
|
|
final tab = _tabs[index];
|
|
final selected = index == _selectedTab;
|
|
return ChoiceChip(
|
|
selected: selected,
|
|
showCheckmark: false,
|
|
avatar: Icon(
|
|
index == 0
|
|
? Icons.memory_outlined
|
|
: index == 1
|
|
? Icons.biotech_outlined
|
|
: Icons.edit_note_outlined,
|
|
size: 18,
|
|
color: selected ? const Color(0xFF0F766E) : Colors.black54,
|
|
),
|
|
label: Text(tab.label),
|
|
onSelected: (_) => setState(() => _selectedTab = index),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _toolForm(_ToolTabConfig tab) {
|
|
final rows = _rows(tab);
|
|
return Card(
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
tab.label,
|
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
),
|
|
BadgeLabel(
|
|
text: '${rows.length} antibiotik',
|
|
color: const Color(0xFF0F766E),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
if (tab.automatic)
|
|
_readonlyBacteria(tab)
|
|
else
|
|
_bacteriaAutocomplete(tab),
|
|
if (tab.antibioticSetField != null)
|
|
_selectText(
|
|
tab.antibioticSetField!,
|
|
'Set Antibiotik',
|
|
_antibioticSetOptions,
|
|
onChanged: () => _autoGenerate(tab),
|
|
),
|
|
_selectText(
|
|
tab.sirField,
|
|
'Resistensi',
|
|
_sirOptions,
|
|
onChanged: () => _autoGenerate(tab),
|
|
),
|
|
_selectText(tab.colonyField, 'Hitung Koloni', _colonyOptions),
|
|
_text(tab.colonyTextField, 'Hitung Koloni Lainnya'),
|
|
_selectText(tab.printField, 'Cetak', const ['YA', 'TIDAK']),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _readonlyBacteria(_ToolTabConfig tab) {
|
|
final controller = widget.textControllers[tab.bacteriaField] ??=
|
|
TextEditingController();
|
|
final text = controller.text.trim();
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: TextField(
|
|
controller: controller,
|
|
readOnly: true,
|
|
decoration: InputDecoration(
|
|
labelText: 'Kuman',
|
|
helperText: text.isEmpty
|
|
? 'Readonly dari hasil alat Vitek'
|
|
: 'Readonly dari hasil alat Vitek',
|
|
border: const OutlineInputBorder(),
|
|
prefixIcon: const Icon(Icons.lock_outline),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _bacteriaAutocomplete(_ToolTabConfig tab) {
|
|
final controller = widget.textControllers[tab.bacteriaField] ??=
|
|
TextEditingController();
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: Autocomplete<String>(
|
|
initialValue: TextEditingValue(text: controller.text),
|
|
optionsBuilder: (value) {
|
|
final query = value.text.trim().toLowerCase();
|
|
if (query.isEmpty) {
|
|
return _bacteriaOptions;
|
|
}
|
|
return _bacteriaOptions.where(
|
|
(option) => option.toLowerCase().contains(query),
|
|
);
|
|
},
|
|
onSelected: (value) {
|
|
controller.text = value;
|
|
_autoGenerate(tab);
|
|
},
|
|
fieldViewBuilder:
|
|
(context, fieldController, focusNode, onFieldSubmitted) {
|
|
if (fieldController.text != controller.text) {
|
|
fieldController.text = controller.text;
|
|
}
|
|
return TextField(
|
|
controller: fieldController,
|
|
focusNode: focusNode,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Kuman',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
onChanged: (value) {
|
|
controller.text = value;
|
|
widget.onChanged();
|
|
},
|
|
onSubmitted: (_) => _autoGenerate(tab),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _toolButtons(_ToolTabConfig tab) {
|
|
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: () => _generateRows(tab, replace: true),
|
|
icon: const Icon(Icons.table_rows_outlined),
|
|
label: Text(
|
|
tab.automatic ? 'Muat Tabel Otomatis' : 'Generate Tabel',
|
|
),
|
|
),
|
|
OutlinedButton.icon(
|
|
onPressed: () => _setAllPrint(tab, printRow: true, printMic: true),
|
|
icon: const Icon(Icons.check_circle_outline),
|
|
label: const Text('Print Semua'),
|
|
),
|
|
OutlinedButton.icon(
|
|
onPressed: () =>
|
|
_setAllPrint(tab, printRow: false, printMic: false),
|
|
icon: const Icon(Icons.block_outlined),
|
|
label: const Text('Unprint Semua'),
|
|
),
|
|
OutlinedButton.icon(
|
|
onPressed: () => _clearRows(tab),
|
|
icon: const Icon(Icons.delete_sweep_outlined),
|
|
label: const Text('Reset Tabel'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _antibioticTable(_ToolTabConfig tab) {
|
|
final rows = _rows(tab);
|
|
if (rows.isEmpty) {
|
|
return EmptyPanel(
|
|
text: tab.automatic
|
|
? 'Belum ada tabel antibiotik otomatis dari Vitek.'
|
|
: 'Pilih kuman dan resistensi untuk membuat tabel antibiotik.',
|
|
);
|
|
}
|
|
|
|
return Card(
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
|
|
child: Text(
|
|
tab.tableId,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.labelMedium?.copyWith(color: Colors.black54),
|
|
),
|
|
),
|
|
SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: DataTable(
|
|
headingRowHeight: 40,
|
|
dataRowMinHeight: 60,
|
|
dataRowMaxHeight: 76,
|
|
columns: [
|
|
const DataColumn(label: Text('#')),
|
|
const DataColumn(label: Text('Print')),
|
|
const DataColumn(label: Text('Antibiotik')),
|
|
const DataColumn(label: Text('Value')),
|
|
const DataColumn(label: Text('Interpretation')),
|
|
if (!tab.automatic) ...[
|
|
const DataColumn(label: Text('S')),
|
|
const DataColumn(label: Text('I')),
|
|
const DataColumn(label: Text('R')),
|
|
],
|
|
const DataColumn(label: Text('Print Value')),
|
|
const DataColumn(label: Text('Delete')),
|
|
],
|
|
rows: [
|
|
for (var i = 0; i < rows.length; i += 1)
|
|
DataRow(
|
|
cells: [
|
|
DataCell(Text('${i + 1}')),
|
|
DataCell(
|
|
IconButton(
|
|
tooltip: rows[i]['printrow'] == true
|
|
? 'Unprint row'
|
|
: 'Print row',
|
|
onPressed: () => _toggleBool(tab, i, 'printrow'),
|
|
icon: Icon(
|
|
rows[i]['printrow'] == true
|
|
? Icons.check_circle
|
|
: Icons.block,
|
|
color: rows[i]['printrow'] == true
|
|
? const Color(0xFF15803D)
|
|
: const Color(0xFFDC2626),
|
|
),
|
|
),
|
|
),
|
|
DataCell(
|
|
SizedBox(
|
|
width: 150,
|
|
child: Text(
|
|
_cell(rows[i], 'antibiotic'),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
),
|
|
DataCell(
|
|
SizedBox(
|
|
width: 104,
|
|
child: TextFormField(
|
|
initialValue: _cell(rows[i], 'value'),
|
|
decoration: const InputDecoration(
|
|
isDense: true,
|
|
border: OutlineInputBorder(),
|
|
),
|
|
onChanged: (value) =>
|
|
_updateRow(tab, i, 'value', value),
|
|
),
|
|
),
|
|
),
|
|
DataCell(
|
|
SizedBox(
|
|
width: 118,
|
|
child: DropdownButtonFormField<String>(
|
|
initialValue:
|
|
_interpretationOptions.contains(
|
|
rows[i]['interpretation'],
|
|
)
|
|
? rows[i]['interpretation'].toString()
|
|
: '',
|
|
isExpanded: true,
|
|
decoration: const InputDecoration(
|
|
isDense: true,
|
|
border: OutlineInputBorder(),
|
|
),
|
|
items: _interpretationOptions
|
|
.map(
|
|
(option) => DropdownMenuItem(
|
|
value: option,
|
|
child: Text(
|
|
option.isEmpty ? 'null' : option,
|
|
),
|
|
),
|
|
)
|
|
.toList(),
|
|
onChanged: (value) => _updateRow(
|
|
tab,
|
|
i,
|
|
'interpretation',
|
|
value ?? '',
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (!tab.automatic) ...[
|
|
DataCell(Text(_cell(rows[i], 'batasatas'))),
|
|
DataCell(Text(_cell(rows[i], 'midrange'))),
|
|
DataCell(Text(_cell(rows[i], 'batasbawah'))),
|
|
],
|
|
DataCell(
|
|
IconButton(
|
|
tooltip: rows[i]['printcol'] == true
|
|
? 'Unprint value'
|
|
: 'Print value',
|
|
onPressed: () => _toggleBool(tab, i, 'printcol'),
|
|
icon: Icon(
|
|
rows[i]['printcol'] == true
|
|
? Icons.check_circle
|
|
: Icons.block,
|
|
color: rows[i]['printcol'] == true
|
|
? const Color(0xFF15803D)
|
|
: const Color(0xFFDC2626),
|
|
),
|
|
),
|
|
),
|
|
DataCell(
|
|
IconButton(
|
|
tooltip: 'Delete MIC',
|
|
color: const Color(0xFFDC2626),
|
|
onPressed: () => _deleteRow(tab, i),
|
|
icon: const Icon(Icons.delete_outline),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _selectText(
|
|
String name,
|
|
String label,
|
|
List<String> options, {
|
|
VoidCallback? onChanged,
|
|
}) {
|
|
final current = widget.selectValues[name] ?? '';
|
|
final effectiveOptions = [
|
|
if (current.isNotEmpty && !options.contains(current)) current,
|
|
...options,
|
|
];
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: DropdownButtonFormField<String>(
|
|
initialValue: 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(decodeHtmlEntities(option)),
|
|
),
|
|
)
|
|
.toList(),
|
|
onChanged: (value) {
|
|
widget.selectValues[name] = value ?? '';
|
|
widget.onChanged();
|
|
onChanged?.call();
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _text(String name, String label) {
|
|
final controller = widget.textControllers[name] ??= TextEditingController();
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: TextField(
|
|
controller: controller,
|
|
decoration: InputDecoration(
|
|
labelText: label,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
onChanged: (_) => widget.onChanged(),
|
|
),
|
|
);
|
|
}
|
|
|
|
List<Map<String, dynamic>> _rows(_ToolTabConfig tab) {
|
|
final raw = widget.textControllers[tab.rowField]?.text ?? '';
|
|
if (raw.trim().isEmpty) {
|
|
return <Map<String, dynamic>>[];
|
|
}
|
|
try {
|
|
final decoded = jsonDecode(raw);
|
|
if (decoded is List) {
|
|
return decoded.map((item) => asMap(item)).toList();
|
|
}
|
|
} catch (_) {
|
|
return <Map<String, dynamic>>[];
|
|
}
|
|
return <Map<String, dynamic>>[];
|
|
}
|
|
|
|
void _saveRows(_ToolTabConfig tab, List<Map<String, dynamic>> rows) {
|
|
widget.textControllers[tab.rowField] ??= TextEditingController();
|
|
widget.textControllers[tab.rowField]!.text = jsonEncode(rows);
|
|
widget.onChanged();
|
|
}
|
|
|
|
void _autoGenerate(_ToolTabConfig tab) {
|
|
if (tab.automatic) {
|
|
return;
|
|
}
|
|
final bacteria =
|
|
widget.textControllers[tab.bacteriaField]?.text.trim() ?? '';
|
|
final sir = widget.selectValues[tab.sirField]?.trim() ?? '';
|
|
if (bacteria.isEmpty || sir.isEmpty) {
|
|
return;
|
|
}
|
|
_generateRows(tab, replace: true);
|
|
}
|
|
|
|
void _generateRows(_ToolTabConfig tab, {required bool replace}) {
|
|
final rows = replace ? <Map<String, dynamic>>[] : _rows(tab);
|
|
final bacteria =
|
|
widget.textControllers[tab.bacteriaField]?.text.trim() ?? '';
|
|
final resistance = widget.selectValues[tab.sirField]?.trim() ?? '';
|
|
final setName = tab.antibioticSetField == null
|
|
? 'otomatis'
|
|
: widget.selectValues[tab.antibioticSetField!]?.trim() ?? 'default';
|
|
final antibiotics = _antibioticsFor(tab, bacteria, resistance, setName);
|
|
|
|
for (var i = 0; i < antibiotics.length; i += 1) {
|
|
final antibiotic = antibiotics[i];
|
|
rows.add({
|
|
'id': '${tab.rowField}_${DateTime.now().microsecondsSinceEpoch}_$i',
|
|
'antibiotic': antibiotic,
|
|
'value': '',
|
|
'interpretation': '',
|
|
'batasatas': 'S',
|
|
'midrange': 'I',
|
|
'batasbawah': 'R',
|
|
'printrow': true,
|
|
'printcol': true,
|
|
'bacteria': bacteria,
|
|
'resistance': resistance,
|
|
'set': setName,
|
|
});
|
|
}
|
|
_saveRows(tab, rows);
|
|
setState(() {});
|
|
}
|
|
|
|
List<String> _antibioticsFor(
|
|
_ToolTabConfig tab,
|
|
String bacteria,
|
|
String resistance,
|
|
String setName,
|
|
) {
|
|
final base = tab.automatic
|
|
? _vitekAntibiotics
|
|
: bacteria.toLowerCase().contains('candida') ||
|
|
bacteria.toLowerCase().contains('yeast') ||
|
|
bacteria.toLowerCase().contains('jamur')
|
|
? _fungalAntibiotics
|
|
: _manualAntibiotics;
|
|
if (resistance.contains('Carbapenem')) {
|
|
return [...base, 'ETP', 'DOR', 'IPM'];
|
|
}
|
|
if (resistance == 'MRSA') {
|
|
return ['FOX', 'OXA', 'VAN', 'LZD', 'CLI', 'ERY', ...base.take(5)];
|
|
}
|
|
if (setName.toLowerCase() != 'default') {
|
|
return [setName, ...base.where((item) => item != setName)];
|
|
}
|
|
return base;
|
|
}
|
|
|
|
void _setAllPrint(
|
|
_ToolTabConfig tab, {
|
|
required bool printRow,
|
|
required bool printMic,
|
|
}) {
|
|
final rows = _rows(tab)
|
|
.map((row) => {...row, 'printrow': printRow, 'printcol': printMic})
|
|
.toList();
|
|
_saveRows(tab, rows);
|
|
setState(() {});
|
|
}
|
|
|
|
void _clearRows(_ToolTabConfig tab) {
|
|
_saveRows(tab, <Map<String, dynamic>>[]);
|
|
setState(() {});
|
|
}
|
|
|
|
void _toggleBool(_ToolTabConfig tab, int index, String key) {
|
|
final rows = _rows(tab);
|
|
if (index < 0 || index >= rows.length) {
|
|
return;
|
|
}
|
|
rows[index][key] = rows[index][key] != true;
|
|
_saveRows(tab, rows);
|
|
setState(() {});
|
|
}
|
|
|
|
void _updateRow(_ToolTabConfig tab, int index, String key, String value) {
|
|
final rows = _rows(tab);
|
|
if (index < 0 || index >= rows.length) {
|
|
return;
|
|
}
|
|
rows[index][key] = value;
|
|
_saveRows(tab, rows);
|
|
}
|
|
|
|
void _deleteRow(_ToolTabConfig tab, int index) {
|
|
final rows = _rows(tab);
|
|
if (index < 0 || index >= rows.length) {
|
|
return;
|
|
}
|
|
rows.removeAt(index);
|
|
_saveRows(tab, rows);
|
|
setState(() {});
|
|
}
|
|
|
|
String _cell(Map<String, dynamic> row, String key) {
|
|
final value = row[key]?.toString() ?? '';
|
|
return value.isEmpty ? '-' : decodeHtmlEntities(value);
|
|
}
|
|
}
|
|
|
|
class _ToolTabConfig {
|
|
const _ToolTabConfig({
|
|
required this.label,
|
|
required this.tableId,
|
|
required this.rowField,
|
|
required this.bacteriaField,
|
|
required this.sirField,
|
|
required this.colonyField,
|
|
required this.colonyTextField,
|
|
required this.printField,
|
|
this.antibioticSetField,
|
|
this.automatic = false,
|
|
});
|
|
|
|
final String label;
|
|
final String tableId;
|
|
final String rowField;
|
|
final String bacteriaField;
|
|
final String sirField;
|
|
final String colonyField;
|
|
final String colonyTextField;
|
|
final String printField;
|
|
final String? antibioticSetField;
|
|
final bool automatic;
|
|
}
|
|
|
|
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<String> options;
|
|
final bool multiline;
|
|
}
|
|
|
|
class _AntibioticColumn {
|
|
const _AntibioticColumn(this.key, this.label);
|
|
|
|
final String key;
|
|
final String label;
|
|
}
|
|
|
|
const List<String> _kumanOptions = [
|
|
'Kuman 1',
|
|
'Kuman 2',
|
|
'Kuman 3',
|
|
'Kuman 4',
|
|
'Kuman 5',
|
|
];
|
|
|
|
const List<String> _bacteriaOptions = [
|
|
'Kuman 1',
|
|
'Kuman 2',
|
|
'Kuman 3',
|
|
'Kuman 4',
|
|
'Kuman 5',
|
|
'Escherichia coli',
|
|
'Klebsiella pneumoniae',
|
|
'Pseudomonas aeruginosa',
|
|
'Acinetobacter baumannii',
|
|
'Staphylococcus aureus',
|
|
'Staphylococcus epidermidis',
|
|
'Enterococcus faecalis',
|
|
'Enterococcus faecium',
|
|
'Streptococcus pneumoniae',
|
|
'Candida albicans',
|
|
'Candida tropicalis',
|
|
'Candida glabrata',
|
|
];
|
|
|
|
const List<String> _antibioticSetOptions = [
|
|
'default',
|
|
'Gram Positive',
|
|
'Gram Negative',
|
|
'Urine',
|
|
'Blood',
|
|
'Respiratory',
|
|
'Candida',
|
|
];
|
|
|
|
const List<String> _interpretationOptions = [
|
|
'',
|
|
'S',
|
|
'I',
|
|
'R',
|
|
'Invalid',
|
|
'No Result',
|
|
'Error',
|
|
'SDD',
|
|
];
|
|
|
|
const List<String> _vitekAntibiotics = [
|
|
'AMK',
|
|
'AMP',
|
|
'AMC',
|
|
'CAZ',
|
|
'CIP',
|
|
'CTX',
|
|
'CRO',
|
|
'FEP',
|
|
'GEN',
|
|
'LEV',
|
|
'MEM',
|
|
'SXT',
|
|
];
|
|
|
|
const List<String> _manualAntibiotics = [
|
|
'AMP',
|
|
'AMC',
|
|
'SAM',
|
|
'TZP',
|
|
'CAZ',
|
|
'CTX',
|
|
'CRO',
|
|
'FEP',
|
|
'CIP',
|
|
'LEV',
|
|
'GEN',
|
|
'AMK',
|
|
'MEM',
|
|
'SXT',
|
|
];
|
|
|
|
const List<String> _fungalAntibiotics = [
|
|
'Fluconazole',
|
|
'Voriconazole',
|
|
'Itraconazole',
|
|
'Amphotericin B',
|
|
'Caspofungin',
|
|
'Micafungin',
|
|
];
|
|
|
|
const List<String> _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<String> _hemolisaOptions = ['Alpha', 'Beta', 'Gamma'];
|
|
const List<String> _posNegOptions = ['POS', 'NEG'];
|
|
const List<String> _yesNoOptions = ['YA', 'TIDAK'];
|
|
|
|
const List<String> _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<String> _fungalStatusOptions = [
|
|
'Tidak Lanjut Identifikasi',
|
|
'PATOGEN (Lanjut Identifikasi Vitek)',
|
|
'PATOGEN (Lanjut Identifikasi Manual)',
|
|
'Subkultur',
|
|
];
|
|
|
|
const List<String> _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<String> 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',
|
|
];
|
|
|
|
const List<String> kToolAntibioticRowFields = [
|
|
'vitek_antibiotic_rows',
|
|
'malditof_antibiotic_rows',
|
|
'manual_antibiotic_rows',
|
|
];
|
|
|
|
class ExpertiseForm extends StatelessWidget {
|
|
const ExpertiseForm({
|
|
super.key,
|
|
required this.dlp,
|
|
required this.fields,
|
|
required this.textControllers,
|
|
required this.selectValues,
|
|
required this.multiValues,
|
|
required this.onChanged,
|
|
});
|
|
|
|
final String dlp;
|
|
final List<dynamic> fields;
|
|
final Map<String, TextEditingController> textControllers;
|
|
final Map<String, String> selectValues;
|
|
final Map<String, Set<String>> multiValues;
|
|
final VoidCallback onChanged;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Card(
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(14),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
_templateTitle(dlp),
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w800),
|
|
),
|
|
const SizedBox(height: 12),
|
|
...fields.map((rawField) => _buildField(context, asMap(rawField))),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildField(BuildContext context, Map<String, dynamic> field) {
|
|
final name = field['name']?.toString() ?? '';
|
|
final label = field['label']?.toString() ?? name;
|
|
final type = field['type']?.toString() ?? 'text';
|
|
final options = asList(
|
|
field['options'],
|
|
).map((item) => item.toString()).toList();
|
|
|
|
if (type == 'select') {
|
|
final currentValue = options.contains(selectValues[name])
|
|
? selectValues[name]
|
|
: null;
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: DropdownButtonFormField<String>(
|
|
initialValue: currentValue,
|
|
isExpanded: true,
|
|
decoration: InputDecoration(
|
|
labelText: label,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
items: options
|
|
.map(
|
|
(option) =>
|
|
DropdownMenuItem(value: option, child: Text(option)),
|
|
)
|
|
.toList(),
|
|
onChanged: (value) {
|
|
selectValues[name] = value ?? '';
|
|
onChanged();
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
if (type == 'multiselect') {
|
|
final selected = multiValues[name] ?? <String>{};
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: CompactMultiSelectField(
|
|
label: label,
|
|
options: options,
|
|
selected: selected,
|
|
onChanged: (value) {
|
|
multiValues[name] = value;
|
|
onChanged();
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
final controller = textControllers[name] ??= TextEditingController();
|
|
if (name == 'keterangan') {
|
|
return ExpertiseHtmlEditor(controller: controller, label: label);
|
|
}
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: TextField(
|
|
controller: controller,
|
|
minLines: type == 'textarea' ? 4 : 1,
|
|
maxLines: type == 'textarea' ? 8 : 1,
|
|
decoration: InputDecoration(
|
|
labelText: label,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class ExpertiseActions extends StatelessWidget {
|
|
const ExpertiseActions({
|
|
super.key,
|
|
required this.isSupervisor,
|
|
required this.saving,
|
|
required this.onSave,
|
|
});
|
|
|
|
final bool isSupervisor;
|
|
final bool saving;
|
|
final ValueChanged<String> onSave;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Card(
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(14),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
FilledButton.icon(
|
|
onPressed: saving ? null : () => onSave('Draft'),
|
|
icon: saving
|
|
? const SizedBox.square(
|
|
dimension: 18,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.save_outlined),
|
|
label: const Text('Save as Draft'),
|
|
),
|
|
if (isSupervisor) ...[
|
|
OutlinedButton.icon(
|
|
onPressed: saving ? null : () => onSave('preliminary'),
|
|
icon: const Icon(Icons.done_all_outlined),
|
|
label: const Text('Save Preliminary result'),
|
|
),
|
|
OutlinedButton.icon(
|
|
onPressed: saving ? null : () => onSave('verifikasi'),
|
|
icon: const Icon(Icons.verified_outlined),
|
|
label: const Text('Save Final Result'),
|
|
),
|
|
] else ...[
|
|
OutlinedButton.icon(
|
|
onPressed: saving
|
|
? null
|
|
: () => onSave('Permohonan Verifikasi'),
|
|
icon: const Icon(Icons.send_outlined),
|
|
label: const Text('Save and Send To SPV'),
|
|
),
|
|
OutlinedButton.icon(
|
|
onPressed: saving
|
|
? null
|
|
: () => onSave('Permohonan Verifikasi Preliminary'),
|
|
icon: const Icon(Icons.outgoing_mail),
|
|
label: const Text('Kirim SPV Preliminary'),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|