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

2458 lines
99 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<KelolaJadwalScreen> createState() => _KelolaJadwalScreenState();
}
class _KelolaJadwalScreenState extends State<KelolaJadwalScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
List<Map<String, dynamic>> petugasList = [];
List<Map<String, dynamic>> shiftList = [];
List<Map<String, dynamic>> lokasiList = [];
int? selectedUserId;
int? selectedShiftId;
int? selectedLokasiId;
int? hariLibur;
DateTimeRange? selectedDateRange;
int? filterPetugasId;
DateTime filterBulan = DateTime(DateTime.now().year, DateTime.now().month);
List<Map<String, dynamic>> 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<Map<String, dynamic>> 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<String?> 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<void> _logoutDanKeLogin() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove('token');
if (!mounted) return;
Navigator.of(context).pushNamedAndRemoveUntil('/login', (_) => false);
}
// ════════════════════════════════════════════════════════════════════════════
// API CALLS
// ════════════════════════════════════════════════════════════════════════════
Future<void> 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<Map<String, dynamic>> parsed =
List<Map<String, dynamic>>.from(data).map((p) {
return {
...p,
'id': _toInt(p['id']),
};
}).toList();
setState(() => petugasList = parsed);
}
} catch (e) {
debugPrint("Error fetch petugas: $e");
}
}
Future<void> 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<Map<String, dynamic>> shifts =
List<Map<String, dynamic>>.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<void> 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<Map<String, dynamic>> parsed =
List<Map<String, dynamic>>.from(data).map((l) {
return {
...l,
'id': _toInt(l['id']),
};
}).toList();
setState(() => lokasiList = parsed);
}
} catch (e) {
debugPrint("Error fetch lokasi: $e");
}
}
Future<void> 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<void> 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<String, dynamic> shift =
Map<String, dynamic>.from(s as Map);
// Normalize id to int throughout the nested structure
shift['id'] = _toInt(shift['id']);
if (shift['shift'] != null) {
shift['shift'] = Map<String, dynamic>.from(
shift['shift'] as Map)
..['id'] = _toInt((shift['shift'] as Map)['id']);
}
if (shift['lokasi'] != null) {
shift['lokasi'] = Map<String, dynamic>.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<void> updateJadwal(
int jadwalId, int? shiftId, int? lokasiId, String status) async {
try {
String? token = await getToken();
if (token == null) return;
final body = <String, dynamic>{"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<void> 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<void> 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<void> 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<void> 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<String, dynamic> 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<int>(
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<int>(
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<int>(
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<int>(
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<DateTime> tanggalAdaJadwal = jadwalGrouped
.map((g) {
try {
final d = DateTime.parse(g['tanggal']);
return DateTime(d.year, d.month, d.day);
} catch (_) {
return null;
}
})
.whereType<DateTime>()
.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<int>(
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<int>(
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<int>(
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<int>(
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<int>(
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<String, dynamic> group) {
final tanggal = DateTime.parse(group['tanggal']);
final shifts = group['shifts'] as List<Map<String, dynamic>>;
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<String, dynamic> 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(),
],
),
);
}
}