969 lines
36 KiB
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),
|
|
),
|
|
);
|
|
}
|
|
} |