MIF_E31231033/lib/screens/dashboard/admin/laporan_admin_screen.dart

969 lines
36 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:open_file/open_file.dart';
import 'package:path_provider/path_provider.dart';
import '../../../services/auth_service.dart';
import 'detail_laporan_admin_screen.dart';
import 'package:permission_handler/permission_handler.dart';
Future<void> requestPermission() async {
await Permission.storage.request();
}
class LaporanAdminScreen extends StatefulWidget {
const LaporanAdminScreen({super.key});
@override
State<LaporanAdminScreen> createState() => _LaporanAdminScreenState();
}
class _LaporanAdminScreenState extends State<LaporanAdminScreen> {
List laporanList = [];
List filteredLaporan = [];
bool isLoading = true;
bool isDownloading = false;
TextEditingController searchController = TextEditingController();
String selectedMonth = "Semua";
String selectedYear = "Semua";
// ✅ Tambahan: state untuk rentang tanggal
DateTime? dateFrom;
DateTime? dateTo;
final Map<String, int?> months = {
"Semua": null,
"Januari": 1,
"Februari": 2,
"Maret": 3,
"April": 4,
"Mei": 5,
"Juni": 6,
"Juli": 7,
"Agustus": 8,
"September": 9,
"Oktober": 10,
"November": 11,
"Desember": 12,
};
List<String> years = ["Semua"];
@override
void initState() {
super.initState();
getLaporan();
}
String _formatDateTime(String? createdAt) {
if (createdAt == null || createdAt.isEmpty) return "";
final date = DateTime.tryParse(createdAt);
if (date == null) return "";
final local = date.toLocal();
const bulanNama = [
'', 'Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun',
'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des',
];
final tgl = local.day.toString().padLeft(2, '0');
final bln = bulanNama[local.month];
final thn = local.year;
final jam = local.hour.toString().padLeft(2, '0');
final mnt = local.minute.toString().padLeft(2, '0');
return "$tgl $bln $thn$jam:$mnt";
}
// ✅ Format DateTime ke string "dd MMM yyyy" untuk tampilan
String _formatDate(DateTime? date) {
if (date == null) return "Pilih tanggal";
const bulanNama = [
'', 'Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun',
'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des',
];
return "${date.day.toString().padLeft(2, '0')} ${bulanNama[date.month]} ${date.year}";
}
// ✅ Format DateTime ke "yyyy-MM-dd" untuk query param API
String _formatDateParam(DateTime date) {
return "${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}";
}
void generateYears() {
Set<String> yearSet = {"Semua"};
for (var laporan in laporanList) {
String createdAt = laporan['created_at'] ?? "";
DateTime? date = DateTime.tryParse(createdAt);
if (date != null) {
yearSet.add(date.year.toString());
}
}
List<String> result = yearSet.toList();
result.remove("Semua");
result.sort();
result.insert(0, "Semua");
setState(() {
years = result;
});
}
Future<void> getLaporan() async {
try {
setState(() => isLoading = true);
final token = await AuthService.getToken();
final response = await http.get(
Uri.parse('${AuthService.baseUrl}/admin/laporan'),
headers: {
"Accept": "application/json",
"Authorization": "Bearer $token",
},
);
final data = jsonDecode(response.body);
if (response.statusCode == 200) {
setState(() {
laporanList = data['data'] ?? [];
filteredLaporan = laporanList;
isLoading = false;
});
generateYears();
}
} catch (e) {
debugPrint("Error laporan: $e");
setState(() => isLoading = false);
}
}
// ✅ Tampilkan bottom sheet pilihan mode download
void showDownloadOptions() {
// Local state untuk bottom sheet
int tabIndex = 0; // 0 = Bulan/Tahun, 1 = Rentang Tanggal
String sheetMonth = selectedMonth;
String sheetYear = selectedYear;
DateTime? sheetFrom = dateFrom;
DateTime? sheetTo = dateTo;
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (ctx) {
return StatefulBuilder(
builder: (ctx, setSheetState) {
return Container(
padding: EdgeInsets.only(
bottom: MediaQuery.of(ctx).viewInsets.bottom,
),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Handle bar
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 16),
const Text(
"Download Rekap PDF",
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
const Text(
"Pilih rentang waktu laporan",
style: TextStyle(color: Colors.grey, fontSize: 13),
),
const SizedBox(height: 16),
// Tab Selector
Container(
decoration: BoxDecoration(
color: const Color(0xFFF1F4F8),
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.all(4),
child: Row(
children: [
_buildTab(
label: "Bulan / Tahun",
icon: Icons.calendar_month_outlined,
selected: tabIndex == 0,
onTap: () => setSheetState(() => tabIndex = 0),
),
_buildTab(
label: "Rentang Tanggal",
icon: Icons.date_range_outlined,
selected: tabIndex == 1,
onTap: () => setSheetState(() => tabIndex = 1),
),
],
),
),
const SizedBox(height: 20),
// === Tab 0: Bulan / Tahun ===
if (tabIndex == 0) ...[
Row(
children: [
Expanded(
child: _buildDropdown(
"Bulan",
sheetMonth,
months.keys.toList(),
(v) => setSheetState(() => sheetMonth = v!),
),
),
const SizedBox(width: 12),
Expanded(
child: _buildDropdown(
"Tahun",
sheetYear,
years,
(v) => setSheetState(() => sheetYear = v!),
),
),
],
),
],
// === Tab 1: Rentang Tanggal ===
if (tabIndex == 1) ...[
Row(
children: [
Expanded(
child: _buildDatePickerButton(
label: "Dari",
date: sheetFrom,
onTap: () async {
final picked = await showDatePicker(
context: ctx,
initialDate: sheetFrom ?? DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime.now(),
builder: (context, child) => Theme(
data: Theme.of(context).copyWith(
colorScheme: const ColorScheme.light(
primary: Color(0xFF2F5BEA),
),
),
child: child!,
),
);
if (picked != null) {
setSheetState(() => sheetFrom = picked);
}
},
),
),
const SizedBox(width: 12),
Expanded(
child: _buildDatePickerButton(
label: "Sampai",
date: sheetTo,
onTap: () async {
final picked = await showDatePicker(
context: ctx,
initialDate: sheetTo ?? DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime.now(),
builder: (context, child) => Theme(
data: Theme.of(context).copyWith(
colorScheme: const ColorScheme.light(
primary: Color(0xFF2F5BEA),
),
),
child: child!,
),
);
if (picked != null) {
setSheetState(() => sheetTo = picked);
}
},
),
),
],
),
// Validasi tanggal
if (sheetFrom != null &&
sheetTo != null &&
sheetFrom!.isAfter(sheetTo!)) ...[
const SizedBox(height: 8),
const Row(
children: [
Icon(Icons.warning_amber_rounded,
color: Colors.orange, size: 14),
SizedBox(width: 4),
Text(
"Tanggal mulai tidak boleh melebihi tanggal akhir",
style: TextStyle(
color: Colors.orange, fontSize: 11),
),
],
),
],
],
const SizedBox(height: 24),
// Tombol Download
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF2F5BEA),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
icon: const Icon(Icons.download_rounded),
label: const Text(
"Download PDF",
style: TextStyle(
fontSize: 15, fontWeight: FontWeight.bold),
),
onPressed: () {
// Validasi rentang tanggal
if (tabIndex == 1) {
if (sheetFrom == null || sheetTo == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
"⚠️ Pilih tanggal mulai dan akhir terlebih dahulu"),
),
);
return;
}
if (sheetFrom!.isAfter(sheetTo!)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
"⚠️ Tanggal mulai tidak boleh melebihi tanggal akhir"),
),
);
return;
}
setState(() {
dateFrom = sheetFrom;
dateTo = sheetTo;
});
Navigator.pop(ctx);
downloadPDF(
mode: 'range',
fromDate: sheetFrom,
toDate: sheetTo,
);
} else {
setState(() {
selectedMonth = sheetMonth;
selectedYear = sheetYear;
});
Navigator.pop(ctx);
downloadPDF(
mode: 'month_year',
month: sheetMonth,
year: sheetYear,
);
}
},
),
),
],
),
),
);
},
);
},
);
}
// ✅ Widget tab selector
Widget _buildTab({
required String label,
required IconData icon,
required bool selected,
required VoidCallback onTap,
}) {
return Expanded(
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: selected ? const Color(0xFF2F5BEA) : Colors.transparent,
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon,
size: 15,
color: selected ? Colors.white : Colors.grey.shade600),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: selected ? Colors.white : Colors.grey.shade600,
),
),
],
),
),
),
);
}
// ✅ Widget tombol date picker
Widget _buildDatePickerButton({
required String label,
required DateTime? date,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
color: const Color(0xFFF1F4F8),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: date != null
? const Color(0xFF2F5BEA).withValues(alpha: 0.4)
: Colors.transparent,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 11,
color: Color(0xFF2F5BEA),
fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
Row(
children: [
const Icon(Icons.calendar_today_outlined,
size: 13, color: Colors.grey),
const SizedBox(width: 6),
Expanded(
child: Text(
_formatDate(date),
style: TextStyle(
fontSize: 12,
color: date != null ? Colors.black87 : Colors.grey,
fontWeight: date != null
? FontWeight.w500
: FontWeight.normal,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
],
),
),
);
}
// ✅ downloadPDF sekarang terima parameter mode, fromDate, toDate, month, year
Future<void> downloadPDF({
String mode = 'month_year',
String? month,
String? year,
DateTime? fromDate,
DateTime? toDate,
}) async {
if (isDownloading) return;
setState(() => isDownloading = true);
// var status = await Permission.storage.request();
// debugPrint("Storage status: $status");
// if (!status.isGranted) {
// ScaffoldMessenger.of(context).showSnackBar(
// const SnackBar(content: Text("❌ Izin storage ditolak")));
// setState(() => isDownloading = false);
// return;
// }
double progressValue = 0;
late StateSetter setStateDialog;
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setS) {
setStateDialog = setS;
return AlertDialog(
title: const Text("Mengunduh laporan..."),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
LinearProgressIndicator(
value: progressValue == 0 ? null : progressValue),
const SizedBox(height: 10),
Text(
progressValue == 0
? "Mempersiapkan..."
: "${(progressValue * 100).toStringAsFixed(0)}%",
),
],
),
);
},
),
);
try {
final token = await AuthService.getToken();
final queryParams = <String, String>{};
if (mode == 'range' && fromDate != null && toDate != null) {
// ✅ Mode rentang tanggal: kirim param "dari" & "sampai"
queryParams['dari'] = _formatDateParam(fromDate);
queryParams['sampai'] = _formatDateParam(toDate);
} else {
// Mode bulan / tahun
final bulan = months[month ?? selectedMonth];
final tahun = (year ?? selectedYear) == "Semua"
? null
: (year ?? selectedYear);
if (bulan != null) queryParams['bulan'] = bulan.toString();
if (tahun != null) queryParams['tahun'] = tahun;
}
final url = Uri.parse("${AuthService.baseUrl}/admin/export-pdf")
.replace(queryParameters: queryParams)
.toString();
debugPrint("URL: $url");
// Nama file dinamis sesuai mode
final String namaFile;
if (mode == 'range' && fromDate != null && toDate != null) {
namaFile =
"laporan_${_formatDateParam(fromDate)}_sd_${_formatDateParam(toDate)}_${DateTime.now().millisecondsSinceEpoch}.pdf";
} else {
namaFile =
"laporan_${month ?? selectedMonth}_${year ?? selectedYear}_${DateTime.now().millisecondsSinceEpoch}.pdf";
}
final tempDir = await getTemporaryDirectory();
final tempPath = "${tempDir.path}/$namaFile";
Dio dio = Dio();
Response response = await dio.download(
url,
tempPath,
options: Options(
headers: {
"Authorization": "Bearer $token",
"Accept": "application/pdf",
},
responseType: ResponseType.bytes,
),
onReceiveProgress: (received, total) {
if (total != -1) {
setStateDialog(() {
progressValue = received / total;
});
}
},
);
if (response.statusCode != 200) throw Exception("Gagal download");
debugPrint("Android version test");
// final downloadDir = Directory("/storage/emulated/0/Download");
// if (!downloadDir.existsSync()) downloadDir.createSync(recursive: true);
// final finalPath = "${downloadDir.path}/$namaFile";
// File(tempPath).copySync(finalPath);
// File(tempPath).deleteSync();
final dir = await getApplicationDocumentsDirectory();
final finalPath = "${dir.path}/$namaFile";
File(tempPath).copySync(finalPath);
File(tempPath).deleteSync();
await Future.delayed(const Duration(milliseconds: 500));
if (!mounted) return;
Navigator.pop(context);
await OpenFile.open(finalPath);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("File berhasil dibuka"),
backgroundColor: Colors.green,
),
);
} catch (e) {
debugPrint("ERROR: $e");
if (e is DioException) {
debugPrint("RESPONSE: ${e.response?.data}");
debugPrint("STATUS: ${e.response?.statusCode}");
}
if (!mounted) return;
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("❌ Download gagal, cek server / token"),
backgroundColor: Colors.red,
),
);
} finally {
if (mounted) setState(() => isDownloading = false);
}
}
void filterLaporan(String query) {
final filtered = laporanList.where((laporan) {
final lokasi =
(laporan['jadwal']?['lokasi']?['nama_lokasi'] ?? "")
.toString()
.toLowerCase();
final petugas = (laporan['user']?['name'] ?? "").toString().toLowerCase();
final keterangan = (laporan['keterangan'] ?? "").toString().toLowerCase();
return lokasi.contains(query.toLowerCase()) ||
petugas.contains(query.toLowerCase()) ||
keterangan.contains(query.toLowerCase());
}).toList();
setState(() => filteredLaporan = filtered);
}
void filterByDate() {
List temp = laporanList.where((laporan) {
String createdAt = laporan['created_at'] ?? "";
DateTime? date = DateTime.tryParse(createdAt);
if (date == null) return false;
bool matchMonth =
selectedMonth == "Semua" || date.month == months[selectedMonth];
bool matchYear =
selectedYear == "Semua" || date.year.toString() == selectedYear;
return matchMonth && matchYear;
}).toList();
setState(() => filteredLaporan = temp);
}
Color getStatusColor(String status) {
switch (status.toLowerCase()) {
case "disetujui":
return const Color(0xFF27AE60);
case "ditolak":
return const Color(0xFFEB5757);
default:
return const Color(0xFFF2994A);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF8F9FD),
appBar: AppBar(
title: const Text(
"Laporan Patroli",
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
backgroundColor: const Color(0xFF2F5BEA),
centerTitle: true,
elevation: 0,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(bottom: Radius.circular(20)),
),
actions: [
IconButton(
icon: isDownloading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
color: Colors.white, strokeWidth: 2),
)
: const Icon(Icons.download, color: Colors.white),
tooltip: "Download Rekap PDF",
// ✅ Panggil showDownloadOptions, bukan langsung downloadPDF
onPressed: isDownloading ? null : showDownloadOptions,
),
],
),
body: isLoading
? const Center(
child: CircularProgressIndicator(color: Color(0xFF2F5BEA)))
: Column(
children: [
/// --- FILTER BOX ---
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
children: [
Row(
children: [
Expanded(
child: _buildDropdown(
"Bulan",
selectedMonth,
months.keys.toList(),
(v) {
setState(() => selectedMonth = v!);
filterByDate();
},
),
),
const SizedBox(width: 12),
Expanded(
child: _buildDropdown(
"Tahun",
selectedYear,
years,
(v) {
setState(() => selectedYear = v!);
filterByDate();
},
),
),
],
),
const SizedBox(height: 12),
TextField(
controller: searchController,
onChanged: filterLaporan,
decoration: InputDecoration(
hintText: "Cari lokasi atau petugas...",
prefixIcon: const Icon(Icons.search,
color: Color(0xFF2F5BEA)),
filled: true,
fillColor: const Color(0xFFF1F4F8),
contentPadding: EdgeInsets.zero,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
),
],
),
),
/// --- LIST LAPORAN ---
Expanded(
child: RefreshIndicator(
onRefresh: getLaporan,
child: filteredLaporan.isEmpty
? const Center(
child: Text("Laporan tidak ditemukan",
style: TextStyle(color: Colors.grey)),
)
: ListView.builder(
padding:
const EdgeInsets.fromLTRB(16, 16, 16, 8),
itemCount: filteredLaporan.length,
itemBuilder: (context, index) {
final laporan = filteredLaporan[index];
final String status =
laporan['status'] ?? "menunggu";
final String petugas =
laporan['user']?['name'] ?? "Anonim";
final String lokasi =
laporan['jadwal']?['lokasi']
?['nama_lokasi'] ??
"Lokasi";
final String waktuInput =
_formatDateTime(laporan['created_at']);
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color:
Colors.black.withValues(alpha: 0.04),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) =>
DetailLaporanAdminScreen(
laporan: laporan),
),
).then((_) => getLaporan());
},
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: const Color(0xFF2F5BEA)
.withValues(alpha: 0.1),
borderRadius:
BorderRadius.circular(12),
),
child: const Icon(
Icons.assignment_outlined,
color: Color(0xFF2F5BEA)),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(lokasi,
style: const TextStyle(
fontWeight:
FontWeight.bold,
fontSize: 16)),
const SizedBox(height: 6),
Row(
children: [
const Icon(
Icons.person_outline,
size: 14,
color: Colors.grey),
const SizedBox(width: 4),
Text(petugas,
style: const TextStyle(
color: Colors.black87,
fontSize: 13)),
],
),
const SizedBox(height: 4),
Text(
laporan['keterangan'] ?? "-",
maxLines: 1,
overflow:
TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.grey,
fontSize: 12),
),
if (waktuInput.isNotEmpty) ...[
const SizedBox(height: 6),
Row(
children: [
const Icon(
Icons
.calendar_today_outlined,
size: 12,
color: Colors.grey),
const SizedBox(width: 4),
Text(waktuInput,
style: const TextStyle(
color: Colors.grey,
fontSize: 11)),
],
),
],
],
),
),
const SizedBox(width: 8),
_buildStatusBadge(status),
],
),
),
),
);
},
),
),
),
],
),
);
}
Widget _buildDropdown(
String label,
String value,
List<String> items,
Function(String?) onChanged,
) {
return DropdownButtonFormField<String>(
value: value,
decoration: InputDecoration(
labelText: label,
labelStyle:
const TextStyle(fontSize: 12, color: Color(0xFF2F5BEA)),
filled: true,
fillColor: const Color(0xFFF1F4F8),
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
),
items: items
.map((m) => DropdownMenuItem(
value: m, child: Text(m, style: const TextStyle(fontSize: 13))))
.toList(),
onChanged: onChanged,
);
}
Widget _buildStatusBadge(String status) {
final color = getStatusColor(status);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Text(
status.toUpperCase(),
style: TextStyle(
color: color, fontSize: 9, fontWeight: FontWeight.bold),
),
);
}
}