568 lines
18 KiB
Dart
568 lines
18 KiB
Dart
part of '../../app/app.dart';
|
|
|
|
class CriticalValuesScreen extends StatefulWidget {
|
|
const CriticalValuesScreen({
|
|
super.key,
|
|
required this.token,
|
|
required this.baseUrl,
|
|
});
|
|
|
|
final String token;
|
|
final String baseUrl;
|
|
|
|
@override
|
|
State<CriticalValuesScreen> createState() => _CriticalValuesScreenState();
|
|
}
|
|
|
|
class _CriticalValuesScreenState extends State<CriticalValuesScreen> {
|
|
late final ApiClient _api;
|
|
late Future<Map<String, dynamic>> _future;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_api = ApiClient(baseUrl: widget.baseUrl, token: widget.token);
|
|
_future = _load();
|
|
}
|
|
|
|
Future<Map<String, dynamic>> _load() {
|
|
return _api.get('api/mobile/critical-values');
|
|
}
|
|
|
|
void _reload() {
|
|
setState(() {
|
|
_future = _load();
|
|
});
|
|
}
|
|
|
|
Future<void> _markRead(Map<String, dynamic> item) async {
|
|
final method = ValueNotifier<String>('');
|
|
final recipient = TextEditingController();
|
|
final reason = TextEditingController();
|
|
|
|
await showModalBottomSheet<void>(
|
|
context: context,
|
|
showDragHandle: true,
|
|
isScrollControlled: true,
|
|
builder: (context) => SafeArea(
|
|
child: Padding(
|
|
padding: EdgeInsets.fromLTRB(
|
|
18,
|
|
0,
|
|
18,
|
|
MediaQuery.viewInsetsOf(context).bottom + 18,
|
|
),
|
|
child: SingleChildScrollView(
|
|
child: ValueListenableBuilder<String>(
|
|
valueListenable: method,
|
|
builder: (context, value, _) => Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Text(
|
|
'Tindak Lanjut Nilai Kritis',
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w900,
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
'${item['nofoto'] ?? '-'} • ${item['nmpasien'] ?? '-'}',
|
|
style: const TextStyle(color: Colors.black54),
|
|
),
|
|
const SizedBox(height: 14),
|
|
DropdownButtonFormField<String>(
|
|
initialValue: value.isEmpty ? null : value,
|
|
isExpanded: true,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Melalui',
|
|
helperText: 'Kosongkan jika belum dilaporkan',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
items: const [
|
|
DropdownMenuItem(value: 'Telpon', child: Text('Telpon')),
|
|
DropdownMenuItem(
|
|
value: 'Whatshap',
|
|
child: Text('Whatshap'),
|
|
),
|
|
DropdownMenuItem(value: 'Email', child: Text('Email')),
|
|
DropdownMenuItem(
|
|
value: 'Ketemu di Jalan',
|
|
child: Text('Ketemu di Jalan'),
|
|
),
|
|
],
|
|
onChanged: (next) => method.value = next ?? '',
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: recipient,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Penerima Laporan',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: reason,
|
|
minLines: 2,
|
|
maxLines: 4,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Alasan belum dilaporkan/Pasien meninggal',
|
|
hintText: 'Meninggal / Pindah Rumah Sakit / dll',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
const SizedBox(height: 14),
|
|
FilledButton.icon(
|
|
onPressed: () async {
|
|
try {
|
|
await _api.post(
|
|
'api/mobile/critical-values/${item['id']}/mark-read',
|
|
{
|
|
'caratindaklanjut': method.value,
|
|
'penerima_laporan': recipient.text.trim(),
|
|
'alasan_belum_laporkan': reason.text.trim(),
|
|
},
|
|
);
|
|
if (!context.mounted) return;
|
|
Navigator.of(context).pop();
|
|
_reload();
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text(
|
|
'Sample nilai kritis sudah ditindaklanjuti.',
|
|
),
|
|
),
|
|
);
|
|
} on ApiException catch (error) {
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(error.message)));
|
|
}
|
|
},
|
|
icon: const Icon(Icons.done_all_outlined),
|
|
label: const Text('Mark as Read'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
recipient.dispose();
|
|
reason.dispose();
|
|
method.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return DefaultTabController(
|
|
length: 2,
|
|
child: Scaffold(
|
|
body: FutureBuilder<Map<String, dynamic>>(
|
|
future: _future,
|
|
builder: (context, snapshot) {
|
|
if (snapshot.connectionState != ConnectionState.done) {
|
|
return const LoadingScreen();
|
|
}
|
|
if (snapshot.hasError) {
|
|
return ErrorView(
|
|
message: _message(snapshot.error),
|
|
onRetry: _reload,
|
|
);
|
|
}
|
|
final data = snapshot.data!;
|
|
final pending = asList(data['pending_items']);
|
|
final followed = asList(data['followed_items']);
|
|
return SafeArea(
|
|
child: Column(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(18, 18, 18, 0),
|
|
child: _CriticalValuesHeader(
|
|
pendingCount: pending.length,
|
|
onRefresh: _reload,
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(18, 22, 18, 0),
|
|
child: _CriticalValuesSummary(
|
|
pendingCount: pending.length,
|
|
followedCount: followed.length,
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(18, 18, 18, 0),
|
|
child: Container(
|
|
height: 48,
|
|
padding: const EdgeInsets.all(4),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFEFF6FF),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: const TabBar(
|
|
indicatorSize: TabBarIndicatorSize.tab,
|
|
dividerColor: Colors.transparent,
|
|
indicator: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.all(Radius.circular(9)),
|
|
),
|
|
labelColor: Color(0xFF0B7BFF),
|
|
unselectedLabelColor: Color(0xFF47517A),
|
|
labelStyle: TextStyle(fontWeight: FontWeight.w900),
|
|
tabs: [
|
|
Tab(text: 'Belum Dilaporkan'),
|
|
Tab(text: 'Riwayat'),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: TabBarView(
|
|
children: [
|
|
CriticalValueList(
|
|
items: pending,
|
|
emptyText:
|
|
'Tidak ada sample nilai kritis yang perlu dilaporkan.',
|
|
onMarkRead: _markRead,
|
|
),
|
|
CriticalValueList(
|
|
items: followed,
|
|
emptyText: 'Belum ada riwayat tindak lanjut.',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CriticalValuesHeader extends StatelessWidget {
|
|
const _CriticalValuesHeader({
|
|
required this.pendingCount,
|
|
required this.onRefresh,
|
|
});
|
|
|
|
final int pendingCount;
|
|
final VoidCallback onRefresh;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Row(
|
|
children: [
|
|
IconButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
icon: const Icon(Icons.arrow_back_ios_new_rounded),
|
|
color: const Color(0xFF080D3D),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Flexible(
|
|
child: Text(
|
|
'Nilai Kritis',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.headlineSmall
|
|
?.copyWith(
|
|
color: const Color(0xFF080D3D),
|
|
fontWeight: FontWeight.w900,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
BadgeLabel(
|
|
text: '$pendingCount',
|
|
color: const Color(0xFFFF1D25),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'Notifikasi & Tindak Lanjut',
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
color: const Color(0xFF47517A),
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
IconButton(
|
|
onPressed: onRefresh,
|
|
icon: const Icon(Icons.refresh_rounded),
|
|
color: const Color(0xFF063B60),
|
|
tooltip: 'Refresh',
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CriticalValuesSummary extends StatelessWidget {
|
|
const _CriticalValuesSummary({
|
|
required this.pendingCount,
|
|
required this.followedCount,
|
|
});
|
|
|
|
final int pendingCount;
|
|
final int followedCount;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(18),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
border: Border.all(color: const Color(0xFFE1E8F5)),
|
|
borderRadius: BorderRadius.circular(16),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: const Color(0xFF2563EB).withValues(alpha: 0.05),
|
|
blurRadius: 18,
|
|
offset: const Offset(0, 10),
|
|
),
|
|
],
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: _CriticalSummaryItem(
|
|
label: 'Belum Dilaporkan',
|
|
value: pendingCount,
|
|
color: const Color(0xFFFF1D25),
|
|
),
|
|
),
|
|
Container(width: 1, height: 48, color: const Color(0xFFE1E8F5)),
|
|
Expanded(
|
|
child: _CriticalSummaryItem(
|
|
label: 'Riwayat',
|
|
value: followedCount,
|
|
color: const Color(0xFF0B7BFF),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CriticalSummaryItem extends StatelessWidget {
|
|
const _CriticalSummaryItem({
|
|
required this.label,
|
|
required this.value,
|
|
required this.color,
|
|
});
|
|
|
|
final String label;
|
|
final int value;
|
|
final Color color;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
children: [
|
|
Text(
|
|
'$value',
|
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
|
color: color,
|
|
fontWeight: FontWeight.w900,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
label,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
color: Color(0xFF47517A),
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class CriticalValueList extends StatelessWidget {
|
|
const CriticalValueList({
|
|
super.key,
|
|
required this.items,
|
|
required this.emptyText,
|
|
this.onMarkRead,
|
|
});
|
|
|
|
final List<dynamic> items;
|
|
final String emptyText;
|
|
final ValueChanged<Map<String, dynamic>>? onMarkRead;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (items.isEmpty) {
|
|
return EmptyPanel(text: emptyText);
|
|
}
|
|
return RefreshIndicator(
|
|
onRefresh: () async {},
|
|
child: ListView.separated(
|
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 24),
|
|
itemCount: items.length,
|
|
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
|
itemBuilder: (context, index) {
|
|
final item = asMap(items[index]);
|
|
return CriticalValueCard(
|
|
item: item,
|
|
index: index,
|
|
onMarkRead: onMarkRead == null ? null : () => onMarkRead!(item),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class CriticalValueCard extends StatelessWidget {
|
|
const CriticalValueCard({
|
|
super.key,
|
|
required this.item,
|
|
required this.index,
|
|
this.onMarkRead,
|
|
});
|
|
|
|
final Map<String, dynamic> item;
|
|
final int index;
|
|
final VoidCallback? onMarkRead;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final followed = item['followed_up_at']?.toString().isNotEmpty == true;
|
|
return Card(
|
|
elevation: 0,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
side: const BorderSide(color: Color(0xFFE1E8F5)),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(14),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
CircleAvatar(
|
|
backgroundColor: const Color(
|
|
0xFFFF1D25,
|
|
).withValues(alpha: 0.12),
|
|
child: Text(
|
|
'${index + 1}',
|
|
style: const TextStyle(
|
|
color: Color(0xFFFF1D25),
|
|
fontWeight: FontWeight.w900,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
item['nmpasien']?.toString() ?? '-',
|
|
style: Theme.of(context).textTheme.titleMedium
|
|
?.copyWith(fontWeight: FontWeight.w900),
|
|
),
|
|
const SizedBox(height: 3),
|
|
Text(
|
|
'${item['nofoto'] ?? '-'} / RM ${item['noregister'] ?? '-'}',
|
|
style: const TextStyle(color: Colors.black54),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
StatusPill(
|
|
status: followed
|
|
? item['tat_status_label']?.toString() ?? '-'
|
|
: 'Belum Dilaporkan',
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Wrap(
|
|
spacing: 10,
|
|
runSpacing: 8,
|
|
children: [
|
|
BadgeLabel(
|
|
text: item['asalpasien']?.toString() ?? '-',
|
|
color: const Color(0xFF0F766E),
|
|
),
|
|
BadgeLabel(
|
|
text: item['nm_spesimen']?.toString() ?? '-',
|
|
color: const Color(0xFF2563EB),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
DetailRow(
|
|
label: 'Waktu Set Nilai Kritis',
|
|
value: _timeWithUser(
|
|
item['critical_set_at'],
|
|
item['critical_set_by_name'],
|
|
),
|
|
),
|
|
if (followed) ...[
|
|
DetailRow(
|
|
label: 'Waktu Ditindaklanjuti',
|
|
value: _timeWithUser(
|
|
item['followed_up_at'],
|
|
item['followed_up_by_name'],
|
|
),
|
|
),
|
|
DetailRow(
|
|
label: 'Selisih Waktu',
|
|
value: item['tat_duration_label'],
|
|
),
|
|
DetailRow(label: 'Aksi', value: _followUpLabel(item)),
|
|
],
|
|
if (onMarkRead != null)
|
|
Align(
|
|
alignment: Alignment.centerRight,
|
|
child: FilledButton.icon(
|
|
onPressed: onMarkRead,
|
|
icon: const Icon(Icons.done_all_outlined),
|
|
label: const Text('Mark as Read'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _timeWithUser(Object? time, Object? user) {
|
|
final first = time?.toString().isNotEmpty == true ? time.toString() : '-';
|
|
final second = user?.toString().isNotEmpty == true ? '\noleh $user' : '';
|
|
return '$first$second';
|
|
}
|
|
|
|
String _followUpLabel(Map<String, dynamic> item) {
|
|
final status = item['follow_up_status']?.toString() ?? '';
|
|
if (status == 'reported') {
|
|
return 'Dilaporkan\nMelalui: ${item['follow_up_method'] ?? '-'}\nPenerima: ${item['follow_up_recipient'] ?? '-'}';
|
|
}
|
|
if (status == 'not_reported') {
|
|
return 'Belum Dilaporkan\nAlasan: ${item['follow_up_reason'] ?? '-'}';
|
|
}
|
|
return '-';
|
|
}
|
|
}
|