added api for dashboard for collection widget
This commit is contained in:
parent
1717cd5e2b
commit
5d73fd6f4f
@ -8,6 +8,7 @@ import 'package:on_field_work/model/dashboard/expense_type_report_model.dart';
|
|||||||
import 'package:on_field_work/model/dashboard/monthly_expence_model.dart';
|
import 'package:on_field_work/model/dashboard/monthly_expence_model.dart';
|
||||||
import 'package:on_field_work/model/expense/expense_type_model.dart';
|
import 'package:on_field_work/model/expense/expense_type_model.dart';
|
||||||
import 'package:on_field_work/model/employees/employee_model.dart';
|
import 'package:on_field_work/model/employees/employee_model.dart';
|
||||||
|
import 'package:on_field_work/model/dashboard/collection_overview_model.dart';
|
||||||
|
|
||||||
class DashboardController extends GetxController {
|
class DashboardController extends GetxController {
|
||||||
// =========================
|
// =========================
|
||||||
@ -53,11 +54,14 @@ class DashboardController extends GetxController {
|
|||||||
|
|
||||||
// Inject ProjectController
|
// Inject ProjectController
|
||||||
final ProjectController projectController = Get.put(ProjectController());
|
final ProjectController projectController = Get.put(ProjectController());
|
||||||
|
|
||||||
|
// =========================
|
||||||
// Pending Expenses overview
|
// Pending Expenses overview
|
||||||
// =========================
|
// =========================
|
||||||
final RxBool isPendingExpensesLoading = false.obs;
|
final RxBool isPendingExpensesLoading = false.obs;
|
||||||
final Rx<PendingExpensesData?> pendingExpensesData =
|
final Rx<PendingExpensesData?> pendingExpensesData =
|
||||||
Rx<PendingExpensesData?>(null);
|
Rx<PendingExpensesData?>(null);
|
||||||
|
|
||||||
// =========================
|
// =========================
|
||||||
// Expense Category Report
|
// Expense Category Report
|
||||||
// =========================
|
// =========================
|
||||||
@ -67,32 +71,60 @@ class DashboardController extends GetxController {
|
|||||||
final Rx<DateTime> expenseReportStartDate =
|
final Rx<DateTime> expenseReportStartDate =
|
||||||
DateTime.now().subtract(const Duration(days: 15)).obs;
|
DateTime.now().subtract(const Duration(days: 15)).obs;
|
||||||
final Rx<DateTime> expenseReportEndDate = DateTime.now().obs;
|
final Rx<DateTime> expenseReportEndDate = DateTime.now().obs;
|
||||||
|
|
||||||
// =========================
|
// =========================
|
||||||
// Monthly Expense Report
|
// Monthly Expense Report
|
||||||
// =========================
|
// =========================
|
||||||
final RxBool isMonthlyExpenseLoading = false.obs;
|
final RxBool isMonthlyExpenseLoading = false.obs;
|
||||||
final RxList<MonthlyExpenseData> monthlyExpenseList =
|
final RxList<MonthlyExpenseData> monthlyExpenseList =
|
||||||
<MonthlyExpenseData>[].obs;
|
<MonthlyExpenseData>[].obs;
|
||||||
// =========================
|
|
||||||
// Monthly Expense Report Filters
|
// Filters
|
||||||
// =========================
|
|
||||||
final Rx<MonthlyExpenseDuration> selectedMonthlyExpenseDuration =
|
final Rx<MonthlyExpenseDuration> selectedMonthlyExpenseDuration =
|
||||||
MonthlyExpenseDuration.twelveMonths.obs;
|
MonthlyExpenseDuration.twelveMonths.obs;
|
||||||
|
|
||||||
final RxInt selectedMonthsCount = 12.obs;
|
final RxInt selectedMonthsCount = 12.obs;
|
||||||
|
|
||||||
final RxList<ExpenseTypeModel> expenseTypes = <ExpenseTypeModel>[].obs;
|
final RxList<ExpenseTypeModel> expenseTypes = <ExpenseTypeModel>[].obs;
|
||||||
final Rx<ExpenseTypeModel?> selectedExpenseType = Rx<ExpenseTypeModel?>(null);
|
final Rx<ExpenseTypeModel?> selectedExpenseType = Rx<ExpenseTypeModel?>(null);
|
||||||
|
|
||||||
final isLoadingEmployees = true.obs;
|
final isLoadingEmployees = true.obs;
|
||||||
// DashboardController
|
|
||||||
final RxList<EmployeeModel> employees = <EmployeeModel>[].obs;
|
final RxList<EmployeeModel> employees = <EmployeeModel>[].obs;
|
||||||
final uploadingStates = <String, RxBool>{}.obs;
|
final uploadingStates = <String, RxBool>{}.obs;
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// Collection Overview
|
||||||
|
// =========================
|
||||||
|
final RxBool isCollectionOverviewLoading = false.obs;
|
||||||
|
final Rx<CollectionOverviewData?> collectionOverviewData =
|
||||||
|
Rx<CollectionOverviewData?>(null);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ⭐ NEW — DSO CALCULATION (Weighted Aging Method)
|
||||||
|
// ============================================================
|
||||||
|
double get calculatedDSO {
|
||||||
|
final data = collectionOverviewData.value;
|
||||||
|
if (data == null || data.totalDueAmount == 0) return 0.0;
|
||||||
|
|
||||||
|
final double totalDue = data.totalDueAmount;
|
||||||
|
|
||||||
|
// Weighted aging midpoints
|
||||||
|
const d0_30 = 15.0;
|
||||||
|
const d30_60 = 45.0;
|
||||||
|
const d60_90 = 75.0;
|
||||||
|
const d90_plus = 105.0; // conservative estimate
|
||||||
|
|
||||||
|
final double weightedDue = (data.bucket0To30Amount * d0_30) +
|
||||||
|
(data.bucket30To60Amount * d30_60) +
|
||||||
|
(data.bucket60To90Amount * d60_90) +
|
||||||
|
(data.bucket90PlusAmount * d90_plus);
|
||||||
|
|
||||||
|
return weightedDue / totalDue; // Final DSO
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update selected expense type
|
||||||
void updateSelectedExpenseType(ExpenseTypeModel? type) {
|
void updateSelectedExpenseType(ExpenseTypeModel? type) {
|
||||||
selectedExpenseType.value = type;
|
selectedExpenseType.value = type;
|
||||||
|
|
||||||
// Debug print to verify
|
|
||||||
print('Selected: ${type?.name ?? "All Types"}');
|
|
||||||
|
|
||||||
if (type == null) {
|
if (type == null) {
|
||||||
fetchMonthlyExpenses();
|
fetchMonthlyExpenses();
|
||||||
} else {
|
} else {
|
||||||
@ -104,23 +136,17 @@ class DashboardController extends GetxController {
|
|||||||
void onInit() {
|
void onInit() {
|
||||||
super.onInit();
|
super.onInit();
|
||||||
|
|
||||||
logSafe(
|
logSafe('DashboardController initialized', level: LogLevel.info);
|
||||||
'DashboardController initialized',
|
|
||||||
level: LogLevel.info,
|
|
||||||
);
|
|
||||||
|
|
||||||
// React to project selection
|
// React to project selection
|
||||||
ever<String>(projectController.selectedProjectId, (id) {
|
ever<String>(projectController.selectedProjectId, (id) {
|
||||||
if (id.isNotEmpty) {
|
if (id.isNotEmpty) {
|
||||||
logSafe('Project selected: $id', level: LogLevel.info);
|
|
||||||
fetchAllDashboardData();
|
fetchAllDashboardData();
|
||||||
fetchTodaysAttendance(id);
|
fetchTodaysAttendance(id);
|
||||||
} else {
|
|
||||||
logSafe('No project selected yet.', level: LogLevel.warning);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// React to expense report date changes
|
// React to date range changes in expense report
|
||||||
everAll([expenseReportStartDate, expenseReportEndDate], (_) {
|
everAll([expenseReportStartDate, expenseReportEndDate], (_) {
|
||||||
if (projectController.selectedProjectId.value.isNotEmpty) {
|
if (projectController.selectedProjectId.value.isNotEmpty) {
|
||||||
fetchExpenseTypeReport(
|
fetchExpenseTypeReport(
|
||||||
@ -130,10 +156,10 @@ class DashboardController extends GetxController {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// React to attendance range changes
|
// Attendance range
|
||||||
ever(attendanceSelectedRange, (_) => fetchRoleWiseAttendance());
|
ever(attendanceSelectedRange, (_) => fetchRoleWiseAttendance());
|
||||||
|
|
||||||
// React to project range changes
|
// Project range
|
||||||
ever(projectSelectedRange, (_) => fetchProjectProgress());
|
ever(projectSelectedRange, (_) => fetchProjectProgress());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -160,39 +186,25 @@ class DashboardController extends GetxController {
|
|||||||
int getAttendanceDays() => _getDaysFromRange(attendanceSelectedRange.value);
|
int getAttendanceDays() => _getDaysFromRange(attendanceSelectedRange.value);
|
||||||
int getProjectDays() => _getDaysFromRange(projectSelectedRange.value);
|
int getProjectDays() => _getDaysFromRange(projectSelectedRange.value);
|
||||||
|
|
||||||
void updateAttendanceRange(String range) {
|
void updateAttendanceRange(String range) =>
|
||||||
attendanceSelectedRange.value = range;
|
attendanceSelectedRange.value = range;
|
||||||
logSafe('Attendance range updated to $range', level: LogLevel.debug);
|
|
||||||
}
|
|
||||||
|
|
||||||
void updateProjectRange(String range) {
|
void updateProjectRange(String range) => projectSelectedRange.value = range;
|
||||||
projectSelectedRange.value = range;
|
|
||||||
logSafe('Project range updated to $range', level: LogLevel.debug);
|
|
||||||
}
|
|
||||||
|
|
||||||
void toggleAttendanceChartView(bool isChart) {
|
void toggleAttendanceChartView(bool isChart) =>
|
||||||
attendanceIsChartView.value = isChart;
|
attendanceIsChartView.value = isChart;
|
||||||
logSafe('Attendance chart view toggled to: $isChart',
|
|
||||||
level: LogLevel.debug);
|
|
||||||
}
|
|
||||||
|
|
||||||
void toggleProjectChartView(bool isChart) {
|
void toggleProjectChartView(bool isChart) =>
|
||||||
projectIsChartView.value = isChart;
|
projectIsChartView.value = isChart;
|
||||||
logSafe('Project chart view toggled to: $isChart', level: LogLevel.debug);
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================
|
// =========================
|
||||||
// Manual Refresh Methods
|
// Manual Refresh
|
||||||
// =========================
|
// =========================
|
||||||
Future<void> refreshDashboard() async {
|
Future<void> refreshDashboard() async => fetchAllDashboardData();
|
||||||
logSafe('Manual dashboard refresh triggered.', level: LogLevel.debug);
|
|
||||||
await fetchAllDashboardData();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> refreshAttendance() async => fetchRoleWiseAttendance();
|
Future<void> refreshAttendance() async => fetchRoleWiseAttendance();
|
||||||
Future<void> refreshTasks() async {
|
Future<void> refreshTasks() async {
|
||||||
final projectId = projectController.selectedProjectId.value;
|
final id = projectController.selectedProjectId.value;
|
||||||
if (projectId.isNotEmpty) await fetchDashboardTasks(projectId: projectId);
|
if (id.isNotEmpty) await fetchDashboardTasks(projectId: id);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> refreshProjects() async => fetchProjectProgress();
|
Future<void> refreshProjects() async => fetchProjectProgress();
|
||||||
@ -202,12 +214,7 @@ class DashboardController extends GetxController {
|
|||||||
// =========================
|
// =========================
|
||||||
Future<void> fetchAllDashboardData() async {
|
Future<void> fetchAllDashboardData() async {
|
||||||
final String projectId = projectController.selectedProjectId.value;
|
final String projectId = projectController.selectedProjectId.value;
|
||||||
|
if (projectId.isEmpty) return;
|
||||||
if (projectId.isEmpty) {
|
|
||||||
logSafe('No project selected. Skipping dashboard API calls.',
|
|
||||||
level: LogLevel.warning);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
fetchRoleWiseAttendance(),
|
fetchRoleWiseAttendance(),
|
||||||
@ -220,24 +227,45 @@ class DashboardController extends GetxController {
|
|||||||
endDate: expenseReportEndDate.value,
|
endDate: expenseReportEndDate.value,
|
||||||
),
|
),
|
||||||
fetchMonthlyExpenses(),
|
fetchMonthlyExpenses(),
|
||||||
fetchMasterData()
|
fetchMasterData(),
|
||||||
|
fetchCollectionOverview(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// API Calls
|
||||||
|
// =========================
|
||||||
|
|
||||||
|
Future<void> fetchCollectionOverview() async {
|
||||||
|
final projectId = projectController.selectedProjectId.value;
|
||||||
|
if (projectId.isEmpty) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
isCollectionOverviewLoading.value = true;
|
||||||
|
|
||||||
|
final response =
|
||||||
|
await ApiService.getCollectionOverview(projectId: projectId);
|
||||||
|
|
||||||
|
if (response != null && response.success) {
|
||||||
|
collectionOverviewData.value = response.data;
|
||||||
|
} else {
|
||||||
|
collectionOverviewData.value = null;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isCollectionOverviewLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> fetchTodaysAttendance(String projectId) async {
|
Future<void> fetchTodaysAttendance(String projectId) async {
|
||||||
isLoadingEmployees.value = true;
|
isLoadingEmployees.value = true;
|
||||||
|
|
||||||
final response = await ApiService.getAttendanceForDashboard(projectId);
|
final response = await ApiService.getAttendanceForDashboard(projectId);
|
||||||
|
|
||||||
if (response != null) {
|
if (response != null) {
|
||||||
employees.value = response;
|
employees.value = response;
|
||||||
for (var emp in employees) {
|
for (var emp in employees) {
|
||||||
uploadingStates[emp.id] = false.obs;
|
uploadingStates[emp.id] = false.obs;
|
||||||
}
|
}
|
||||||
logSafe(
|
|
||||||
"Dashboard Attendance fetched: ${employees.length} for project $projectId");
|
|
||||||
} else {
|
|
||||||
logSafe("Failed to fetch Dashboard Attendance for project $projectId",
|
|
||||||
level: LogLevel.error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoadingEmployees.value = false;
|
isLoadingEmployees.value = false;
|
||||||
@ -272,102 +300,70 @@ class DashboardController extends GetxController {
|
|||||||
|
|
||||||
Future<void> fetchMasterData() async {
|
Future<void> fetchMasterData() async {
|
||||||
try {
|
try {
|
||||||
final expenseTypesData = await ApiService.getMasterExpenseTypes();
|
final data = await ApiService.getMasterExpenseTypes();
|
||||||
if (expenseTypesData is List) {
|
if (data is List) {
|
||||||
expenseTypes.value =
|
expenseTypes.value =
|
||||||
expenseTypesData.map((e) => ExpenseTypeModel.fromJson(e)).toList();
|
data.map((e) => ExpenseTypeModel.fromJson(e)).toList();
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
logSafe('Error fetching master data', level: LogLevel.error, error: e);
|
|
||||||
}
|
}
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> fetchMonthlyExpenses({String? categoryId}) async {
|
Future<void> fetchMonthlyExpenses({String? categoryId}) async {
|
||||||
try {
|
try {
|
||||||
isMonthlyExpenseLoading.value = true;
|
isMonthlyExpenseLoading.value = true;
|
||||||
|
|
||||||
int months = selectedMonthsCount.value;
|
|
||||||
logSafe(
|
|
||||||
'Fetching Monthly Expense Report for last $months months'
|
|
||||||
'${categoryId != null ? ' (categoryId: $categoryId)' : ''}',
|
|
||||||
level: LogLevel.info,
|
|
||||||
);
|
|
||||||
|
|
||||||
final response = await ApiService.getDashboardMonthlyExpensesApi(
|
final response = await ApiService.getDashboardMonthlyExpensesApi(
|
||||||
categoryId: categoryId,
|
categoryId: categoryId,
|
||||||
months: months,
|
months: selectedMonthsCount.value,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response != null && response.success) {
|
if (response != null && response.success) {
|
||||||
monthlyExpenseList.value = response.data;
|
monthlyExpenseList.value = response.data;
|
||||||
logSafe('Monthly Expense Report fetched successfully.',
|
|
||||||
level: LogLevel.info);
|
|
||||||
} else {
|
} else {
|
||||||
monthlyExpenseList.clear();
|
monthlyExpenseList.clear();
|
||||||
logSafe('Failed to fetch Monthly Expense Report.',
|
|
||||||
level: LogLevel.error);
|
|
||||||
}
|
}
|
||||||
} catch (e, st) {
|
|
||||||
monthlyExpenseList.clear();
|
|
||||||
logSafe('Error fetching Monthly Expense Report',
|
|
||||||
level: LogLevel.error, error: e, stackTrace: st);
|
|
||||||
} finally {
|
} finally {
|
||||||
isMonthlyExpenseLoading.value = false;
|
isMonthlyExpenseLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> fetchPendingExpenses() async {
|
Future<void> fetchPendingExpenses() async {
|
||||||
final String projectId = projectController.selectedProjectId.value;
|
final id = projectController.selectedProjectId.value;
|
||||||
if (projectId.isEmpty) return;
|
if (id.isEmpty) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isPendingExpensesLoading.value = true;
|
isPendingExpensesLoading.value = true;
|
||||||
final response =
|
|
||||||
await ApiService.getPendingExpensesApi(projectId: projectId);
|
final response = await ApiService.getPendingExpensesApi(projectId: id);
|
||||||
|
|
||||||
if (response != null && response.success) {
|
if (response != null && response.success) {
|
||||||
pendingExpensesData.value = response.data;
|
pendingExpensesData.value = response.data;
|
||||||
logSafe('Pending expenses fetched successfully.', level: LogLevel.info);
|
|
||||||
} else {
|
} else {
|
||||||
pendingExpensesData.value = null;
|
pendingExpensesData.value = null;
|
||||||
logSafe('Failed to fetch pending expenses.', level: LogLevel.error);
|
|
||||||
}
|
}
|
||||||
} catch (e, st) {
|
|
||||||
pendingExpensesData.value = null;
|
|
||||||
logSafe('Error fetching pending expenses',
|
|
||||||
level: LogLevel.error, error: e, stackTrace: st);
|
|
||||||
} finally {
|
} finally {
|
||||||
isPendingExpensesLoading.value = false;
|
isPendingExpensesLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================
|
|
||||||
// API Calls
|
|
||||||
// =========================
|
|
||||||
Future<void> fetchRoleWiseAttendance() async {
|
Future<void> fetchRoleWiseAttendance() async {
|
||||||
final String projectId = projectController.selectedProjectId.value;
|
final id = projectController.selectedProjectId.value;
|
||||||
if (projectId.isEmpty) return;
|
if (id.isEmpty) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isAttendanceLoading.value = true;
|
isAttendanceLoading.value = true;
|
||||||
final List<dynamic>? response =
|
|
||||||
await ApiService.getDashboardAttendanceOverview(
|
final response = await ApiService.getDashboardAttendanceOverview(
|
||||||
projectId, getAttendanceDays());
|
id,
|
||||||
|
getAttendanceDays(),
|
||||||
|
);
|
||||||
|
|
||||||
if (response != null) {
|
if (response != null) {
|
||||||
roleWiseData.value =
|
roleWiseData.value =
|
||||||
response.map((e) => Map<String, dynamic>.from(e)).toList();
|
response.map((e) => Map<String, dynamic>.from(e)).toList();
|
||||||
logSafe('Attendance overview fetched successfully.',
|
|
||||||
level: LogLevel.info);
|
|
||||||
} else {
|
} else {
|
||||||
roleWiseData.clear();
|
roleWiseData.clear();
|
||||||
logSafe('Failed to fetch attendance overview: response is null.',
|
|
||||||
level: LogLevel.error);
|
|
||||||
}
|
}
|
||||||
} catch (e, st) {
|
|
||||||
roleWiseData.clear();
|
|
||||||
logSafe('Error fetching attendance overview',
|
|
||||||
level: LogLevel.error, error: e, stackTrace: st);
|
|
||||||
} finally {
|
} finally {
|
||||||
isAttendanceLoading.value = false;
|
isAttendanceLoading.value = false;
|
||||||
}
|
}
|
||||||
@ -377,109 +373,82 @@ class DashboardController extends GetxController {
|
|||||||
required DateTime startDate,
|
required DateTime startDate,
|
||||||
required DateTime endDate,
|
required DateTime endDate,
|
||||||
}) async {
|
}) async {
|
||||||
final String projectId = projectController.selectedProjectId.value;
|
final id = projectController.selectedProjectId.value;
|
||||||
if (projectId.isEmpty) return;
|
if (id.isEmpty) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isExpenseTypeReportLoading.value = true;
|
isExpenseTypeReportLoading.value = true;
|
||||||
|
|
||||||
final response = await ApiService.getExpenseTypeReportApi(
|
final response = await ApiService.getExpenseTypeReportApi(
|
||||||
projectId: projectId,
|
projectId: id,
|
||||||
startDate: startDate,
|
startDate: startDate,
|
||||||
endDate: endDate,
|
endDate: endDate,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response != null && response.success) {
|
if (response != null && response.success) {
|
||||||
expenseTypeReportData.value = response.data;
|
expenseTypeReportData.value = response.data;
|
||||||
logSafe('Expense Category Report fetched successfully.',
|
|
||||||
level: LogLevel.info);
|
|
||||||
} else {
|
} else {
|
||||||
expenseTypeReportData.value = null;
|
expenseTypeReportData.value = null;
|
||||||
logSafe('Failed to fetch Expense Category Report.',
|
|
||||||
level: LogLevel.error);
|
|
||||||
}
|
}
|
||||||
} catch (e, st) {
|
|
||||||
expenseTypeReportData.value = null;
|
|
||||||
logSafe('Error fetching Expense Category Report',
|
|
||||||
level: LogLevel.error, error: e, stackTrace: st);
|
|
||||||
} finally {
|
} finally {
|
||||||
isExpenseTypeReportLoading.value = false;
|
isExpenseTypeReportLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> fetchProjectProgress() async {
|
Future<void> fetchProjectProgress() async {
|
||||||
final String projectId = projectController.selectedProjectId.value;
|
final id = projectController.selectedProjectId.value;
|
||||||
if (projectId.isEmpty) return;
|
if (id.isEmpty) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isProjectLoading.value = true;
|
isProjectLoading.value = true;
|
||||||
|
|
||||||
final response = await ApiService.getProjectProgress(
|
final response = await ApiService.getProjectProgress(
|
||||||
projectId: projectId, days: getProjectDays());
|
projectId: id,
|
||||||
|
days: getProjectDays(),
|
||||||
|
);
|
||||||
|
|
||||||
if (response != null && response.success) {
|
if (response != null && response.success) {
|
||||||
projectChartData.value =
|
projectChartData.value =
|
||||||
response.data.map((d) => ChartTaskData.fromProjectData(d)).toList();
|
response.data.map((d) => ChartTaskData.fromProjectData(d)).toList();
|
||||||
logSafe('Project progress data mapped for chart', level: LogLevel.info);
|
|
||||||
} else {
|
} else {
|
||||||
projectChartData.clear();
|
projectChartData.clear();
|
||||||
logSafe('Failed to fetch project progress', level: LogLevel.error);
|
|
||||||
}
|
}
|
||||||
} catch (e, st) {
|
|
||||||
projectChartData.clear();
|
|
||||||
logSafe('Error fetching project progress',
|
|
||||||
level: LogLevel.error, error: e, stackTrace: st);
|
|
||||||
} finally {
|
} finally {
|
||||||
isProjectLoading.value = false;
|
isProjectLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> fetchDashboardTasks({required String projectId}) async {
|
Future<void> fetchDashboardTasks({required String projectId}) async {
|
||||||
if (projectId.isEmpty) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isTasksLoading.value = true;
|
isTasksLoading.value = true;
|
||||||
|
|
||||||
final response = await ApiService.getDashboardTasks(projectId: projectId);
|
final response = await ApiService.getDashboardTasks(projectId: projectId);
|
||||||
|
|
||||||
if (response != null && response.success) {
|
if (response != null && response.success) {
|
||||||
totalTasks.value = response.data?.totalTasks ?? 0;
|
totalTasks.value = response.data?.totalTasks ?? 0;
|
||||||
completedTasks.value = response.data?.completedTasks ?? 0;
|
completedTasks.value = response.data?.completedTasks ?? 0;
|
||||||
logSafe('Dashboard tasks fetched', level: LogLevel.info);
|
|
||||||
} else {
|
} else {
|
||||||
totalTasks.value = 0;
|
totalTasks.value = 0;
|
||||||
completedTasks.value = 0;
|
completedTasks.value = 0;
|
||||||
logSafe('Failed to fetch tasks', level: LogLevel.error);
|
|
||||||
}
|
}
|
||||||
} catch (e, st) {
|
|
||||||
totalTasks.value = 0;
|
|
||||||
completedTasks.value = 0;
|
|
||||||
logSafe('Error fetching tasks',
|
|
||||||
level: LogLevel.error, error: e, stackTrace: st);
|
|
||||||
} finally {
|
} finally {
|
||||||
isTasksLoading.value = false;
|
isTasksLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> fetchDashboardTeams({required String projectId}) async {
|
Future<void> fetchDashboardTeams({required String projectId}) async {
|
||||||
if (projectId.isEmpty) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isTeamsLoading.value = true;
|
isTeamsLoading.value = true;
|
||||||
|
|
||||||
final response = await ApiService.getDashboardTeams(projectId: projectId);
|
final response = await ApiService.getDashboardTeams(projectId: projectId);
|
||||||
|
|
||||||
if (response != null && response.success) {
|
if (response != null && response.success) {
|
||||||
totalEmployees.value = response.data?.totalEmployees ?? 0;
|
totalEmployees.value = response.data?.totalEmployees ?? 0;
|
||||||
inToday.value = response.data?.inToday ?? 0;
|
inToday.value = response.data?.inToday ?? 0;
|
||||||
logSafe('Dashboard teams fetched', level: LogLevel.info);
|
|
||||||
} else {
|
} else {
|
||||||
totalEmployees.value = 0;
|
totalEmployees.value = 0;
|
||||||
inToday.value = 0;
|
inToday.value = 0;
|
||||||
logSafe('Failed to fetch teams', level: LogLevel.error);
|
|
||||||
}
|
}
|
||||||
} catch (e, st) {
|
|
||||||
totalEmployees.value = 0;
|
|
||||||
inToday.value = 0;
|
|
||||||
logSafe('Error fetching teams',
|
|
||||||
level: LogLevel.error, error: e, stackTrace: st);
|
|
||||||
} finally {
|
} finally {
|
||||||
isTeamsLoading.value = false;
|
isTeamsLoading.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,6 +36,7 @@ class ApiEndpoints {
|
|||||||
"/Dashboard/expense/monthly";
|
"/Dashboard/expense/monthly";
|
||||||
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";
|
||||||
|
static const String getCollectionOverview = "/dashboard/collection-overview";
|
||||||
|
|
||||||
///// Projects Module API Endpoints
|
///// Projects Module API Endpoints
|
||||||
static const String createProject = "/project";
|
static const String createProject = "/project";
|
||||||
|
|||||||
@ -45,6 +45,8 @@ import 'package:on_field_work/model/service_project/job_comments.dart';
|
|||||||
import 'package:on_field_work/model/employees/employee_model.dart';
|
import 'package:on_field_work/model/employees/employee_model.dart';
|
||||||
import 'package:on_field_work/model/infra_project/infra_project_list.dart';
|
import 'package:on_field_work/model/infra_project/infra_project_list.dart';
|
||||||
import 'package:on_field_work/model/infra_project/infra_project_details.dart';
|
import 'package:on_field_work/model/infra_project/infra_project_details.dart';
|
||||||
|
import 'package:on_field_work/model/dashboard/collection_overview_model.dart';
|
||||||
|
|
||||||
|
|
||||||
class ApiService {
|
class ApiService {
|
||||||
static const bool enableLogs = true;
|
static const bool enableLogs = true;
|
||||||
@ -315,6 +317,43 @@ class ApiService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/// ============================================
|
||||||
|
/// GET COLLECTION OVERVIEW (Dashboard)
|
||||||
|
/// ============================================
|
||||||
|
static Future<CollectionOverviewResponse?> getCollectionOverview({
|
||||||
|
String? projectId,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
// Build query params (only add projectId if not null)
|
||||||
|
final queryParams = <String, String>{};
|
||||||
|
if (projectId != null && projectId.isNotEmpty) {
|
||||||
|
queryParams['projectId'] = projectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
final response = await _getRequest(
|
||||||
|
ApiEndpoints.getCollectionOverview,
|
||||||
|
queryParams: queryParams,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response == null) {
|
||||||
|
_log("getCollectionOverview: No response from server",
|
||||||
|
level: LogLevel.error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse full JSON (success, message, data, etc.)
|
||||||
|
final parsedJson =
|
||||||
|
_parseResponseForAllData(response, label: "CollectionOverview");
|
||||||
|
|
||||||
|
if (parsedJson == null) return null;
|
||||||
|
|
||||||
|
return CollectionOverviewResponse.fromJson(parsedJson);
|
||||||
|
} catch (e, stack) {
|
||||||
|
_log("Exception in getCollectionOverview: $e\n$stack",
|
||||||
|
level: LogLevel.error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Infra Project Module APIs
|
// Infra Project Module APIs
|
||||||
|
|
||||||
|
|||||||
@ -1,118 +1,128 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
import 'package:on_field_work/helpers/widgets/my_text.dart';
|
import 'package:on_field_work/helpers/widgets/my_text.dart';
|
||||||
|
import 'package:on_field_work/controller/dashboard/dashboard_controller.dart';
|
||||||
|
import 'package:on_field_work/model/dashboard/collection_overview_model.dart';
|
||||||
|
|
||||||
// --- MAIN WIDGET FILE ---
|
// ===============================================================
|
||||||
|
// MAIN WIDGET
|
||||||
|
// ===============================================================
|
||||||
class CollectionsHealthWidget extends StatelessWidget {
|
class CollectionsHealthWidget extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Derived Metrics from the JSON Analysis:
|
return GetBuilder<DashboardController>(
|
||||||
const double totalDue = 34190.0;
|
builder: (controller) {
|
||||||
const double totalCollected = 5000.0;
|
final data = controller.collectionOverviewData.value;
|
||||||
const double totalValue = totalDue + totalCollected;
|
final isLoading = controller.isCollectionOverviewLoading.value;
|
||||||
|
|
||||||
// Calculate Pending Percentage for Gauge
|
if (isLoading) {
|
||||||
final double pendingPercentage =
|
return const Center(
|
||||||
totalValue > 0 ? totalDue / totalValue : 0.0;
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(32.0),
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 1. MAIN CARD CONTAINER (White Theme)
|
if (data == null) {
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: _boxDecoration(),
|
||||||
color: Colors.white,
|
padding: const EdgeInsets.all(16.0),
|
||||||
borderRadius: BorderRadius.circular(5),
|
child: Center(
|
||||||
boxShadow: [
|
child: MyText.bodyMedium(
|
||||||
BoxShadow(
|
'No collection overview data available.',
|
||||||
color: Colors.black.withOpacity(0.1),
|
|
||||||
blurRadius: 10,
|
|
||||||
offset: const Offset(0, 5),
|
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final double totalDue = data.totalDueAmount;
|
||||||
|
final double totalCollected = data.totalCollectedAmount;
|
||||||
|
final double pendingPercentage = data.pendingPercentage / 100.0;
|
||||||
|
final double dsoDays = controller.calculatedDSO;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: _boxDecoration(),
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
// 1. HEADER
|
|
||||||
_buildHeader(),
|
_buildHeader(),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
// 2. MAIN CONTENT ROW (Layout)
|
|
||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
// Left Section: Gauge Chart, Due Amount, & Timelines
|
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 5,
|
flex: 5,
|
||||||
child: _buildLeftChartSection(
|
child: _buildLeftChartSection(
|
||||||
totalDue: totalDue,
|
totalDue: totalDue,
|
||||||
pendingPercentage: pendingPercentage,
|
pendingPercentage: pendingPercentage,
|
||||||
|
totalCollected: totalCollected,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
// Right Section: Metric Cards
|
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 4,
|
flex: 4,
|
||||||
child: _buildRightMetricsSection(
|
child: _buildRightMetricsSection(
|
||||||
totalCollected: totalCollected,
|
data: data,
|
||||||
|
dsoDays: dsoDays,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
// 3. AGING ANALYSIS SECTION
|
_buildAgingAnalysis(data: data),
|
||||||
_buildAgingAnalysis(),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- HELPER METHOD 1: HEADER ---
|
// ===============================================================
|
||||||
|
// HEADER
|
||||||
|
// ===============================================================
|
||||||
Widget _buildHeader() {
|
Widget _buildHeader() {
|
||||||
return Row(mainAxisAlignment: MainAxisAlignment.start, children: [
|
return Row(
|
||||||
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
MyText.bodyMedium(
|
MyText.bodyMedium('Collections Health Overview', fontWeight: 700),
|
||||||
'Collections Health Overview',
|
|
||||||
fontWeight: 700,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
MyText.bodySmall(
|
MyText.bodySmall('View your collection health data.',
|
||||||
'View your collection health data.',
|
color: Colors.grey),
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
]);
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- HELPER METHOD 2: LEFT SECTION (CHARTS) ---
|
// ===============================================================
|
||||||
|
// LEFT SECTION (GAUGE + SUMMARY + TREND PLACEHOLDERS)
|
||||||
|
// ===============================================================
|
||||||
Widget _buildLeftChartSection({
|
Widget _buildLeftChartSection({
|
||||||
required double totalDue,
|
required double totalDue,
|
||||||
required double pendingPercentage,
|
required double pendingPercentage,
|
||||||
|
required double totalCollected,
|
||||||
}) {
|
}) {
|
||||||
// Format the percentage for display
|
|
||||||
String pendingPercentStr = (pendingPercentage * 100).toStringAsFixed(0);
|
String pendingPercentStr = (pendingPercentage * 100).toStringAsFixed(0);
|
||||||
// Use the derived totalCollected for a better context
|
String collectedPercentStr =
|
||||||
const double totalCollected = 5000.0;
|
((1 - pendingPercentage) * 100).toStringAsFixed(0);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
// Top: Gauge Chart
|
Row(children: [
|
||||||
Row(
|
|
||||||
children: <Widget>[
|
|
||||||
_GaugeChartPlaceholder(
|
_GaugeChartPlaceholder(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
pendingPercentage: pendingPercentage,
|
pendingPercentage: pendingPercentage,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
],
|
]),
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// Total Due + Summary
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@ -125,56 +135,11 @@ class CollectionsHealthWidget extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
MyText.bodySmall(
|
MyText.bodySmall(
|
||||||
'• Pending ($pendingPercentStr%) • ₹${totalCollected.toStringAsFixed(0)} Collected',
|
'• Pending ($pendingPercentStr%) • Collected ($collectedPercentStr%)',
|
||||||
color: Colors.black54,
|
color: Colors.black54,
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
|
|
||||||
// Bottom: Timeline Charts (Trend Analysis)
|
|
||||||
Row(
|
|
||||||
children: <Widget>[
|
|
||||||
// Expected Collections Timeline (Bar Chart Placeholder)
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: <Widget>[
|
|
||||||
MyText.bodySmall(
|
MyText.bodySmall(
|
||||||
'Expected Collections Trend',
|
'₹${totalCollected.toStringAsFixed(0)} Collected',
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
const _TimelineChartPlaceholder(
|
|
||||||
isBar: true,
|
|
||||||
barColor: Color(0xFF2196F3),
|
|
||||||
),
|
|
||||||
MyText.bodySmall(
|
|
||||||
'Week 16 Nov 2025',
|
|
||||||
color: Colors.black54,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
|
|
||||||
// Collection Rate Trend (Area Chart Placeholder)
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: <Widget>[
|
|
||||||
MyText.bodySmall(
|
|
||||||
'Collection Rate Trend',
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
const _TimelineChartPlaceholder(
|
|
||||||
isBar: false,
|
|
||||||
areaColor: Color(0xFF4CAF50),
|
|
||||||
),
|
|
||||||
MyText.bodySmall(
|
|
||||||
'Week 14 Nov 2025',
|
|
||||||
color: Colors.black54,
|
color: Colors.black54,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -186,56 +151,42 @@ class CollectionsHealthWidget extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- HELPER METHOD 3: RIGHT SECTION (METRICS) ---
|
// ===============================================================
|
||||||
|
// RIGHT SIDE METRICS
|
||||||
|
// ===============================================================
|
||||||
Widget _buildRightMetricsSection({
|
Widget _buildRightMetricsSection({
|
||||||
required double totalCollected,
|
required CollectionOverviewData data,
|
||||||
|
required double dsoDays,
|
||||||
}) {
|
}) {
|
||||||
|
final double totalCollected = data.totalCollectedAmount;
|
||||||
|
|
||||||
|
final String topClientName = data.topClient?.name ?? 'N/A';
|
||||||
|
final double topClientBalance = data.topClientBalance;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
// Metric Card 1: Top Client
|
|
||||||
_buildMetricCard(
|
_buildMetricCard(
|
||||||
title: 'Top Client Balance',
|
title: 'Top Client Balance',
|
||||||
value: 'Peninsula Land Limited',
|
value: topClientName,
|
||||||
subValue: '₹34,190',
|
subValue: '₹${topClientBalance.toStringAsFixed(0)}',
|
||||||
valueColor: const Color(0xFFF44336), // Red (Pending/Due)
|
valueColor: Colors.red,
|
||||||
isDetailed: true,
|
isDetailed: true,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
// Metric Card 2: Total Collected (YTD)
|
|
||||||
_buildMetricCard(
|
_buildMetricCard(
|
||||||
title: 'Total Collected (YTD)',
|
title: 'Total Collected (YTD)',
|
||||||
value: '₹${totalCollected.toStringAsFixed(0)}',
|
value: '₹${totalCollected.toStringAsFixed(0)}',
|
||||||
subValue: 'Collected',
|
subValue: 'Collected',
|
||||||
valueColor: const Color(0xFF4CAF50), // Green (Positive Value)
|
valueColor: Colors.green,
|
||||||
isDetailed: false,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
|
|
||||||
// Metric Card 3: DSO
|
|
||||||
_buildMetricCard(
|
|
||||||
title: 'Days Sales Outstanding (DSO)',
|
|
||||||
value: '45 Days',
|
|
||||||
subValue: '↑ 5 Days',
|
|
||||||
valueColor: const Color(0xFFFF9800), // Orange (Needs improvement)
|
|
||||||
isDetailed: false,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
|
|
||||||
// Metric Card 4: Bad Debt Ratio
|
|
||||||
_buildMetricCard(
|
|
||||||
title: 'Bad Debt Ratio',
|
|
||||||
value: '0.8%',
|
|
||||||
subValue: '↓ 0.2%',
|
|
||||||
valueColor: const Color(0xFF4CAF50), // Green (Positive Change)
|
|
||||||
isDetailed: false,
|
isDetailed: false,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- HELPER METHOD 4: METRIC CARD WIDGET ---
|
// ===============================================================
|
||||||
|
// METRIC CARD UI
|
||||||
|
// ===============================================================
|
||||||
Widget _buildMetricCard({
|
Widget _buildMetricCard({
|
||||||
required String title,
|
required String title,
|
||||||
required String value,
|
required String value,
|
||||||
@ -252,34 +203,17 @@ class CollectionsHealthWidget extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
MyText.bodySmall(
|
MyText.bodySmall(title, color: Colors.black54),
|
||||||
title,
|
|
||||||
color: Colors.black54,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
if (isDetailed) ...[
|
if (isDetailed) ...[
|
||||||
MyText.bodySmall(
|
MyText.bodySmall(value, fontWeight: 600),
|
||||||
value,
|
MyText.bodyMedium(subValue, color: valueColor, fontWeight: 700),
|
||||||
fontWeight: 600,
|
|
||||||
),
|
|
||||||
MyText.bodyMedium(
|
|
||||||
subValue,
|
|
||||||
color: valueColor,
|
|
||||||
fontWeight: 700,
|
|
||||||
),
|
|
||||||
] else
|
] else
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
MyText.bodySmall(
|
MyText.bodySmall(value, fontWeight: 600),
|
||||||
value,
|
MyText.bodySmall(subValue, color: valueColor, fontWeight: 600),
|
||||||
fontWeight: 600,
|
|
||||||
),
|
|
||||||
MyText.bodySmall(
|
|
||||||
subValue,
|
|
||||||
color: valueColor,
|
|
||||||
fontWeight: 600,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -287,101 +221,109 @@ class CollectionsHealthWidget extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- NEW HELPER METHOD: AGING ANALYSIS ---
|
// ===============================================================
|
||||||
Widget _buildAgingAnalysis() {
|
// AGING ANALYSIS (DYNAMIC)
|
||||||
// Hardcoded data
|
// ===============================================================
|
||||||
const double due0to20Days = 0.0;
|
Widget _buildAgingAnalysis({required CollectionOverviewData data}) {
|
||||||
const double due20to45Days = 34190.0;
|
final buckets = [
|
||||||
const double due45to90Days = 0.0;
|
|
||||||
const double dueOver90Days = 0.0;
|
|
||||||
|
|
||||||
final double totalOutstanding =
|
|
||||||
due0to20Days + due20to45Days + due45to90Days + dueOver90Days;
|
|
||||||
|
|
||||||
// Define buckets with their risk color
|
|
||||||
final List<AgingBucketData> buckets = [
|
|
||||||
AgingBucketData(
|
AgingBucketData(
|
||||||
'0-20 Days',
|
'0-30 Days',
|
||||||
due0to20Days,
|
data.bucket0To30Amount,
|
||||||
const Color(0xFF4CAF50), // Green (Low Risk)
|
Colors.green,
|
||||||
|
data.bucket0To30Invoices,
|
||||||
),
|
),
|
||||||
AgingBucketData(
|
AgingBucketData(
|
||||||
'20-45 Days',
|
'30-60 Days',
|
||||||
due20to45Days,
|
data.bucket30To60Amount,
|
||||||
const Color(0xFFFF9800), // Orange (Medium Risk)
|
Colors.orange,
|
||||||
|
data.bucket30To60Invoices,
|
||||||
),
|
),
|
||||||
AgingBucketData(
|
AgingBucketData(
|
||||||
'45-90 Days',
|
'60-90 Days',
|
||||||
due45to90Days,
|
data.bucket60To90Amount,
|
||||||
const Color(0xFFF44336).withOpacity(0.7), // Light Red
|
Colors.red.shade300,
|
||||||
|
data.bucket60To90Invoices,
|
||||||
),
|
),
|
||||||
AgingBucketData(
|
AgingBucketData(
|
||||||
'> 90 Days',
|
'> 90 Days',
|
||||||
dueOver90Days,
|
data.bucket90PlusAmount,
|
||||||
const Color(0xFFF44336), // Dark Red
|
Colors.red,
|
||||||
|
data.bucket90PlusInvoices,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
final double totalOutstanding = buckets.fold(0, (sum, b) => sum + b.amount);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: [
|
||||||
MyText.bodyMedium(
|
MyText.bodyMedium(
|
||||||
'Outstanding Collections Aging Analysis',
|
'Outstanding Collections Aging Analysis',
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
),
|
),
|
||||||
|
MyText.bodySmall(
|
||||||
|
'Total Outstanding: ₹${totalOutstanding.toStringAsFixed(0)}',
|
||||||
|
color: Colors.black54,
|
||||||
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
// Stacked bar visualization
|
|
||||||
_AgingStackedBar(
|
_AgingStackedBar(
|
||||||
buckets: buckets,
|
buckets: buckets,
|
||||||
totalOutstanding: totalOutstanding,
|
totalOutstanding: totalOutstanding,
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 15),
|
const SizedBox(height: 15),
|
||||||
|
|
||||||
// Legend / Bucket details
|
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 12,
|
spacing: 12,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: buckets
|
children: buckets
|
||||||
.map(
|
.map((bucket) => _buildAgingLegendItem(bucket.title,
|
||||||
(bucket) => _buildAgingLegendItem(
|
bucket.amount, bucket.color, bucket.invoiceCount))
|
||||||
bucket.title,
|
|
||||||
bucket.amount,
|
|
||||||
bucket.color,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
.toList(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legend item for aging buckets
|
Widget _buildAgingLegendItem(
|
||||||
Widget _buildAgingLegendItem(String title, double amount, Color color) {
|
String title, double amount, Color color, int count // Updated parameter
|
||||||
|
) {
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
|
||||||
color: color,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
MyText.bodySmall(
|
MyText.bodySmall(
|
||||||
'$title: ₹${amount.toStringAsFixed(0)}',
|
'$title: ₹${amount.toStringAsFixed(0)} (${count} Invoices)' // Updated text
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===============================================================
|
||||||
|
// COMMON BOX DECORATION
|
||||||
|
// ===============================================================
|
||||||
|
BoxDecoration _boxDecoration() {
|
||||||
|
return BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.1),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 5),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- CUSTOM PAINTERS / PLACEHOLDERS ---
|
// =====================================================================
|
||||||
|
// CUSTOM PLACEHOLDER WIDGETS (Gauge + Bar/Area + Aging Bars)
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
// Placeholder for the Semi-Circle Gauge Chart
|
// Gauge Chart
|
||||||
class _GaugeChartPlaceholder extends StatelessWidget {
|
class _GaugeChartPlaceholder extends StatelessWidget {
|
||||||
final Color backgroundColor;
|
final Color backgroundColor;
|
||||||
final double pendingPercentage;
|
final double pendingPercentage;
|
||||||
@ -398,7 +340,6 @@ class _GaugeChartPlaceholder extends StatelessWidget {
|
|||||||
height: 80,
|
height: 80,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
// BACKGROUND GAUGE
|
|
||||||
CustomPaint(
|
CustomPaint(
|
||||||
size: const Size(120, 70),
|
size: const Size(120, 70),
|
||||||
painter: _SemiCirclePainter(
|
painter: _SemiCirclePainter(
|
||||||
@ -406,17 +347,12 @@ class _GaugeChartPlaceholder extends StatelessWidget {
|
|||||||
pendingPercentage: pendingPercentage,
|
pendingPercentage: pendingPercentage,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// CENTER TEXT
|
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.bottomCenter,
|
alignment: Alignment.bottomCenter,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
child: FittedBox(
|
child: FittedBox(
|
||||||
child: MyText.bodySmall(
|
child: MyText.bodySmall('RISK LEVEL', fontWeight: 600),
|
||||||
'RISK LEVEL',
|
|
||||||
fontWeight: 600,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -426,15 +362,12 @@ class _GaugeChartPlaceholder extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Painter for the semi-circular gauge chart visualization
|
|
||||||
class _SemiCirclePainter extends CustomPainter {
|
class _SemiCirclePainter extends CustomPainter {
|
||||||
final Color canvasColor;
|
final Color canvasColor;
|
||||||
final double pendingPercentage;
|
final double pendingPercentage;
|
||||||
|
|
||||||
_SemiCirclePainter({
|
_SemiCirclePainter(
|
||||||
required this.canvasColor,
|
{required this.canvasColor, required this.pendingPercentage});
|
||||||
required this.pendingPercentage,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void paint(Canvas canvas, Size size) {
|
void paint(Canvas canvas, Size size) {
|
||||||
@ -443,184 +376,47 @@ class _SemiCirclePainter extends CustomPainter {
|
|||||||
radius: size.width / 2,
|
radius: size.width / 2,
|
||||||
);
|
);
|
||||||
|
|
||||||
const double totalArc = 3.14159;
|
const double arc = 3.14159;
|
||||||
final double pendingSweepAngle = totalArc * pendingPercentage;
|
final double pendingSweep = arc * pendingPercentage;
|
||||||
final double collectedSweepAngle = totalArc * (1.0 - pendingPercentage);
|
final double collectedSweep = arc * (1 - pendingPercentage);
|
||||||
|
|
||||||
// Background Arc
|
|
||||||
final backgroundPaint = Paint()
|
final backgroundPaint = Paint()
|
||||||
..color = Colors.black.withOpacity(0.1)
|
..color = Colors.black.withOpacity(0.1)
|
||||||
..style = PaintingStyle.stroke
|
|
||||||
..strokeWidth = 10;
|
|
||||||
canvas.drawArc(rect, totalArc, totalArc, false, backgroundPaint);
|
|
||||||
|
|
||||||
// Pending Arc
|
|
||||||
final pendingPaint = Paint()
|
|
||||||
..style = PaintingStyle.stroke
|
|
||||||
..strokeWidth = 10
|
..strokeWidth = 10
|
||||||
..shader = const LinearGradient(
|
|
||||||
colors: [
|
|
||||||
Color(0xFFFF9800),
|
|
||||||
Color(0xFFF44336),
|
|
||||||
],
|
|
||||||
).createShader(rect);
|
|
||||||
canvas.drawArc(rect, totalArc, pendingSweepAngle, false, pendingPaint);
|
|
||||||
|
|
||||||
// Collected Arc
|
|
||||||
final collectedPaint = Paint()
|
|
||||||
..color = const Color(0xFF4CAF50)
|
|
||||||
..style = PaintingStyle.stroke
|
|
||||||
..strokeWidth = 10;
|
|
||||||
canvas.drawArc(
|
|
||||||
rect,
|
|
||||||
totalArc + pendingSweepAngle,
|
|
||||||
collectedSweepAngle,
|
|
||||||
false,
|
|
||||||
collectedPaint,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Placeholder for the Bar/Area Charts
|
|
||||||
class _TimelineChartPlaceholder extends StatelessWidget {
|
|
||||||
final bool isBar;
|
|
||||||
final Color? barColor;
|
|
||||||
final Color? areaColor;
|
|
||||||
|
|
||||||
const _TimelineChartPlaceholder({
|
|
||||||
required this.isBar,
|
|
||||||
this.barColor,
|
|
||||||
this.areaColor,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
height: 50,
|
|
||||||
width: double.infinity,
|
|
||||||
color: Colors.transparent,
|
|
||||||
child: isBar
|
|
||||||
? _BarChartVisual(barColor: barColor!)
|
|
||||||
: _AreaChartVisual(areaColor: areaColor!),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _BarChartVisual extends StatelessWidget {
|
|
||||||
final Color barColor;
|
|
||||||
|
|
||||||
const _BarChartVisual({required this.barColor});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
||||||
children: const [
|
|
||||||
_Bar(0.4),
|
|
||||||
_Bar(0.7),
|
|
||||||
_Bar(1.0),
|
|
||||||
_Bar(0.6),
|
|
||||||
_Bar(0.8),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _Bar extends StatelessWidget {
|
|
||||||
final double heightFactor;
|
|
||||||
|
|
||||||
const _Bar(this.heightFactor);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
// Bar color is taken from parent via DefaultTextStyle/Theme if needed;
|
|
||||||
// you can wrap with Theme if you want dynamic colors.
|
|
||||||
return Container(
|
|
||||||
width: 8,
|
|
||||||
height: 50 * heightFactor,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey.shade400,
|
|
||||||
borderRadius: BorderRadius.circular(5),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AreaChartVisual extends StatelessWidget {
|
|
||||||
final Color areaColor;
|
|
||||||
|
|
||||||
const _AreaChartVisual({required this.areaColor});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return CustomPaint(
|
|
||||||
painter: _AreaChartPainter(areaColor: areaColor),
|
|
||||||
size: const Size(double.infinity, 50),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AreaChartPainter extends CustomPainter {
|
|
||||||
final Color areaColor;
|
|
||||||
|
|
||||||
_AreaChartPainter({required this.areaColor});
|
|
||||||
|
|
||||||
@override
|
|
||||||
void paint(Canvas canvas, Size size) {
|
|
||||||
final points = [
|
|
||||||
Offset(0, size.height * 0.5),
|
|
||||||
Offset(size.width * 0.25, size.height * 0.8),
|
|
||||||
Offset(size.width * 0.5, size.height * 0.3),
|
|
||||||
Offset(size.width * 0.75, size.height * 0.9),
|
|
||||||
Offset(size.width, size.height * 0.4),
|
|
||||||
];
|
|
||||||
|
|
||||||
final path = Path()
|
|
||||||
..moveTo(points.first.dx, size.height)
|
|
||||||
..lineTo(points.first.dx, points.first.dy);
|
|
||||||
for (int i = 1; i < points.length; i++) {
|
|
||||||
path.lineTo(points[i].dx, points[i].dy);
|
|
||||||
}
|
|
||||||
path.lineTo(points.last.dx, size.height);
|
|
||||||
path.close();
|
|
||||||
|
|
||||||
final areaPaint = Paint()
|
|
||||||
..shader = LinearGradient(
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
colors: [
|
|
||||||
areaColor.withOpacity(0.5),
|
|
||||||
areaColor.withOpacity(0.0),
|
|
||||||
],
|
|
||||||
).createShader(Rect.fromLTWH(0, 0, size.width, size.height))
|
|
||||||
..style = PaintingStyle.fill;
|
|
||||||
canvas.drawPath(path, areaPaint);
|
|
||||||
|
|
||||||
final linePaint = Paint()
|
|
||||||
..color = areaColor
|
|
||||||
..strokeWidth = 2
|
|
||||||
..style = PaintingStyle.stroke;
|
..style = PaintingStyle.stroke;
|
||||||
canvas.drawPath(Path()..addPolygon(points, false), linePaint);
|
canvas.drawArc(rect, arc, arc, false, backgroundPaint);
|
||||||
|
|
||||||
|
final pendingPaint = Paint()
|
||||||
|
..strokeWidth = 10
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..shader = const LinearGradient(
|
||||||
|
colors: [Colors.orange, Colors.red],
|
||||||
|
).createShader(rect);
|
||||||
|
canvas.drawArc(rect, arc, pendingSweep, false, pendingPaint);
|
||||||
|
|
||||||
|
final collectedPaint = Paint()
|
||||||
|
..color = Colors.green
|
||||||
|
..strokeWidth = 10
|
||||||
|
..style = PaintingStyle.stroke;
|
||||||
|
canvas.drawArc(
|
||||||
|
rect, arc + pendingSweep, collectedSweep, false, collectedPaint);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- DATA MODEL ---
|
// AGING BUCKET
|
||||||
class AgingBucketData {
|
class AgingBucketData {
|
||||||
final String title;
|
final String title;
|
||||||
final double amount;
|
final double amount;
|
||||||
final Color color;
|
final Color color;
|
||||||
|
final int invoiceCount; // ADDED
|
||||||
|
|
||||||
AgingBucketData(this.title, this.amount, this.color);
|
// UPDATED CONSTRUCTOR
|
||||||
|
AgingBucketData(this.title, this.amount, this.color, this.invoiceCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- STACKED BAR VISUAL ---
|
|
||||||
class _AgingStackedBar extends StatelessWidget {
|
class _AgingStackedBar extends StatelessWidget {
|
||||||
final List<AgingBucketData> buckets;
|
final List<AgingBucketData> buckets;
|
||||||
final double totalOutstanding;
|
final double totalOutstanding;
|
||||||
@ -640,31 +436,22 @@ class _AgingStackedBar extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: MyText.bodySmall(
|
child: MyText.bodySmall('No Outstanding Collections',
|
||||||
'No Outstanding Collections',
|
color: Colors.black54),
|
||||||
color: Colors.black54,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final List<Widget> segments =
|
|
||||||
buckets.where((b) => b.amount > 0).map((bucket) {
|
|
||||||
final double flexValue = bucket.amount / totalOutstanding;
|
|
||||||
return Expanded(
|
|
||||||
flex: (flexValue * 100).toInt(),
|
|
||||||
child: Container(
|
|
||||||
height: 16,
|
|
||||||
color: bucket.color,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
return ClipRRect(
|
return ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
children: buckets.where((b) => b.amount > 0).map((bucket) {
|
||||||
children: segments,
|
final flexValue = bucket.amount / totalOutstanding;
|
||||||
|
return Expanded(
|
||||||
|
flex: (flexValue * 1000).toInt(),
|
||||||
|
child: Container(height: 16, color: bucket.color),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
192
lib/model/dashboard/collection_overview_model.dart
Normal file
192
lib/model/dashboard/collection_overview_model.dart
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
/// ===============================
|
||||||
|
/// MAIN MODEL: CollectionOverview
|
||||||
|
/// ===============================
|
||||||
|
|
||||||
|
class CollectionOverviewResponse {
|
||||||
|
final bool success;
|
||||||
|
final String message;
|
||||||
|
final CollectionOverviewData data;
|
||||||
|
final dynamic errors;
|
||||||
|
final int statusCode;
|
||||||
|
final DateTime timestamp;
|
||||||
|
|
||||||
|
CollectionOverviewResponse({
|
||||||
|
required this.success,
|
||||||
|
required this.message,
|
||||||
|
required this.data,
|
||||||
|
required this.errors,
|
||||||
|
required this.statusCode,
|
||||||
|
required this.timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory CollectionOverviewResponse.fromJson(Map<String, dynamic> json) {
|
||||||
|
return CollectionOverviewResponse(
|
||||||
|
success: json['success'] ?? false,
|
||||||
|
message: json['message'] ?? '',
|
||||||
|
data: CollectionOverviewData.fromJson(json['data'] ?? {}),
|
||||||
|
errors: json['errors'],
|
||||||
|
statusCode: json['statusCode'] ?? 0,
|
||||||
|
timestamp: DateTime.tryParse(json['timestamp'] ?? '') ?? DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'success': success,
|
||||||
|
'message': message,
|
||||||
|
'data': data.toJson(),
|
||||||
|
'errors': errors,
|
||||||
|
'statusCode': statusCode,
|
||||||
|
'timestamp': timestamp.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ===============================
|
||||||
|
/// DATA BLOCK
|
||||||
|
/// ===============================
|
||||||
|
|
||||||
|
class CollectionOverviewData {
|
||||||
|
final double totalDueAmount;
|
||||||
|
final double totalCollectedAmount;
|
||||||
|
final double totalValue;
|
||||||
|
final double pendingPercentage;
|
||||||
|
final double collectedPercentage;
|
||||||
|
|
||||||
|
final int bucket0To30Invoices;
|
||||||
|
final int bucket30To60Invoices;
|
||||||
|
final int bucket60To90Invoices;
|
||||||
|
final int bucket90PlusInvoices;
|
||||||
|
|
||||||
|
final double bucket0To30Amount;
|
||||||
|
final double bucket30To60Amount;
|
||||||
|
final double bucket60To90Amount;
|
||||||
|
final double bucket90PlusAmount;
|
||||||
|
|
||||||
|
final double topClientBalance;
|
||||||
|
final TopClient? topClient;
|
||||||
|
|
||||||
|
CollectionOverviewData({
|
||||||
|
required this.totalDueAmount,
|
||||||
|
required this.totalCollectedAmount,
|
||||||
|
required this.totalValue,
|
||||||
|
required this.pendingPercentage,
|
||||||
|
required this.collectedPercentage,
|
||||||
|
required this.bucket0To30Invoices,
|
||||||
|
required this.bucket30To60Invoices,
|
||||||
|
required this.bucket60To90Invoices,
|
||||||
|
required this.bucket90PlusInvoices,
|
||||||
|
required this.bucket0To30Amount,
|
||||||
|
required this.bucket30To60Amount,
|
||||||
|
required this.bucket60To90Amount,
|
||||||
|
required this.bucket90PlusAmount,
|
||||||
|
required this.topClientBalance,
|
||||||
|
required this.topClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory CollectionOverviewData.fromJson(Map<String, dynamic> json) {
|
||||||
|
return CollectionOverviewData(
|
||||||
|
totalDueAmount: (json['totalDueAmount'] ?? 0).toDouble(),
|
||||||
|
totalCollectedAmount: (json['totalCollectedAmount'] ?? 0).toDouble(),
|
||||||
|
totalValue: (json['totalValue'] ?? 0).toDouble(),
|
||||||
|
pendingPercentage: (json['pendingPercentage'] ?? 0).toDouble(),
|
||||||
|
collectedPercentage: (json['collectedPercentage'] ?? 0).toDouble(),
|
||||||
|
|
||||||
|
bucket0To30Invoices: json['bucket0To30Invoices'] ?? 0,
|
||||||
|
bucket30To60Invoices: json['bucket30To60Invoices'] ?? 0,
|
||||||
|
bucket60To90Invoices: json['bucket60To90Invoices'] ?? 0,
|
||||||
|
bucket90PlusInvoices: json['bucket90PlusInvoices'] ?? 0,
|
||||||
|
|
||||||
|
bucket0To30Amount: (json['bucket0To30Amount'] ?? 0).toDouble(),
|
||||||
|
bucket30To60Amount: (json['bucket30To60Amount'] ?? 0).toDouble(),
|
||||||
|
bucket60To90Amount: (json['bucket60To90Amount'] ?? 0).toDouble(),
|
||||||
|
bucket90PlusAmount: (json['bucket90PlusAmount'] ?? 0).toDouble(),
|
||||||
|
|
||||||
|
topClientBalance: (json['topClientBalance'] ?? 0).toDouble(),
|
||||||
|
topClient: json['topClient'] != null
|
||||||
|
? TopClient.fromJson(json['topClient'])
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'totalDueAmount': totalDueAmount,
|
||||||
|
'totalCollectedAmount': totalCollectedAmount,
|
||||||
|
'totalValue': totalValue,
|
||||||
|
'pendingPercentage': pendingPercentage,
|
||||||
|
'collectedPercentage': collectedPercentage,
|
||||||
|
'bucket0To30Invoices': bucket0To30Invoices,
|
||||||
|
'bucket30To60Invoices': bucket30To60Invoices,
|
||||||
|
'bucket60To90Invoices': bucket60To90Invoices,
|
||||||
|
'bucket90PlusInvoices': bucket90PlusInvoices,
|
||||||
|
'bucket0To30Amount': bucket0To30Amount,
|
||||||
|
'bucket30To60Amount': bucket30To60Amount,
|
||||||
|
'bucket60To90Amount': bucket60To90Amount,
|
||||||
|
'bucket90PlusAmount': bucket90PlusAmount,
|
||||||
|
'topClientBalance': topClientBalance,
|
||||||
|
'topClient': topClient?.toJson(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ===============================
|
||||||
|
/// NESTED MODEL: Top Client
|
||||||
|
/// ===============================
|
||||||
|
|
||||||
|
class TopClient {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String? email;
|
||||||
|
final String? contactPerson;
|
||||||
|
final String? address;
|
||||||
|
final String? gstNumber;
|
||||||
|
final String? contactNumber;
|
||||||
|
final int? sprid;
|
||||||
|
|
||||||
|
TopClient({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
this.email,
|
||||||
|
this.contactPerson,
|
||||||
|
this.address,
|
||||||
|
this.gstNumber,
|
||||||
|
this.contactNumber,
|
||||||
|
this.sprid,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory TopClient.fromJson(Map<String, dynamic> json) {
|
||||||
|
return TopClient(
|
||||||
|
id: json['id'] ?? '',
|
||||||
|
name: json['name'] ?? '',
|
||||||
|
email: json['email'],
|
||||||
|
contactPerson: json['contactPerson'],
|
||||||
|
address: json['address'],
|
||||||
|
gstNumber: json['gstNumber'],
|
||||||
|
contactNumber: json['contactNumber'],
|
||||||
|
sprid: json['sprid'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'email': email,
|
||||||
|
'contactPerson': contactPerson,
|
||||||
|
'address': address,
|
||||||
|
'gstNumber': gstNumber,
|
||||||
|
'contactNumber': contactNumber,
|
||||||
|
'sprid': sprid,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ===============================
|
||||||
|
/// Optional: Quick decode method
|
||||||
|
/// ===============================
|
||||||
|
CollectionOverviewResponse parseCollectionOverview(String jsonString) {
|
||||||
|
return CollectionOverviewResponse.fromJson(jsonDecode(jsonString));
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user