import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'dart:convert'; import 'package:table_calendar/table_calendar.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:intl/intl.dart'; import '../../../services/auth_service.dart'; class KelolaJadwalScreen extends StatefulWidget { const KelolaJadwalScreen({super.key}); @override State createState() => _KelolaJadwalScreenState(); } class _KelolaJadwalScreenState extends State with SingleTickerProviderStateMixin { late TabController _tabController; List> petugasList = []; List> shiftList = []; List> lokasiList = []; int? selectedUserId; int? selectedShiftId; int? selectedLokasiId; int? hariLibur; DateTimeRange? selectedDateRange; int? filterPetugasId; DateTime filterBulan = DateTime(DateTime.now().year, DateTime.now().month); List> jadwalGrouped = []; bool isLoadingJadwal = false; DateTimeRange? hapusDateRange; final Color primaryColor = const Color(0xFF2F5BEA); final Color scaffoldBg = const Color(0xFFF3F4F9); final Color cardColor = Colors.white; // ── Safe int parser ──────────────────────────────────────────────────────── int _toInt(dynamic val) => val is int ? val : int.tryParse(val?.toString() ?? '') ?? 0; // ── Filter shift libur dari dropdown ──────────────────────────────────────── List> get _shiftAktifList => shiftList .where((s) => !(s['nama_shift']?.toString() ?? '') .toLowerCase() .contains('libur')) .toList(); Color _statusColor(String status) { switch (status.toLowerCase()) { case 'aktif': return const Color(0xFF22C55E); case 'libur': return const Color(0xFFF59E0B); default: return Colors.grey; } } @override void initState() { super.initState(); _tabController = TabController(length: 2, vsync: this); fetchPetugas(); fetchShift(); fetchLokasi(); } @override void dispose() { _tabController.dispose(); super.dispose(); } Future getToken() async { final prefs = await SharedPreferences.getInstance(); return prefs.getString('token'); } void showLoadingDialog([String msg = "Menyimpan jadwal..."]) { showDialog( context: context, barrierDismissible: false, builder: (_) => AlertDialog( backgroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), content: Padding( padding: const EdgeInsets.symmetric(vertical: 10), child: Row( children: [ CircularProgressIndicator(strokeWidth: 3, color: primaryColor), const SizedBox(width: 25), Text(msg, style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w500)), ], ), ), ), ); } void closeLoadingDialog() { if (mounted && Navigator.canPop(context)) { Navigator.of(context, rootNavigator: true).pop(); } } bool _handleResponse(http.Response response) { if (response.statusCode == 401) { _logoutDanKeLogin(); return false; } return true; } Future _logoutDanKeLogin() async { final prefs = await SharedPreferences.getInstance(); await prefs.remove('token'); if (!mounted) return; Navigator.of(context).pushNamedAndRemoveUntil('/login', (_) => false); } // ════════════════════════════════════════════════════════════════════════════ // API CALLS // ════════════════════════════════════════════════════════════════════════════ Future fetchPetugas() async { try { String? token = await getToken(); if (token == null) return; final response = await http.get( Uri.parse("${AuthService.baseUrl}/users"), headers: { "Accept": "application/json", "Authorization": "Bearer $token", }, ); if (!_handleResponse(response)) return; if (response.statusCode == 200) { final data = jsonDecode(response.body); final List> parsed = List>.from(data).map((p) { return { ...p, 'id': _toInt(p['id']), }; }).toList(); setState(() => petugasList = parsed); } } catch (e) { debugPrint("Error fetch petugas: $e"); } } Future fetchShift() async { try { String? token = await getToken(); if (token == null) return; final response = await http.get( Uri.parse("${AuthService.baseUrl}/shifts"), headers: { "Accept": "application/json", "Authorization": "Bearer $token", }, ); if (!_handleResponse(response)) return; if (response.statusCode == 200) { final data = jsonDecode(response.body); List> shifts = List>.from(data).map((s) { return { ...s, 'id': _toInt(s['id']), }; }).toList(); setState(() { shiftList = shifts; if (!shiftList.any((s) => s['id'] == selectedShiftId)) { selectedShiftId = null; } }); } } catch (e) { debugPrint("Error fetch shift: $e"); } } Future fetchLokasi() async { try { String? token = await getToken(); if (token == null) return; final response = await http.get( Uri.parse("${AuthService.baseUrl}/lokasi"), headers: { "Accept": "application/json", "Authorization": "Bearer $token", }, ); if (!_handleResponse(response)) return; if (response.statusCode == 200) { final data = jsonDecode(response.body); final List> parsed = List>.from(data).map((l) { return { ...l, 'id': _toInt(l['id']), }; }).toList(); setState(() => lokasiList = parsed); } } catch (e) { debugPrint("Error fetch lokasi: $e"); } } Future tambahLokasi(String namaLokasi) async { try { String? token = await getToken(); if (token == null) return; final response = await http.post( Uri.parse("${AuthService.baseUrl}/lokasi"), headers: { "Accept": "application/json", "Content-Type": "application/json", "Authorization": "Bearer $token", }, body: jsonEncode({"nama_lokasi": namaLokasi}), ); if (!_handleResponse(response)) return; if (response.statusCode == 200 || response.statusCode == 201) { await fetchLokasi(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text("Lokasi berhasil ditambahkan"))); } } } catch (e) { debugPrint("Error tambah lokasi: $e"); } } Future fetchJadwal({DateTime? bulan}) async { if (filterPetugasId == null) return; final targetBulan = bulan ?? filterBulan; setState(() => isLoadingJadwal = true); try { String? token = await getToken(); if (token == null) return; final bulanStr = DateFormat('yyyy-MM').format(targetBulan); final response = await http.get( Uri.parse( "${AuthService.baseUrl}/admin/jadwal?user_id=$filterPetugasId&bulan=$bulanStr"), headers: { "Accept": "application/json", "Authorization": "Bearer $token", }, ); if (!_handleResponse(response)) return; if (response.statusCode == 200) { final data = jsonDecode(response.body) as List; setState(() { jadwalGrouped = data.map((item) { return { 'tanggal': item['tanggal'], 'shifts': (item['shifts'] as List).map((s) { final Map shift = Map.from(s as Map); // Normalize id to int throughout the nested structure shift['id'] = _toInt(shift['id']); if (shift['shift'] != null) { shift['shift'] = Map.from( shift['shift'] as Map) ..['id'] = _toInt((shift['shift'] as Map)['id']); } if (shift['lokasi'] != null) { shift['lokasi'] = Map.from( shift['lokasi'] as Map) ..['id'] = _toInt((shift['lokasi'] as Map)['id']); } return shift; }).toList(), }; }).toList(); }); } } catch (e) { debugPrint("Error fetch jadwal: $e"); } finally { if (mounted) setState(() => isLoadingJadwal = false); } } Future updateJadwal( int jadwalId, int? shiftId, int? lokasiId, String status) async { try { String? token = await getToken(); if (token == null) return; final body = {"status": status}; if (status != "libur") { body["shift_id"] = shiftId; body["lokasi_id"] = lokasiId; } final response = await http.put( Uri.parse("${AuthService.baseUrl}/jadwal/$jadwalId"), headers: { "Accept": "application/json", "Content-Type": "application/json", "Authorization": "Bearer $token", }, body: jsonEncode(body), ); if (!_handleResponse(response)) return; if (response.statusCode == 200) { await fetchJadwal(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar( behavior: SnackBarBehavior.floating, backgroundColor: Color(0xFF22C55E), content: Text("Jadwal berhasil diperbarui"), )); } } else if (response.statusCode == 409) { try { final data = jsonDecode(response.body); _showErrorSnackbar( data['message'] ?? "Konflik: jadwal di tanggal ini sudah ada"); } catch (_) { _showErrorSnackbar("Konflik: jadwal di tanggal ini sudah ada"); } } else { try { final data = jsonDecode(response.body); _showErrorSnackbar( data['message'] ?? "Gagal memperbarui (${response.statusCode})"); } catch (_) { _showErrorSnackbar("Gagal memperbarui (${response.statusCode})"); } } } catch (e) { _showErrorSnackbar("Error: $e"); } } Future tambahShift(String tanggal, int shiftId, int lokasiId) async { try { String? token = await getToken(); if (token == null) return; final response = await http.post( Uri.parse("${AuthService.baseUrl}/jadwal"), headers: { "Accept": "application/json", "Content-Type": "application/json", "Authorization": "Bearer $token", }, body: jsonEncode({ "user_id": filterPetugasId, "tanggal": tanggal, "shift_id": shiftId, "lokasi_id": lokasiId, "status": "aktif", }), ); if (!_handleResponse(response)) return; if (response.statusCode == 200 || response.statusCode == 201) { await fetchJadwal(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar( behavior: SnackBarBehavior.floating, backgroundColor: Color(0xFF22C55E), content: Text("Shift berhasil ditambahkan"), )); } } else { final data = jsonDecode(response.body); _showErrorSnackbar(data['message'] ?? "Gagal menambah shift"); } } catch (e) { _showErrorSnackbar("Error: $e"); } } Future hapusSingleShift(int jadwalId) async { try { String? token = await getToken(); if (token == null) return; final response = await http.delete( Uri.parse("${AuthService.baseUrl}/jadwal/$jadwalId"), headers: { "Accept": "application/json", "Authorization": "Bearer $token", }, ); if (!_handleResponse(response)) return; if (response.statusCode == 200) { await fetchJadwal(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar( behavior: SnackBarBehavior.floating, backgroundColor: Colors.redAccent, content: Text("Shift berhasil dihapus"), )); } } else { _showErrorSnackbar("Gagal hapus: ${response.statusCode}"); } } catch (e) { _showErrorSnackbar("Error: $e"); } } Future simpanJadwal() async { if (selectedUserId == null || selectedShiftId == null || selectedLokasiId == null || selectedDateRange == null || hariLibur == null) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar( behavior: SnackBarBehavior.floating, content: Text("Harap lengkapi semua data!"), )); return; } showLoadingDialog("Menyimpan jadwal..."); try { String? token = await getToken(); if (token == null) { closeLoadingDialog(); _showErrorSnackbar("Token tidak ditemukan, silakan login ulang"); return; } http.Response response; try { response = await http .post( Uri.parse("${AuthService.baseUrl}/admin/jadwal-generate"), headers: { "Accept": "application/json", "Content-Type": "application/json", "Authorization": "Bearer $token", }, body: jsonEncode({ "user_id": selectedUserId, "shift_id": selectedShiftId, "lokasi_id": selectedLokasiId, "tanggal_mulai": DateFormat('yyyy-MM-dd') .format(selectedDateRange!.start), "tanggal_selesai": DateFormat('yyyy-MM-dd') .format(selectedDateRange!.end), "hari_libur": hariLibur, }), ) .timeout( const Duration(seconds: 30), onTimeout: () => throw Exception("timeout"), ); } catch (e) { if (mounted) closeLoadingDialog(); _showErrorSnackbar(e.toString().contains("timeout") ? "Koneksi timeout, coba lagi" : "Gagal terhubung ke server"); return; } if (mounted) closeLoadingDialog(); if (!_handleResponse(response)) return; if (response.statusCode == 200 || response.statusCode == 201) { final data = jsonDecode(response.body); final totalDibuat = data['total_dibuat'] ?? 0; final totalLibur = data['total_libur'] ?? 0; final totalSkip = data['total_skip'] ?? 0; if (filterPetugasId == selectedUserId) await fetchJadwal(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( behavior: SnackBarBehavior.floating, backgroundColor: const Color(0xFF22C55E), content: Text( "Berhasil! $totalDibuat hari kerja, $totalLibur hari libur" "${totalSkip > 0 ? ", $totalSkip dilewati (sudah ada)" : ""}", ), )); } } else { try { final data = jsonDecode(response.body); _showErrorSnackbar(data['message'] ?? "Gagal menyimpan jadwal (${response.statusCode})"); } catch (_) { _showErrorSnackbar( "Gagal menyimpan jadwal (${response.statusCode})"); } } } catch (e) { if (mounted) closeLoadingDialog(); _showErrorSnackbar("Terjadi kesalahan saat menyimpan jadwal"); } } Future hapusJadwalRange(int userId, DateTimeRange range) async { try { String? token = await getToken(); if (token == null) return; final response = await http .post( Uri.parse("${AuthService.baseUrl}/jadwal/hapus-range"), headers: { "Accept": "application/json", "Content-Type": "application/json", "Authorization": "Bearer $token", }, body: jsonEncode({ "user_id": userId, "tanggal_mulai": DateFormat('yyyy-MM-dd').format(range.start), "tanggal_selesai": DateFormat('yyyy-MM-dd').format(range.end), }), ) .timeout( const Duration(seconds: 15), onTimeout: () => throw Exception("timeout"), ); if (!_handleResponse(response)) return; if (response.statusCode == 200) { final data = jsonDecode(response.body); final totalDihapus = data['total_dihapus'] ?? 0; setState(() => hapusDateRange = null); await fetchJadwal(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( behavior: SnackBarBehavior.floating, backgroundColor: Colors.redAccent, content: Text("$totalDihapus jadwal berhasil dihapus"), )); } } else { _showErrorSnackbar("Gagal menghapus jadwal (${response.statusCode})"); } } catch (e) { _showErrorSnackbar(e.toString().contains("timeout") ? "Koneksi timeout, coba lagi" : "Terjadi kesalahan saat menghapus"); } } void _showErrorSnackbar(String msg) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar( behavior: SnackBarBehavior.floating, backgroundColor: Colors.redAccent, content: Text(msg), )); } // ════════════════════════════════════════════════════════════════════════════ // BOTTOM SHEETS & DIALOGS // ════════════════════════════════════════════════════════════════════════════ void showTambahLokasiDialog() { TextEditingController lokasiController = TextEditingController(); showDialog( context: context, builder: (_) => AlertDialog( backgroundColor: Colors.white, title: const Text("Tambah Lokasi Baru"), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), content: TextField( controller: lokasiController, decoration: _inputDecor("Contoh: Kantor Pusat", Icons.location_city), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text("Batal", style: TextStyle(color: Colors.grey.shade600)), ), ElevatedButton( onPressed: () async { if (lokasiController.text.isNotEmpty) { Navigator.pop(context); await tambahLokasi(lokasiController.text); } }, style: ElevatedButton.styleFrom(backgroundColor: primaryColor), child: const Text("Simpan", style: TextStyle(color: Colors.white)), ), ], ), ); } void showEditJadwalSheet(Map jadwal, {bool bisaDihapus = false}) { int? editShiftId = jadwal['shift_id'] != null ? _toInt(jadwal['shift_id']) : (jadwal['shift'] != null ? _toInt(jadwal['shift']['id']) : null); int? editLokasiId = jadwal['lokasi_id'] != null ? _toInt(jadwal['lokasi_id']) : (jadwal['lokasi'] != null ? _toInt(jadwal['lokasi']['id']) : null); String editStatus = jadwal['status'] ?? 'aktif'; if (editStatus != 'libur') editStatus = 'aktif'; showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (_) { return StatefulBuilder( builder: (context, setSheetState) { return Container( padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewInsets.bottom), decoration: const BoxDecoration( color: Colors.white, borderRadius: BorderRadius.vertical(top: Radius.circular(28)), ), child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(24, 20, 24, 30), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Center( child: Container( width: 40, height: 4, decoration: BoxDecoration( color: Colors.grey.shade300, borderRadius: BorderRadius.circular(10), ), ), ), const SizedBox(height: 18), Row( children: [ Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: primaryColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), ), child: Icon(Icons.edit_calendar_outlined, color: primaryColor, size: 22), ), const SizedBox(width: 12), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text("Edit Shift", style: TextStyle( fontSize: 17, fontWeight: FontWeight.bold)), Text( DateFormat('EEEE, dd MMMM yyyy', 'id_ID') .format(DateTime.parse(jadwal['tanggal'])), style: TextStyle( fontSize: 13, color: Colors.grey.shade500), ), ], ), const Spacer(), if (bisaDihapus) IconButton( onPressed: () { Navigator.pop(context); _showKonfirmasiHapusSingleShift( _toInt(jadwal['id'])); }, icon: const Icon(Icons.delete_outline, color: Colors.redAccent), style: IconButton.styleFrom( backgroundColor: Colors.red.shade50, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10)), ), ), ], ), const SizedBox(height: 24), const Divider(height: 1), const SizedBox(height: 20), _buildLabel("STATUS HARI INI"), Row( children: ["aktif", "libur"].map((s) { final isSelected = editStatus == s; return Expanded( child: GestureDetector( onTap: () { setSheetState(() { editStatus = s; if (s == 'libur') { editShiftId = null; editLokasiId = null; } }); }, child: Container( margin: EdgeInsets.only( right: s == "aktif" ? 8 : 0), padding: const EdgeInsets.symmetric(vertical: 13), decoration: BoxDecoration( color: isSelected ? _statusColor(s) : Colors.grey.shade100, borderRadius: BorderRadius.circular(12), border: Border.all( color: isSelected ? _statusColor(s) : Colors.grey.shade200, ), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( s == "aktif" ? Icons.check_circle_outline : Icons.event_busy_outlined, size: 18, color: isSelected ? Colors.white : Colors.grey.shade500, ), const SizedBox(width: 6), Text( s == "aktif" ? "Aktif" : "Libur", style: TextStyle( fontWeight: FontWeight.w600, color: isSelected ? Colors.white : Colors.grey.shade500, ), ), ], ), ), ), ); }).toList(), ), const SizedBox(height: 20), AnimatedOpacity( opacity: editStatus == "libur" ? 0.35 : 1.0, duration: const Duration(milliseconds: 250), child: IgnorePointer( ignoring: editStatus == "libur", child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildLabel("SHIFT"), DropdownButtonFormField( value: editShiftId, isExpanded: true, itemHeight: 56, icon: const Icon( Icons.keyboard_arrow_down_rounded), decoration: _inputDecor( "Pilih shift", Icons.timer_outlined), items: _shiftAktifList .map((s) => DropdownMenuItem( value: _toInt(s['id']), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Text( "${s['kode_shift']} - ${s['nama_shift']}", style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600), ), Text( "${s['jam_mulai'] ?? '-'} – ${s['jam_selesai'] ?? '-'}", style: TextStyle( fontSize: 12, color: Colors.grey.shade500), ), ], ), )) .toList(), onChanged: (v) => setSheetState(() => editShiftId = v), ), const SizedBox(height: 16), _buildLabel("LOKASI"), DropdownButtonFormField( value: editLokasiId, isExpanded: true, icon: const Icon( Icons.keyboard_arrow_down_rounded), decoration: _inputDecor( "Pilih lokasi", Icons.map_outlined), items: lokasiList .map((l) => DropdownMenuItem( value: _toInt(l['id']), child: Text(l['nama_lokasi'], style: const TextStyle( fontSize: 14)), )) .toList(), onChanged: (v) => setSheetState(() => editLokasiId = v), ), ], ), ), ), const SizedBox(height: 28), SizedBox( width: double.infinity, height: 52, child: ElevatedButton( onPressed: () { if (editStatus == "aktif" && (editShiftId == null || editLokasiId == null)) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( behavior: SnackBarBehavior.floating, content: Text( "Pilih shift dan lokasi untuk hari aktif"))); return; } Navigator.pop(context); updateJadwal(_toInt(jadwal['id']), editShiftId, editLokasiId, editStatus); }, style: ElevatedButton.styleFrom( backgroundColor: primaryColor, foregroundColor: Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(14)), elevation: 0, ), child: const Text("SIMPAN PERUBAHAN", style: TextStyle( fontWeight: FontWeight.bold, fontSize: 15, letterSpacing: 0.8)), ), ), ], ), ), ); }, ); }, ); } void showTambahShiftSheet(String tanggal) { int? newShiftId; int? newLokasiId; showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (_) { return StatefulBuilder( builder: (context, setSheetState) { return Container( padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewInsets.bottom), decoration: const BoxDecoration( color: Colors.white, borderRadius: BorderRadius.vertical(top: Radius.circular(28)), ), child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(24, 20, 24, 30), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Center( child: Container( width: 40, height: 4, decoration: BoxDecoration( color: Colors.grey.shade300, borderRadius: BorderRadius.circular(10), ), ), ), const SizedBox(height: 18), Row( children: [ Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: primaryColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), ), child: Icon(Icons.add_circle_outline, color: primaryColor, size: 22), ), const SizedBox(width: 12), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text("Tambah Shift", style: TextStyle( fontSize: 17, fontWeight: FontWeight.bold)), Text( DateFormat('EEEE, dd MMMM yyyy', 'id_ID') .format(DateTime.parse(tanggal)), style: TextStyle( fontSize: 13, color: Colors.grey.shade500), ), ], ), ], ), const SizedBox(height: 24), const Divider(height: 1), const SizedBox(height: 20), _buildLabel("SHIFT"), DropdownButtonFormField( value: newShiftId, isExpanded: true, itemHeight: 56, icon: const Icon(Icons.keyboard_arrow_down_rounded), decoration: _inputDecor("Pilih shift", Icons.timer_outlined), items: _shiftAktifList .map((s) => DropdownMenuItem( value: _toInt(s['id']), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Text( "${s['kode_shift']} - ${s['nama_shift']}", style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600), ), Text( "${s['jam_mulai'] ?? '-'} – ${s['jam_selesai'] ?? '-'}", style: TextStyle( fontSize: 12, color: Colors.grey.shade500), ), ], ), )) .toList(), onChanged: (v) => setSheetState(() => newShiftId = v), ), const SizedBox(height: 16), _buildLabel("LOKASI"), DropdownButtonFormField( value: newLokasiId, isExpanded: true, icon: const Icon(Icons.keyboard_arrow_down_rounded), decoration: _inputDecor("Pilih lokasi", Icons.map_outlined), items: lokasiList .map((l) => DropdownMenuItem( value: _toInt(l['id']), child: Text(l['nama_lokasi'], style: const TextStyle(fontSize: 14)), )) .toList(), onChanged: (v) => setSheetState(() => newLokasiId = v), ), const SizedBox(height: 28), SizedBox( width: double.infinity, height: 52, child: ElevatedButton( onPressed: () { if (newShiftId == null || newLokasiId == null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( behavior: SnackBarBehavior.floating, content: Text( "Pilih shift dan lokasi terlebih dahulu"))); return; } Navigator.pop(context); tambahShift(tanggal, newShiftId!, newLokasiId!); }, style: ElevatedButton.styleFrom( backgroundColor: primaryColor, foregroundColor: Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(14)), elevation: 0, ), child: const Text("TAMBAH SHIFT", style: TextStyle( fontWeight: FontWeight.bold, fontSize: 15, letterSpacing: 0.8)), ), ), ], ), ), ); }, ); }, ); } void _showKonfirmasiHapusSingleShift(int jadwalId) { showDialog( context: context, builder: (_) => AlertDialog( backgroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), title: const Row( children: [ Icon(Icons.warning_rounded, color: Colors.redAccent), SizedBox(width: 8), Text("Hapus Shift Ini?", style: TextStyle(fontSize: 16)), ], ), content: const Text( "Shift ini akan dihapus permanen.\nData yang dihapus tidak bisa dikembalikan.", style: TextStyle(fontSize: 14, height: 1.5), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text("Batal", style: TextStyle(color: Colors.grey.shade600)), ), ElevatedButton( onPressed: () { Navigator.pop(context); hapusSingleShift(jadwalId); }, style: ElevatedButton.styleFrom( backgroundColor: Colors.redAccent, foregroundColor: Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10)), ), child: const Text("Ya, Hapus"), ), ], ), ); } void showHapusRangeSheet() { // Kumpulkan semua tanggal yang ada jadwal dari data yang sudah di-load final Set tanggalAdaJadwal = jadwalGrouped .map((g) { try { final d = DateTime.parse(g['tanggal']); return DateTime(d.year, d.month, d.day); } catch (_) { return null; } }) .whereType() .toSet(); DateTime? rangeStart; DateTime? rangeEnd; DateTime focusedDay = filterBulan; // Restore dari state sebelumnya jika ada if (hapusDateRange != null) { rangeStart = hapusDateRange!.start; rangeEnd = hapusDateRange!.end; } showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (_) { return StatefulBuilder( builder: (context, setSheetState) { // Hitung jumlah tanggal ber-jadwal dalam range yang dipilih int jumlahTanggalBerJadwal = 0; int totalHariRange = 0; if (rangeStart != null && rangeEnd != null) { totalHariRange = rangeEnd!.difference(rangeStart!).inDays + 1; for (int i = 0; i < totalHariRange; i++) { final tgl = rangeStart!.add(Duration(days: i)); if (tanggalAdaJadwal.contains( DateTime(tgl.year, tgl.month, tgl.day))) { jumlahTanggalBerJadwal++; } } } return DraggableScrollableSheet( initialChildSize: 0.92, minChildSize: 0.6, maxChildSize: 0.95, expand: false, builder: (_, scrollController) { return Container( decoration: const BoxDecoration( color: Colors.white, borderRadius: BorderRadius.vertical(top: Radius.circular(28)), ), child: Column( children: [ // ── Handle bar ───────────────────────────── Padding( padding: const EdgeInsets.only(top: 12, bottom: 4), child: Center( child: Container( width: 40, height: 4, decoration: BoxDecoration( color: Colors.grey.shade300, borderRadius: BorderRadius.circular(10), ), ), ), ), Expanded( child: ListView( controller: scrollController, padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), children: [ // ── Header ─────────────────────────── Row( children: [ Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: Colors.red.shade50, borderRadius: BorderRadius.circular(12), ), child: const Icon( Icons.delete_sweep_outlined, color: Colors.redAccent, size: 22), ), const SizedBox(width: 12), const Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text("Hapus Jadwal", style: TextStyle( fontSize: 17, fontWeight: FontWeight.bold)), Text( "Ketuk tanggal awal lalu tanggal akhir", style: TextStyle( fontSize: 12, color: Colors.grey)), ], ), ), ], ), const SizedBox(height: 16), const Divider(height: 1), const SizedBox(height: 14), // ── Info petugas ────────────────────── Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: const Color(0xFFF3F4F9), borderRadius: BorderRadius.circular(12), ), child: Row( children: [ Icon(Icons.person_outline, size: 18, color: primaryColor), const SizedBox(width: 8), Text( petugasList.firstWhere( (p) => p['id'] == filterPetugasId, orElse: () => {'name': '-'}, )['name'] ?? '-', style: const TextStyle( fontWeight: FontWeight.w600, fontSize: 14), ), ], ), ), const SizedBox(height: 14), // ── Legend ──────────────────────────── Row( children: [ _legendDot(Colors.redAccent), const SizedBox(width: 6), const Text("Ada jadwal", style: TextStyle(fontSize: 12)), const SizedBox(width: 16), _legendDot(Colors.redAccent.withValues(alpha: 0.25), border: true), const SizedBox(width: 6), const Text("Tidak ada jadwal", style: TextStyle(fontSize: 12)), const SizedBox(width: 16), Container( width: 12, height: 12, decoration: BoxDecoration( color: Colors.redAccent .withValues(alpha: 0.15), borderRadius: BorderRadius.circular(3), ), ), const SizedBox(width: 6), const Text("Range dipilih", style: TextStyle(fontSize: 12)), ], ), const SizedBox(height: 12), // ── TableCalendar ───────────────────── Container( decoration: BoxDecoration( border: Border.all( color: Colors.grey.shade200), borderRadius: BorderRadius.circular(16), ), clipBehavior: Clip.hardEdge, child: TableCalendar( locale: 'id_ID', firstDay: DateTime(2020), lastDay: DateTime(2030), focusedDay: focusedDay, rangeStartDay: rangeStart, rangeEndDay: rangeEnd, calendarFormat: CalendarFormat.month, rangeSelectionMode: RangeSelectionMode.toggledOn, headerStyle: HeaderStyle( formatButtonVisible: false, titleCentered: true, titleTextStyle: const TextStyle( fontWeight: FontWeight.bold, fontSize: 15), leftChevronIcon: Icon( Icons.chevron_left_rounded, color: primaryColor), rightChevronIcon: Icon( Icons.chevron_right_rounded, color: primaryColor), headerPadding: const EdgeInsets.symmetric( vertical: 12), ), daysOfWeekStyle: DaysOfWeekStyle( weekdayStyle: TextStyle( fontSize: 12, color: Colors.grey.shade600, fontWeight: FontWeight.w600), weekendStyle: TextStyle( fontSize: 12, color: Colors.redAccent.shade200, fontWeight: FontWeight.w600), ), calendarStyle: CalendarStyle( outsideDaysVisible: false, todayDecoration: BoxDecoration( border: Border.all( color: primaryColor, width: 1.5), shape: BoxShape.circle, ), todayTextStyle: TextStyle(color: primaryColor), rangeHighlightColor: Colors.redAccent .withValues(alpha: 0.12), rangeStartDecoration: const BoxDecoration( color: Colors.redAccent, shape: BoxShape.circle, ), rangeEndDecoration: const BoxDecoration( color: Colors.redAccent, shape: BoxShape.circle, ), withinRangeTextStyle: const TextStyle( color: Colors.redAccent, fontWeight: FontWeight.w600), selectedDecoration: const BoxDecoration( color: Colors.redAccent, shape: BoxShape.circle, ), markerDecoration: const BoxDecoration( color: Colors.redAccent, shape: BoxShape.circle, ), markersMaxCount: 1, markerSize: 5, markerMargin: const EdgeInsets.only(top: 1), ), // Dot merah untuk tanggal yang ada jadwal eventLoader: (day) { final d = DateTime( day.year, day.month, day.day); return tanggalAdaJadwal.contains(d) ? [true] : []; }, onRangeSelected: (start, end, focused) { setSheetState(() { rangeStart = start; rangeEnd = end; focusedDay = focused; }); if (start != null && end != null) { setState(() { hapusDateRange = DateTimeRange( start: start, end: end); }); } }, onPageChanged: (focused) { setSheetState( () => focusedDay = focused); }, ), ), const SizedBox(height: 16), // ── Info range dipilih ──────────────── if (rangeStart != null) ...[ Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: rangeEnd != null ? Colors.red.shade50 : Colors.orange.shade50, borderRadius: BorderRadius.circular(12), border: Border.all( color: rangeEnd != null ? Colors.redAccent.shade100 : Colors.orange.shade200, ), ), child: Row( children: [ Icon( rangeEnd != null ? Icons.date_range_outlined : Icons.touch_app_outlined, size: 18, color: rangeEnd != null ? Colors.redAccent : Colors.orange, ), const SizedBox(width: 10), Expanded( child: rangeEnd == null ? Text( "Mulai: ${DateFormat('dd MMM yyyy').format(rangeStart!)}\nKetuk tanggal akhir", style: const TextStyle( fontSize: 13, color: Colors.orange, fontWeight: FontWeight.w500, height: 1.5), ) : RichText( text: TextSpan( style: const TextStyle( fontSize: 13, color: Colors.black87, height: 1.5), children: [ TextSpan( text: "${DateFormat('dd MMM yyyy').format(rangeStart!)} → ${DateFormat('dd MMM yyyy').format(rangeEnd!)}", style: const TextStyle( fontWeight: FontWeight .bold, color: Colors .redAccent), ), TextSpan( text: "\n$totalHariRange hari • $jumlahTanggalBerJadwal shift akan dihapus", style: TextStyle( fontSize: 12, color: Colors .grey.shade600), ), ], ), ), ), if (rangeStart != null) GestureDetector( onTap: () { setSheetState(() { rangeStart = null; rangeEnd = null; }); setState( () => hapusDateRange = null); }, child: Icon(Icons.close_rounded, size: 18, color: Colors.grey.shade400), ), ], ), ), const SizedBox(height: 12), ], // ── Warning box ─────────────────────── Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.amber.shade50, borderRadius: BorderRadius.circular(12), border: Border.all( color: Colors.amber.shade200), ), child: Row( children: [ Icon(Icons.warning_amber_rounded, size: 18, color: Colors.amber.shade700), const SizedBox(width: 8), const Expanded( child: Text( "Semua shift dalam rentang ini akan dihapus permanen.", style: TextStyle( fontSize: 12, color: Colors.black87), ), ), ], ), ), const SizedBox(height: 20), // ── Tombol hapus ────────────────────── SizedBox( width: double.infinity, height: 52, child: ElevatedButton.icon( onPressed: (rangeStart == null || rangeEnd == null) ? null : () { Navigator.pop(context); _showKonfirmasiHapusRange( filterPetugasId!, DateTimeRange( start: rangeStart!, end: rangeEnd!)); }, icon: const Icon(Icons.delete_outline, size: 20), label: Text( jumlahTanggalBerJadwal > 0 ? "HAPUS $jumlahTanggalBerJadwal JADWAL" : "HAPUS JADWAL", style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 15, letterSpacing: 0.8), ), style: ElevatedButton.styleFrom( backgroundColor: Colors.redAccent, foregroundColor: Colors.white, disabledBackgroundColor: Colors.grey.shade200, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(14)), elevation: 0, ), ), ), ], ), ), ], ), ); }, ); }, ); }, ); } Widget _legendDot(Color color, {bool border = false}) { return Container( width: 12, height: 12, decoration: BoxDecoration( color: border ? Colors.transparent : color, shape: BoxShape.circle, border: border ? Border.all(color: color, width: 1.5) : null, ), ); } void _showKonfirmasiHapusRange(int userId, DateTimeRange range) { final namaPetugas = petugasList.firstWhere( (p) => p['id'] == userId, orElse: () => {'name': '-'}, )['name'] ?? '-'; showDialog( context: context, builder: (_) => AlertDialog( backgroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), title: const Row( children: [ Icon(Icons.warning_rounded, color: Colors.redAccent), SizedBox(width: 8), Text("Konfirmasi Hapus", style: TextStyle(fontSize: 16)), ], ), content: RichText( text: TextSpan( style: const TextStyle( fontSize: 14, color: Colors.black87, height: 1.5), children: [ const TextSpan(text: "Hapus semua shift "), TextSpan( text: namaPetugas, style: const TextStyle(fontWeight: FontWeight.bold)), const TextSpan(text: " dari "), TextSpan( text: DateFormat('dd MMM yyyy').format(range.start), style: const TextStyle(fontWeight: FontWeight.bold)), const TextSpan(text: " sampai "), TextSpan( text: DateFormat('dd MMM yyyy').format(range.end), style: const TextStyle(fontWeight: FontWeight.bold)), const TextSpan( text: "?\n\nData yang dihapus tidak bisa dikembalikan."), ], ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text("Batal", style: TextStyle(color: Colors.grey.shade600)), ), ElevatedButton( onPressed: () { Navigator.pop(context); hapusJadwalRange(userId, range); }, style: ElevatedButton.styleFrom( backgroundColor: Colors.redAccent, foregroundColor: Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10)), ), child: const Text("Ya, Hapus"), ), ], ), ); } // ════════════════════════════════════════════════════════════════════════════ // UI HELPERS // ════════════════════════════════════════════════════════════════════════════ Widget _buildLabel(String text) { return Padding( padding: const EdgeInsets.only(bottom: 8.0, left: 4.0), child: Text( text, style: TextStyle( fontWeight: FontWeight.w600, fontSize: 13, color: Colors.grey.shade800, letterSpacing: 0.5, ), ), ); } InputDecoration _inputDecor(String hint, IconData icon) { return InputDecoration( prefixIcon: Icon(icon, color: primaryColor, size: 20), hintText: hint, hintStyle: TextStyle(color: Colors.grey.shade400, fontSize: 14), filled: true, fillColor: const Color(0xFFF9FAFB), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Colors.grey.shade200), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Colors.grey.shade200), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: primaryColor, width: 1.5), ), ); } // ════════════════════════════════════════════════════════════════════════════ // TAB 1: GENERATE JADWAL // ════════════════════════════════════════════════════════════════════════════ Widget _buildTabGenerate() { return SingleChildScrollView( physics: const BouncingScrollPhysics(), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 25), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildCard(Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildLabel("PILIH PETUGAS"), DropdownButtonFormField( value: selectedUserId, isExpanded: true, icon: const Icon(Icons.keyboard_arrow_down_rounded), decoration: _inputDecor("Cari nama petugas", Icons.person_outline), items: petugasList .map((p) => DropdownMenuItem( value: _toInt(p['id']), child: Text(p['name'] ?? '', style: const TextStyle(fontSize: 14)), )) .toList(), onChanged: (v) => setState(() => selectedUserId = v), ), const SizedBox(height: 20), _buildLabel("SHIFT UTAMA"), _shiftAktifList.isEmpty ? Container( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 15), decoration: BoxDecoration( color: const Color(0xFFF9FAFB), borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.grey.shade200), ), child: Row(children: [ Icon(Icons.timer_outlined, color: primaryColor, size: 20), const SizedBox(width: 10), Text("Memuat data shift...", style: TextStyle( color: Colors.grey.shade400, fontSize: 14)), const Spacer(), SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, color: primaryColor), ), ]), ) : DropdownButtonFormField( value: selectedShiftId, isExpanded: true, itemHeight: 56, icon: const Icon(Icons.keyboard_arrow_down_rounded), decoration: _inputDecor( "Pilih jam kerja", Icons.timer_outlined), items: _shiftAktifList .map((s) => DropdownMenuItem( value: _toInt(s['id']), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Text( "${s['kode_shift']} - ${s['nama_shift']}", style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600), ), Text( "${s['jam_mulai'] ?? '-'} – ${s['jam_selesai'] ?? '-'}", style: TextStyle( fontSize: 12, color: Colors.grey.shade500), ), ], ), )) .toList(), onChanged: (v) => setState(() => selectedShiftId = v), ), ], )), const SizedBox(height: 20), _buildCard(Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ _buildLabel("LOKASI PENEMPATAN"), GestureDetector( onTap: showTambahLokasiDialog, child: Text("+ Tambah", style: TextStyle( color: primaryColor, fontWeight: FontWeight.bold, fontSize: 13)), ), ], ), DropdownButtonFormField( value: selectedLokasiId, isExpanded: true, decoration: _inputDecor("Pilih lokasi kerja", Icons.map_outlined), items: lokasiList .map((l) => DropdownMenuItem( value: _toInt(l['id']), child: Text(l['nama_lokasi'] ?? '', style: const TextStyle(fontSize: 15)), )) .toList(), onChanged: (v) => setState(() => selectedLokasiId = v), ), const SizedBox(height: 20), _buildLabel("HARI LIBUR RUTIN"), DropdownButtonFormField( value: hariLibur, isExpanded: true, decoration: _inputDecor( "Pilih hari libur", Icons.event_busy_outlined), items: const [ DropdownMenuItem(value: 1, child: Text("Senin")), DropdownMenuItem(value: 2, child: Text("Selasa")), DropdownMenuItem(value: 3, child: Text("Rabu")), DropdownMenuItem(value: 4, child: Text("Kamis")), DropdownMenuItem(value: 5, child: Text("Jumat")), DropdownMenuItem(value: 6, child: Text("Sabtu")), DropdownMenuItem(value: 7, child: Text("Minggu")), ], onChanged: (v) => setState(() => hariLibur = v), ), ], )), const SizedBox(height: 20), _buildCard(Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildLabel("PERIODE JADWAL"), TextFormField( readOnly: true, onTap: () async { DateTimeRange? picked = await showDateRangePicker( context: context, firstDate: DateTime.now().subtract(const Duration(days: 30)), lastDate: DateTime(2030), builder: (context, child) => Theme( data: Theme.of(context).copyWith( colorScheme: ColorScheme.light(primary: primaryColor), ), child: child!, ), ); if (picked != null) { setState(() => selectedDateRange = picked); } }, decoration: _inputDecor( "Pilih rentang tanggal", Icons.calendar_month_outlined), controller: TextEditingController( text: selectedDateRange == null ? "" : "${DateFormat('dd MMM').format(selectedDateRange!.start)} - ${DateFormat('dd MMM yyyy').format(selectedDateRange!.end)}", ), ), ], )), const SizedBox(height: 35), Container( width: double.infinity, height: 55, decoration: BoxDecoration( borderRadius: BorderRadius.circular(15), boxShadow: [ BoxShadow( color: primaryColor.withValues(alpha: 0.3), blurRadius: 12, offset: const Offset(0, 6), ), ], ), child: ElevatedButton( onPressed: simpanJadwal, style: ElevatedButton.styleFrom( backgroundColor: primaryColor, foregroundColor: Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(15)), elevation: 0, ), child: const Text( "GENERATE JADWAL SEKARANG", style: TextStyle( fontSize: 15, fontWeight: FontWeight.bold, letterSpacing: 1), ), ), ), const SizedBox(height: 30), ], ), ); } // ════════════════════════════════════════════════════════════════════════════ // TAB 2: LIHAT & EDIT // ════════════════════════════════════════════════════════════════════════════ Widget _buildTabLihat() { return Column( children: [ Container( color: Colors.white, padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // FIX: safe cast id + guard fetchJadwal after setState DropdownButtonFormField( value: filterPetugasId, isExpanded: true, icon: const Icon(Icons.keyboard_arrow_down_rounded), decoration: _inputDecor( "Pilih petugas", Icons.person_search_outlined), items: petugasList .map((p) => DropdownMenuItem( value: _toInt(p['id']), child: Text(p['name'] ?? '', style: const TextStyle(fontSize: 14)), )) .toList(), onChanged: (v) { setState(() { filterPetugasId = v; jadwalGrouped = []; }); if (v != null) fetchJadwal(); }, ), const SizedBox(height: 12), Row( children: [ IconButton( onPressed: () { final newBulan = DateTime( filterBulan.year, filterBulan.month - 1); setState(() => filterBulan = newBulan); fetchJadwal(bulan: newBulan); }, icon: const Icon(Icons.chevron_left_rounded), style: IconButton.styleFrom( backgroundColor: const Color(0xFFF3F4F9), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10)), ), ), Expanded( child: Center( child: Text( DateFormat('MMMM yyyy', 'id_ID') .format(filterBulan), style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 15), ), ), ), IconButton( onPressed: () { final newBulan = DateTime( filterBulan.year, filterBulan.month + 1); setState(() => filterBulan = newBulan); fetchJadwal(bulan: newBulan); }, icon: const Icon(Icons.chevron_right_rounded), style: IconButton.styleFrom( backgroundColor: const Color(0xFFF3F4F9), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10)), ), ), ], ), ], ), ), if (filterPetugasId != null) Container( color: Colors.white, padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: SizedBox( width: double.infinity, height: 42, child: OutlinedButton.icon( onPressed: showHapusRangeSheet, icon: const Icon(Icons.delete_sweep_outlined, size: 18, color: Colors.redAccent), label: const Text("Hapus Jadwal per Range", style: TextStyle( color: Colors.redAccent, fontWeight: FontWeight.w600, fontSize: 13)), style: OutlinedButton.styleFrom( side: const BorderSide(color: Colors.redAccent), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10)), ), ), ), ), const Divider(height: 1), Expanded( child: filterPetugasId == null ? _buildEmptyState(Icons.person_search_outlined, "Pilih petugas terlebih dahulu", "Jadwal akan ditampilkan di sini") : isLoadingJadwal ? Center( child: CircularProgressIndicator(color: primaryColor)) : jadwalGrouped.isEmpty ? _buildEmptyState(Icons.calendar_today_outlined, "Belum ada jadwal", "Jadwal bulan ini belum di-generate") : ListView.builder( physics: const BouncingScrollPhysics(), padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), itemCount: jadwalGrouped.length, itemBuilder: (_, i) => _buildTanggalGroup(jadwalGrouped[i]), ), ), ], ); } Widget _buildTanggalGroup(Map group) { final tanggal = DateTime.parse(group['tanggal']); final shifts = group['shifts'] as List>; final namaHari = DateFormat('EEEE, dd MMMM yyyy', 'id_ID').format(tanggal); final now = DateTime.now(); // FIX: compare date-only so today is NOT considered "lewat" final todayOnly = DateTime(now.year, now.month, now.day); final tglOnly = DateTime(tanggal.year, tanggal.month, tanggal.day); final isHariLewat = tglOnly.isBefore(todayOnly); // FIX: deteksi libur dari SEMUA entry, bukan cuma shifts.first final isLiburHari = shifts.any((s) => (s['status'] ?? '').toString().toLowerCase() == 'libur'); // FIX: kalau hari itu libur, jangan tampilkan shift aktif lain yang // nyangkut di tanggal yang sama — cukup card "Hari Libur" aja final shiftsToShow = isLiburHari ? shifts .where((s) => (s['status'] ?? '').toString().toLowerCase() == 'libur') .toList() : shifts; return Container( margin: const EdgeInsets.only(bottom: 14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.only(left: 4, bottom: 8), child: Text( namaHari, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: Colors.grey.shade600, letterSpacing: 0.3, ), ), ), ...shiftsToShow.asMap().entries.map((entry) { final idx = entry.key; final shift = entry.value; final isFirst = idx == 0; final bisaDihapus = shiftsToShow.length > 1; return Column( children: [ if (!isFirst) Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Row( children: [ const SizedBox(width: 42), Container( width: 1.5, height: 12, color: Colors.grey.shade300, ), const SizedBox(width: 8), Text( "shift ke-${idx + 1}", style: TextStyle( fontSize: 10, color: Colors.grey.shade400, fontStyle: FontStyle.italic, ), ), ], ), ), _buildShiftCard(shift, bisaDihapus: bisaDihapus), ], ); }), // FIX: show "Tambah shift" for today AND future dates, skip past & libur if (!isHariLewat && shiftsToShow.isNotEmpty && !isLiburHari) Padding( padding: const EdgeInsets.only(top: 6), child: GestureDetector( onTap: () => showTambahShiftSheet(group['tanggal']), child: Container( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 10), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all( color: primaryColor.withValues(alpha: 0.3), ), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.add_circle_outline, size: 16, color: primaryColor), const SizedBox(width: 6), Text( "Tambah shift", style: TextStyle( fontSize: 12, color: primaryColor, fontWeight: FontWeight.w600, ), ), ], ), ), ), ), ], ), ); } Widget _buildShiftCard(Map jadwal, {bool bisaDihapus = false}) { final tanggal = DateTime.parse(jadwal['tanggal']); final status = jadwal['status'] ?? 'aktif'; final isLibur = status.toString().toLowerCase() == 'libur'; final namaHari = DateFormat('EEE', 'id_ID').format(tanggal).toUpperCase(); final statusColor = _statusColor(status.toString()); final jamMulai = jadwal['shift']?['jam_mulai']?.toString() ?? ''; final isMalam = jamMulai.isNotEmpty && int.tryParse(jamMulai.split(':')[0]) != null && int.parse(jamMulai.split(':')[0]) >= 20; // FIX: always use _toInt for safe id extraction final int jadwalId = _toInt(jadwal['id']); return Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), border: Border.all(color: Colors.grey.shade100), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.03), blurRadius: 8, offset: const Offset(0, 3), ), ], ), child: ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), leading: Container( width: 46, height: 54, decoration: BoxDecoration( color: isLibur ? Colors.amber.shade50 : isMalam ? Colors.purple.shade50 : primaryColor.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(12), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( namaHari, style: TextStyle( fontSize: 10, fontWeight: FontWeight.w600, color: isLibur ? Colors.amber.shade700 : isMalam ? Colors.purple.shade700 : primaryColor, ), ), Text( DateFormat('dd').format(tanggal), style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, color: isLibur ? Colors.amber.shade700 : isMalam ? Colors.purple.shade700 : primaryColor, ), ), ], ), ), title: isLibur ? const Text("Hari Libur", style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)) : Text( jadwal['shift'] != null ? "${jadwal['shift']['kode_shift']} - ${jadwal['shift']['nama_shift']}" : "Shift tidak tersedia", style: const TextStyle( fontWeight: FontWeight.w600, fontSize: 14), ), subtitle: Padding( padding: const EdgeInsets.only(top: 4, right: 12), child: Wrap( spacing: 6, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [ Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 3), decoration: BoxDecoration( color: isMalam && !isLibur ? Colors.purple.shade50 : statusColor.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(20), ), child: Text( isMalam && !isLibur ? "MALAM" : status.toString().toUpperCase(), style: TextStyle( fontSize: 10, fontWeight: FontWeight.bold, color: isMalam && !isLibur ? Colors.purple.shade700 : statusColor, ), ), ), if (!isLibur) ...[ if (jadwal['shift'] != null) Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.access_time_outlined, size: 12, color: Colors.grey.shade400), const SizedBox(width: 2), Text( "${jadwal['shift']['jam_mulai'] ?? '-'} – ${jadwal['shift']['jam_selesai'] ?? '-'}", style: TextStyle( fontSize: 11, color: Colors.grey.shade500), ), ], ), if (jadwal['lokasi'] != null) Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.location_on_outlined, size: 12, color: Colors.grey.shade400), const SizedBox(width: 2), Text( jadwal['lokasi']['nama_lokasi'], style: TextStyle( fontSize: 11, color: Colors.grey.shade500), ), ], ), _buildLaporanBadge( (jadwal['laporan_count'] ?? 0) is int ? jadwal['laporan_count'] ?? 0 : int.tryParse( jadwal['laporan_count'].toString()) ?? 0, ), ], ], ), ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ Container( margin: const EdgeInsets.only(right: 6), child: IconButton( onPressed: () => showEditJadwalSheet(jadwal, bisaDihapus: bisaDihapus), icon: Icon(Icons.edit_outlined, color: primaryColor, size: 18), style: IconButton.styleFrom( backgroundColor: primaryColor.withValues(alpha: 0.08), minimumSize: const Size(38, 38), padding: EdgeInsets.zero, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10)), ), ), ), IconButton( // FIX: use safe-parsed jadwalId onPressed: () => _showKonfirmasiHapusSingleShift(jadwalId), icon: const Icon(Icons.delete_outline, color: Colors.redAccent, size: 18), style: IconButton.styleFrom( backgroundColor: Colors.redAccent.withValues(alpha: 0.08), minimumSize: const Size(38, 38), padding: EdgeInsets.zero, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10)), ), ), ], ), ), ); } Widget _buildEmptyState(IconData icon, String title, String subtitle) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(icon, size: 60, color: Colors.grey.shade300), const SizedBox(height: 16), Text(title, style: TextStyle( fontWeight: FontWeight.w600, fontSize: 15, color: Colors.grey.shade600)), const SizedBox(height: 4), Text(subtitle, style: TextStyle( fontSize: 13, color: Colors.grey.shade400)), ], ), ); } Widget _buildCard(Widget child) { return Material( color: cardColor, borderRadius: BorderRadius.circular(20), elevation: 0, child: Container( decoration: BoxDecoration( color: cardColor, borderRadius: BorderRadius.circular(20), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.04), blurRadius: 10, offset: const Offset(0, 4), ), ], ), padding: const EdgeInsets.all(20), child: child, ), ); } Widget _buildLaporanBadge(int count) { final bool sudah = count > 0; return Container( padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), decoration: BoxDecoration( color: sudah ? const Color(0xFF22C55E).withValues(alpha: 0.12) : Colors.orange.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(20), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( sudah ? Icons.check_circle_outline : Icons.radio_button_unchecked, size: 11, color: sudah ? const Color(0xFF22C55E) : Colors.orange, ), const SizedBox(width: 3), Text( sudah ? '$count laporan' : 'Belum laporan', style: TextStyle( fontSize: 10, fontWeight: FontWeight.bold, color: sudah ? const Color(0xFF22C55E) : Colors.orange, ), overflow: TextOverflow.ellipsis, ), ], ), ); } // ════════════════════════════════════════════════════════════════════════════ // BUILD // ════════════════════════════════════════════════════════════════════════════ @override Widget build(BuildContext context) { return Scaffold( backgroundColor: scaffoldBg, appBar: AppBar( title: const Text( "Kelola Jadwal", style: TextStyle( fontWeight: FontWeight.bold, color: Colors.white, fontSize: 18), ), centerTitle: true, backgroundColor: primaryColor, elevation: 0, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(bottom: Radius.circular(20)), ), bottom: TabBar( controller: _tabController, indicatorColor: Colors.white, indicatorWeight: 3, labelColor: Colors.white, unselectedLabelColor: Colors.white60, labelStyle: const TextStyle( fontWeight: FontWeight.bold, fontSize: 13), tabs: const [ Tab( icon: Icon(Icons.add_circle_outline, size: 18), text: "Generate"), Tab( icon: Icon(Icons.list_alt_outlined, size: 18), text: "Lihat & Edit"), ], ), ), body: TabBarView( controller: _tabController, children: [ _buildTabGenerate(), _buildTabLihat(), ], ), ); } }