From e526b8ff1a3bdfdad41ff93666cfe8f4709cbf6a Mon Sep 17 00:00:00 2001 From: jouel88 Date: Sun, 29 Mar 2026 11:14:09 +0700 Subject: [PATCH] perbaikan --- lib/main.dart | 5 +- lib/models/announcement_model.dart | 6 + lib/models/pengajuan_model.dart | 24 + lib/providers/attendance_provider.dart | 26 +- lib/repositories/attendance_repository.dart | 7 +- .../home/pengumuman_detail_screen.dart | 232 ++++++ .../notification/notification_screen.dart | 44 +- .../notifikasi_detail_screen.dart | 397 ++++++++++ .../pengajuan/forms/cuti_form_screen.dart | 15 +- .../pengajuan/forms/izin_form_screen.dart | 48 +- lib/screens/poin/point_usage_screen.dart | 8 +- lib/screens/presensi/presensi_screen.dart | 544 +++++++++----- lib/screens/profile/profile_screen.dart | 5 - lib/screens/profile/signature_screen.dart | 15 +- lib/services/fcm_service.dart | 37 +- lib/widgets/molecules/pengumuman_card.dart | 12 +- .../organisms/pengajuan_detail_sheet.dart | 191 ++++- .../organisms/presensi_detail_sheet.dart | 708 +++++++++--------- .../organisms/surat_izin_detail_sheet.dart | 295 ++++++++ 19 files changed, 1979 insertions(+), 640 deletions(-) create mode 100644 lib/screens/home/pengumuman_detail_screen.dart create mode 100644 lib/screens/notification/notifikasi_detail_screen.dart create mode 100644 lib/widgets/organisms/surat_izin_detail_sheet.dart diff --git a/lib/main.dart b/lib/main.dart index d918da5..54ebb2c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -38,7 +38,6 @@ import 'screens/notification/notification_screen.dart'; import 'screens/profile/face_test_screen.dart'; import 'screens/profile/signature_screen.dart'; import 'screens/profile/edit_profile_screen.dart'; -import 'screens/documents/surat_izin_screen.dart'; import 'screens/onboarding/onboarding_check_screen.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; @@ -49,6 +48,8 @@ import 'package:intl/date_symbol_data_local.dart'; import 'package:timezone/data/latest_all.dart' as tz; import 'services/reminder_service.dart'; +final GlobalKey navigatorKey = GlobalKey(); + void main() async { WidgetsFlutterBinding.ensureInitialized(); await dotenv.load(fileName: ".env"); @@ -85,6 +86,7 @@ class MyApp extends StatelessWidget { ChangeNotifierProvider(create: (_) => SetupCheckProvider()), ], child: MaterialApp( + navigatorKey: navigatorKey, title: 'MPG HRIS', scaffoldMessengerKey: ErrorHandler.scaffoldMessengerKey, debugShowCheckedModeBanner: false, @@ -117,7 +119,6 @@ class MyApp extends StatelessWidget { '/profile/face-test': (context) => const FaceTestScreen(), '/profile/signature': (context) => const SignatureScreen(), '/profile/edit': (context) => const EditProfileScreen(), - '/surat-izin': (context) => const SuratIzinScreen(), }, ), ); diff --git a/lib/models/announcement_model.dart b/lib/models/announcement_model.dart index 06dc5a0..e308d5b 100644 --- a/lib/models/announcement_model.dart +++ b/lib/models/announcement_model.dart @@ -1,24 +1,30 @@ class AnnouncementModel { + final int id; final String title; final String description; final String date; final String jabatan; + final String namaPembuat; final String? avatarUrl; AnnouncementModel({ + required this.id, required this.title, required this.description, required this.date, required this.jabatan, + this.namaPembuat = 'Admin', this.avatarUrl, }); factory AnnouncementModel.fromJson(Map json) { return AnnouncementModel( + id: json['id'] ?? 0, title: json['title']?.toString() ?? '', description: json['description']?.toString() ?? '', date: json['tanggal']?.toString() ?? '', jabatan: json['jabatan']?.toString() ?? '', + namaPembuat: json['nama_pembuat']?.toString() ?? 'Admin', avatarUrl: json['avatar_url']?.toString(), ); } diff --git a/lib/models/pengajuan_model.dart b/lib/models/pengajuan_model.dart index 35f8312..ebe0777 100644 --- a/lib/models/pengajuan_model.dart +++ b/lib/models/pengajuan_model.dart @@ -10,6 +10,12 @@ class PengajuanModel { final String? approvedAt; final String? jamMulai; final String? jamSelesai; + final String? lampiranUrl; + final bool hasSurat; + final String? idSurat; + final String? namaKompensasi; + final int? durasiMenit; + final int? jumlahPoin; PengajuanModel({ required this.id, @@ -23,6 +29,12 @@ class PengajuanModel { this.approvedAt, this.jamMulai, this.jamSelesai, + this.lampiranUrl, + this.hasSurat = false, + this.idSurat, + this.namaKompensasi, + this.durasiMenit, + this.jumlahPoin, }); factory PengajuanModel.fromJson(Map json) { @@ -40,6 +52,9 @@ class PengajuanModel { keterangan: json['alasan'] ?? '', status: _mapStatus(json['id_status']), approvedAt: json['approved_at'], + lampiranUrl: json['bukti_file_url'], + hasSurat: json['has_surat'] == true, + idSurat: json['id_surat']?.toString(), ); } @@ -58,6 +73,15 @@ class PengajuanModel { approvedAt: json['approved_at'], jamMulai: json['jam_mulai'], jamSelesai: json['jam_selesai'], + namaKompensasi: json['kompensasi'] != null + ? json['kompensasi']['nama_kompensasi'] + : null, + durasiMenit: json['durasi_menit'] != null + ? int.tryParse(json['durasi_menit'].toString()) + : null, + jumlahPoin: json['jumlah_poin'] != null + ? int.tryParse(json['jumlah_poin'].toString()) + : null, ); } diff --git a/lib/providers/attendance_provider.dart b/lib/providers/attendance_provider.dart index bed91cf..78454cf 100644 --- a/lib/providers/attendance_provider.dart +++ b/lib/providers/attendance_provider.dart @@ -48,7 +48,9 @@ class AttendanceProvider extends ChangeNotifier { } Position position = await Geolocator.getCurrentPosition( - desiredAccuracy: LocationAccuracy.bestForNavigation, + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.bestForNavigation, + ), ); await updateLocation(position.latitude, position.longitude); @@ -94,7 +96,10 @@ class AttendanceProvider extends ChangeNotifier { isLoading = true; notifyListeners(); try { - historyList = await _repository.getHistory(); + historyList = await _repository.getHistory( + month: selectedMonth, + year: selectedYear, + ); } catch (e) { print('Error fetching history: $e'); } finally { @@ -103,6 +108,18 @@ class AttendanceProvider extends ChangeNotifier { } } + void setMonth(int month) { + selectedMonth = month; + notifyListeners(); + fetchHistory(); + } + + void setYear(int year) { + selectedYear = year; + notifyListeners(); + fetchHistory(); + } + Future submitPresensi(String type, File photoFile) async { if (currentLocation == null) { print('Location not available'); @@ -137,11 +154,6 @@ class AttendanceProvider extends ChangeNotifier { } } - void setMonth(int month) { - selectedMonth = month; - fetchHistory(); - notifyListeners(); - } Future resubmitPresensi(int idPresensi, String keterangan) async { isLoading = true; diff --git a/lib/repositories/attendance_repository.dart b/lib/repositories/attendance_repository.dart index edce52a..d22bc56 100644 --- a/lib/repositories/attendance_repository.dart +++ b/lib/repositories/attendance_repository.dart @@ -84,9 +84,12 @@ class AttendanceRepository { } } - Future> getHistory() async { + Future> getHistory({required int month, required int year}) async { try { - final response = await _client.dio.get('${ApiUrl.baseUrl}/presensi/history'); + final response = await _client.dio.get( + '${ApiUrl.baseUrl}/presensi/history', + queryParameters: {'month': month, 'year': year}, + ); if (response.statusCode == 200 && response.data['success'] == true) { final List data = response.data['data']['data'] ?? []; diff --git a/lib/screens/home/pengumuman_detail_screen.dart b/lib/screens/home/pengumuman_detail_screen.dart new file mode 100644 index 0000000..8e3ffec --- /dev/null +++ b/lib/screens/home/pengumuman_detail_screen.dart @@ -0,0 +1,232 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import '../../core/theme.dart'; +import '../../models/announcement_model.dart'; +import '../../widgets/atoms/custom_avatar.dart'; +import '../../widgets/atoms/fade_in_up.dart'; + +class PengumumanDetailScreen extends StatelessWidget { + final AnnouncementModel pengumuman; + + const PengumumanDetailScreen({super.key, required this.pengumuman}); + + String _formatTanggal(String date) { + try { + final dt = DateTime.parse(date); + return DateFormat('EEEE, d MMMM yyyy', 'id_ID').format(dt); + } catch (_) { + return date; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.bgLight, + body: CustomScrollView( + slivers: [ + SliverAppBar( + expandedHeight: 200, + pinned: true, + backgroundColor: AppTheme.primaryBlue, + leading: Padding( + padding: const EdgeInsets.all(8), + child: Container( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(12), + ), + child: IconButton( + icon: const Icon(Icons.arrow_back_ios_new, color: Colors.white, size: 18), + onPressed: () => Navigator.pop(context), + ), + ), + ), + flexibleSpace: FlexibleSpaceBar( + background: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Color(0xFF1E3A8A), + Color(0xFF3B82F6), + Color(0xFF6366F1), + ], + ), + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 56, 20, 20), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.campaign_rounded, color: Colors.white, size: 14), + const SizedBox(width: 4), + Text( + 'Pengumuman', + style: AppTheme.bodySmall.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + const SizedBox(height: 10), + Text( + pengumuman.title, + style: AppTheme.heading2.copyWith( + color: Colors.white, + fontSize: 20, + height: 1.3, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + ), + collapseMode: CollapseMode.parallax, + ), + ), + + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(AppTheme.spacingMd), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FadeInUp( + delayMs: 0, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + boxShadow: AppTheme.shadowSm, + ), + child: Row( + children: [ + CustomAvatar( + name: pengumuman.namaPembuat, + imageUrl: pengumuman.avatarUrl, + size: 44, + backgroundColor: AppTheme.primaryBlue.withValues(alpha: 0.1), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + pengumuman.namaPembuat, + style: AppTheme.labelLarge.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + pengumuman.jabatan, + style: AppTheme.bodySmall.copyWith( + color: AppTheme.textTertiary, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: AppTheme.primaryBlue.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.calendar_today_rounded, size: 12, color: AppTheme.primaryBlue), + const SizedBox(width: 4), + Text( + _formatTanggal(pengumuman.date), + style: AppTheme.bodySmall.copyWith( + color: AppTheme.primaryBlue, + fontWeight: FontWeight.w600, + fontSize: 10, + ), + ), + ], + ), + ), + ], + ), + ), + ), + + const SizedBox(height: 16), + + FadeInUp( + delayMs: 80, + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + boxShadow: AppTheme.shadowSm, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 4, + height: 20, + decoration: BoxDecoration( + color: AppTheme.primaryBlue, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 8), + Text( + 'Isi Pengumuman', + style: AppTheme.heading3.copyWith(fontSize: 14), + ), + ], + ), + const SizedBox(height: 16), + Text( + pengumuman.description, + style: AppTheme.bodyLarge.copyWith( + color: AppTheme.textSecondary, + height: 1.7, + fontSize: 14, + ), + ), + ], + ), + ), + ), + + const SizedBox(height: 80), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/notification/notification_screen.dart b/lib/screens/notification/notification_screen.dart index 5ec5eeb..90e0bd7 100644 --- a/lib/screens/notification/notification_screen.dart +++ b/lib/screens/notification/notification_screen.dart @@ -4,6 +4,7 @@ import '../../core/theme.dart'; import '../../providers/notification_provider.dart'; import '../../models/notifikasi_model.dart'; import '../../widgets/atoms/fade_in_up.dart'; +import 'notifikasi_detail_screen.dart'; class NotificationScreen extends StatefulWidget { const NotificationScreen({super.key}); @@ -81,6 +82,18 @@ class _NotificationScreenState extends State { } } + void _openDetail(NotifikasiModel item, NotificationProvider provider) { + if (!item.isRead) { + provider.markAsRead(item.id); + } + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => NotifikasiDetailScreen(notifikasi: item), + ), + ); + } + @override Widget build(BuildContext context) { return PopScope( @@ -214,11 +227,7 @@ class _NotificationScreenState extends State { child: Padding( padding: const EdgeInsets.only(bottom: AppTheme.spacingSm), child: GestureDetector( - onTap: () { - if (!item.isRead) { - provider.markAsRead(item.id); - } - }, + onTap: () => _openDetail(item, provider), child: Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( @@ -277,12 +286,23 @@ class _NotificationScreenState extends State { overflow: TextOverflow.ellipsis, ), const SizedBox(height: 5), - Text( - _relativeTime(item.createdAt), - style: AppTheme.bodySmall.copyWith( - color: AppTheme.textTertiary, - fontSize: 11, - ), + Row( + children: [ + Expanded( + child: Text( + _relativeTime(item.createdAt), + style: AppTheme.bodySmall.copyWith( + color: AppTheme.textTertiary, + fontSize: 11, + ), + ), + ), + Icon( + Icons.chevron_right_rounded, + size: 16, + color: AppTheme.textTertiary.withValues(alpha: 0.5), + ), + ], ), ], ), @@ -305,3 +325,5 @@ class _NotificationScreenState extends State { ); } } + + diff --git a/lib/screens/notification/notifikasi_detail_screen.dart b/lib/screens/notification/notifikasi_detail_screen.dart new file mode 100644 index 0000000..5fd9eff --- /dev/null +++ b/lib/screens/notification/notifikasi_detail_screen.dart @@ -0,0 +1,397 @@ +import 'package:flutter/material.dart'; +import '../../core/theme.dart'; +import '../../models/notifikasi_model.dart'; +import '../../widgets/atoms/fade_in_up.dart'; + +class NotifikasiDetailScreen extends StatelessWidget { + final NotifikasiModel notifikasi; + + const NotifikasiDetailScreen({super.key, required this.notifikasi}); + + IconData _iconForTipe(String tipe) { + if (tipe.contains('disetujui')) return Icons.check_circle_rounded; + if (tipe.contains('ditolak')) return Icons.cancel_rounded; + if (tipe.contains('proses')) return Icons.hourglass_top_rounded; + if (tipe.contains('pengumuman')) return Icons.campaign_rounded; + if (tipe.contains('lembur')) return Icons.schedule_rounded; + if (tipe.contains('presensi')) return Icons.fingerprint_rounded; + if (tipe.contains('izin')) return Icons.description_rounded; + if (tipe.contains('cuti')) return Icons.beach_access_rounded; + if (tipe.contains('poin')) return Icons.stars_rounded; + return Icons.notifications_outlined; + } + + Color _colorForTipe(String tipe) { + if (tipe.contains('disetujui')) return const Color(0xFF10b981); + if (tipe.contains('ditolak')) return const Color(0xFFef4444); + if (tipe.contains('pengumuman')) return AppTheme.primaryBlue; + if (tipe.contains('lembur')) return const Color(0xFFf59e0b); + if (tipe.contains('poin')) return const Color(0xFFe11d48); + if (tipe.contains('izin')) return const Color(0xFF8b5cf6); + if (tipe.contains('cuti')) return const Color(0xFF06b6d4); + if (tipe.contains('presensi')) return const Color(0xFF06b6d4); + return const Color(0xFF6366f1); + } + + String _labelForTipe(String tipe) { + if (tipe.contains('disetujui')) return 'Disetujui'; + if (tipe.contains('ditolak')) return 'Ditolak'; + if (tipe.contains('proses')) return 'Dalam Proses'; + if (tipe.contains('pengumuman')) return 'Pengumuman'; + if (tipe.contains('lembur')) return 'Lembur'; + if (tipe.contains('presensi')) return 'Presensi'; + if (tipe.contains('izin')) return 'Izin'; + if (tipe.contains('cuti')) return 'Cuti'; + if (tipe.contains('poin')) return 'Poin'; + return 'Notifikasi'; + } + + String _relativeTime(String createdAt) { + try { + final dt = DateTime.parse(createdAt).toLocal(); + final now = DateTime.now(); + final diff = now.difference(dt); + + if (diff.inSeconds < 60) return 'Baru saja'; + if (diff.inMinutes < 60) return '${diff.inMinutes} menit lalu'; + if (diff.inHours < 24) return '${diff.inHours} jam lalu'; + if (diff.inDays == 1) return 'Kemarin'; + if (diff.inDays < 7) return '${diff.inDays} hari lalu'; + if (diff.inDays < 30) return '${(diff.inDays / 7).floor()} minggu lalu'; + + return '${dt.day.toString().padLeft(2, '0')}/' + '${dt.month.toString().padLeft(2, '0')}/' + '${dt.year}'; + } catch (_) { + return createdAt; + } + } + + String _fullDateTime(String createdAt) { + try { + final dt = DateTime.parse(createdAt).toLocal(); + final hari = ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu', 'Minggu']; + final bulan = [ + 'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni', + 'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember' + ]; + return '${hari[dt.weekday - 1]}, ${dt.day} ${bulan[dt.month - 1]} ${dt.year} • ' + '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; + } catch (_) { + return createdAt; + } + } + + String? _actionRoute(String tipe) { + if (tipe.contains('pengumuman')) return null; + if (tipe.contains('izin') || tipe.contains('cuti') || tipe.contains('lembur') || tipe.contains('sakit')) { + return '/pengajuan'; + } + if (tipe.contains('presensi')) return '/presensi'; + return null; + } + + String? _actionLabel(String tipe) { + if (tipe.contains('izin') || tipe.contains('cuti') || tipe.contains('lembur') || tipe.contains('sakit')) { + return 'Lihat Pengajuan'; + } + if (tipe.contains('presensi')) return 'Lihat Riwayat Presensi'; + return null; + } + + @override + Widget build(BuildContext context) { + final color = _colorForTipe(notifikasi.tipe); + final icon = _iconForTipe(notifikasi.tipe); + final label = _labelForTipe(notifikasi.tipe); + final actionRoute = _actionRoute(notifikasi.tipe); + final actionLabel = _actionLabel(notifikasi.tipe); + + return Scaffold( + backgroundColor: AppTheme.bgLight, + appBar: AppBar( + title: Text("Detail Notifikasi", style: AppTheme.heading3), + backgroundColor: AppTheme.bgLight, + elevation: 0, + centerTitle: true, + leading: Padding( + padding: const EdgeInsets.all(8), + child: Container( + decoration: BoxDecoration( + color: AppTheme.bgInput, + borderRadius: BorderRadius.circular(12), + ), + child: IconButton( + icon: const Icon(Icons.arrow_back_ios_new, color: AppTheme.textPrimary, size: 18), + onPressed: () => Navigator.pop(context), + ), + ), + ), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(AppTheme.spacingMd), + child: Column( + children: [ + FadeInUp( + delayMs: 0, + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(AppTheme.radiusXl), + boxShadow: AppTheme.shadowMd, + ), + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + color.withValues(alpha: 0.15), + color.withValues(alpha: 0.05), + ], + ), + shape: BoxShape.circle, + ), + child: Icon(icon, color: color, size: 32), + ), + const SizedBox(height: 16), + + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + label, + style: AppTheme.bodySmall.copyWith( + color: color, + fontWeight: FontWeight.w700, + fontSize: 11, + ), + ), + ), + const SizedBox(height: 16), + + Text( + notifikasi.judul, + style: AppTheme.heading2.copyWith(fontSize: 18), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.access_time_rounded, size: 13, color: AppTheme.textTertiary), + const SizedBox(width: 4), + Text( + _relativeTime(notifikasi.createdAt), + style: AppTheme.bodySmall.copyWith( + color: AppTheme.textTertiary, + fontSize: 12, + ), + ), + ], + ), + ], + ), + ), + ), + + const SizedBox(height: 16), + + FadeInUp( + delayMs: 60, + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + boxShadow: AppTheme.shadowSm, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 4, + height: 20, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 8), + Text( + 'Detail Pesan', + style: AppTheme.heading3.copyWith(fontSize: 14), + ), + ], + ), + const SizedBox(height: 16), + + Text( + notifikasi.pesan, + style: AppTheme.bodyLarge.copyWith( + color: AppTheme.textSecondary, + height: 1.7, + fontSize: 14, + ), + ), + + const SizedBox(height: 20), + + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.bgInput, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + Icon(Icons.schedule_rounded, size: 14, color: AppTheme.textTertiary), + const SizedBox(width: 8), + Expanded( + child: Text( + _fullDateTime(notifikasi.createdAt), + style: AppTheme.bodySmall.copyWith( + color: AppTheme.textSecondary, + fontSize: 12, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + + if (notifikasi.data != null && notifikasi.data!.isNotEmpty) ...[ + const SizedBox(height: 16), + FadeInUp( + delayMs: 120, + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + boxShadow: AppTheme.shadowSm, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 4, + height: 20, + decoration: BoxDecoration( + color: AppTheme.textTertiary, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 8), + Text( + 'Informasi Tambahan', + style: AppTheme.heading3.copyWith(fontSize: 14), + ), + ], + ), + const SizedBox(height: 12), + ...notifikasi.data!.entries + .where((e) => e.key != 'tipe' && e.value != null && e.value.toString().isNotEmpty) + .map((e) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 120, + child: Text( + _formatDataKey(e.key), + style: AppTheme.bodySmall.copyWith( + color: AppTheme.textTertiary, + fontSize: 12, + ), + ), + ), + Expanded( + child: Text( + e.value.toString(), + style: AppTheme.bodyMedium.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + )), + ], + ), + ), + ), + ], + + if (actionRoute != null && actionLabel != null) ...[ + const SizedBox(height: 24), + FadeInUp( + delayMs: 160, + child: SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () { + Navigator.pop(context); + Navigator.pushNamed(context, actionRoute); + }, + icon: Icon( + notifikasi.tipe.contains('presensi') + ? Icons.fingerprint_rounded + : Icons.description_rounded, + size: 18, + ), + label: Text(actionLabel), + style: ElevatedButton.styleFrom( + backgroundColor: color, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppTheme.radiusMd), + ), + elevation: 0, + textStyle: AppTheme.labelLarge.copyWith( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ), + ], + + const SizedBox(height: 40), + ], + ), + ), + ); + } + + String _formatDataKey(String key) { + return key + .replaceAll('_', ' ') + .split(' ') + .map((w) => w.isNotEmpty ? '${w[0].toUpperCase()}${w.substring(1)}' : '') + .join(' '); + } +} diff --git a/lib/screens/pengajuan/forms/cuti_form_screen.dart b/lib/screens/pengajuan/forms/cuti_form_screen.dart index 0262e00..314b15a 100644 --- a/lib/screens/pengajuan/forms/cuti_form_screen.dart +++ b/lib/screens/pengajuan/forms/cuti_form_screen.dart @@ -44,6 +44,12 @@ class _CutiFormScreenState extends State { return; } + final jumlahHari = _endDate!.difference(_startDate!).inDays + 1; + if (jumlahHari > 3) { + ErrorHandler.showWarning('Pengajuan cuti maksimal 3 hari berturut-turut'); + return; + } + final success = await context.read().submitCuti({ 'tanggal_mulai': DateFormat('yyyy-MM-dd').format(_startDate!), 'tanggal_selesai': DateFormat('yyyy-MM-dd').format(_endDate!), @@ -79,12 +85,13 @@ class _CutiFormScreenState extends State { Future _selectEndDate(DateTime minDate) async { final DateTime initialDate = _startDate ?? minDate; + final DateTime maxEndDate = initialDate.add(const Duration(days: 2)); final DateTime? picked = await showDatePicker( context: context, - initialDate: _endDate ?? initialDate, + initialDate: _endDate != null && _endDate!.isAfter(maxEndDate) ? initialDate : (_endDate ?? initialDate), firstDate: initialDate, - lastDate: DateTime(2100), - helpText: "Pilih Tanggal Selesai", + lastDate: maxEndDate, + helpText: "Pilih Tanggal Selesai (maks 3 hari)", ); if (picked != null) { setState(() { @@ -149,7 +156,7 @@ class _CutiFormScreenState extends State { const SizedBox(width: 12), Expanded( child: Text( - "Cuti harus diajukan minimal 7 hari sebelum tanggal mulai", + "Cuti harus diajukan minimal H-7 dan maksimal 3 hari berturut-turut", style: AppTheme.bodySmall.copyWith( color: AppTheme.primaryBlue, fontWeight: FontWeight.w500, diff --git a/lib/screens/pengajuan/forms/izin_form_screen.dart b/lib/screens/pengajuan/forms/izin_form_screen.dart index f0792fd..b26aead 100644 --- a/lib/screens/pengajuan/forms/izin_form_screen.dart +++ b/lib/screens/pengajuan/forms/izin_form_screen.dart @@ -4,7 +4,7 @@ import '../../../core/theme.dart'; import '../../../widgets/atoms/custom_button.dart'; import '../../../widgets/atoms/custom_text_field.dart'; import '../../../providers/pengajuan_provider.dart'; -import '../../../providers/home_provider.dart'; + import 'package:provider/provider.dart'; import 'package:intl/intl.dart'; import 'package:file_picker/file_picker.dart'; @@ -110,7 +110,7 @@ class _IzinFormScreenState extends State { final pengajuanProvider = context.watch(); final hasSignature = pengajuanProvider.hasSignature; final isLoading = pengajuanProvider.isLoading; - final sisaCuti = context.watch().sisaCuti; + return Scaffold( backgroundColor: AppTheme.bgLight, @@ -148,50 +148,6 @@ class _IzinFormScreenState extends State { ), ), - // Banner sisa cuti - Container( - margin: const EdgeInsets.only(bottom: 16), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: sisaCuti > 0 - ? AppTheme.statusGreen.withValues(alpha: 0.08) - : AppTheme.statusRed.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(AppTheme.radiusMd), - border: Border.all( - color: sisaCuti > 0 - ? AppTheme.statusGreen.withValues(alpha: 0.3) - : AppTheme.statusRed.withValues(alpha: 0.3), - ), - ), - child: Row( - children: [ - Icon( - sisaCuti > 0 ? Icons.event_available : Icons.event_busy, - color: sisaCuti > 0 ? AppTheme.statusGreen : AppTheme.statusRed, - size: 20, - ), - const SizedBox(width: 10), - Expanded( - child: RichText( - text: TextSpan( - style: AppTheme.bodySmall.copyWith( - color: sisaCuti > 0 ? AppTheme.statusGreen : AppTheme.statusRed, - ), - children: [ - const TextSpan(text: 'Sisa cuti Anda: '), - TextSpan( - text: '$sisaCuti hari', - style: const TextStyle(fontWeight: FontWeight.bold), - ), - if (sisaCuti <= 0) - const TextSpan(text: '. Kuota cuti Anda sudah habis.'), - ], - ), - ), - ), - ], - ), - ), CustomTextField( label: "Tanggal Izin", diff --git a/lib/screens/poin/point_usage_screen.dart b/lib/screens/poin/point_usage_screen.dart index e61f710..5cd3ea0 100644 --- a/lib/screens/poin/point_usage_screen.dart +++ b/lib/screens/poin/point_usage_screen.dart @@ -317,7 +317,12 @@ class _PointUsageScreenState extends State { ), ], ), - body: SingleChildScrollView( + body: RefreshIndicator( + onRefresh: () async { + await context.read().loadExpiringPoints(); + }, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.all(AppTheme.spacingMd), child: Form( key: _formKey, @@ -568,6 +573,7 @@ class _PointUsageScreenState extends State { ), ), ), + ), ); } } diff --git a/lib/screens/presensi/presensi_screen.dart b/lib/screens/presensi/presensi_screen.dart index 948717e..547f569 100644 --- a/lib/screens/presensi/presensi_screen.dart +++ b/lib/screens/presensi/presensi_screen.dart @@ -27,6 +27,38 @@ class _PresensiScreenState extends State { }); } + static const List _monthNames = [ + 'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni', + 'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember', + ]; + + String _getMonthName(int month) => _monthNames[month - 1]; + + void _goToPrevMonth(AttendanceProvider p) { + if (p.selectedMonth == 1) { + p.setYear(p.selectedYear - 1); + p.setMonth(12); + } else { + p.setMonth(p.selectedMonth - 1); + } + } + + void _goToNextMonth(AttendanceProvider p) { + final now = DateTime.now(); + if (p.selectedYear == now.year && p.selectedMonth == now.month) return; + if (p.selectedMonth == 12) { + p.setYear(p.selectedYear + 1); + p.setMonth(1); + } else { + p.setMonth(p.selectedMonth + 1); + } + } + + bool _isCurrentMonth(AttendanceProvider p) { + final now = DateTime.now(); + return p.selectedYear == now.year && p.selectedMonth == now.month; + } + @override Widget build(BuildContext context) { final homeProvider = context.watch(); @@ -88,207 +120,195 @@ class _PresensiScreenState extends State { const SizedBox(height: AppTheme.spacingXl), Text("History Presensi", style: AppTheme.heading3), - const SizedBox(height: AppTheme.spacingMd), + const SizedBox(height: 12), - attendanceProvider.historyList.isEmpty - ? Center( - child: Container( - padding: const EdgeInsets.all(AppTheme.spacingXl), - child: Column( + // Month/Year Picker + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: AppTheme.bgWhite, + borderRadius: BorderRadius.circular(AppTheme.radiusMd), + boxShadow: AppTheme.shadowSm, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + InkWell( + onTap: () => _goToPrevMonth(attendanceProvider), + borderRadius: BorderRadius.circular(AppTheme.radiusFull), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppTheme.primaryDark.withValues(alpha: 0.08), + shape: BoxShape.circle, + ), + child: const Icon(Icons.chevron_left, size: 20, color: AppTheme.primaryDark), + ), + ), + GestureDetector( + onTap: () => _showMonthYearPicker(context, attendanceProvider), + child: Row( + mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.calendar_today, size: 48, color: AppTheme.textTertiary), - const SizedBox(height: AppTheme.spacingMd), + const Icon(Icons.calendar_month, size: 18, color: AppTheme.primaryDark), + const SizedBox(width: 8), Text( - "Tidak ada data presensi", - style: AppTheme.bodyMedium.copyWith(color: AppTheme.textSecondary), + "${_getMonthName(attendanceProvider.selectedMonth)} ${attendanceProvider.selectedYear}", + style: AppTheme.labelLarge.copyWith(fontSize: 16, color: AppTheme.primaryDark), ), ], ), ), - ) - : ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: attendanceProvider.historyList.length, - itemBuilder: (context, index) { - final history = attendanceProvider.historyList[index]; - return FadeInUp( - delayMs: index * 50, - child: InkWell( - onTap: () => _showPresensiDetail(context, history), - borderRadius: BorderRadius.circular(AppTheme.radiusLg), - child: Container( - margin: const EdgeInsets.only(bottom: AppTheme.spacingMd), - padding: const EdgeInsets.all(AppTheme.spacingMd), - decoration: BoxDecoration( - color: AppTheme.bgWhite, - borderRadius: BorderRadius.circular(AppTheme.radiusLg), - boxShadow: AppTheme.shadowSm, - ), - child: Column( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon(Icons.calendar_today, size: 16, color: AppTheme.primaryDark), - const SizedBox(width: 8), - Expanded( - child: Text( - history.tanggal, - style: AppTheme.labelMedium, - overflow: TextOverflow.ellipsis, - ), + InkWell( + onTap: _isCurrentMonth(attendanceProvider) ? null : () => _goToNextMonth(attendanceProvider), + borderRadius: BorderRadius.circular(AppTheme.radiusFull), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: _isCurrentMonth(attendanceProvider) + ? AppTheme.textTertiary.withValues(alpha: 0.1) + : AppTheme.primaryDark.withValues(alpha: 0.08), + shape: BoxShape.circle, + ), + child: Icon( + Icons.chevron_right, + size: 20, + color: _isCurrentMonth(attendanceProvider) + ? AppTheme.textTertiary + : AppTheme.primaryDark, + ), + ), + ), + ], + ), + ), + const SizedBox(height: AppTheme.spacingMd), + + // History List + if (attendanceProvider.isLoading) + const Center( + child: Padding( + padding: EdgeInsets.all(32), + child: CircularProgressIndicator(), + ), + ) + else if (attendanceProvider.historyList.isEmpty) + Center( + child: Container( + padding: const EdgeInsets.all(AppTheme.spacingXl), + child: Column( + children: [ + Icon(Icons.calendar_today, size: 48, color: AppTheme.textTertiary), + const SizedBox(height: AppTheme.spacingMd), + Text( + "Tidak ada data presensi bulan ini", + style: AppTheme.bodyMedium.copyWith(color: AppTheme.textSecondary), + ), + ], + ), + ), + ) + else + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: attendanceProvider.historyList.length, + itemBuilder: (context, index) { + final history = attendanceProvider.historyList[index]; + return FadeInUp( + delayMs: index * 50, + child: InkWell( + onTap: () => _showPresensiDetail(context, history), + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + child: Container( + margin: const EdgeInsets.only(bottom: AppTheme.spacingMd), + padding: const EdgeInsets.all(AppTheme.spacingMd), + decoration: BoxDecoration( + color: AppTheme.bgWhite, + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + boxShadow: AppTheme.shadowSm, + ), + child: Column( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.calendar_today, size: 16, color: AppTheme.primaryDark), + const SizedBox(width: 8), + Expanded( + child: Text( + history.tanggal, + style: AppTheme.labelMedium, + overflow: TextOverflow.ellipsis, ), - ], - ), - const SizedBox(height: 8), - Wrap( - spacing: 6, - runSpacing: 6, - children: [ - if (history.statusValidasi == 3) - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: AppTheme.statusRed.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(AppTheme.radiusFull), - ), - child: Text( - "Ditolak", - style: AppTheme.bodySmall.copyWith( - color: AppTheme.statusRed, - fontWeight: FontWeight.w600, - fontSize: 10, - ), - ), - ), - if (history.statusValidasi == 2) - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: AppTheme.statusYellow.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(AppTheme.radiusFull), - ), - child: Text( - "Menunggu Validasi", - style: AppTheme.bodySmall.copyWith( - color: AppTheme.statusOrange, - fontWeight: FontWeight.w600, - fontSize: 10, - ), - ), - ), - if (!history.verifikasiWajah && history.statusMasuk != 'Izin' && history.statusMasuk != 'Cuti' && history.statusMasuk != 'Sakit' && history.statusMasuk != 'Alpha') - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: AppTheme.statusOrange.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(AppTheme.radiusFull), - ), - child: Text( - "Wajah Tidak Terverifikasi", - style: AppTheme.bodySmall.copyWith( - color: AppTheme.statusOrange, - fontWeight: FontWeight.w600, - fontSize: 10, - ), - ), - ), - if (history.keteranganLuarRadius != null) - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: AppTheme.primaryDark.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(AppTheme.radiusFull), - ), - child: Text( - "Luar Radius", - style: AppTheme.bodySmall.copyWith( - color: AppTheme.primaryDark, - fontWeight: FontWeight.w600, - fontSize: 10, - ), - ), - ), - if (history.statusMasuk != null && history.statusMasuk != '-') - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: _getStatusColor(history.statusMasuk!).withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(AppTheme.radiusFull), - ), - child: Text( - history.statusMasuk!, - style: AppTheme.bodySmall.copyWith( - color: _getStatusColor(history.statusMasuk!), - fontWeight: FontWeight.w600, - fontSize: 10, - ), - ), - ), - if (history.statusPulang != null && - history.statusPulang != '-' && - history.statusPulang != 'Tepat Waktu' && - history.statusPulang != history.statusMasuk) - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: _getStatusColor(history.statusPulang!).withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(AppTheme.radiusFull), - ), - child: Text( - history.statusPulang!, - style: AppTheme.bodySmall.copyWith( - color: _getStatusColor(history.statusPulang!), - fontWeight: FontWeight.w600, - fontSize: 10, - ), - ), - ), - ], - ), - ], - ), - const SizedBox(height: AppTheme.spacingMd), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: StatCard( - label: "Total Jam Kerja", - value: history.totalJam, ), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + if (history.statusValidasi == 3) + _buildBadge("Ditolak", AppTheme.statusRed), + if (history.statusValidasi == 2) + _buildBadge("Menunggu Validasi", AppTheme.statusOrange, bgColor: AppTheme.statusYellow), + if (!history.verifikasiWajah && history.statusMasuk != 'Izin' && history.statusMasuk != 'Cuti' && history.statusMasuk != 'Sakit' && history.statusMasuk != 'Alpha') + _buildBadge("Wajah Tidak Terverifikasi", AppTheme.statusOrange), + if (history.keteranganLuarRadius != null) + _buildBadge("Luar Radius", AppTheme.primaryDark), + if (history.statusMasuk != null && history.statusMasuk != '-') + _buildBadge(history.statusMasuk!, _getStatusColor(history.statusMasuk!)), + if (history.statusPulang != null && + history.statusPulang != '-' && + history.statusPulang != 'Tepat Waktu' && + history.statusPulang != history.statusMasuk) + _buildBadge(history.statusPulang!, _getStatusColor(history.statusPulang!)), + ], + ), + ], + ), + const SizedBox(height: AppTheme.spacingMd), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: StatCard( + label: "Total Jam Kerja", + value: history.totalJam, ), - const SizedBox(width: AppTheme.spacingMd), - Expanded( - child: AttendanceTimeCard( - jamMasuk: history.jamMasuk ?? '-', - jamPulang: history.jamPulang ?? '-', - ), - ), - ], - ), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Text( - "Lihat Detail", - style: AppTheme.bodySmall.copyWith(color: AppTheme.primaryBlue, fontWeight: FontWeight.w500), - ), - const SizedBox(width: 4), - const Icon(Icons.chevron_right, size: 16, color: AppTheme.primaryBlue), - ], - ), - ], - ), + ), + const SizedBox(width: AppTheme.spacingMd), + Expanded( + child: AttendanceTimeCard( + jamMasuk: history.jamMasuk ?? '-', + jamPulang: history.jamPulang ?? '-', + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + "Lihat Detail", + style: AppTheme.bodySmall.copyWith(color: AppTheme.primaryBlue, fontWeight: FontWeight.w500), + ), + const SizedBox(width: 4), + const Icon(Icons.chevron_right, size: 16, color: AppTheme.primaryBlue), + ], + ), + ], ), ), - ); - }, - ), + ), + ); + }, + ), + const SizedBox(height: 16), ], ), @@ -297,6 +317,152 @@ class _PresensiScreenState extends State { ); } + Widget _buildBadge(String label, Color color, {Color? bgColor}) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: (bgColor ?? color).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(AppTheme.radiusFull), + ), + child: Text( + label, + style: AppTheme.bodySmall.copyWith( + color: color, + fontWeight: FontWeight.w600, + fontSize: 10, + ), + ), + ); + } + + void _showMonthYearPicker(BuildContext context, AttendanceProvider provider) { + final now = DateTime.now(); + int tempMonth = provider.selectedMonth; + int tempYear = provider.selectedYear; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) { + return StatefulBuilder( + builder: (ctx, setModalState) { + return Container( + decoration: const BoxDecoration( + color: AppTheme.bgWhite, + borderRadius: BorderRadius.vertical(top: Radius.circular(AppTheme.radiusXl)), + ), + padding: const EdgeInsets.fromLTRB(24, 16, 24, 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 40, height: 4, + decoration: BoxDecoration( + color: AppTheme.textTertiary.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 20), + Text("Pilih Bulan", style: AppTheme.heading3), + const SizedBox(height: 16), + + // Year selector row + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + onPressed: () => setModalState(() => tempYear--), + icon: const Icon(Icons.chevron_left), + ), + Text("$tempYear", style: AppTheme.labelLarge.copyWith(fontSize: 18)), + IconButton( + onPressed: tempYear >= now.year ? null : () => setModalState(() => tempYear++), + icon: const Icon(Icons.chevron_right), + ), + ], + ), + const SizedBox(height: 12), + + // Month grid + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + childAspectRatio: 2.5, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + ), + itemCount: 12, + itemBuilder: (ctx, idx) { + final m = idx + 1; + final isSelected = m == tempMonth && tempYear == provider.selectedYear; + final isFuture = tempYear == now.year && m > now.month; + + return GestureDetector( + onTap: isFuture ? null : () { + setModalState(() => tempMonth = m); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + decoration: BoxDecoration( + color: isSelected + ? AppTheme.primaryDark + : isFuture + ? AppTheme.textTertiary.withValues(alpha: 0.05) + : m == tempMonth + ? AppTheme.primaryDark.withValues(alpha: 0.1) + : AppTheme.bgCard, + borderRadius: BorderRadius.circular(AppTheme.radiusSm), + ), + alignment: Alignment.center, + child: Text( + _monthNames[idx].substring(0, 3), + style: AppTheme.labelMedium.copyWith( + color: isSelected + ? Colors.white + : isFuture + ? AppTheme.textTertiary + : m == tempMonth + ? AppTheme.primaryDark + : AppTheme.textPrimary, + ), + ), + ), + ); + }, + ), + const SizedBox(height: 20), + + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + Navigator.pop(ctx); + provider.setYear(tempYear); + provider.setMonth(tempMonth); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryDark, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppTheme.radiusMd), + ), + ), + child: Text("Terapkan", style: AppTheme.labelLarge.copyWith(color: Colors.white)), + ), + ), + ], + ), + ); + }, + ); + }, + ); + } + void _showScheduleModal(BuildContext context) { Navigator.pushNamed(context, '/schedule'); } diff --git a/lib/screens/profile/profile_screen.dart b/lib/screens/profile/profile_screen.dart index 0a0bb74..ee8f1f4 100644 --- a/lib/screens/profile/profile_screen.dart +++ b/lib/screens/profile/profile_screen.dart @@ -348,11 +348,6 @@ class _ProfileScreenState extends State { label: "Tanda Tangan Digital", onTap: () => Navigator.pushNamed(context, '/profile/signature'), ), - _buildActionItem( - icon: Icons.description_outlined, - label: "Surat Izin", - onTap: () => Navigator.pushNamed(context, '/surat-izin'), - ), _buildActionItem( icon: Icons.settings_ethernet, label: "Pengaturan Server", diff --git a/lib/screens/profile/signature_screen.dart b/lib/screens/profile/signature_screen.dart index 907b979..237d340 100644 --- a/lib/screens/profile/signature_screen.dart +++ b/lib/screens/profile/signature_screen.dart @@ -57,19 +57,18 @@ class _SignatureScreenState extends State { Future _captureCanvas() async { try { - // If we want PNG, hand_signature has a way to export to image - final picture = _signatureController.toPicture( + final byteData = await _signatureController.toImage( width: 1080, height: 720, color: const Color(0xFF1E293B), - strokeWidth: 4.5, + background: Colors.white, + fit: true, + border: 20, + format: ImageByteFormat.png, ); - - final image = await picture?.toImage(1080, 720); - if (image == null) return null; - final byteData = await image.toByteData(format: ImageByteFormat.png); - return byteData?.buffer.asUint8List(); + if (byteData == null) return null; + return byteData.buffer.asUint8List(); } catch (e) { print('[Signature] Capture error: $e'); return null; diff --git a/lib/services/fcm_service.dart b/lib/services/fcm_service.dart index b4a4ca3..036e6dc 100644 --- a/lib/services/fcm_service.dart +++ b/lib/services/fcm_service.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import '../core/theme.dart'; import '../providers/notification_provider.dart'; import '../repositories/notifikasi_repository.dart'; +import '../main.dart'; @pragma('vm:entry-point') Future firebaseMessagingBackgroundHandler(RemoteMessage message) async {} @@ -18,7 +19,12 @@ class FcmService { final FlutterLocalNotificationsPlugin _localNotifications = FlutterLocalNotificationsPlugin(); final NotifikasiRepository _repository = NotifikasiRepository(); + bool _initialized = false; + Future initialize(BuildContext context) async { + if (_initialized) return; + _initialized = true; + final settings = await _messaging.requestPermission( alert: true, badge: true, @@ -29,12 +35,7 @@ class FcmService { const initSettings = InitializationSettings(android: androidSettings); await _localNotifications.initialize( settings: initSettings, - onDidReceiveNotificationResponse: (details) { - if (context.mounted) { - Navigator.of(context).pushNamedAndRemoveUntil('/home', (route) => false); - Navigator.of(context).pushNamed('/notification'); - } - }, + onDidReceiveNotificationResponse: _onNotificationTap, ); if (settings.authorizationStatus == AuthorizationStatus.denied) return; @@ -53,17 +54,29 @@ class FcmService { }); FirebaseMessaging.onMessageOpenedApp.listen((msg) { - if (context.mounted) _handleTap(context, msg); + _navigateToNotification(); }); final initial = await _messaging.getInitialMessage(); if (initial != null) { Future.delayed(const Duration(seconds: 1), () { - if (context.mounted) _handleTap(context, initial); + _navigateToNotification(); }); } } + void _onNotificationTap(NotificationResponse details) { + _navigateToNotification(); + } + + void _navigateToNotification() { + final nav = navigatorKey.currentState; + if (nav != null) { + nav.pushNamedAndRemoveUntil('/home', (route) => false); + nav.pushNamed('/notification'); + } + } + IconData _iconForTipe(String? tipe) { if (tipe == null) return Icons.notifications_outlined; if (tipe.contains('disetujui')) return Icons.check_circle_rounded; @@ -106,7 +119,7 @@ class FcmService { color: color, onTap: () { entry.remove(); - Navigator.of(context).pushNamed('/notification'); + _navigateToNotification(); }, onDismiss: () => entry.remove(), ), @@ -137,13 +150,9 @@ class FcmService { title: title, body: body, notificationDetails: details, + payload: 'notification', ); } - - void _handleTap(BuildContext context, RemoteMessage message) { - Navigator.of(context).pushNamedAndRemoveUntil('/home', (route) => false); - Navigator.of(context).pushNamed('/notification'); - } } class _NotificationBanner extends StatefulWidget { diff --git a/lib/widgets/molecules/pengumuman_card.dart b/lib/widgets/molecules/pengumuman_card.dart index 90bcca0..3894f04 100644 --- a/lib/widgets/molecules/pengumuman_card.dart +++ b/lib/widgets/molecules/pengumuman_card.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../core/theme.dart'; import '../../models/announcement_model.dart'; +import '../../screens/home/pengumuman_detail_screen.dart'; import '../atoms/custom_avatar.dart'; class PengumumanCard extends StatelessWidget { @@ -27,7 +28,14 @@ class PengumumanCard extends StatelessWidget { color: Colors.transparent, borderRadius: BorderRadius.circular(AppTheme.radiusLg), child: InkWell( - onTap: onTap ?? () {}, + onTap: onTap ?? () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => PengumumanDetailScreen(pengumuman: pengumuman), + ), + ); + }, borderRadius: BorderRadius.circular(AppTheme.radiusLg), child: Padding( padding: const EdgeInsets.all(AppTheme.spacingMd), @@ -35,7 +43,7 @@ class PengumumanCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ CustomAvatar( - name: "HR", + name: pengumuman.namaPembuat, imageUrl: pengumuman.avatarUrl, size: 40, backgroundColor: AppTheme.primaryOrange.withOpacity(0.1), diff --git a/lib/widgets/organisms/pengajuan_detail_sheet.dart b/lib/widgets/organisms/pengajuan_detail_sheet.dart index bf2ff14..00fcf15 100644 --- a/lib/widgets/organisms/pengajuan_detail_sheet.dart +++ b/lib/widgets/organisms/pengajuan_detail_sheet.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../core/theme.dart'; import '../../models/pengajuan_model.dart'; import '../atoms/badge_widget.dart'; +import 'surat_izin_detail_sheet.dart'; import 'package:intl/intl.dart'; class PengajuanDetailSheet extends StatelessWidget { @@ -81,13 +82,15 @@ class PengajuanDetailSheet extends StatelessWidget { return ClipRRect( borderRadius: const BorderRadius.vertical(top: Radius.circular(AppTheme.radiusXl)), child: Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.85, + ), padding: const EdgeInsets.fromLTRB(24, 12, 24, 32), decoration: const BoxDecoration( color: AppTheme.bgWhite, ), child: Column( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, children: [ Center( child: Container( @@ -100,7 +103,12 @@ class PengajuanDetailSheet extends StatelessWidget { ), ), const SizedBox(height: 20), - + Flexible( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Row( children: [ Expanded( @@ -209,6 +217,185 @@ class PengajuanDetailSheet extends StatelessWidget { ), const SizedBox(height: 8), ], + + // Lampiran Section + if (pengajuan.lampiranUrl != null && pengajuan.lampiranUrl!.isNotEmpty) ...[ + const Divider(height: 1, color: AppTheme.bgCard), + const SizedBox(height: 14), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.attach_file, size: 20, color: _jenisColor), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Lampiran / Bukti", + style: AppTheme.bodySmall.copyWith(color: AppTheme.textTertiary), + ), + const SizedBox(height: 8), + GestureDetector( + onTap: () => _showFullImage(context, pengajuan.lampiranUrl!), + child: Container( + width: double.infinity, + constraints: const BoxConstraints(maxHeight: 200), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppTheme.radiusMd), + border: Border.all(color: AppTheme.textTertiary.withValues(alpha: 0.2)), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(AppTheme.radiusMd), + child: Image.network( + pengajuan.lampiranUrl!, + fit: BoxFit.cover, + loadingBuilder: (context, child, progress) { + if (progress == null) return child; + return Container( + height: 120, + alignment: Alignment.center, + child: CircularProgressIndicator( + value: progress.expectedTotalBytes != null + ? progress.cumulativeBytesLoaded / progress.expectedTotalBytes! + : null, + strokeWidth: 2, + ), + ); + }, + errorBuilder: (context, error, stackTrace) { + return Container( + height: 80, + alignment: Alignment.center, + color: AppTheme.bgCard, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.broken_image_outlined, size: 32, color: AppTheme.textTertiary), + const SizedBox(height: 4), + Text( + "Gagal memuat lampiran", + style: AppTheme.bodySmall.copyWith(color: AppTheme.textTertiary), + ), + ], + ), + ); + }, + ), + ), + ), + ), + const SizedBox(height: 6), + Text( + "Ketuk untuk memperbesar", + style: AppTheme.bodySmall.copyWith( + color: AppTheme.primaryBlue, + fontSize: 11, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 8), + ], + + if (pengajuan.hasSurat && pengajuan.idSurat != null) ...[ + const Divider(height: 1, color: AppTheme.bgCard), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () { + Navigator.pop(context); + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => SuratIzinDetailSheet(idSurat: pengajuan.idSurat!), + ); + }, + icon: const Icon(Icons.description_outlined, size: 20), + label: const Text("Lihat Surat Izin"), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryDark, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppTheme.radiusMd), + ), + elevation: 0, + textStyle: AppTheme.labelLarge.copyWith(fontSize: 14), + ), + ), + ), + const SizedBox(height: 8), + ], + ], + ), + ), + ), + ], + ), + ), + ); + } + + void _showFullImage(BuildContext context, String imageUrl) { + showDialog( + context: context, + builder: (ctx) => Dialog( + backgroundColor: Colors.black, + insetPadding: EdgeInsets.zero, + child: Stack( + children: [ + InteractiveViewer( + minScale: 0.5, + maxScale: 4.0, + child: Center( + child: Image.network( + imageUrl, + fit: BoxFit.contain, + loadingBuilder: (context, child, progress) { + if (progress == null) return child; + return const Center( + child: CircularProgressIndicator(color: Colors.white), + ); + }, + errorBuilder: (context, error, stackTrace) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.broken_image, color: Colors.white54, size: 48), + const SizedBox(height: 8), + Text( + "Gagal memuat gambar", + style: AppTheme.bodyMedium.copyWith(color: Colors.white54), + ), + ], + ), + ); + }, + ), + ), + ), + Positioned( + top: MediaQuery.of(ctx).padding.top + 8, + right: 8, + child: IconButton( + onPressed: () => Navigator.pop(ctx), + icon: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + shape: BoxShape.circle, + ), + child: const Icon(Icons.close, color: Colors.white, size: 22), + ), + ), + ), ], ), ), diff --git a/lib/widgets/organisms/presensi_detail_sheet.dart b/lib/widgets/organisms/presensi_detail_sheet.dart index b2b40fd..b923152 100644 --- a/lib/widgets/organisms/presensi_detail_sheet.dart +++ b/lib/widgets/organisms/presensi_detail_sheet.dart @@ -102,14 +102,17 @@ class _PresensiDetailSheetState extends State { return ClipRRect( borderRadius: const BorderRadius.vertical(top: Radius.circular(AppTheme.radiusXl)), child: Container( - padding: const EdgeInsets.fromLTRB(24, 12, 24, 32), + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.85, + ), + padding: EdgeInsets.fromLTRB(24, 12, 24, MediaQuery.of(context).viewInsets.bottom + 32), decoration: const BoxDecoration( color: AppTheme.bgWhite, ), child: Column( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Drag handle Center( child: Container( width: 40, @@ -122,360 +125,371 @@ class _PresensiDetailSheetState extends State { ), const SizedBox(height: 20), - Row( - children: [ - Expanded( - child: Text("Detail Presensi", style: AppTheme.heading3), - ), - if (history.shift != null && history.shift != '-') - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: AppTheme.primaryDark.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(AppTheme.radiusFull), - ), - child: Text( - history.shift!, - style: AppTheme.labelMedium.copyWith(color: AppTheme.primaryDark), - ), - ), - ], - ), - const SizedBox(height: 4), - Text( - history.tanggal, - style: AppTheme.bodyMedium.copyWith(color: AppTheme.textSecondary), - ), - if (history.statusValidasi == 3) ...[ - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: AppTheme.statusRed.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(AppTheme.radiusSm), - border: Border.all(color: AppTheme.statusRed.withValues(alpha: 0.3)), - ), - child: Row( + // Scrollable content + Flexible( + child: SingleChildScrollView( + child: Column( mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.cancel, color: AppTheme.statusRed, size: 16), - const SizedBox(width: 6), - Text( - "Presensi Ditolak", - style: AppTheme.labelMedium.copyWith(color: AppTheme.statusRed), - ), - ], - ), - ), - ], - if (history.statusValidasi == 2) ...[ - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: AppTheme.statusYellow.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(AppTheme.radiusSm), - border: Border.all(color: AppTheme.statusYellow.withValues(alpha: 0.3)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.hourglass_top, color: AppTheme.statusOrange, size: 16), - const SizedBox(width: 6), - Text( - "Menunggu Validasi Admin", - style: AppTheme.labelMedium.copyWith(color: AppTheme.statusOrange), - ), - ], - ), - ), - ], - - const SizedBox(height: 24), - - Container( - width: double.infinity, - padding: const EdgeInsets.all(AppTheme.spacingMd), - decoration: BoxDecoration( - color: AppTheme.bgCard, - borderRadius: BorderRadius.circular(AppTheme.radiusMd), - ), - child: Row( - children: [ - Expanded( - child: Column( - children: [ - Icon(Icons.login_rounded, color: AppTheme.statusGreen, size: 28), - const SizedBox(height: 8), - Text("Masuk", style: AppTheme.bodySmall), - const SizedBox(height: 4), - Text( - _formatTime(history.jamMasuk), - style: AppTheme.heading3.copyWith(fontSize: 20), - ), - ], - ), - ), - Container( - width: 1, - height: 60, - color: AppTheme.textTertiary.withValues(alpha: 0.2), - ), - Expanded( - child: Column( - children: [ - Icon(Icons.logout_rounded, color: AppTheme.primaryOrange, size: 28), - const SizedBox(height: 8), - Text("Pulang", style: AppTheme.bodySmall), - const SizedBox(height: 4), - Text( - _formatTime(history.jamPulang), - style: AppTheme.heading3.copyWith(fontSize: 20), - ), - ], - ), - ), - Container( - width: 1, - height: 60, - color: AppTheme.textTertiary.withValues(alpha: 0.2), - ), - Expanded( - child: Column( - children: [ - Icon(Icons.schedule_rounded, color: AppTheme.primaryBlue, size: 28), - const SizedBox(height: 8), - Text("Total", style: AppTheme.bodySmall), - const SizedBox(height: 4), - Text( - history.totalJam, - style: AppTheme.heading3.copyWith(fontSize: 20), - ), - ], - ), - ), - ], - ), - ), - - const SizedBox(height: 20), - - _buildDetailRow( - icon: _statusIcon(history.statusMasuk), - iconColor: _statusColor(history.statusMasuk), - label: "Status Masuk", - value: history.statusMasuk ?? '-', - valueColor: _statusColor(history.statusMasuk), - ), - - if (history.jamPulang != null) - _buildDetailRow( - icon: _statusIcon(history.statusPulang), - iconColor: _statusColor(history.statusPulang), - label: "Status Pulang", - value: history.statusPulang ?? '-', - valueColor: _statusColor(history.statusPulang), - ), - - if (history.waktuTerlambat != null) - _buildDetailRow( - icon: Icons.timer_off_outlined, - iconColor: AppTheme.statusRed, - label: "Waktu Terlambat", - value: history.waktuTerlambat!, - valueColor: AppTheme.statusRed, - ), - - _buildDetailRow( - icon: history.verifikasiWajah ? Icons.face : Icons.face_retouching_off, - iconColor: history.verifikasiWajah ? AppTheme.statusGreen : AppTheme.textTertiary, - label: "Verifikasi Wajah", - value: history.verifikasiWajah ? "Terverifikasi" : "Tidak Terverifikasi", - valueColor: history.verifikasiWajah ? AppTheme.statusGreen : AppTheme.textTertiary, - ), - - if (history.keteranganLuarRadius != null && history.keteranganLuarRadius!.isNotEmpty) ...[ - const SizedBox(height: 12), - Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppTheme.statusYellow.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(AppTheme.radiusSm), - border: Border.all(color: AppTheme.statusYellow.withValues(alpha: 0.3)), - ), - child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon(Icons.info_outline, size: 18, color: AppTheme.statusYellow), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Keterangan Luar Radius", - style: AppTheme.labelMedium.copyWith(color: AppTheme.textSecondary), - ), - const SizedBox(height: 4), - Text( - history.keteranganLuarRadius!, - style: AppTheme.bodyMedium, - ), - ], - ), - ), - ], - ), - ), - ], - - if (history.statusValidasi == 3 && history.alasanPenolakan != null && history.alasanPenolakan!.isNotEmpty) ...[ - const SizedBox(height: 12), - Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppTheme.statusRed.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(AppTheme.radiusSm), - border: Border.all(color: AppTheme.statusRed.withValues(alpha: 0.3)), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Icon(Icons.error_outline, size: 18, color: AppTheme.statusRed), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Alasan Penolakan", - style: AppTheme.labelMedium.copyWith(color: AppTheme.statusRed), - ), - const SizedBox(height: 4), - Text( - history.alasanPenolakan!, - style: AppTheme.bodyMedium, - ), - ], - ), - ), - ], - ), - ), - ], - - if (history.statusValidasi == 3 && history.idPresensi != null) ...[ - const SizedBox(height: 16), - - if (!_showResubmitForm) - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: () => setState(() => _showResubmitForm = true), - icon: const Icon(Icons.refresh, size: 18), - label: const Text("Ajukan Ulang"), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.primaryBlue, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppTheme.radiusMd), - ), - ), - ), - ), - - if (_showResubmitForm) ...[ - Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppTheme.primaryBlue.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(AppTheme.radiusMd), - border: Border.all(color: AppTheme.primaryBlue.withValues(alpha: 0.2)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Ajukan Ulang Presensi", - style: AppTheme.labelLarge.copyWith(color: AppTheme.primaryBlue), - ), - const SizedBox(height: 4), - Text( - "Jelaskan alasan pengajuan ulang presensi Anda.", - style: AppTheme.bodySmall.copyWith(color: AppTheme.textSecondary), - ), - const SizedBox(height: 12), - TextField( - controller: _keteranganController, - maxLines: 3, - maxLength: 500, - decoration: InputDecoration( - hintText: "Contoh: Saya berada di lokasi klien untuk meeting...", - hintStyle: AppTheme.bodySmall.copyWith(color: AppTheme.textTertiary), - filled: true, - fillColor: AppTheme.bgWhite, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppTheme.radiusSm), - borderSide: BorderSide(color: AppTheme.textTertiary.withValues(alpha: 0.3)), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppTheme.radiusSm), - borderSide: BorderSide(color: AppTheme.textTertiary.withValues(alpha: 0.3)), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppTheme.radiusSm), - borderSide: const BorderSide(color: AppTheme.primaryBlue), - ), - contentPadding: const EdgeInsets.all(12), + Row( + children: [ + Expanded( + child: Text("Detail Presensi", style: AppTheme.heading3), ), - ), + if (history.shift != null && history.shift != '-') + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: AppTheme.primaryDark.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(AppTheme.radiusFull), + ), + child: Text( + history.shift!, + style: AppTheme.labelMedium.copyWith(color: AppTheme.primaryDark), + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + history.tanggal, + style: AppTheme.bodyMedium.copyWith(color: AppTheme.textSecondary), + ), + if (history.statusValidasi == 3) ...[ const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: OutlinedButton( - onPressed: _isSubmitting ? null : () => setState(() => _showResubmitForm = false), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppTheme.radiusSm), - ), - ), - child: const Text("Batal"), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: AppTheme.statusRed.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(AppTheme.radiusSm), + border: Border.all(color: AppTheme.statusRed.withValues(alpha: 0.3)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.cancel, color: AppTheme.statusRed, size: 16), + const SizedBox(width: 6), + Text( + "Presensi Ditolak", + style: AppTheme.labelMedium.copyWith(color: AppTheme.statusRed), ), - ), - const SizedBox(width: 12), - Expanded( - child: ElevatedButton( - onPressed: _isSubmitting ? null : _handleResubmit, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.primaryBlue, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppTheme.radiusSm), - ), - ), - child: _isSubmitting - ? const SizedBox( - height: 18, - width: 18, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), - ) - : const Text("Kirim"), - ), - ), - ], + ], + ), ), ], - ), + if (history.statusValidasi == 2) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: AppTheme.statusYellow.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(AppTheme.radiusSm), + border: Border.all(color: AppTheme.statusYellow.withValues(alpha: 0.3)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.hourglass_top, color: AppTheme.statusOrange, size: 16), + const SizedBox(width: 6), + Text( + "Menunggu Validasi Admin", + style: AppTheme.labelMedium.copyWith(color: AppTheme.statusOrange), + ), + ], + ), + ), + ], + + const SizedBox(height: 24), + + Container( + width: double.infinity, + padding: const EdgeInsets.all(AppTheme.spacingMd), + decoration: BoxDecoration( + color: AppTheme.bgCard, + borderRadius: BorderRadius.circular(AppTheme.radiusMd), + ), + child: Row( + children: [ + Expanded( + child: Column( + children: [ + Icon(Icons.login_rounded, color: AppTheme.statusGreen, size: 28), + const SizedBox(height: 8), + Text("Masuk", style: AppTheme.bodySmall), + const SizedBox(height: 4), + Text( + _formatTime(history.jamMasuk), + style: AppTheme.heading3.copyWith(fontSize: 20), + ), + ], + ), + ), + Container( + width: 1, + height: 60, + color: AppTheme.textTertiary.withValues(alpha: 0.2), + ), + Expanded( + child: Column( + children: [ + Icon(Icons.logout_rounded, color: AppTheme.primaryOrange, size: 28), + const SizedBox(height: 8), + Text("Pulang", style: AppTheme.bodySmall), + const SizedBox(height: 4), + Text( + _formatTime(history.jamPulang), + style: AppTheme.heading3.copyWith(fontSize: 20), + ), + ], + ), + ), + Container( + width: 1, + height: 60, + color: AppTheme.textTertiary.withValues(alpha: 0.2), + ), + Expanded( + child: Column( + children: [ + Icon(Icons.schedule_rounded, color: AppTheme.primaryBlue, size: 28), + const SizedBox(height: 8), + Text("Total", style: AppTheme.bodySmall), + const SizedBox(height: 4), + Text( + history.totalJam, + style: AppTheme.heading3.copyWith(fontSize: 20), + ), + ], + ), + ), + ], + ), + ), + + const SizedBox(height: 20), + + _buildDetailRow( + icon: _statusIcon(history.statusMasuk), + iconColor: _statusColor(history.statusMasuk), + label: "Status Masuk", + value: history.statusMasuk ?? '-', + valueColor: _statusColor(history.statusMasuk), + ), + + if (history.jamPulang != null) + _buildDetailRow( + icon: _statusIcon(history.statusPulang), + iconColor: _statusColor(history.statusPulang), + label: "Status Pulang", + value: history.statusPulang ?? '-', + valueColor: _statusColor(history.statusPulang), + ), + + if (history.waktuTerlambat != null) + _buildDetailRow( + icon: Icons.timer_off_outlined, + iconColor: AppTheme.statusRed, + label: "Waktu Terlambat", + value: history.waktuTerlambat!, + valueColor: AppTheme.statusRed, + ), + + _buildDetailRow( + icon: history.verifikasiWajah ? Icons.face : Icons.face_retouching_off, + iconColor: history.verifikasiWajah ? AppTheme.statusGreen : AppTheme.textTertiary, + label: "Verifikasi Wajah", + value: history.verifikasiWajah ? "Terverifikasi" : "Tidak Terverifikasi", + valueColor: history.verifikasiWajah ? AppTheme.statusGreen : AppTheme.textTertiary, + ), + + if (history.keteranganLuarRadius != null && history.keteranganLuarRadius!.isNotEmpty) ...[ + const SizedBox(height: 12), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.statusYellow.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(AppTheme.radiusSm), + border: Border.all(color: AppTheme.statusYellow.withValues(alpha: 0.3)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.info_outline, size: 18, color: AppTheme.statusYellow), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Keterangan Luar Radius", + style: AppTheme.labelMedium.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 4), + Text( + history.keteranganLuarRadius!, + style: AppTheme.bodyMedium, + ), + ], + ), + ), + ], + ), + ), + ], + + if (history.statusValidasi == 3 && history.alasanPenolakan != null && history.alasanPenolakan!.isNotEmpty) ...[ + const SizedBox(height: 12), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.statusRed.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(AppTheme.radiusSm), + border: Border.all(color: AppTheme.statusRed.withValues(alpha: 0.3)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.error_outline, size: 18, color: AppTheme.statusRed), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Alasan Penolakan", + style: AppTheme.labelMedium.copyWith(color: AppTheme.statusRed), + ), + const SizedBox(height: 4), + Text( + history.alasanPenolakan!, + style: AppTheme.bodyMedium, + ), + ], + ), + ), + ], + ), + ), + ], + + if (history.statusValidasi == 3 && history.idPresensi != null) ...[ + const SizedBox(height: 16), + + if (!_showResubmitForm) + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () => setState(() => _showResubmitForm = true), + icon: const Icon(Icons.refresh, size: 18), + label: const Text("Ajukan Ulang"), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryBlue, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppTheme.radiusMd), + ), + ), + ), + ), + + if (_showResubmitForm) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.primaryBlue.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(AppTheme.radiusMd), + border: Border.all(color: AppTheme.primaryBlue.withValues(alpha: 0.2)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Ajukan Ulang Presensi", + style: AppTheme.labelLarge.copyWith(color: AppTheme.primaryBlue), + ), + const SizedBox(height: 4), + Text( + "Jelaskan alasan pengajuan ulang presensi Anda.", + style: AppTheme.bodySmall.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 12), + TextField( + controller: _keteranganController, + maxLines: 3, + maxLength: 500, + decoration: InputDecoration( + hintText: "Contoh: Saya berada di lokasi klien untuk meeting...", + hintStyle: AppTheme.bodySmall.copyWith(color: AppTheme.textTertiary), + filled: true, + fillColor: AppTheme.bgWhite, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppTheme.radiusSm), + borderSide: BorderSide(color: AppTheme.textTertiary.withValues(alpha: 0.3)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppTheme.radiusSm), + borderSide: BorderSide(color: AppTheme.textTertiary.withValues(alpha: 0.3)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppTheme.radiusSm), + borderSide: const BorderSide(color: AppTheme.primaryBlue), + ), + contentPadding: const EdgeInsets.all(12), + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: _isSubmitting ? null : () => setState(() => _showResubmitForm = false), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppTheme.radiusSm), + ), + ), + child: const Text("Batal"), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + onPressed: _isSubmitting ? null : _handleResubmit, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryBlue, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppTheme.radiusSm), + ), + ), + child: _isSubmitting + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + : const Text("Kirim"), + ), + ), + ], + ), + ], + ), + ), + ], + ], + ], ), - ], - ], + ), + ), ], ), ), diff --git a/lib/widgets/organisms/surat_izin_detail_sheet.dart b/lib/widgets/organisms/surat_izin_detail_sheet.dart new file mode 100644 index 0000000..eaf5d16 --- /dev/null +++ b/lib/widgets/organisms/surat_izin_detail_sheet.dart @@ -0,0 +1,295 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import '../../core/theme.dart'; +import '../../providers/surat_izin_provider.dart'; + +class SuratIzinDetailSheet extends StatefulWidget { + final String idSurat; + + const SuratIzinDetailSheet({super.key, required this.idSurat}); + + @override + State createState() => _SuratIzinDetailSheetState(); +} + +class _SuratIzinDetailSheetState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().fetchDetail(widget.idSurat); + }); + } + + Color _statusColor(String status) { + switch (status) { + case 'disetujui': return AppTheme.statusGreen; + case 'ditolak': return AppTheme.statusRed; + case 'menunggu_hrd': return AppTheme.primaryDark; + default: return AppTheme.statusYellow; + } + } + + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + initialChildSize: 0.85, + maxChildSize: 0.95, + minChildSize: 0.5, + builder: (_, scrollController) => Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(AppTheme.radiusXl)), + ), + child: Consumer( + builder: (context, provider, _) { + final detail = provider.selectedSurat; + + return Column( + children: [ + Container( + margin: const EdgeInsets.only(top: 12), + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppTheme.textTertiary, + borderRadius: BorderRadius.circular(2), + ), + ), + + Padding( + padding: const EdgeInsets.all(AppTheme.spacingMd), + child: Row( + children: [ + GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppTheme.bgCard, + borderRadius: BorderRadius.circular(AppTheme.radiusSm), + ), + child: const Icon(Icons.arrow_back, size: 20, color: AppTheme.textPrimary), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text("Detail Surat Izin", style: AppTheme.heading3), + ), + if (detail != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: _statusColor(detail.statusSurat).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(AppTheme.radiusFull), + ), + child: Text( + detail.statusLabel, + style: AppTheme.labelMedium.copyWith( + color: _statusColor(detail.statusSurat), + fontSize: 11, + ), + ), + ), + ], + ), + ), + + if (provider.isLoadingDetail) + const Expanded( + child: Center(child: CircularProgressIndicator()), + ) + else if (detail == null) + Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.description_outlined, size: 48, color: AppTheme.textTertiary), + const SizedBox(height: 12), + Text( + "Gagal memuat detail surat", + style: AppTheme.bodyMedium.copyWith(color: AppTheme.textTertiary), + ), + ], + ), + ), + ) + else + Expanded( + child: SingleChildScrollView( + controller: scrollController, + padding: const EdgeInsets.symmetric(horizontal: AppTheme.spacingMd), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDetailSection("Informasi Surat", [ + _buildDetailRow("No. Surat", detail.nomorSurat), + _buildDetailRow("Jenis Izin", detail.jenisIzin), + if (detail.tanggalMulai != null) _buildDetailRow("Tanggal Mulai", detail.tanggalMulai!), + if (detail.tanggalSelesai != null) _buildDetailRow("Tanggal Selesai", detail.tanggalSelesai!), + if (detail.alasan != null) _buildDetailRow("Alasan", detail.alasan!), + ]), + + const SizedBox(height: AppTheme.spacingMd), + + if (detail.isiSurat != null) ...[ + _buildDetailSection("Isi Surat", []), + Container( + width: double.infinity, + padding: const EdgeInsets.all(AppTheme.spacingMd), + decoration: BoxDecoration( + color: AppTheme.bgCard, + borderRadius: BorderRadius.circular(AppTheme.radiusSm), + ), + child: Text( + detail.isiSurat!, + style: AppTheme.bodyMedium.copyWith(height: 1.6), + ), + ), + const SizedBox(height: AppTheme.spacingMd), + ], + + _buildDetailSection("Tanda Tangan", []), + _buildSignatureArea("Yang Mengajukan", detail.pengajuNama ?? '-', detail.ttdPengaju), + + ...detail.approvals.where((a) => a.tahap == 1).map((a) => + _buildSignatureArea("Manajer", a.approverNama, a.ttdApprover, + status: a.status, catatan: a.catatan), + ), + + if (!detail.approvals.any((a) => a.tahap == 1)) + _buildSignatureArea("Manajer", "Menunggu Approval", null, isPending: true), + + ...detail.approvals.where((a) => a.tahap == 2).map((a) => + _buildSignatureArea("HRD", a.approverNama, a.ttdApprover, + status: a.status, catatan: a.catatan), + ), + + if (!detail.approvals.any((a) => a.tahap == 2)) + _buildSignatureArea("HRD", "Menunggu Approval", null, isPending: true), + + const SizedBox(height: AppTheme.spacingXl), + ], + ), + ), + ), + ], + ); + }, + ), + ), + ); + } + + Widget _buildDetailSection(String title, List children) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title.toUpperCase(), + style: AppTheme.labelMedium.copyWith( + color: AppTheme.textTertiary, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: AppTheme.spacingSm), + if (children.isNotEmpty) + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(AppTheme.radiusSm), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column(children: children), + ), + ], + ); + } + + Widget _buildDetailRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: AppTheme.spacingMd, vertical: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 120, + child: Text(label, style: AppTheme.bodySmall.copyWith(color: AppTheme.textTertiary)), + ), + const SizedBox(width: 8), + Expanded( + child: Text(value, style: AppTheme.bodyMedium.copyWith(fontWeight: FontWeight.w500)), + ), + ], + ), + ); + } + + Widget _buildSignatureArea(String label, String name, String? ttdUrl, { + String? status, + String? catatan, + bool isPending = false, + }) { + return Container( + margin: const EdgeInsets.only(bottom: AppTheme.spacingSm), + padding: const EdgeInsets.all(AppTheme.spacingMd), + decoration: BoxDecoration( + color: isPending ? AppTheme.bgCard : Colors.white, + borderRadius: BorderRadius.circular(AppTheme.radiusSm), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + children: [ + Text( + label, + style: AppTheme.labelMedium.copyWith(color: AppTheme.textTertiary, letterSpacing: 0.3), + ), + const SizedBox(height: AppTheme.spacingSm), + + SizedBox( + height: 80, + child: ttdUrl != null + ? CachedNetworkImage( + imageUrl: ttdUrl, + fit: BoxFit.contain, + placeholder: (_, __) => const SizedBox.shrink(), + errorWidget: (_, __, ___) => Icon(Icons.error_outline, color: AppTheme.textTertiary), + ) + : isPending + ? Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.hourglass_empty, color: AppTheme.textTertiary, size: 28), + const SizedBox(height: 4), + Text("Menunggu", style: AppTheme.bodySmall.copyWith(color: AppTheme.textTertiary)), + ], + ) + : status == 'ditolak' + ? Text("DITOLAK", style: AppTheme.labelLarge.copyWith(color: AppTheme.statusRed)) + : const SizedBox.shrink(), + ), + + const SizedBox(height: AppTheme.spacingSm), + Container( + padding: const EdgeInsets.only(top: AppTheme.spacingSm), + decoration: const BoxDecoration( + border: Border(top: BorderSide(color: Color(0xFFE2E8F0))), + ), + child: Text(name, style: AppTheme.labelLarge.copyWith(fontSize: 13)), + ), + + if (catatan != null && catatan.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + "Catatan: $catatan", + style: AppTheme.bodySmall.copyWith(color: AppTheme.textSecondary, fontStyle: FontStyle.italic), + ), + ], + ], + ), + ); + } +}