Files
lis/mylis/lib/screens/dashboard/critical_values_screen.dart
T
2026-08-02 10:46:34 +07:00

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 '-';
}
}