added finance code
This commit is contained in:
parent
eb46194679
commit
1070f04d1a
144
lib/controller/finance/advance_payment_controller.dart
Normal file
144
lib/controller/finance/advance_payment_controller.dart
Normal file
@ -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 = <AdvancePayment>[].obs;
|
||||||
|
var isLoading = false.obs;
|
||||||
|
|
||||||
|
/// Employees for dropdown search
|
||||||
|
var employees = <Employee>[].obs;
|
||||||
|
var allEmployees = <Employee>[]; // cache of last API response
|
||||||
|
var employeesLoading = false.obs;
|
||||||
|
var searchQuery = ''.obs;
|
||||||
|
var selectedEmployee = Rxn<Employee>();
|
||||||
|
|
||||||
|
/// Prevents unwanted API calls while programmatically updating search
|
||||||
|
var _suppressSearch = false.obs;
|
||||||
|
|
||||||
|
Timer? _debounceTimer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
super.onInit();
|
||||||
|
|
||||||
|
ever<String>(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<void> 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<void> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,6 +15,9 @@ class ApiEndpoints {
|
|||||||
static const String getExpenseTypeReport = "/Dashboard/expense/type";
|
static const String getExpenseTypeReport = "/Dashboard/expense/type";
|
||||||
static const String getPendingExpenses = "/Dashboard/expense/pendings";
|
static const String getPendingExpenses = "/Dashboard/expense/pendings";
|
||||||
|
|
||||||
|
///// Projects Module API Endpoints
|
||||||
|
static const String createProject = "/project";
|
||||||
|
|
||||||
// Attendance Module API Endpoints
|
// Attendance Module API Endpoints
|
||||||
static const String getProjects = "/project/list";
|
static const String getProjects = "/project/list";
|
||||||
static const String getGlobalProjects = "/project/list/basic";
|
static const String getGlobalProjects = "/project/list/basic";
|
||||||
@ -104,4 +107,5 @@ class ApiEndpoints {
|
|||||||
static const getAllOrganizations = "/organization/list";
|
static const getAllOrganizations = "/organization/list";
|
||||||
|
|
||||||
static const String getAssignedServices = "/Project/get/assigned/services";
|
static const String getAssignedServices = "/Project/get/assigned/services";
|
||||||
|
static const String getAdvancePayments = '/Expense/get/transactions';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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/pending_expenses_model.dart';
|
||||||
import 'package:marco/model/dashboard/expense_type_report_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/dashboard/monthly_expence_model.dart';
|
||||||
|
import 'package:marco/model/finance/advance_payment_model.dart';
|
||||||
|
|
||||||
class ApiService {
|
class ApiService {
|
||||||
static const bool enableLogs = true;
|
static const bool enableLogs = true;
|
||||||
static const Duration extendedTimeout = Duration(seconds: 60);
|
static const Duration extendedTimeout = Duration(seconds: 60);
|
||||||
|
|
||||||
static Future<String?> _getToken() async {
|
static Future<String?> _getToken() async {
|
||||||
final token = await LocalStorage.getJwtToken();
|
final token = LocalStorage.getJwtToken();
|
||||||
|
|
||||||
if (token == null) {
|
if (token == null) {
|
||||||
logSafe("No JWT token found. Logging out...");
|
logSafe("No JWT token found. Logging out...");
|
||||||
@ -45,7 +46,7 @@ class ApiService {
|
|||||||
logSafe("Access token is expired. Attempting refresh...");
|
logSafe("Access token is expired. Attempting refresh...");
|
||||||
final refreshed = await AuthService.refreshToken();
|
final refreshed = await AuthService.refreshToken();
|
||||||
if (refreshed) {
|
if (refreshed) {
|
||||||
return await LocalStorage.getJwtToken();
|
return LocalStorage.getJwtToken();
|
||||||
} else {
|
} else {
|
||||||
logSafe("Token refresh failed. Logging out immediately...");
|
logSafe("Token refresh failed. Logging out immediately...");
|
||||||
await LocalStorage.logout();
|
await LocalStorage.logout();
|
||||||
@ -62,7 +63,7 @@ class ApiService {
|
|||||||
"Access token is about to expire in ${difference.inSeconds}s. Refreshing...");
|
"Access token is about to expire in ${difference.inSeconds}s. Refreshing...");
|
||||||
final refreshed = await AuthService.refreshToken();
|
final refreshed = await AuthService.refreshToken();
|
||||||
if (refreshed) {
|
if (refreshed) {
|
||||||
return await LocalStorage.getJwtToken();
|
return LocalStorage.getJwtToken();
|
||||||
} else {
|
} else {
|
||||||
logSafe("Token refresh failed (near expiry). Logging out...");
|
logSafe("Token refresh failed (near expiry). Logging out...");
|
||||||
await LocalStorage.logout();
|
await LocalStorage.logout();
|
||||||
@ -1314,6 +1315,73 @@ class ApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<List<AdvancePayment>> 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<String, dynamic> 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<dynamic>)
|
||||||
|
static Future<List<dynamic>> getEmployees({String query = ''}) async {
|
||||||
|
try {
|
||||||
|
// endpoint relative to ApiEndpoints.baseUrl; _getRequest builds full url
|
||||||
|
var endpoint = ApiEndpoints.getEmployeesWithoutPermission;
|
||||||
|
Map<String, String>? 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
|
/// Fetch Master Payment Modes
|
||||||
static Future<List<dynamic>?> getMasterPaymentModes() async {
|
static Future<List<dynamic>?> getMasterPaymentModes() async {
|
||||||
const endpoint = ApiEndpoints.getMasterPaymentModes;
|
const endpoint = ApiEndpoints.getMasterPaymentModes;
|
||||||
|
|||||||
63
lib/model/finance/advance_payment_model.dart
Normal file
63
lib/model/finance/advance_payment_model.dart
Normal file
@ -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<String, dynamic> 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<AdvancePayment> listFromJson(dynamic data) {
|
||||||
|
if (data is List) {
|
||||||
|
return data
|
||||||
|
.map((e) => e is Map<String, dynamic>
|
||||||
|
? AdvancePayment.fromJson(Map<String, dynamic>.from(e))
|
||||||
|
: const AdvancePayment(
|
||||||
|
id: '',
|
||||||
|
title: '',
|
||||||
|
name: '',
|
||||||
|
amount: 0,
|
||||||
|
balance: 0,
|
||||||
|
date: '',
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
102
lib/model/finance/get_employee_model.dart
Normal file
102
lib/model/finance/get_employee_model.dart
Normal file
@ -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<String, dynamic> 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<Employee> listFromJson(dynamic data) {
|
||||||
|
if (data is List) {
|
||||||
|
return data.map((e) {
|
||||||
|
if (e is Map<String, dynamic>) return Employee.fromJson(e);
|
||||||
|
return Employee.fromJson(Map<String, dynamic>.from(e));
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
// In case API returns { data: [...] }, that should already be unwrapped by ApiService
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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/expense/expense_screen.dart';
|
||||||
import 'package:marco/view/document/user_document_screen.dart';
|
import 'package:marco/view/document/user_document_screen.dart';
|
||||||
import 'package:marco/view/tenant/tenant_selection_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 {
|
class AuthMiddleware extends GetMiddleware {
|
||||||
@override
|
@override
|
||||||
RouteSettings? redirect(String? route) {
|
RouteSettings? redirect(String? route) {
|
||||||
@ -114,6 +115,19 @@ getPageRoute() {
|
|||||||
name: '/error/404',
|
name: '/error/404',
|
||||||
page: () => Error404Screen(),
|
page: () => Error404Screen(),
|
||||||
middlewares: [AuthMiddleware()]),
|
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
|
return routes
|
||||||
.map((e) => GetPage(
|
.map((e) => GetPage(
|
||||||
|
|||||||
@ -30,7 +30,7 @@ class DashboardScreen extends StatefulWidget {
|
|||||||
static const String dailyTasksProgressRoute =
|
static const String dailyTasksProgressRoute =
|
||||||
"/dashboard/daily-task-progress";
|
"/dashboard/daily-task-progress";
|
||||||
static const String directoryMainPageRoute = "/dashboard/directory-main-page";
|
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";
|
static const String documentMainPageRoute = "/dashboard/document-main-page";
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -249,8 +249,8 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
contentTheme.info, DashboardScreen.dailyTasksProgressRoute),
|
contentTheme.info, DashboardScreen.dailyTasksProgressRoute),
|
||||||
_StatItem(LucideIcons.folder, "Directory", contentTheme.info,
|
_StatItem(LucideIcons.folder, "Directory", contentTheme.info,
|
||||||
DashboardScreen.directoryMainPageRoute),
|
DashboardScreen.directoryMainPageRoute),
|
||||||
_StatItem(LucideIcons.badge_dollar_sign, "Expense", contentTheme.info,
|
_StatItem(LucideIcons.wallet, "Finance", contentTheme.info,
|
||||||
DashboardScreen.expenseMainPageRoute),
|
DashboardScreen.financeMainPageRoute),
|
||||||
_StatItem(LucideIcons.file_text, "Documents", contentTheme.info,
|
_StatItem(LucideIcons.file_text, "Documents", contentTheme.info,
|
||||||
DashboardScreen.documentMainPageRoute),
|
DashboardScreen.documentMainPageRoute),
|
||||||
];
|
];
|
||||||
|
|||||||
471
lib/view/finance/advance_payment_screen.dart
Normal file
471
lib/view/finance/advance_payment_screen.dart
Normal file
@ -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<AdvancePaymentScreen> createState() => _AdvancePaymentScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
|
||||||
|
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<ProjectController>();
|
||||||
|
|
||||||
|
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<ProjectController>(
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
589
lib/view/finance/finance_screen.dart
Normal file
589
lib/view/finance/finance_screen.dart
Normal file
@ -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<FinanceScreen> createState() => _FinanceScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FinanceScreenState extends State<FinanceScreen>
|
||||||
|
with UIMixin, TickerProviderStateMixin {
|
||||||
|
final projectController = Get.find<ProjectController>();
|
||||||
|
late AnimationController _animationController;
|
||||||
|
late Animation<double> _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<ProjectController>(
|
||||||
|
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<double>(
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user