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

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