1346 lines
42 KiB
Dart
1346 lines
42 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 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 _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> _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',
|
|
];
|
|
|
|
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();
|
|
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'),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|