2458 lines
99 KiB
Dart
2458 lines
99 KiB
Dart
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(),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
} |