diff --git a/lib/controller/finance/advance_payment_controller.dart b/lib/controller/finance/advance_payment_controller.dart new file mode 100644 index 0000000..1e159fc --- /dev/null +++ b/lib/controller/finance/advance_payment_controller.dart @@ -0,0 +1,144 @@ +import 'dart:async'; +import 'package:get/get.dart'; +import 'package:marco/model/finance/advance_payment_model.dart'; +import 'package:marco/model/finance/get_employee_model.dart'; +import 'package:marco/helpers/services/app_logger.dart'; +import 'package:marco/helpers/services/api_service.dart'; + +class AdvancePaymentController extends GetxController { + /// Advance payments list + var payments = [].obs; + var isLoading = false.obs; + + /// Employees for dropdown search + var employees = [].obs; + var allEmployees = []; // cache of last API response + var employeesLoading = false.obs; + var searchQuery = ''.obs; + var selectedEmployee = Rxn(); + + /// Prevents unwanted API calls while programmatically updating search + var _suppressSearch = false.obs; + + Timer? _debounceTimer; + + @override + void onInit() { + super.onInit(); + + ever(searchQuery, (q) { + if (_suppressSearch.value) return; // Skip while selecting employee + + // 🔹 When user types new text, clear previous employee + payments instantly + if (selectedEmployee.value != null) { + selectedEmployee.value = null; + payments.clear(); + } + + // 🔹 Show fresh dropdown results for new query + _debounceTimer?.cancel(); + _debounceTimer = Timer(const Duration(milliseconds: 400), () { + if (q.isNotEmpty) { + fetchEmployees(q); // repopulate dropdown + } else { + employees.clear(); // hide dropdown when search cleared + } + }); + }); + } + + @override + void onClose() { + _debounceTimer?.cancel(); + super.onClose(); + } + + /// Fetch employees by query + Future fetchEmployees(String q) async { + if (q.isEmpty) { + employees.clear(); + return; + } + + if (employeesLoading.value) return; + + try { + employeesLoading.value = true; + + final list = await ApiService.getEmployees(query: q); + final parsed = Employee.listFromJson(list); + logSafe("✅ Employees fetched from API: ${parsed.map((e) => e.name).toList()}"); + + + // Save full result and filter locally + allEmployees = parsed; + _filterEmployees(q); + } catch (e, s) { + logSafe("❌ fetchEmployees error: $e\n$s", level: LogLevel.error); + employees.clear(); + } finally { + employeesLoading.value = false; + } + } + + /// Local filter to update list based on search text + void _filterEmployees(String query) { + final q = query.toLowerCase(); + employees + ..clear() + ..addAll(allEmployees.where((e) { + return e.name.toLowerCase().contains(q) || + e.email.toLowerCase().contains(q); + })); + } + + /// When user selects employee + void selectEmployee(Employee emp) { + _suppressSearch.value = true; + + selectedEmployee.value = emp; + employees.clear(); // hide dropdown + searchQuery.value = emp.name; + + fetchAdvancePayments(emp.id); + + // Re-enable search after a short delay + Future.delayed(const Duration(milliseconds: 300), () { + _suppressSearch.value = false; + }); + } + + /// Fetch advance payments for the selected employee + Future fetchAdvancePayments(String employeeId) async { + if (employeeId.isEmpty) { + payments.clear(); + return; + } + + try { + isLoading.value = true; + final list = await ApiService.getAdvancePayments(employeeId); + payments.assignAll(list); + } catch (e, s) { + logSafe("❌ fetchAdvancePayments error: $e\n$s", level: LogLevel.error); + payments.clear(); + } finally { + isLoading.value = false; + } + } + + /// Clear employee selection + void clearSelection() { + selectedEmployee.value = null; + payments.clear(); + employees.clear(); + searchQuery.value = ''; + } + + void resetSelectionOnNewSearch() { + if (selectedEmployee.value != null) { + selectedEmployee.value = null; + payments.clear(); + } + } +} diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 906e33e..1fb047d 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -15,6 +15,9 @@ class ApiEndpoints { static const String getExpenseTypeReport = "/Dashboard/expense/type"; static const String getPendingExpenses = "/Dashboard/expense/pendings"; +///// Projects Module API Endpoints + static const String createProject = "/project"; + // Attendance Module API Endpoints static const String getProjects = "/project/list"; static const String getGlobalProjects = "/project/list/basic"; @@ -104,4 +107,5 @@ class ApiEndpoints { static const getAllOrganizations = "/organization/list"; static const String getAssignedServices = "/Project/get/assigned/services"; + static const String getAdvancePayments = '/Expense/get/transactions'; } diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 86b3e8d..3c83de7 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -26,13 +26,14 @@ import 'package:marco/model/all_organization_model.dart'; import 'package:marco/model/dashboard/pending_expenses_model.dart'; import 'package:marco/model/dashboard/expense_type_report_model.dart'; import 'package:marco/model/dashboard/monthly_expence_model.dart'; +import 'package:marco/model/finance/advance_payment_model.dart'; class ApiService { static const bool enableLogs = true; static const Duration extendedTimeout = Duration(seconds: 60); static Future _getToken() async { - final token = await LocalStorage.getJwtToken(); + final token = LocalStorage.getJwtToken(); if (token == null) { logSafe("No JWT token found. Logging out..."); @@ -45,7 +46,7 @@ class ApiService { logSafe("Access token is expired. Attempting refresh..."); final refreshed = await AuthService.refreshToken(); if (refreshed) { - return await LocalStorage.getJwtToken(); + return LocalStorage.getJwtToken(); } else { logSafe("Token refresh failed. Logging out immediately..."); await LocalStorage.logout(); @@ -62,7 +63,7 @@ class ApiService { "Access token is about to expire in ${difference.inSeconds}s. Refreshing..."); final refreshed = await AuthService.refreshToken(); if (refreshed) { - return await LocalStorage.getJwtToken(); + return LocalStorage.getJwtToken(); } else { logSafe("Token refresh failed (near expiry). Logging out..."); await LocalStorage.logout(); @@ -1314,6 +1315,73 @@ class ApiService { } } + static Future> getAdvancePayments( + String employeeId) async { + try { + final endpoint = "${ApiEndpoints.getAdvancePayments}/$employeeId"; + + final response = await _getRequest(endpoint); + + if (response == null) { + logSafe("❌ getAdvancePayments: Null response"); + return []; + } + + if (response.statusCode == 200) { + // 🟢 Added log to inspect raw JSON + logSafe("🔍 AdvancePayment raw response: ${response.body}"); + + final Map body = jsonDecode(response.body); + final data = body['data'] ?? body; + return AdvancePayment.listFromJson(data); + } else { + logSafe("⚠ getAdvancePayments failed → ${response.statusCode}"); + return []; + } + } catch (e, s) { + logSafe("❌ ApiService.getAdvancePayments error: $e\n$s", + level: LogLevel.error); + return []; + } + } + + /// Fetch employees with optional query. Returns raw list (List) + static Future> getEmployees({String query = ''}) async { + try { + // endpoint relative to ApiEndpoints.baseUrl; _getRequest builds full url + var endpoint = ApiEndpoints.getEmployeesWithoutPermission; + Map? queryParams; + if (query.isNotEmpty) { + // server may expect a query param name other than 'q'. Adjust if needed. + queryParams = {'q': query}; + } + + final resp = await _getRequest(endpoint, queryParams: queryParams); + if (resp == null) return []; + + // parse response + try { + final body = jsonDecode(resp.body); + if (body is Map && body.containsKey('data')) { + final data = body['data']; + if (data is List) return data; + return []; + } else if (body is List) { + return body; + } else { + return []; + } + } catch (e, s) { + logSafe("❌ ApiService.getEmployees: parse error $e\n$s", + level: LogLevel.error); + return []; + } + } catch (e, s) { + logSafe("❌ ApiService.getEmployees error: $e\n$s", level: LogLevel.error); + return []; + } + } + /// Fetch Master Payment Modes static Future?> getMasterPaymentModes() async { const endpoint = ApiEndpoints.getMasterPaymentModes; diff --git a/lib/model/finance/advance_payment_model.dart b/lib/model/finance/advance_payment_model.dart new file mode 100644 index 0000000..0626c2d --- /dev/null +++ b/lib/model/finance/advance_payment_model.dart @@ -0,0 +1,63 @@ +class AdvancePayment { + final String id; + final String title; + final String name; + final double amount; + final double balance; + final String date; + + const AdvancePayment({ + required this.id, + required this.title, + required this.name, + required this.amount, + required this.balance, + required this.date, + }); + + factory AdvancePayment.fromJson(Map json) { + double parseDouble(dynamic value) { + if (value == null) return 0.0; + if (value is num) return value.toDouble(); + if (value is String) return double.tryParse(value) ?? 0.0; + return 0.0; + } + + String extractProjectName(dynamic project) { + if (project is Map && project.containsKey('name')) { + return project['name']?.toString() ?? ''; + } else if (project is String) { + return project; + } + return ''; + } + + return AdvancePayment( + id: json['id']?.toString() ?? '', + // 👇 Fallback for APIs using "description" instead of "title" + title: json['title']?.toString() ?? json['description']?.toString() ?? '', + name: extractProjectName(json['project']), + amount: parseDouble(json['amount']), + balance: parseDouble(json['currentBalance']), + date: json['paidAt']?.toString() ?? json['createdAt']?.toString() ?? '', + ); + } + + static List listFromJson(dynamic data) { + if (data is List) { + return data + .map((e) => e is Map + ? AdvancePayment.fromJson(Map.from(e)) + : const AdvancePayment( + id: '', + title: '', + name: '', + amount: 0, + balance: 0, + date: '', + )) + .toList(); + } + return []; + } +} diff --git a/lib/model/finance/get_employee_model.dart b/lib/model/finance/get_employee_model.dart new file mode 100644 index 0000000..66d2448 --- /dev/null +++ b/lib/model/finance/get_employee_model.dart @@ -0,0 +1,102 @@ +class Employee { + final String id; + final String name; + final String email; + + Employee({ + required this.id, + required this.name, + required this.email, + }); + + /// Computed getters for first and last names + String get firstName { + final parts = name.trim().split(RegExp(r'\s+')); + return parts.isNotEmpty ? parts.first : ''; + } + + String get lastName { + final parts = name.trim().split(RegExp(r'\s+')); + if (parts.length > 1) { + return parts.sublist(1).join(' '); + } + return ''; + } + + factory Employee.fromJson(Map json) { + // Try many possible id fields + final idVal = (json['id'] ?? + json['Id'] ?? + json['employeeId'] ?? + json['empId'] ?? + json['employee_id']) + ?.toString(); + + // Try many possible first/last name fields + final first = (json['firstName'] ?? + json['first_name'] ?? + json['firstname'] ?? + json['fname'] ?? + '') + .toString() + .trim(); + final last = (json['lastName'] ?? + json['last_name'] ?? + json['lastname'] ?? + json['lname'] ?? + '') + .toString() + .trim(); + + // Name may come as a single field in multiple variants + String nameVal = (json['name'] ?? + json['Name'] ?? + json['fullName'] ?? + json['full_name'] ?? + json['employeeName'] ?? + json['employee_name'] ?? + json['empName'] ?? + json['employee'] ?? + '') + .toString() + .trim(); + + // If separate first/last found and name empty, combine them + if (nameVal.isEmpty && (first.isNotEmpty || last.isNotEmpty)) { + nameVal = ('$first ${last}').trim(); + } + + // If name still empty, fallback to email or id to avoid blank name + if (nameVal.isEmpty && (json['email'] != null)) { + nameVal = json['email'].toString().split('@').first; + } + if (nameVal.isEmpty) { + nameVal = idVal ?? ''; + } + + final emailVal = (json['email'] ?? + json['emailAddress'] ?? + json['email_address'] ?? + json['employeeEmail'] ?? + json['employee_email'] ?? + '') + .toString(); + + return Employee( + id: idVal ?? '', + name: nameVal, + email: emailVal, + ); + } + + static List listFromJson(dynamic data) { + if (data is List) { + return data.map((e) { + if (e is Map) return Employee.fromJson(e); + return Employee.fromJson(Map.from(e)); + }).toList(); + } + // In case API returns { data: [...] }, that should already be unwrapped by ApiService + return []; + } +} diff --git a/lib/routes.dart b/lib/routes.dart index 37a166e..381eb07 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -21,7 +21,8 @@ import 'package:marco/view/directory/directory_main_screen.dart'; import 'package:marco/view/expense/expense_screen.dart'; import 'package:marco/view/document/user_document_screen.dart'; import 'package:marco/view/tenant/tenant_selection_screen.dart'; - +import 'package:marco/view/finance/finance_screen.dart'; +import 'package:marco/view/finance/advance_payment_screen.dart'; class AuthMiddleware extends GetMiddleware { @override RouteSettings? redirect(String? route) { @@ -114,6 +115,19 @@ getPageRoute() { name: '/error/404', page: () => Error404Screen(), middlewares: [AuthMiddleware()]), + + // Finance + GetPage( + name: '/dashboard/finance', + page: () => FinanceScreen(), + middlewares: [AuthMiddleware()], + ), + // Advance Payment + GetPage( + name: '/dashboard/finance/advance-payment', + page: () => AdvancePaymentScreen(), + middlewares: [AuthMiddleware()], + ), ]; return routes .map((e) => GetPage( diff --git a/lib/view/dashboard/dashboard_screen.dart b/lib/view/dashboard/dashboard_screen.dart index 8f303a8..aff2670 100644 --- a/lib/view/dashboard/dashboard_screen.dart +++ b/lib/view/dashboard/dashboard_screen.dart @@ -30,7 +30,7 @@ class DashboardScreen extends StatefulWidget { static const String dailyTasksProgressRoute = "/dashboard/daily-task-progress"; static const String directoryMainPageRoute = "/dashboard/directory-main-page"; - static const String expenseMainPageRoute = "/dashboard/expense-main-page"; + static const String financeMainPageRoute = "/dashboard/finance"; static const String documentMainPageRoute = "/dashboard/document-main-page"; @override @@ -249,8 +249,8 @@ class _DashboardScreenState extends State with UIMixin { contentTheme.info, DashboardScreen.dailyTasksProgressRoute), _StatItem(LucideIcons.folder, "Directory", contentTheme.info, DashboardScreen.directoryMainPageRoute), - _StatItem(LucideIcons.badge_dollar_sign, "Expense", contentTheme.info, - DashboardScreen.expenseMainPageRoute), + _StatItem(LucideIcons.wallet, "Finance", contentTheme.info, + DashboardScreen.financeMainPageRoute), _StatItem(LucideIcons.file_text, "Documents", contentTheme.info, DashboardScreen.documentMainPageRoute), ]; diff --git a/lib/view/finance/advance_payment_screen.dart b/lib/view/finance/advance_payment_screen.dart new file mode 100644 index 0000000..f4e22ba --- /dev/null +++ b/lib/view/finance/advance_payment_screen.dart @@ -0,0 +1,471 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/controller/finance/advance_payment_controller.dart'; +import 'package:marco/controller/project_controller.dart'; +import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:flutter/services.dart'; +import 'package:intl/intl.dart'; + +class AdvancePaymentScreen extends StatefulWidget { + const AdvancePaymentScreen({super.key}); + + @override + State createState() => _AdvancePaymentScreenState(); +} + +class _AdvancePaymentScreenState extends State + with UIMixin { + late final AdvancePaymentController controller; + late final TextEditingController _searchCtrl; + final FocusNode _searchFocus = FocusNode(); + + @override + void initState() { + super.initState(); + controller = Get.put(AdvancePaymentController()); + _searchCtrl = TextEditingController(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + final employeeId = Get.arguments?['employeeId'] ?? ''; + if (employeeId.isNotEmpty) { + controller.fetchAdvancePayments(employeeId); + } + }); + + controller.searchQuery.listen((q) { + if (_searchCtrl.text != q) _searchCtrl.text = q; + }); + } + + @override + void dispose() { + _searchCtrl.dispose(); + _searchFocus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final projectController = Get.find(); + + return Scaffold( + backgroundColor: const Color( + 0xFFF5F5F5), // ✅ light grey background (Expense screen style) + appBar: _buildAppBar(projectController), + body: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: RefreshIndicator( + onRefresh: () async { + final emp = controller.selectedEmployee.value; + if (emp != null) { + await controller.fetchAdvancePayments(emp.id.toString()); + } + }, + color: Colors.white, // spinner color + backgroundColor: Colors.blue, // circle background color + strokeWidth: 2.5, + displacement: 60, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Container( + color: + const Color(0xFFF5F5F5), // ✅ match background inside scroll + child: Column( + children: [ + _buildSearchBar(), + _buildEmployeeDropdown(context), + _buildTopBalance(), + _buildPaymentList(), + ], + ), + ), + ), + ), + ), + ); + } + + // ---------------- AppBar ---------------- + PreferredSizeWidget _buildAppBar(ProjectController projectController) { + return AppBar( + backgroundColor: Colors.grey[100], + elevation: 0.5, + automaticallyImplyLeading: false, + titleSpacing: 0, + title: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, + color: Colors.black, size: 20), + onPressed: () => Get.offNamed('/dashboard/finance'), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleLarge("Advance Payment", + fontWeight: 700, color: Colors.black), + MySpacing.height(2), + GetBuilder( + builder: (_) { + final projectName = + projectController.selectedProject?.name ?? + 'Select Project'; + return Row( + children: [ + const Icon(Icons.work_outline, + size: 14, color: Colors.grey), + MySpacing.width(4), + Expanded( + child: MyText.bodySmall( + projectName, + overflow: TextOverflow.ellipsis, + fontWeight: 600, + color: Colors.grey[700], + ), + ), + ], + ); + }, + ), + ], + ), + ), + ], + ), + ), + ); + } + + // ---------------- Search ---------------- + Widget _buildSearchBar() { + return Container( + color: Colors.grey[100], + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + Expanded( + child: SizedBox( + height: 38, + child: TextField( + controller: _searchCtrl, + focusNode: _searchFocus, + onChanged: (v) => controller.searchQuery.value = v.trim(), + decoration: InputDecoration( + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 0), + prefixIcon: + const Icon(Icons.search, size: 20, color: Colors.grey), + hintText: 'Search Employee...', + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: + BorderSide(color: Colors.grey.shade300, width: 1), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: + BorderSide(color: Colors.grey.shade300, width: 1), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: + BorderSide(color: contentTheme.primary, width: 1.5), + ), + ), + ), + ), + ), + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.tune, color: Colors.black), + onPressed: () {}, + ), + ], + ), + ); + } + + // ---------------- Employee Dropdown ---------------- + Widget _buildEmployeeDropdown(BuildContext context) { + return Obx(() { + if (controller.employees.isEmpty || + controller.selectedEmployee.value != null) { + return const SizedBox.shrink(); + } + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 6, + offset: const Offset(0, 3)) + ], + ), + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.4, + ), + child: ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 6), + shrinkWrap: true, + physics: const BouncingScrollPhysics(), + itemCount: controller.employees.length, + separatorBuilder: (_, __) => + Divider(height: 1, color: Colors.grey.shade200), + itemBuilder: (_, i) => _buildEmployeeItem(controller.employees[i]), + ), + ); + }); + } + + Widget _buildEmployeeItem(dynamic e) { + return InkWell( + onTap: () { + controller.selectEmployee(e); + _searchCtrl.text = e.name; + controller.searchQuery.value = e.name; + FocusScope.of(context).unfocus(); + SystemChannels.textInput.invokeMethod('TextInput.hide'); + controller.employees.clear(); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + CircleAvatar( + radius: 18, + backgroundColor: _avatarColorFor(e.name), + child: Text( + _initials(e.firstName, e.lastName), + style: const TextStyle(color: Colors.white), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(e.name, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Colors.black87)), + if (e.email.isNotEmpty) + Text(e.email, + style: TextStyle( + fontSize: 13, color: Colors.grey.shade600)), + ], + ), + ), + ], + ), + ), + ); + } + + // ---------------- Current Balance ---------------- + Widget _buildTopBalance() { + return Obx(() { + if (controller.payments.isEmpty) return const SizedBox.shrink(); + final bal = controller.payments.first.balance.truncate(); + + return Container( + width: double.infinity, + color: Colors.grey[100], + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + mainAxisAlignment: + MainAxisAlignment.end, + children: [ + const Text( + "Current Balance : ", + style: TextStyle( + fontWeight: FontWeight.w600, + color: Colors.green, + fontSize: 22, + ), + ), + Text( + "₹${_formatAmount(bal)}", + style: const TextStyle( + fontWeight: FontWeight.bold, + color: Colors.green, + fontSize: 22, + ), + ), + ], + ), + ); + }); + } + + // ---------------- Payments List ---------------- + Widget _buildPaymentList() { + return Obx(() { + if (controller.isLoading.value) { + return const Padding( + padding: EdgeInsets.only(top: 100), + child: Center(child: CircularProgressIndicator(color: Colors.blue)), + ); + } + + // ✅ No employee selected yet + if (controller.selectedEmployee.value == null) { + return const Padding( + padding: EdgeInsets.only(top: 100), + child: Center(child: Text("Please select an Employee")), + ); + } + + // ✅ Employee selected but no payments found + if (controller.payments.isEmpty) { + return const Padding( + padding: EdgeInsets.only(top: 100), + child: Center( + child: Text("No advance payment transactions found."), + ), + ); + } + + // ✅ Payments available + return ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 6), + itemCount: controller.payments.length, + itemBuilder: (context, index) => + _buildPaymentItem(controller.payments[index]), + ); + }); + } + + // ---------------- Payment Item ---------------- + Widget _buildPaymentItem(dynamic item) { + final dateStr = (item.date ?? '').toString(); + DateTime? parsedDate; + try { + parsedDate = DateTime.parse(dateStr); + } catch (_) {} + + final formattedDate = parsedDate != null + ? DateFormat('dd MMM yyyy').format(parsedDate) + : (dateStr.isNotEmpty ? dateStr : '—'); + + final formattedTime = + parsedDate != null ? DateFormat('hh:mm a').format(parsedDate) : ''; + + final project = item.name ?? ''; + final desc = item.title ?? ''; + final amount = (item.amount ?? 0).toDouble(); + final isCredit = amount >= 0; + final accentColor = isCredit ? Colors.green.shade700 : Colors.red.shade700; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: Colors.grey[100], + border: Border( + bottom: BorderSide(color: Color(0xFFE0E0E0), width: 0.9), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + formattedDate, + style: + TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), + if (formattedTime.isNotEmpty) ...[ + const SizedBox(width: 6), + Text( + formattedTime, + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade500, + fontStyle: FontStyle.italic), + ), + ] + ], + ), + const SizedBox(height: 4), + Text( + project.isNotEmpty ? project : 'No Project', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Colors.black, + ), + ), + const SizedBox(height: 4), + Text( + desc.isNotEmpty ? desc : 'No Details', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade700, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Text( + "${isCredit ? '+' : '-'} ₹${_formatAmount(amount)}", + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: accentColor, + ), + ), + ], + ), + ); + } + + // ---------------- Utilities ---------------- + String _initials(String? firstName, [String? lastName]) { + if ((firstName?.isEmpty ?? true) && (lastName?.isEmpty ?? true)) return '?'; + return ((firstName?.isNotEmpty == true ? firstName![0] : '') + + (lastName?.isNotEmpty == true ? lastName![0] : '')) + .toUpperCase(); + } + + String _formatAmount(num amount) { + final format = NumberFormat('#,##,###.##', 'en_IN'); + return format.format(amount); + } + + static Color _avatarColorFor(String name) { + final colors = [ + Colors.green, + Colors.indigo, + Colors.orange, + Colors.blueGrey, + Colors.deepPurple, + Colors.teal, + Colors.amber, + ]; + final hash = name.codeUnits.fold(0, (p, e) => p + e); + return colors[hash % colors.length]; + } +} diff --git a/lib/view/finance/finance_screen.dart b/lib/view/finance/finance_screen.dart new file mode 100644 index 0000000..64f5bb1 --- /dev/null +++ b/lib/view/finance/finance_screen.dart @@ -0,0 +1,589 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_lucide/flutter_lucide.dart'; +import 'package:get/get.dart'; +import 'package:marco/controller/project_controller.dart'; +import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; + +class FinanceScreen extends StatefulWidget { + const FinanceScreen({super.key}); + + @override + State createState() => _FinanceScreenState(); +} + +class _FinanceScreenState extends State + with UIMixin, TickerProviderStateMixin { + final projectController = Get.find(); + late AnimationController _animationController; + late Animation _fadeAnimation; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: const Duration(milliseconds: 800), + vsync: this, + ); + _fadeAnimation = CurvedAnimation( + parent: _animationController, + curve: Curves.easeInOut, + ); + _animationController.forward(); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF8F9FA), + appBar: PreferredSize( + preferredSize: const Size.fromHeight(72), + child: AppBar( + backgroundColor: const Color(0xFFF5F5F5), + elevation: 0.5, + automaticallyImplyLeading: false, + titleSpacing: 0, + title: Padding( + padding: MySpacing.xy(16, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, + color: Colors.black, size: 20), + onPressed: () => Get.offNamed('/dashboard'), + ), + MySpacing.width(8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + MyText.titleLarge( + 'Finance', + fontWeight: 700, + color: Colors.black, + ), + MySpacing.height(2), + GetBuilder( + builder: (projectController) { + final projectName = + projectController.selectedProject?.name ?? + 'Select Project'; + return Row( + children: [ + const Icon(Icons.work_outline, + size: 14, color: Colors.grey), + MySpacing.width(4), + Expanded( + child: MyText.bodySmall( + projectName, + fontWeight: 600, + overflow: TextOverflow.ellipsis, + color: Colors.grey[700], + ), + ), + ], + ); + }, + ), + ], + ), + ), + ], + ), + ), + ), + ), + body: FadeTransition( + opacity: _fadeAnimation, + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildWelcomeSection(), + MySpacing.height(24), + _buildFinanceModules(), + MySpacing.height(24), + _buildQuickStatsSection(), + ], + ), + ), + ), + ); + } + + Widget _buildWelcomeSection() { + final projectSelected = projectController.selectedProject != null; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + contentTheme.primary.withValues(alpha: 0.1), + contentTheme.info.withValues(alpha: 0.05), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: contentTheme.primary.withValues(alpha: 0.2), + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: contentTheme.primary.withValues(alpha: 0.1), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Icon( + LucideIcons.landmark, + color: contentTheme.primary, + size: 24, + ), + ), + MySpacing.width(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleMedium( + 'Financial Management', + fontWeight: 700, + color: Colors.black87, + ), + MySpacing.height(2), + MyText.bodySmall( + projectSelected + ? 'Manage your project finances' + : 'Select a project to get started', + color: Colors.grey[600], + ), + ], + ), + ), + ], + ), + if (!projectSelected) ...[ + MySpacing.height(12), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.orange.withValues(alpha: 0.3)), + ), + child: Row( + children: [ + Icon( + LucideIcons.badge_alert, + size: 16, + color: Colors.orange[700], + ), + MySpacing.width(8), + Expanded( + child: MyText.bodySmall( + 'Please select a project to access finance modules', + color: Colors.orange[700], + fontWeight: 500, + ), + ), + ], + ), + ), + ], + ], + ), + ); + } + + Widget _buildFinanceModules() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleMedium( + 'Finance Modules', + fontWeight: 700, + color: Colors.black87, + ), + MySpacing.height(4), + MyText.bodySmall( + 'Select a module to manage', + color: Colors.grey[600], + ), + MySpacing.height(16), + _buildModuleGrid(), + ], + ); + } + + Widget _buildModuleGrid() { + final stats = [ + _FinanceStatItem( + LucideIcons.badge_dollar_sign, + "Expense", + "Track and manage expenses", + contentTheme.info, + "/dashboard/expense-main-page", + ), + _FinanceStatItem( + LucideIcons.receipt_text, + "Payment Request", + "Submit payment requests", + contentTheme.primary, + "/dashboard/payment-request", + ), + _FinanceStatItem( + LucideIcons.wallet, + "Advance Payment", + "Manage advance payments", + contentTheme.warning, + "/dashboard/finance/advance-payment", + ), + ]; + + final projectSelected = projectController.selectedProject != null; + + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + childAspectRatio: 1.1, + ), + itemCount: stats.length, + itemBuilder: (context, index) { + return _buildModernFinanceCard( + stats[index], + projectSelected, + index, + ); + }, + ); + } + + Widget _buildModernFinanceCard( + _FinanceStatItem statItem, + bool isProjectSelected, + int index, + ) { + final bool isEnabled = isProjectSelected; + + return TweenAnimationBuilder( + duration: Duration(milliseconds: 400 + (index * 100)), + tween: Tween(begin: 0.0, end: 1.0), + curve: Curves.easeOutCubic, + builder: (context, value, child) { + return Transform.scale( + scale: value, + child: Opacity( + opacity: isEnabled ? 1.0 : 0.5, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => _onCardTap(statItem, isEnabled), + borderRadius: BorderRadius.circular(16), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isEnabled + ? statItem.color.withValues(alpha: 0.2) + : Colors.grey.withValues(alpha: 0.2), + width: 1.5, + ), + ), + child: Stack( + children: [ + // Content + Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(12), + child: Icon( + statItem.icon, + size: 28, + color: statItem.color, + ), + ), + MySpacing.height(12), + MyText.titleSmall( + statItem.title, + fontWeight: 700, + color: Colors.black87, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + MySpacing.height(4), + if (isEnabled) + Row( + children: [ + MyText.bodySmall( + 'View Details', + color: statItem.color, + fontWeight: 600, + fontSize: 11, + ), + MySpacing.width(4), + Icon( + LucideIcons.arrow_right, + size: 14, + color: statItem.color, + ), + ], + ), + ], + ), + ), + // Lock icon for disabled state + if (!isEnabled) + Positioned( + top: 12, + right: 12, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + LucideIcons.lock, + size: 14, + color: Colors.grey[600], + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + }, + ); + } + + Widget _buildQuickStatsSection() { + final projectSelected = projectController.selectedProject != null; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleMedium( + 'Quick Stats', + fontWeight: 700, + color: Colors.black87, + ), + MySpacing.height(4), + MyText.bodySmall( + 'Overview of your finances', + color: Colors.grey[600], + ), + MySpacing.height(16), + _buildStatsRow(projectSelected), + ], + ); + } + + Widget _buildStatsRow(bool projectSelected) { + final stats = [ + _QuickStat( + icon: LucideIcons.trending_up, + label: 'Total Expenses', + value: projectSelected ? '₹0' : '--', + color: contentTheme.danger, + ), + _QuickStat( + icon: LucideIcons.clock, + label: 'Pending', + value: projectSelected ? '0' : '--', + color: contentTheme.warning, + ), + ]; + + return Row( + children: stats + .map((stat) => Expanded( + child: Padding( + padding: const EdgeInsets.only(right: 8), + child: _buildStatCard(stat, projectSelected), + ), + )) + .toList(), + ); + } + + Widget _buildStatCard(_QuickStat stat, bool isEnabled) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: stat.color.withValues(alpha: 0.2), + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: stat.color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + stat.icon, + size: 20, + color: stat.color, + ), + ), + MySpacing.height(12), + MyText.bodySmall( + stat.label, + color: Colors.grey[600], + fontSize: 11, + ), + MySpacing.height(4), + MyText.titleLarge( + stat.value, + fontWeight: 700, + color: isEnabled ? Colors.black87 : Colors.grey[400], + ), + ], + ), + ); + } + + void _onCardTap(_FinanceStatItem statItem, bool isEnabled) { + if (!isEnabled) { + Get.dialog( + Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: Icon( + LucideIcons.badge_alert, + color: Colors.orange[700], + size: 32, + ), + ), + MySpacing.height(16), + MyText.titleMedium( + "No Project Selected", + fontWeight: 700, + color: Colors.black87, + textAlign: TextAlign.center, + ), + MySpacing.height(8), + MyText.bodyMedium( + "Please select a project before accessing this section.", + color: Colors.grey[600], + textAlign: TextAlign.center, + ), + MySpacing.height(24), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => Get.back(), + style: ElevatedButton.styleFrom( + backgroundColor: contentTheme.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + elevation: 0, + ), + child: MyText.bodyMedium( + "OK", + color: Colors.white, + fontWeight: 600, + ), + ), + ), + ], + ), + ), + ), + ); + return; + } + + Get.toNamed(statItem.route); + } +} + +class _FinanceStatItem { + final IconData icon; + final String title; + final String subtitle; + final Color color; + final String route; + + _FinanceStatItem( + this.icon, + this.title, + this.subtitle, + this.color, + this.route, + ); +} + +class _QuickStat { + final IconData icon; + final String label; + final String value; + final Color color; + + _QuickStat({ + required this.icon, + required this.label, + required this.value, + required this.color, + }); +}