Merge pull request 'Collection_Purchase_Widget' (#87) from Collection_Purchase_Widget into main
Reviewed-on: #87
This commit is contained in:
commit
9ec7dee0f1
@ -8,119 +8,127 @@ 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';
|
||||||
|
import 'package:on_field_work/model/dashboard/purchase_invoice_model.dart';
|
||||||
|
|
||||||
class DashboardController extends GetxController {
|
class DashboardController extends GetxController {
|
||||||
// =========================
|
// Dependencies
|
||||||
// Attendance overview
|
|
||||||
// =========================
|
|
||||||
final RxList<Map<String, dynamic>> roleWiseData =
|
|
||||||
<Map<String, dynamic>>[].obs;
|
|
||||||
final RxString attendanceSelectedRange = '15D'.obs;
|
|
||||||
final RxBool attendanceIsChartView = true.obs;
|
|
||||||
final RxBool isAttendanceLoading = false.obs;
|
|
||||||
|
|
||||||
// =========================
|
|
||||||
// Project progress overview
|
|
||||||
// =========================
|
|
||||||
final RxList<ChartTaskData> projectChartData = <ChartTaskData>[].obs;
|
|
||||||
final RxString projectSelectedRange = '15D'.obs;
|
|
||||||
final RxBool projectIsChartView = true.obs;
|
|
||||||
final RxBool isProjectLoading = false.obs;
|
|
||||||
|
|
||||||
// =========================
|
|
||||||
// Projects overview
|
|
||||||
// =========================
|
|
||||||
final RxInt totalProjects = 0.obs;
|
|
||||||
final RxInt ongoingProjects = 0.obs;
|
|
||||||
final RxBool isProjectsLoading = false.obs;
|
|
||||||
|
|
||||||
// =========================
|
|
||||||
// Tasks overview
|
|
||||||
// =========================
|
|
||||||
final RxInt totalTasks = 0.obs;
|
|
||||||
final RxInt completedTasks = 0.obs;
|
|
||||||
final RxBool isTasksLoading = false.obs;
|
|
||||||
|
|
||||||
// =========================
|
|
||||||
// Teams overview
|
|
||||||
// =========================
|
|
||||||
final RxInt totalEmployees = 0.obs;
|
|
||||||
final RxInt inToday = 0.obs;
|
|
||||||
final RxBool isTeamsLoading = false.obs;
|
|
||||||
|
|
||||||
// Common ranges
|
|
||||||
final List<String> ranges = ['7D', '15D', '30D'];
|
|
||||||
|
|
||||||
// Inject ProjectController
|
|
||||||
final ProjectController projectController = Get.put(ProjectController());
|
final ProjectController projectController = Get.put(ProjectController());
|
||||||
// Pending Expenses overview
|
|
||||||
// =========================
|
|
||||||
final RxBool isPendingExpensesLoading = false.obs;
|
|
||||||
final Rx<PendingExpensesData?> pendingExpensesData =
|
|
||||||
Rx<PendingExpensesData?>(null);
|
|
||||||
// =========================
|
|
||||||
// Expense Category Report
|
|
||||||
// =========================
|
|
||||||
final RxBool isExpenseTypeReportLoading = false.obs;
|
|
||||||
final Rx<ExpenseTypeReportData?> expenseTypeReportData =
|
|
||||||
Rx<ExpenseTypeReportData?>(null);
|
|
||||||
final Rx<DateTime> expenseReportStartDate =
|
|
||||||
DateTime.now().subtract(const Duration(days: 15)).obs;
|
|
||||||
final Rx<DateTime> expenseReportEndDate = DateTime.now().obs;
|
|
||||||
// =========================
|
|
||||||
// Monthly Expense Report
|
|
||||||
// =========================
|
|
||||||
final RxBool isMonthlyExpenseLoading = false.obs;
|
|
||||||
final RxList<MonthlyExpenseData> monthlyExpenseList =
|
|
||||||
<MonthlyExpenseData>[].obs;
|
|
||||||
// =========================
|
|
||||||
// Monthly Expense Report Filters
|
|
||||||
// =========================
|
|
||||||
final Rx<MonthlyExpenseDuration> selectedMonthlyExpenseDuration =
|
|
||||||
MonthlyExpenseDuration.twelveMonths.obs;
|
|
||||||
|
|
||||||
final RxInt selectedMonthsCount = 12.obs;
|
// =========================
|
||||||
final RxList<ExpenseTypeModel> expenseTypes = <ExpenseTypeModel>[].obs;
|
// 1. STATE VARIABLES
|
||||||
final Rx<ExpenseTypeModel?> selectedExpenseType = Rx<ExpenseTypeModel?>(null);
|
// =========================
|
||||||
|
|
||||||
|
// Attendance
|
||||||
|
final roleWiseData = <Map<String, dynamic>>[].obs;
|
||||||
|
final attendanceSelectedRange = '15D'.obs;
|
||||||
|
final attendanceIsChartView = true.obs;
|
||||||
|
final isAttendanceLoading = false.obs;
|
||||||
|
|
||||||
|
// Project Progress
|
||||||
|
final projectChartData = <ChartTaskData>[].obs;
|
||||||
|
final projectSelectedRange = '15D'.obs;
|
||||||
|
final projectIsChartView = true.obs;
|
||||||
|
final isProjectLoading = false.obs;
|
||||||
|
|
||||||
|
// Overview Counts
|
||||||
|
final totalProjects = 0.obs;
|
||||||
|
final ongoingProjects = 0.obs;
|
||||||
|
final isProjectsLoading = false.obs;
|
||||||
|
|
||||||
|
final totalTasks = 0.obs;
|
||||||
|
final completedTasks = 0.obs;
|
||||||
|
final isTasksLoading = false.obs;
|
||||||
|
|
||||||
|
final totalEmployees = 0.obs;
|
||||||
|
final inToday = 0.obs;
|
||||||
|
final isTeamsLoading = false.obs;
|
||||||
|
|
||||||
|
// Expenses & Reports
|
||||||
|
final isPendingExpensesLoading = false.obs;
|
||||||
|
final pendingExpensesData = Rx<PendingExpensesData?>(null);
|
||||||
|
|
||||||
|
final isExpenseTypeReportLoading = false.obs;
|
||||||
|
final expenseTypeReportData = Rx<ExpenseTypeReportData?>(null);
|
||||||
|
final expenseReportStartDate =
|
||||||
|
DateTime.now().subtract(const Duration(days: 15)).obs;
|
||||||
|
final expenseReportEndDate = DateTime.now().obs;
|
||||||
|
|
||||||
|
final isMonthlyExpenseLoading = false.obs;
|
||||||
|
final monthlyExpenseList = <MonthlyExpenseData>[].obs;
|
||||||
|
final selectedMonthlyExpenseDuration =
|
||||||
|
MonthlyExpenseDuration.twelveMonths.obs;
|
||||||
|
final selectedMonthsCount = 12.obs;
|
||||||
|
|
||||||
|
final expenseTypes = <ExpenseTypeModel>[].obs;
|
||||||
|
final selectedExpenseType = Rx<ExpenseTypeModel?>(null);
|
||||||
|
|
||||||
|
// Teams/Employees
|
||||||
final isLoadingEmployees = true.obs;
|
final isLoadingEmployees = true.obs;
|
||||||
// DashboardController
|
final employees = <EmployeeModel>[].obs;
|
||||||
final RxList<EmployeeModel> employees = <EmployeeModel>[].obs;
|
|
||||||
final uploadingStates = <String, RxBool>{}.obs;
|
final uploadingStates = <String, RxBool>{}.obs;
|
||||||
|
|
||||||
void updateSelectedExpenseType(ExpenseTypeModel? type) {
|
// Collection
|
||||||
selectedExpenseType.value = type;
|
final isCollectionOverviewLoading = true.obs;
|
||||||
|
final collectionOverviewData = Rx<CollectionOverviewData?>(null);
|
||||||
|
// =========================
|
||||||
|
// Purchase Invoice Overview
|
||||||
|
// =========================
|
||||||
|
final isPurchaseInvoiceLoading = true.obs;
|
||||||
|
final purchaseInvoiceOverviewData = Rx<PurchaseInvoiceOverviewData?>(null);
|
||||||
|
// Constants
|
||||||
|
final List<String> ranges = ['7D', '15D', '30D'];
|
||||||
|
static const _rangeDaysMap = {
|
||||||
|
'7D': 7,
|
||||||
|
'15D': 15,
|
||||||
|
'30D': 30,
|
||||||
|
'3M': 90,
|
||||||
|
'6M': 180
|
||||||
|
};
|
||||||
|
|
||||||
// Debug print to verify
|
// =========================
|
||||||
print('Selected: ${type?.name ?? "All Types"}');
|
// 2. COMPUTED PROPERTIES
|
||||||
|
// =========================
|
||||||
|
|
||||||
if (type == null) {
|
int getAttendanceDays() => _rangeDaysMap[attendanceSelectedRange.value] ?? 7;
|
||||||
fetchMonthlyExpenses();
|
int getProjectDays() => _rangeDaysMap[projectSelectedRange.value] ?? 7;
|
||||||
} else {
|
|
||||||
fetchMonthlyExpenses(categoryId: type.id);
|
// DSO Calculation Constants
|
||||||
}
|
static const double _w0_30 = 15.0;
|
||||||
|
static const double _w30_60 = 45.0;
|
||||||
|
static const double _w60_90 = 75.0;
|
||||||
|
static const double _w90_plus = 105.0;
|
||||||
|
|
||||||
|
double get calculatedDSO {
|
||||||
|
final data = collectionOverviewData.value;
|
||||||
|
if (data == null || data.totalDueAmount == 0) return 0.0;
|
||||||
|
|
||||||
|
final double weightedDue = (data.bucket0To30Amount * _w0_30) +
|
||||||
|
(data.bucket30To60Amount * _w30_60) +
|
||||||
|
(data.bucket60To90Amount * _w60_90) +
|
||||||
|
(data.bucket90PlusAmount * _w90_plus);
|
||||||
|
|
||||||
|
return weightedDue / data.totalDueAmount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// 3. LIFECYCLE
|
||||||
|
// =========================
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onInit() {
|
void onInit() {
|
||||||
super.onInit();
|
super.onInit();
|
||||||
|
logSafe('DashboardController initialized', level: LogLevel.info);
|
||||||
|
|
||||||
logSafe(
|
// Project Selection Listener
|
||||||
'DashboardController initialized',
|
|
||||||
level: LogLevel.info,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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
|
// Expense Report Date Listener
|
||||||
everAll([expenseReportStartDate, expenseReportEndDate], (_) {
|
everAll([expenseReportStartDate, expenseReportEndDate], (_) {
|
||||||
if (projectController.selectedProjectId.value.isNotEmpty) {
|
if (projectController.selectedProjectId.value.isNotEmpty) {
|
||||||
fetchExpenseTypeReport(
|
fetchExpenseTypeReport(
|
||||||
@ -130,84 +138,70 @@ class DashboardController extends GetxController {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// React to attendance range changes
|
// Chart Range Listeners
|
||||||
ever(attendanceSelectedRange, (_) => fetchRoleWiseAttendance());
|
ever(attendanceSelectedRange, (_) => fetchRoleWiseAttendance());
|
||||||
|
|
||||||
// React to project range changes
|
|
||||||
ever(projectSelectedRange, (_) => fetchProjectProgress());
|
ever(projectSelectedRange, (_) => fetchProjectProgress());
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================
|
// =========================
|
||||||
// Helper Methods
|
// 4. USER ACTIONS
|
||||||
// =========================
|
// =========================
|
||||||
int _getDaysFromRange(String range) {
|
|
||||||
switch (range) {
|
void updateAttendanceRange(String range) =>
|
||||||
case '7D':
|
attendanceSelectedRange.value = range;
|
||||||
return 7;
|
void updateProjectRange(String range) => projectSelectedRange.value = range;
|
||||||
case '15D':
|
void toggleAttendanceChartView(bool isChart) =>
|
||||||
return 15;
|
attendanceIsChartView.value = isChart;
|
||||||
case '30D':
|
void toggleProjectChartView(bool isChart) =>
|
||||||
return 30;
|
projectIsChartView.value = isChart;
|
||||||
case '3M':
|
|
||||||
return 90;
|
void updateSelectedExpenseType(ExpenseTypeModel? type) {
|
||||||
case '6M':
|
selectedExpenseType.value = type;
|
||||||
return 180;
|
fetchMonthlyExpenses(categoryId: type?.id);
|
||||||
default:
|
}
|
||||||
return 7;
|
|
||||||
|
void updateMonthlyExpenseDuration(MonthlyExpenseDuration duration) {
|
||||||
|
selectedMonthlyExpenseDuration.value = duration;
|
||||||
|
|
||||||
|
// Efficient Map lookup instead of Switch
|
||||||
|
const durationMap = {
|
||||||
|
MonthlyExpenseDuration.oneMonth: 1,
|
||||||
|
MonthlyExpenseDuration.threeMonths: 3,
|
||||||
|
MonthlyExpenseDuration.sixMonths: 6,
|
||||||
|
MonthlyExpenseDuration.twelveMonths: 12,
|
||||||
|
MonthlyExpenseDuration.all: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
selectedMonthsCount.value = durationMap[duration] ?? 12;
|
||||||
|
fetchMonthlyExpenses();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refreshDashboard() => fetchAllDashboardData();
|
||||||
|
Future<void> refreshAttendance() => fetchRoleWiseAttendance();
|
||||||
|
Future<void> refreshProjects() => fetchProjectProgress();
|
||||||
|
Future<void> refreshTasks() async {
|
||||||
|
final id = projectController.selectedProjectId.value;
|
||||||
|
if (id.isNotEmpty) await fetchDashboardTasks(projectId: id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// 5. DATA FETCHING (API)
|
||||||
|
// =========================
|
||||||
|
|
||||||
|
/// Wrapper to reduce try-finally boilerplate for loading states
|
||||||
|
Future<void> _executeApiCall(
|
||||||
|
RxBool loader, Future<void> Function() apiLogic) async {
|
||||||
|
loader.value = true;
|
||||||
|
try {
|
||||||
|
await apiLogic();
|
||||||
|
} finally {
|
||||||
|
loader.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
int getAttendanceDays() => _getDaysFromRange(attendanceSelectedRange.value);
|
|
||||||
int getProjectDays() => _getDaysFromRange(projectSelectedRange.value);
|
|
||||||
|
|
||||||
void updateAttendanceRange(String range) {
|
|
||||||
attendanceSelectedRange.value = range;
|
|
||||||
logSafe('Attendance range updated to $range', level: LogLevel.debug);
|
|
||||||
}
|
|
||||||
|
|
||||||
void updateProjectRange(String range) {
|
|
||||||
projectSelectedRange.value = range;
|
|
||||||
logSafe('Project range updated to $range', level: LogLevel.debug);
|
|
||||||
}
|
|
||||||
|
|
||||||
void toggleAttendanceChartView(bool isChart) {
|
|
||||||
attendanceIsChartView.value = isChart;
|
|
||||||
logSafe('Attendance chart view toggled to: $isChart',
|
|
||||||
level: LogLevel.debug);
|
|
||||||
}
|
|
||||||
|
|
||||||
void toggleProjectChartView(bool isChart) {
|
|
||||||
projectIsChartView.value = isChart;
|
|
||||||
logSafe('Project chart view toggled to: $isChart', level: LogLevel.debug);
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================
|
|
||||||
// Manual Refresh Methods
|
|
||||||
// =========================
|
|
||||||
Future<void> refreshDashboard() async {
|
|
||||||
logSafe('Manual dashboard refresh triggered.', level: LogLevel.debug);
|
|
||||||
await fetchAllDashboardData();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> refreshAttendance() async => fetchRoleWiseAttendance();
|
|
||||||
Future<void> refreshTasks() async {
|
|
||||||
final projectId = projectController.selectedProjectId.value;
|
|
||||||
if (projectId.isNotEmpty) await fetchDashboardTasks(projectId: projectId);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> refreshProjects() async => fetchProjectProgress();
|
|
||||||
|
|
||||||
// =========================
|
|
||||||
// Fetch All Dashboard Data
|
|
||||||
// =========================
|
|
||||||
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,269 +214,150 @@ class DashboardController extends GetxController {
|
|||||||
endDate: expenseReportEndDate.value,
|
endDate: expenseReportEndDate.value,
|
||||||
),
|
),
|
||||||
fetchMonthlyExpenses(),
|
fetchMonthlyExpenses(),
|
||||||
fetchMasterData()
|
fetchMasterData(),
|
||||||
|
fetchCollectionOverview(),
|
||||||
|
fetchPurchaseInvoiceOverview(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> fetchTodaysAttendance(String projectId) async {
|
Future<void> fetchCollectionOverview() async {
|
||||||
isLoadingEmployees.value = true;
|
final projectId = projectController.selectedProjectId.value;
|
||||||
|
if (projectId.isEmpty) return;
|
||||||
|
|
||||||
final response = await ApiService.getAttendanceForDashboard(projectId);
|
await _executeApiCall(isCollectionOverviewLoading, () async {
|
||||||
if (response != null) {
|
final response =
|
||||||
employees.value = response;
|
await ApiService.getCollectionOverview(projectId: projectId);
|
||||||
for (var emp in employees) {
|
collectionOverviewData.value =
|
||||||
uploadingStates[emp.id] = false.obs;
|
(response?.success == true) ? response!.data : null;
|
||||||
}
|
});
|
||||||
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;
|
|
||||||
update();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateMonthlyExpenseDuration(MonthlyExpenseDuration duration) {
|
Future<void> fetchTodaysAttendance(String projectId) async {
|
||||||
selectedMonthlyExpenseDuration.value = duration;
|
await _executeApiCall(isLoadingEmployees, () async {
|
||||||
|
final response = await ApiService.getAttendanceForDashboard(projectId);
|
||||||
// Set months count based on selection
|
if (response != null) {
|
||||||
switch (duration) {
|
employees.value = response;
|
||||||
case MonthlyExpenseDuration.oneMonth:
|
for (var emp in employees) {
|
||||||
selectedMonthsCount.value = 1;
|
uploadingStates.putIfAbsent(emp.id, () => false.obs);
|
||||||
break;
|
}
|
||||||
case MonthlyExpenseDuration.threeMonths:
|
}
|
||||||
selectedMonthsCount.value = 3;
|
});
|
||||||
break;
|
|
||||||
case MonthlyExpenseDuration.sixMonths:
|
|
||||||
selectedMonthsCount.value = 6;
|
|
||||||
break;
|
|
||||||
case MonthlyExpenseDuration.twelveMonths:
|
|
||||||
selectedMonthsCount.value = 12;
|
|
||||||
break;
|
|
||||||
case MonthlyExpenseDuration.all:
|
|
||||||
selectedMonthsCount.value = 0; // 0 = All months in your API
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-fetch updated data
|
|
||||||
fetchMonthlyExpenses();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
} catch (_) {}
|
||||||
logSafe('Error fetching master data', level: LogLevel.error, error: e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> fetchMonthlyExpenses({String? categoryId}) async {
|
Future<void> fetchMonthlyExpenses({String? categoryId}) async {
|
||||||
try {
|
await _executeApiCall(isMonthlyExpenseLoading, () async {
|
||||||
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,
|
||||||
);
|
);
|
||||||
|
monthlyExpenseList.value =
|
||||||
|
(response?.success == true) ? response!.data : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (response != null && response.success) {
|
Future<void> fetchPurchaseInvoiceOverview() async {
|
||||||
monthlyExpenseList.value = response.data;
|
final projectId = projectController.selectedProjectId.value;
|
||||||
logSafe('Monthly Expense Report fetched successfully.',
|
if (projectId.isEmpty) return;
|
||||||
level: LogLevel.info);
|
|
||||||
} else {
|
await _executeApiCall(isPurchaseInvoiceLoading, () async {
|
||||||
monthlyExpenseList.clear();
|
final response = await ApiService.getPurchaseInvoiceOverview(
|
||||||
logSafe('Failed to fetch Monthly Expense Report.',
|
projectId: projectId,
|
||||||
level: LogLevel.error);
|
);
|
||||||
}
|
purchaseInvoiceOverviewData.value =
|
||||||
} catch (e, st) {
|
(response?.success == true) ? response!.data : null;
|
||||||
monthlyExpenseList.clear();
|
});
|
||||||
logSafe('Error fetching Monthly Expense Report',
|
|
||||||
level: LogLevel.error, error: e, stackTrace: st);
|
|
||||||
} finally {
|
|
||||||
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 {
|
await _executeApiCall(isPendingExpensesLoading, () async {
|
||||||
isPendingExpensesLoading.value = true;
|
final response = await ApiService.getPendingExpensesApi(projectId: id);
|
||||||
final response =
|
pendingExpensesData.value =
|
||||||
await ApiService.getPendingExpensesApi(projectId: projectId);
|
(response?.success == true) ? response!.data : null;
|
||||||
|
});
|
||||||
if (response != null && response.success) {
|
|
||||||
pendingExpensesData.value = response.data;
|
|
||||||
logSafe('Pending expenses fetched successfully.', level: LogLevel.info);
|
|
||||||
} else {
|
|
||||||
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 {
|
|
||||||
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 {
|
await _executeApiCall(isAttendanceLoading, () async {
|
||||||
isAttendanceLoading.value = true;
|
final response = await ApiService.getDashboardAttendanceOverview(
|
||||||
final List<dynamic>? response =
|
id, getAttendanceDays());
|
||||||
await ApiService.getDashboardAttendanceOverview(
|
roleWiseData.value =
|
||||||
projectId, getAttendanceDays());
|
response?.map((e) => Map<String, dynamic>.from(e)).toList() ?? [];
|
||||||
|
});
|
||||||
if (response != null) {
|
|
||||||
roleWiseData.value =
|
|
||||||
response.map((e) => Map<String, dynamic>.from(e)).toList();
|
|
||||||
logSafe('Attendance overview fetched successfully.',
|
|
||||||
level: LogLevel.info);
|
|
||||||
} else {
|
|
||||||
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 {
|
|
||||||
isAttendanceLoading.value = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> fetchExpenseTypeReport({
|
Future<void> fetchExpenseTypeReport(
|
||||||
required DateTime startDate,
|
{required DateTime startDate, required DateTime endDate}) async {
|
||||||
required DateTime endDate,
|
final id = projectController.selectedProjectId.value;
|
||||||
}) async {
|
if (id.isEmpty) return;
|
||||||
final String projectId = projectController.selectedProjectId.value;
|
|
||||||
if (projectId.isEmpty) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
isExpenseTypeReportLoading.value = true;
|
|
||||||
|
|
||||||
|
await _executeApiCall(isExpenseTypeReportLoading, () async {
|
||||||
final response = await ApiService.getExpenseTypeReportApi(
|
final response = await ApiService.getExpenseTypeReportApi(
|
||||||
projectId: projectId,
|
projectId: id,
|
||||||
startDate: startDate,
|
startDate: startDate,
|
||||||
endDate: endDate,
|
endDate: endDate,
|
||||||
);
|
);
|
||||||
|
expenseTypeReportData.value =
|
||||||
if (response != null && response.success) {
|
(response?.success == true) ? response!.data : null;
|
||||||
expenseTypeReportData.value = response.data;
|
});
|
||||||
logSafe('Expense Category Report fetched successfully.',
|
|
||||||
level: LogLevel.info);
|
|
||||||
} else {
|
|
||||||
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 {
|
|
||||||
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 {
|
await _executeApiCall(isProjectLoading, () async {
|
||||||
isProjectLoading.value = true;
|
|
||||||
final response = await ApiService.getProjectProgress(
|
final response = await ApiService.getProjectProgress(
|
||||||
projectId: projectId, days: getProjectDays());
|
projectId: id, days: getProjectDays());
|
||||||
|
if (response?.success == true) {
|
||||||
if (response != null && response.success) {
|
projectChartData.value = response!.data
|
||||||
projectChartData.value =
|
.map((d) => ChartTaskData.fromProjectData(d))
|
||||||
response.data.map((d) => ChartTaskData.fromProjectData(d)).toList();
|
.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 {
|
|
||||||
isProjectLoading.value = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> fetchDashboardTasks({required String projectId}) async {
|
Future<void> fetchDashboardTasks({required String projectId}) async {
|
||||||
if (projectId.isEmpty) return;
|
await _executeApiCall(isTasksLoading, () async {
|
||||||
|
|
||||||
try {
|
|
||||||
isTasksLoading.value = true;
|
|
||||||
final response = await ApiService.getDashboardTasks(projectId: projectId);
|
final response = await ApiService.getDashboardTasks(projectId: projectId);
|
||||||
|
if (response?.success == true) {
|
||||||
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 {
|
|
||||||
isTasksLoading.value = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> fetchDashboardTeams({required String projectId}) async {
|
Future<void> fetchDashboardTeams({required String projectId}) async {
|
||||||
if (projectId.isEmpty) return;
|
await _executeApiCall(isTeamsLoading, () async {
|
||||||
|
|
||||||
try {
|
|
||||||
isTeamsLoading.value = true;
|
|
||||||
final response = await ApiService.getDashboardTeams(projectId: projectId);
|
final response = await ApiService.getDashboardTeams(projectId: projectId);
|
||||||
|
if (response?.success == true) {
|
||||||
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 {
|
|
||||||
isTeamsLoading.value = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
class ApiEndpoints {
|
class ApiEndpoints {
|
||||||
// static const String baseUrl = "https://stageapi.marcoaiot.com/api";
|
static const String baseUrl = "https://stageapi.marcoaiot.com/api";
|
||||||
// static const String baseUrl = "https://api.marcoaiot.com/api";
|
// static const String baseUrl = "https://api.marcoaiot.com/api";
|
||||||
// static const String baseUrl = "https://devapi.marcoaiot.com/api";
|
// static const String baseUrl = "https://devapi.marcoaiot.com/api";
|
||||||
// static const String baseUrl = "https://mapi.marcoaiot.com/api";
|
// static const String baseUrl = "https://mapi.marcoaiot.com/api";
|
||||||
static const String baseUrl = "https://api.onfieldwork.com/api";
|
// static const String baseUrl = "https://api.onfieldwork.com/api";
|
||||||
|
|
||||||
|
|
||||||
static const String getMasterCurrencies = "/Master/currencies/list";
|
static const String getMasterCurrencies = "/Master/currencies/list";
|
||||||
@ -36,6 +36,10 @@ 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";
|
||||||
|
|
||||||
|
static const String getPurchaseInvoiceOverview =
|
||||||
|
"/dashboard/purchase-invoice-overview";
|
||||||
|
|
||||||
///// Projects Module API Endpoints
|
///// Projects Module API Endpoints
|
||||||
static const String createProject = "/project";
|
static const String createProject = "/project";
|
||||||
|
|||||||
@ -45,6 +45,9 @@ 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';
|
||||||
|
import 'package:on_field_work/model/dashboard/purchase_invoice_model.dart';
|
||||||
|
|
||||||
|
|
||||||
class ApiService {
|
class ApiService {
|
||||||
static const bool enableLogs = true;
|
static const bool enableLogs = true;
|
||||||
@ -316,6 +319,82 @@ class ApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// ============================================
|
||||||
|
/// GET PURCHASE INVOICE OVERVIEW (Dashboard)
|
||||||
|
/// ============================================
|
||||||
|
static Future<PurchaseInvoiceOverviewResponse?> getPurchaseInvoiceOverview({
|
||||||
|
String? projectId,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final queryParams = <String, String>{};
|
||||||
|
if (projectId != null && projectId.isNotEmpty) {
|
||||||
|
queryParams['projectId'] = projectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
final response = await _getRequest(
|
||||||
|
ApiEndpoints.getPurchaseInvoiceOverview,
|
||||||
|
queryParams: queryParams,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response == null) {
|
||||||
|
_log("getPurchaseInvoiceOverview: No response from server",
|
||||||
|
level: LogLevel.error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final parsedJson = _parseResponseForAllData(
|
||||||
|
response,
|
||||||
|
label: "PurchaseInvoiceOverview",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parsedJson == null) return null;
|
||||||
|
|
||||||
|
return PurchaseInvoiceOverviewResponse.fromJson(parsedJson);
|
||||||
|
} catch (e, stack) {
|
||||||
|
_log("Exception in getPurchaseInvoiceOverview: $e\n$stack",
|
||||||
|
level: LogLevel.error);
|
||||||
|
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
|
||||||
|
|
||||||
/// ================================
|
/// ================================
|
||||||
|
|||||||
426
lib/helpers/widgets/dashbaord/collection_dashboard_card.dart
Normal file
426
lib/helpers/widgets/dashbaord/collection_dashboard_card.dart
Normal file
@ -0,0 +1,426 @@
|
|||||||
|
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/controller/dashboard/dashboard_controller.dart';
|
||||||
|
import 'package:on_field_work/model/dashboard/collection_overview_model.dart';
|
||||||
|
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
|
||||||
|
|
||||||
|
class CollectionsHealthWidget extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final DashboardController controller = Get.find<DashboardController>();
|
||||||
|
|
||||||
|
return Obx(() {
|
||||||
|
final data = controller.collectionOverviewData.value;
|
||||||
|
final isLoading = controller.isCollectionOverviewLoading.value;
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (isLoading) {
|
||||||
|
return Container(
|
||||||
|
decoration: _boxDecoration(), // Maintain the outer box decoration
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: SkeletonLoaders.collectionHealthSkeleton(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No data
|
||||||
|
if (data == null) {
|
||||||
|
return Container(
|
||||||
|
decoration: _boxDecoration(),
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Center(
|
||||||
|
child: MyText.bodyMedium('No collection overview data available.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data available
|
||||||
|
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),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
_buildHeader(),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Expanded(
|
||||||
|
flex: 5,
|
||||||
|
child: _buildLeftChartSection(
|
||||||
|
totalDue: totalDue,
|
||||||
|
pendingPercentage: pendingPercentage,
|
||||||
|
totalCollected: totalCollected,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
flex: 4,
|
||||||
|
child: _buildRightMetricsSection(
|
||||||
|
data: data,
|
||||||
|
dsoDays: dsoDays,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildAgingAnalysis(data: data),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// HEADER
|
||||||
|
// ==============================
|
||||||
|
Widget _buildHeader() {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText.bodyMedium('Collections Health Overview', fontWeight: 700),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
MyText.bodySmall('View your collection health data.',
|
||||||
|
color: Colors.grey),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// LEFT SECTION (GAUGE + SUMMARY)
|
||||||
|
// ==============================
|
||||||
|
Widget _buildLeftChartSection({
|
||||||
|
required double totalDue,
|
||||||
|
required double pendingPercentage,
|
||||||
|
required double totalCollected,
|
||||||
|
}) {
|
||||||
|
String pendingPercentStr = (pendingPercentage * 100).toStringAsFixed(0);
|
||||||
|
String collectedPercentStr =
|
||||||
|
((1 - pendingPercentage) * 100).toStringAsFixed(0);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_GaugeChartPlaceholder(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
pendingPercentage: pendingPercentage,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
MyText.bodyLarge(
|
||||||
|
'₹${totalDue.toStringAsFixed(0)} DUE',
|
||||||
|
fontWeight: 700,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
MyText.bodySmall(
|
||||||
|
'• Pending ($pendingPercentStr%) • Collected ($collectedPercentStr%)',
|
||||||
|
color: Colors.black54,
|
||||||
|
),
|
||||||
|
MyText.bodySmall(
|
||||||
|
'₹${totalCollected.toStringAsFixed(0)} Collected',
|
||||||
|
color: Colors.black54,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// RIGHT METRICS SECTION
|
||||||
|
// ==============================
|
||||||
|
Widget _buildRightMetricsSection({
|
||||||
|
required CollectionOverviewData data,
|
||||||
|
required double dsoDays,
|
||||||
|
}) {
|
||||||
|
final String topClientName = data.topClient?.name ?? 'N/A';
|
||||||
|
final double topClientBalance = data.topClientBalance;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: <Widget>[
|
||||||
|
_buildMetricCard(
|
||||||
|
title: 'Top Client Balance',
|
||||||
|
value: topClientName,
|
||||||
|
subValue: '₹${topClientBalance.toStringAsFixed(0)}',
|
||||||
|
valueColor: Colors.red,
|
||||||
|
isDetailed: true,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_buildMetricCard(
|
||||||
|
title: 'Total Collected (YTD)',
|
||||||
|
value: '₹${data.totalCollectedAmount.toStringAsFixed(0)}',
|
||||||
|
subValue: 'Collected',
|
||||||
|
valueColor: Colors.green,
|
||||||
|
isDetailed: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMetricCard({
|
||||||
|
required String title,
|
||||||
|
required String value,
|
||||||
|
required String subValue,
|
||||||
|
required Color valueColor,
|
||||||
|
required bool isDetailed,
|
||||||
|
}) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF5F5F5),
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
MyText.bodySmall(title, color: Colors.black54),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
if (isDetailed) ...[
|
||||||
|
MyText.bodySmall(value, fontWeight: 600),
|
||||||
|
MyText.bodyMedium(subValue, color: valueColor, fontWeight: 700),
|
||||||
|
] else
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: <Widget>[
|
||||||
|
MyText.bodySmall(value, fontWeight: 600),
|
||||||
|
MyText.bodySmall(subValue, color: valueColor, fontWeight: 600),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// AGING ANALYSIS
|
||||||
|
// ==============================
|
||||||
|
Widget _buildAgingAnalysis({required CollectionOverviewData data}) {
|
||||||
|
final buckets = [
|
||||||
|
AgingBucketData('0-30 Days', data.bucket0To30Amount, Colors.green,
|
||||||
|
data.bucket0To30Invoices),
|
||||||
|
AgingBucketData('30-60 Days', data.bucket30To60Amount, Colors.orange,
|
||||||
|
data.bucket30To60Invoices),
|
||||||
|
AgingBucketData('60-90 Days', data.bucket60To90Amount,
|
||||||
|
Colors.red.shade300, data.bucket60To90Invoices),
|
||||||
|
AgingBucketData('> 90 Days', data.bucket90PlusAmount, Colors.red,
|
||||||
|
data.bucket90PlusInvoices),
|
||||||
|
];
|
||||||
|
|
||||||
|
final double totalOutstanding = buckets.fold(0, (sum, b) => sum + b.amount);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText.bodyMedium('Outstanding Collections Aging Analysis',
|
||||||
|
fontWeight: 700),
|
||||||
|
MyText.bodySmall(
|
||||||
|
'Total Outstanding: ₹${totalOutstanding.toStringAsFixed(0)}',
|
||||||
|
color: Colors.black54),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_AgingStackedBar(buckets: buckets, totalOutstanding: totalOutstanding),
|
||||||
|
const SizedBox(height: 15),
|
||||||
|
Wrap(
|
||||||
|
spacing: 12,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: buckets
|
||||||
|
.map((bucket) => _buildAgingLegendItem(bucket.title,
|
||||||
|
bucket.amount, bucket.color, bucket.invoiceCount))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAgingLegendItem(
|
||||||
|
String title, double amount, Color color, int count) {
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
MyText.bodySmall(
|
||||||
|
'$title: ₹${amount.toStringAsFixed(0)} ($count Invoices)'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 PLACEHOLDER WIDGETS (Gauge + Bar/Area + Aging Bars)
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
// Gauge Chart
|
||||||
|
class _GaugeChartPlaceholder extends StatelessWidget {
|
||||||
|
final Color backgroundColor;
|
||||||
|
final double pendingPercentage;
|
||||||
|
|
||||||
|
const _GaugeChartPlaceholder({
|
||||||
|
required this.backgroundColor,
|
||||||
|
required this.pendingPercentage,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
width: 120,
|
||||||
|
height: 80,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
CustomPaint(
|
||||||
|
size: const Size(120, 70),
|
||||||
|
painter: _SemiCirclePainter(
|
||||||
|
canvasColor: backgroundColor,
|
||||||
|
pendingPercentage: pendingPercentage,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: FittedBox(
|
||||||
|
child: MyText.bodySmall('RISK LEVEL', fontWeight: 600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SemiCirclePainter extends CustomPainter {
|
||||||
|
final Color canvasColor;
|
||||||
|
final double pendingPercentage;
|
||||||
|
|
||||||
|
_SemiCirclePainter(
|
||||||
|
{required this.canvasColor, required this.pendingPercentage});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final rect = Rect.fromCircle(
|
||||||
|
center: Offset(size.width / 2, size.height),
|
||||||
|
radius: size.width / 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
const double arc = 3.14159;
|
||||||
|
final double pendingSweep = arc * pendingPercentage;
|
||||||
|
final double collectedSweep = arc * (1 - pendingPercentage);
|
||||||
|
|
||||||
|
final backgroundPaint = Paint()
|
||||||
|
..color = Colors.black.withOpacity(0.1)
|
||||||
|
..strokeWidth = 10
|
||||||
|
..style = PaintingStyle.stroke;
|
||||||
|
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
|
||||||
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AGING BUCKET
|
||||||
|
class AgingBucketData {
|
||||||
|
final String title;
|
||||||
|
final double amount;
|
||||||
|
final Color color;
|
||||||
|
final int invoiceCount; // ADDED
|
||||||
|
|
||||||
|
// UPDATED CONSTRUCTOR
|
||||||
|
AgingBucketData(this.title, this.amount, this.color, this.invoiceCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AgingStackedBar extends StatelessWidget {
|
||||||
|
final List<AgingBucketData> buckets;
|
||||||
|
final double totalOutstanding;
|
||||||
|
|
||||||
|
const _AgingStackedBar({
|
||||||
|
required this.buckets,
|
||||||
|
required this.totalOutstanding,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (totalOutstanding == 0) {
|
||||||
|
return Container(
|
||||||
|
height: 16,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[200],
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: MyText.bodySmall('No Outstanding Collections',
|
||||||
|
color: Colors.black54),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Row(
|
||||||
|
children: buckets.where((b) => b.amount > 0).map((bucket) {
|
||||||
|
final flexValue = bucket.amount / totalOutstanding;
|
||||||
|
return Expanded(
|
||||||
|
flex: (flexValue * 1000).toInt(),
|
||||||
|
child: Container(height: 16, color: bucket.color),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
717
lib/helpers/widgets/dashbaord/purchase_invoice_dashboard.dart
Normal file
717
lib/helpers/widgets/dashbaord/purchase_invoice_dashboard.dart
Normal file
@ -0,0 +1,717 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:on_field_work/controller/dashboard/dashboard_controller.dart';
|
||||||
|
import 'package:on_field_work/helpers/widgets/my_text.dart';
|
||||||
|
import 'package:on_field_work/helpers/widgets/my_spacing.dart';
|
||||||
|
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
|
||||||
|
|
||||||
|
class CompactPurchaseInvoiceDashboard extends StatelessWidget {
|
||||||
|
const CompactPurchaseInvoiceDashboard({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final DashboardController controller = Get.find();
|
||||||
|
|
||||||
|
// Use Obx to reactively listen to data changes
|
||||||
|
return Obx(() {
|
||||||
|
final data = controller.purchaseInvoiceOverviewData.value;
|
||||||
|
|
||||||
|
// Show loading state while API call is in progress
|
||||||
|
if (controller.isPurchaseInvoiceLoading.value) {
|
||||||
|
return SkeletonLoaders.purchaseInvoiceDashboardSkeleton();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show empty state if no data
|
||||||
|
if (data == null || data.totalInvoices == 0) {
|
||||||
|
return Center(
|
||||||
|
child: MyText.bodySmall('No purchase invoices found.'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert API response to internal PurchaseInvoiceData list
|
||||||
|
final invoices = (data.projectBreakdown ?? [])
|
||||||
|
.map((project) => PurchaseInvoiceData(
|
||||||
|
id: project.id ?? '',
|
||||||
|
title: project.name ?? 'Unknown',
|
||||||
|
proformaInvoiceAmount: project.totalValue ?? 0.0,
|
||||||
|
supplierName: data.topSupplier?.name ?? 'N/A',
|
||||||
|
projectName: project.name ?? 'Unknown',
|
||||||
|
statusName: 'Unknown', // API might have status if needed
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final metrics = PurchaseInvoiceMetricsCalculator().calculate(invoices);
|
||||||
|
|
||||||
|
return _buildDashboard(metrics);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDashboard(PurchaseInvoiceMetrics metrics) {
|
||||||
|
const double spacing = 16.0;
|
||||||
|
const double smallSpacing = 8.0;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(spacing),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.08),
|
||||||
|
blurRadius: 15,
|
||||||
|
offset: const Offset(0, 5),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const _DashboardHeader(),
|
||||||
|
const SizedBox(height: spacing),
|
||||||
|
_TotalValueCard(
|
||||||
|
totalProformaAmount: metrics.totalProformaAmount,
|
||||||
|
totalCount: metrics.totalCount,
|
||||||
|
),
|
||||||
|
const SizedBox(height: spacing),
|
||||||
|
_CondensedMetricsRow(
|
||||||
|
draftCount: metrics.draftCount,
|
||||||
|
avgInvoiceValue: metrics.avgInvoiceValue,
|
||||||
|
topSupplierName: metrics.topSupplierName,
|
||||||
|
spacing: smallSpacing,
|
||||||
|
),
|
||||||
|
const SizedBox(height: spacing),
|
||||||
|
const Divider(height: 1, thickness: 0.5),
|
||||||
|
const SizedBox(height: spacing),
|
||||||
|
const _SectionTitle('Status Breakdown by Value'),
|
||||||
|
const SizedBox(height: smallSpacing),
|
||||||
|
_StatusDonutChart(
|
||||||
|
statusBuckets: metrics.statusBuckets,
|
||||||
|
totalAmount: metrics.totalProformaAmount,
|
||||||
|
),
|
||||||
|
const SizedBox(height: spacing),
|
||||||
|
const Divider(height: 1, thickness: 0.5),
|
||||||
|
const SizedBox(height: spacing),
|
||||||
|
const _SectionTitle('Top Projects by Proforma Value'),
|
||||||
|
const SizedBox(height: smallSpacing),
|
||||||
|
_ProjectBreakdown(
|
||||||
|
projects: metrics.projectBuckets.take(3).toList(),
|
||||||
|
totalAmount: metrics.totalProformaAmount,
|
||||||
|
spacing: smallSpacing,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Container object used internally
|
||||||
|
class PurchaseInvoiceDashboardData {
|
||||||
|
final List<PurchaseInvoiceData> invoices;
|
||||||
|
final PurchaseInvoiceMetrics metrics;
|
||||||
|
|
||||||
|
const PurchaseInvoiceDashboardData({
|
||||||
|
required this.invoices,
|
||||||
|
required this.metrics,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// =======================
|
||||||
|
/// DATA MODELS
|
||||||
|
/// =======================
|
||||||
|
|
||||||
|
class PurchaseInvoiceData {
|
||||||
|
final String id;
|
||||||
|
final String title;
|
||||||
|
final double proformaInvoiceAmount;
|
||||||
|
final String supplierName;
|
||||||
|
final String projectName;
|
||||||
|
final String statusName;
|
||||||
|
|
||||||
|
const PurchaseInvoiceData({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
required this.proformaInvoiceAmount,
|
||||||
|
required this.supplierName,
|
||||||
|
required this.projectName,
|
||||||
|
required this.statusName,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory PurchaseInvoiceData.fromJson(Map<String, dynamic> json) {
|
||||||
|
final supplier = json['supplier'] as Map<String, dynamic>? ?? const {};
|
||||||
|
final project = json['project'] as Map<String, dynamic>? ?? const {};
|
||||||
|
final status = json['status'] as Map<String, dynamic>? ?? const {};
|
||||||
|
|
||||||
|
return PurchaseInvoiceData(
|
||||||
|
id: json['id']?.toString() ?? '',
|
||||||
|
title: json['title']?.toString() ?? '',
|
||||||
|
proformaInvoiceAmount:
|
||||||
|
(json['proformaInvoiceAmount'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
supplierName: supplier['name']?.toString() ?? 'Unknown Supplier',
|
||||||
|
projectName: project['name']?.toString() ?? 'Unknown Project',
|
||||||
|
statusName: status['displayName']?.toString() ?? 'Unknown',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class StatusBucketData {
|
||||||
|
final String title;
|
||||||
|
final double amount;
|
||||||
|
final Color color;
|
||||||
|
final int count;
|
||||||
|
|
||||||
|
const StatusBucketData({
|
||||||
|
required this.title,
|
||||||
|
required this.amount,
|
||||||
|
required this.color,
|
||||||
|
required this.count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProjectMetricData {
|
||||||
|
final String name;
|
||||||
|
final double amount;
|
||||||
|
|
||||||
|
const ProjectMetricData({
|
||||||
|
required this.name,
|
||||||
|
required this.amount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class PurchaseInvoiceMetrics {
|
||||||
|
final double totalProformaAmount;
|
||||||
|
final int totalCount;
|
||||||
|
final int draftCount;
|
||||||
|
final String topSupplierName;
|
||||||
|
final double topSupplierAmount;
|
||||||
|
final List<StatusBucketData> statusBuckets;
|
||||||
|
final List<ProjectMetricData> projectBuckets;
|
||||||
|
final double avgInvoiceValue;
|
||||||
|
|
||||||
|
const PurchaseInvoiceMetrics({
|
||||||
|
required this.totalProformaAmount,
|
||||||
|
required this.totalCount,
|
||||||
|
required this.draftCount,
|
||||||
|
required this.topSupplierName,
|
||||||
|
required this.topSupplierAmount,
|
||||||
|
required this.statusBuckets,
|
||||||
|
required this.projectBuckets,
|
||||||
|
required this.avgInvoiceValue,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// =======================
|
||||||
|
/// METRICS CALCULATOR
|
||||||
|
/// =======================
|
||||||
|
|
||||||
|
class PurchaseInvoiceMetricsCalculator {
|
||||||
|
PurchaseInvoiceMetrics calculate(List<PurchaseInvoiceData> invoices) {
|
||||||
|
final double totalProformaAmount =
|
||||||
|
invoices.fold(0.0, (sum, item) => sum + item.proformaInvoiceAmount);
|
||||||
|
final int totalCount = invoices.length;
|
||||||
|
final int draftCount =
|
||||||
|
invoices.where((item) => item.statusName == 'Draft').length;
|
||||||
|
|
||||||
|
final Map<String, double> supplierTotals = <String, double>{};
|
||||||
|
for (final invoice in invoices) {
|
||||||
|
supplierTotals.update(
|
||||||
|
invoice.supplierName,
|
||||||
|
(value) => value + invoice.proformaInvoiceAmount,
|
||||||
|
ifAbsent: () => invoice.proformaInvoiceAmount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final MapEntry<String, double>? topSupplierEntry = supplierTotals
|
||||||
|
.entries.isEmpty
|
||||||
|
? null
|
||||||
|
: supplierTotals.entries.reduce((a, b) => a.value > b.value ? a : b);
|
||||||
|
|
||||||
|
final String topSupplierName = topSupplierEntry?.key ?? 'N/A';
|
||||||
|
final double topSupplierAmount = topSupplierEntry?.value ?? 0.0;
|
||||||
|
|
||||||
|
final Map<String, double> projectTotals = <String, double>{};
|
||||||
|
for (final invoice in invoices) {
|
||||||
|
projectTotals.update(
|
||||||
|
invoice.projectName,
|
||||||
|
(value) => value + invoice.proformaInvoiceAmount,
|
||||||
|
ifAbsent: () => invoice.proformaInvoiceAmount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<ProjectMetricData> projectBuckets = projectTotals.entries
|
||||||
|
.map((e) => ProjectMetricData(name: e.key, amount: e.value))
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) => b.amount.compareTo(a.amount));
|
||||||
|
|
||||||
|
final Map<String, List<PurchaseInvoiceData>> statusGroups =
|
||||||
|
<String, List<PurchaseInvoiceData>>{};
|
||||||
|
for (final invoice in invoices) {
|
||||||
|
statusGroups.putIfAbsent(
|
||||||
|
invoice.statusName,
|
||||||
|
() => <PurchaseInvoiceData>[],
|
||||||
|
);
|
||||||
|
statusGroups[invoice.statusName]!.add(invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<StatusBucketData> statusBuckets = statusGroups.entries.map(
|
||||||
|
(entry) {
|
||||||
|
final double statusTotal = entry.value
|
||||||
|
.fold(0.0, (sum, item) => sum + item.proformaInvoiceAmount);
|
||||||
|
return StatusBucketData(
|
||||||
|
title: entry.key,
|
||||||
|
amount: statusTotal,
|
||||||
|
color: getColorForStatus(entry.key),
|
||||||
|
count: entry.value.length,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
).toList();
|
||||||
|
|
||||||
|
final double avgInvoiceValue =
|
||||||
|
totalCount > 0 ? totalProformaAmount / totalCount : 0.0;
|
||||||
|
|
||||||
|
return PurchaseInvoiceMetrics(
|
||||||
|
totalProformaAmount: totalProformaAmount,
|
||||||
|
totalCount: totalCount,
|
||||||
|
draftCount: draftCount,
|
||||||
|
topSupplierName: topSupplierName,
|
||||||
|
topSupplierAmount: topSupplierAmount,
|
||||||
|
statusBuckets: statusBuckets,
|
||||||
|
projectBuckets: projectBuckets,
|
||||||
|
avgInvoiceValue: avgInvoiceValue,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// =======================
|
||||||
|
/// UTILITIES
|
||||||
|
/// =======================
|
||||||
|
|
||||||
|
Color _getProjectColor(String name) {
|
||||||
|
final int hash = name.hashCode;
|
||||||
|
const List<Color> colors = <Color>[
|
||||||
|
Color(0xFF42A5F5), // Blue
|
||||||
|
Color(0xFF66BB6A), // Green
|
||||||
|
Color(0xFFFFA726), // Orange
|
||||||
|
Color(0xFFEC407A), // Pink
|
||||||
|
Color(0xFF7E57C2), // Deep Purple
|
||||||
|
Color(0xFF26C6DA), // Cyan
|
||||||
|
Color(0xFFFFEE58), // Yellow
|
||||||
|
];
|
||||||
|
return colors[hash.abs() % colors.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
Color getColorForStatus(String status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'Draft':
|
||||||
|
return Colors.blueGrey;
|
||||||
|
case 'Pending Approval':
|
||||||
|
return Colors.orange;
|
||||||
|
case 'Approved':
|
||||||
|
return Colors.green;
|
||||||
|
case 'Paid':
|
||||||
|
return Colors.blue;
|
||||||
|
default:
|
||||||
|
return Colors.grey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// =======================
|
||||||
|
/// REDESIGNED INTERNAL UI WIDGETS
|
||||||
|
/// =======================
|
||||||
|
|
||||||
|
class _SectionTitle extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
const _SectionTitle(this.title);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MyText.bodySmall(
|
||||||
|
title,
|
||||||
|
color: Colors.grey.shade700,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DashboardHeader extends StatelessWidget {
|
||||||
|
const _DashboardHeader();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(mainAxisAlignment: MainAxisAlignment.start, children: [
|
||||||
|
Expanded(
|
||||||
|
child:
|
||||||
|
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
MyText.bodyMedium(
|
||||||
|
'Purchase Invoice ',
|
||||||
|
fontWeight: 700,
|
||||||
|
),
|
||||||
|
SizedBox(height: 2),
|
||||||
|
MyText.bodySmall(
|
||||||
|
'View your purchase invoice data.',
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
]))
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total Value Card - Refined Style
|
||||||
|
class _TotalValueCard extends StatelessWidget {
|
||||||
|
final double totalProformaAmount;
|
||||||
|
final int totalCount;
|
||||||
|
|
||||||
|
const _TotalValueCard({
|
||||||
|
required this.totalProformaAmount,
|
||||||
|
required this.totalCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFE3F2FD), // Lighter Blue
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
border: Border.all(color: const Color(0xFFBBDEFB), width: 1),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
MyText.bodySmall(
|
||||||
|
'TOTAL PROFORMA VALUE (₹)',
|
||||||
|
color: Colors.blue.shade800,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: 1.0,
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
Icons.account_balance_wallet_outlined,
|
||||||
|
color: Colors.blue.shade700,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
MySpacing.height(8),
|
||||||
|
MyText.bodyMedium(
|
||||||
|
totalProformaAmount.toStringAsFixed(0),
|
||||||
|
),
|
||||||
|
MySpacing.height(4),
|
||||||
|
MyText.bodySmall(
|
||||||
|
'Over $totalCount Total Invoices',
|
||||||
|
color: Colors.blueGrey.shade600,
|
||||||
|
fontWeight: 500,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Condensed Metrics Row - Replaces the GridView
|
||||||
|
class _CondensedMetricsRow extends StatelessWidget {
|
||||||
|
final int draftCount;
|
||||||
|
final double avgInvoiceValue;
|
||||||
|
final String topSupplierName;
|
||||||
|
final double spacing;
|
||||||
|
|
||||||
|
const _CondensedMetricsRow({
|
||||||
|
required this.draftCount,
|
||||||
|
required this.avgInvoiceValue,
|
||||||
|
required this.topSupplierName,
|
||||||
|
required this.spacing,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Only showing 3 key metrics in a row for a tighter feel
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _CondensedMetricCard(
|
||||||
|
title: 'Drafts',
|
||||||
|
value: draftCount.toString(),
|
||||||
|
caption: 'To Complete',
|
||||||
|
color: Colors.orange.shade700,
|
||||||
|
icon: Icons.edit_note_outlined,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: spacing),
|
||||||
|
Expanded(
|
||||||
|
child: _CondensedMetricCard(
|
||||||
|
title: 'Avg. Value',
|
||||||
|
value: '₹${avgInvoiceValue.toStringAsFixed(0)}',
|
||||||
|
caption: 'Per Invoice',
|
||||||
|
color: Colors.purple.shade700,
|
||||||
|
icon: Icons.calculate_outlined,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: spacing),
|
||||||
|
Expanded(
|
||||||
|
child: _CondensedMetricCard(
|
||||||
|
title: 'Top Supplier',
|
||||||
|
value: topSupplierName,
|
||||||
|
caption: 'By Value',
|
||||||
|
color: Colors.green.shade700,
|
||||||
|
icon: Icons.business_center_outlined,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Condensed Metric Card - Small, impactful display
|
||||||
|
class _CondensedMetricCard extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String value;
|
||||||
|
final String caption;
|
||||||
|
final Color color;
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
const _CondensedMetricCard({
|
||||||
|
required this.title,
|
||||||
|
required this.value,
|
||||||
|
required this.caption,
|
||||||
|
required this.color,
|
||||||
|
required this.icon,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.05),
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
border: Border.all(color: color.withOpacity(0.15), width: 1),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: color, size: 16),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
child: MyText.bodySmall(
|
||||||
|
title,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
color: color,
|
||||||
|
fontWeight: 700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
MySpacing.height(6),
|
||||||
|
MyText.bodyMedium(
|
||||||
|
value,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
fontWeight: 800,
|
||||||
|
),
|
||||||
|
MyText.bodySmall(
|
||||||
|
caption,
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
fontWeight: 500,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status Breakdown (Donut Chart + Legend) - Stronger Visualization
|
||||||
|
class _StatusDonutChart extends StatelessWidget {
|
||||||
|
final List<StatusBucketData> statusBuckets;
|
||||||
|
final double totalAmount;
|
||||||
|
|
||||||
|
const _StatusDonutChart({
|
||||||
|
required this.statusBuckets,
|
||||||
|
required this.totalAmount,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final List<StatusBucketData> activeBuckets = statusBuckets
|
||||||
|
.where((b) => b.amount > 0)
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) => b.amount.compareTo(a.amount));
|
||||||
|
|
||||||
|
if (activeBuckets.isEmpty) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
|
child: MyText.bodySmall(
|
||||||
|
'No active invoices to display status breakdown.',
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine the percentage of the largest bucket for the center text
|
||||||
|
final double mainPercentage =
|
||||||
|
totalAmount > 0 ? activeBuckets.first.amount / totalAmount : 0.0;
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Simulated Donut Chart (Center Focus)
|
||||||
|
Container(
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: activeBuckets.first.color.withOpacity(0.5), width: 6),
|
||||||
|
color: activeBuckets.first.color.withOpacity(0.05),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
MyText.bodySmall(
|
||||||
|
activeBuckets.first.title,
|
||||||
|
color: activeBuckets.first.color,
|
||||||
|
fontWeight: 700,
|
||||||
|
),
|
||||||
|
MyText.bodyMedium(
|
||||||
|
'${(mainPercentage * 100).toStringAsFixed(0)}%',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
// Legend/Details
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: activeBuckets.map((bucket) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8.0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
margin: const EdgeInsets.only(right: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: bucket.color,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: MyText.bodySmall(
|
||||||
|
'${bucket.title} (${bucket.count})',
|
||||||
|
color: Colors.grey.shade800,
|
||||||
|
fontWeight: 500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MyText.bodySmall(
|
||||||
|
'₹${bucket.amount.toStringAsFixed(0)}',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: bucket.color.withOpacity(0.9),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project Breakdown - Denser and with clearer value
|
||||||
|
class _ProjectBreakdown extends StatelessWidget {
|
||||||
|
final List<ProjectMetricData> projects;
|
||||||
|
final double totalAmount;
|
||||||
|
final double spacing;
|
||||||
|
|
||||||
|
const _ProjectBreakdown({
|
||||||
|
required this.projects,
|
||||||
|
required this.totalAmount,
|
||||||
|
required this.spacing,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (projects.isEmpty) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
|
child: MyText.bodySmall(
|
||||||
|
'No project data available.',
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: projects.map((project) {
|
||||||
|
final double percentage =
|
||||||
|
totalAmount > 0 ? (project.amount / totalAmount) : 0.0;
|
||||||
|
final Color color = _getProjectColor(project.name);
|
||||||
|
final String percentageText = (percentage * 100).toStringAsFixed(1);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: spacing),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
margin: const EdgeInsets.only(right: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText.bodyMedium(
|
||||||
|
project.name,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
fontWeight: 600,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: percentage,
|
||||||
|
backgroundColor: Colors.grey.shade200,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(color),
|
||||||
|
minHeight: 4, // Smaller bar height
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
MyText.bodyMedium(
|
||||||
|
'₹${project.amount.toStringAsFixed(0)}',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: color.withOpacity(0.9),
|
||||||
|
),
|
||||||
|
MyText.bodySmall(
|
||||||
|
'$percentageText%',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1531,6 +1531,424 @@ class SkeletonLoaders {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ====================================================================
|
||||||
|
// NEW SKELETON LOADER METHODS
|
||||||
|
// ====================================================================
|
||||||
|
|
||||||
|
/// Skeleton for the CollectionsHealthWidget
|
||||||
|
static Widget collectionHealthSkeleton() {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.1),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 5),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: ShimmerEffect(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
// Header Skeleton
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
height: 14, width: 180, color: Colors.grey.shade300),
|
||||||
|
MySpacing.height(4),
|
||||||
|
Container(
|
||||||
|
height: 10, width: 140, color: Colors.grey.shade300),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Main Content Row (Left Chart + Right Metrics)
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
// Left Chart Section
|
||||||
|
Expanded(
|
||||||
|
flex: 5,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Gauge Chart Placeholder
|
||||||
|
Container(
|
||||||
|
width: 120,
|
||||||
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.vertical(
|
||||||
|
top: Radius.circular(60), bottom: Radius.zero),
|
||||||
|
),
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: Container(
|
||||||
|
height: 12, width: 60, color: Colors.grey.shade400),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
// Summary Text Placeholders
|
||||||
|
Container(
|
||||||
|
height: 16, width: 150, color: Colors.grey.shade300),
|
||||||
|
MySpacing.height(6),
|
||||||
|
Container(
|
||||||
|
height: 12, width: 200, color: Colors.grey.shade300),
|
||||||
|
MySpacing.height(4),
|
||||||
|
Container(
|
||||||
|
height: 12, width: 100, color: Colors.grey.shade300),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
// Right Metrics Section
|
||||||
|
Expanded(
|
||||||
|
flex: 4,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Metric Card 1
|
||||||
|
_buildMetricCardSkeleton(),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
// Metric Card 2
|
||||||
|
_buildMetricCardSkeleton(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Aging Analysis Section
|
||||||
|
Container(height: 14, width: 220, color: Colors.grey.shade300),
|
||||||
|
MySpacing.height(4),
|
||||||
|
Container(height: 10, width: 180, color: Colors.grey.shade300),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
|
// Aging Stacked Bar Placeholder
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Row(
|
||||||
|
children: List.generate(
|
||||||
|
4,
|
||||||
|
(index) => Expanded(
|
||||||
|
flex: 1,
|
||||||
|
child: Container(
|
||||||
|
height: 16, color: Colors.grey.shade400),
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 15),
|
||||||
|
|
||||||
|
// Aging Legend Placeholders
|
||||||
|
Wrap(
|
||||||
|
spacing: 12,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: List.generate(
|
||||||
|
4,
|
||||||
|
(index) => Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
shape: BoxShape.circle)),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Container(
|
||||||
|
height: 12,
|
||||||
|
width: 120,
|
||||||
|
color: Colors.grey.shade300),
|
||||||
|
],
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper for Metric Card Skeleton
|
||||||
|
static Widget _buildMetricCardSkeleton() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade200, // Background color for the card
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Container(height: 10, width: 90, color: Colors.grey.shade300),
|
||||||
|
MySpacing.height(4),
|
||||||
|
Container(height: 12, width: 100, color: Colors.grey.shade300),
|
||||||
|
MySpacing.height(4),
|
||||||
|
Container(height: 14, width: 80, color: Colors.grey.shade300),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Skeleton for the CompactPurchaseInvoiceDashboard
|
||||||
|
static Widget purchaseInvoiceDashboardSkeleton() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.08),
|
||||||
|
blurRadius: 15,
|
||||||
|
offset: const Offset(0, 5),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ShimmerEffect(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Header Skeleton
|
||||||
|
Row(mainAxisAlignment: MainAxisAlignment.start, children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
height: 14, width: 200, color: Colors.grey.shade300),
|
||||||
|
MySpacing.height(4),
|
||||||
|
Container(
|
||||||
|
height: 10, width: 150, color: Colors.grey.shade300),
|
||||||
|
]))
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Total Value Card Skeleton
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade200, // Simulated light blue background
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
height: 12, width: 160, color: Colors.grey.shade300),
|
||||||
|
Icon(Icons.account_balance_wallet_outlined,
|
||||||
|
color: Colors.grey.shade300, size: 20),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
MySpacing.height(8),
|
||||||
|
Container(
|
||||||
|
height: 16, width: 120, color: Colors.grey.shade300),
|
||||||
|
MySpacing.height(4),
|
||||||
|
Container(
|
||||||
|
height: 12, width: 180, color: Colors.grey.shade300),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Condensed Metrics Row Skeleton
|
||||||
|
Row(
|
||||||
|
children: List.generate(
|
||||||
|
3,
|
||||||
|
(index) => Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: index == 1
|
||||||
|
? MySpacing.symmetric(horizontal: 8)
|
||||||
|
: EdgeInsets.zero,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade200, // Card background
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.circle,
|
||||||
|
color: Colors.grey.shade300, size: 16),
|
||||||
|
MySpacing.width(4),
|
||||||
|
Container(
|
||||||
|
height: 10,
|
||||||
|
width: 50,
|
||||||
|
color: Colors.grey.shade300),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
MySpacing.height(6),
|
||||||
|
Container(
|
||||||
|
height: 14,
|
||||||
|
width: 80,
|
||||||
|
color: Colors.grey.shade300),
|
||||||
|
Container(
|
||||||
|
height: 10,
|
||||||
|
width: 60,
|
||||||
|
color: Colors.grey.shade300),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Divider(
|
||||||
|
height: 1,
|
||||||
|
thickness: 0.5,
|
||||||
|
color: Colors.transparent), // Hidden divider for spacing
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Status Breakdown Section Skeleton (Chart + Legend)
|
||||||
|
Container(height: 12, width: 180, color: Colors.grey.shade300),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Donut Chart Placeholder
|
||||||
|
Container(
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: Colors.grey.shade300, width: 6),
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
height: 10, width: 60, color: Colors.grey.shade300),
|
||||||
|
MySpacing.height(4),
|
||||||
|
Container(
|
||||||
|
height: 14, width: 40, color: Colors.grey.shade300),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
// Legend/Details Placeholder
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: List.generate(
|
||||||
|
3,
|
||||||
|
(index) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment:
|
||||||
|
MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
margin:
|
||||||
|
const EdgeInsets.only(right: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
shape: BoxShape.circle)),
|
||||||
|
Container(
|
||||||
|
height: 12,
|
||||||
|
width: 80,
|
||||||
|
color: Colors.grey.shade300),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
height: 14,
|
||||||
|
width: 50,
|
||||||
|
color: Colors.grey.shade300),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Divider(
|
||||||
|
height: 1,
|
||||||
|
thickness: 0.5,
|
||||||
|
color: Colors.transparent), // Hidden divider for spacing
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Top Projects Section Skeleton
|
||||||
|
Container(height: 12, width: 200, color: Colors.grey.shade300),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Column(
|
||||||
|
children: List.generate(
|
||||||
|
3,
|
||||||
|
(index) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
margin: const EdgeInsets.only(right: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
shape: BoxShape.circle)),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
height: 12,
|
||||||
|
width: double.infinity,
|
||||||
|
color: Colors.grey.shade300),
|
||||||
|
MySpacing.height(4),
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
child: Container(
|
||||||
|
height: 4, color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
height: 14,
|
||||||
|
width: 70,
|
||||||
|
color: Colors.grey.shade300),
|
||||||
|
MySpacing.height(2),
|
||||||
|
Container(
|
||||||
|
height: 10,
|
||||||
|
width: 40,
|
||||||
|
color: Colors.grey.shade300),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A custom reusable Shimmer Effect widget.
|
/// A custom reusable Shimmer Effect widget.
|
||||||
|
|||||||
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));
|
||||||
|
}
|
||||||
221
lib/model/dashboard/purchase_invoice_model.dart
Normal file
221
lib/model/dashboard/purchase_invoice_model.dart
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
// ============================
|
||||||
|
// PurchaseInvoiceOverviewModel.dart
|
||||||
|
// ============================
|
||||||
|
|
||||||
|
class PurchaseInvoiceOverviewResponse {
|
||||||
|
final bool? success;
|
||||||
|
final String? message;
|
||||||
|
final PurchaseInvoiceOverviewData? data;
|
||||||
|
final dynamic errors;
|
||||||
|
final int? statusCode;
|
||||||
|
final DateTime? timestamp;
|
||||||
|
|
||||||
|
PurchaseInvoiceOverviewResponse({
|
||||||
|
this.success,
|
||||||
|
this.message,
|
||||||
|
this.data,
|
||||||
|
this.errors,
|
||||||
|
this.statusCode,
|
||||||
|
this.timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory PurchaseInvoiceOverviewResponse.fromJson(Map<String, dynamic> json) {
|
||||||
|
return PurchaseInvoiceOverviewResponse(
|
||||||
|
success: json['success'] as bool?,
|
||||||
|
message: json['message'] as String?,
|
||||||
|
data: json['data'] != null
|
||||||
|
? PurchaseInvoiceOverviewData.fromJson(json['data'])
|
||||||
|
: null,
|
||||||
|
errors: json['errors'],
|
||||||
|
statusCode: json['statusCode'] as int?,
|
||||||
|
timestamp: json['timestamp'] != null
|
||||||
|
? DateTime.tryParse(json['timestamp'])
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'success': success,
|
||||||
|
'message': message,
|
||||||
|
'data': data?.toJson(),
|
||||||
|
'errors': errors,
|
||||||
|
'statusCode': statusCode,
|
||||||
|
'timestamp': timestamp?.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PurchaseInvoiceOverviewData {
|
||||||
|
final int? totalInvoices;
|
||||||
|
final double? totalValue;
|
||||||
|
final double? averageValue;
|
||||||
|
final List<StatusBreakdown>? statusBreakdown;
|
||||||
|
final List<ProjectBreakdown>? projectBreakdown;
|
||||||
|
final TopSupplier? topSupplier;
|
||||||
|
|
||||||
|
PurchaseInvoiceOverviewData({
|
||||||
|
this.totalInvoices,
|
||||||
|
this.totalValue,
|
||||||
|
this.averageValue,
|
||||||
|
this.statusBreakdown,
|
||||||
|
this.projectBreakdown,
|
||||||
|
this.topSupplier,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory PurchaseInvoiceOverviewData.fromJson(Map<String, dynamic> json) {
|
||||||
|
return PurchaseInvoiceOverviewData(
|
||||||
|
totalInvoices: json['totalInvoices'] as int?,
|
||||||
|
totalValue: (json['totalValue'] != null)
|
||||||
|
? (json['totalValue'] as num).toDouble()
|
||||||
|
: null,
|
||||||
|
averageValue: (json['averageValue'] != null)
|
||||||
|
? (json['averageValue'] as num).toDouble()
|
||||||
|
: null,
|
||||||
|
statusBreakdown: json['statusBreakdown'] != null
|
||||||
|
? (json['statusBreakdown'] as List)
|
||||||
|
.map((e) => StatusBreakdown.fromJson(e))
|
||||||
|
.toList()
|
||||||
|
: null,
|
||||||
|
projectBreakdown: json['projectBreakdown'] != null
|
||||||
|
? (json['projectBreakdown'] as List)
|
||||||
|
.map((e) => ProjectBreakdown.fromJson(e))
|
||||||
|
.toList()
|
||||||
|
: null,
|
||||||
|
topSupplier: json['topSupplier'] != null
|
||||||
|
? TopSupplier.fromJson(json['topSupplier'])
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'totalInvoices': totalInvoices,
|
||||||
|
'totalValue': totalValue,
|
||||||
|
'averageValue': averageValue,
|
||||||
|
'statusBreakdown': statusBreakdown?.map((e) => e.toJson()).toList(),
|
||||||
|
'projectBreakdown': projectBreakdown?.map((e) => e.toJson()).toList(),
|
||||||
|
'topSupplier': topSupplier?.toJson(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class StatusBreakdown {
|
||||||
|
final String? id;
|
||||||
|
final String? name;
|
||||||
|
final int? count;
|
||||||
|
final double? totalValue;
|
||||||
|
final double? percentage;
|
||||||
|
|
||||||
|
StatusBreakdown({
|
||||||
|
this.id,
|
||||||
|
this.name,
|
||||||
|
this.count,
|
||||||
|
this.totalValue,
|
||||||
|
this.percentage,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory StatusBreakdown.fromJson(Map<String, dynamic> json) {
|
||||||
|
return StatusBreakdown(
|
||||||
|
id: json['id'] as String?,
|
||||||
|
name: json['name'] as String?,
|
||||||
|
count: json['count'] as int?,
|
||||||
|
totalValue: (json['totalValue'] != null)
|
||||||
|
? (json['totalValue'] as num).toDouble()
|
||||||
|
: null,
|
||||||
|
percentage: (json['percentage'] != null)
|
||||||
|
? (json['percentage'] as num).toDouble()
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'count': count,
|
||||||
|
'totalValue': totalValue,
|
||||||
|
'percentage': percentage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProjectBreakdown {
|
||||||
|
final String? id;
|
||||||
|
final String? name;
|
||||||
|
final int? count;
|
||||||
|
final double? totalValue;
|
||||||
|
final double? percentage;
|
||||||
|
|
||||||
|
ProjectBreakdown({
|
||||||
|
this.id,
|
||||||
|
this.name,
|
||||||
|
this.count,
|
||||||
|
this.totalValue,
|
||||||
|
this.percentage,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ProjectBreakdown.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ProjectBreakdown(
|
||||||
|
id: json['id'] as String?,
|
||||||
|
name: json['name'] as String?,
|
||||||
|
count: json['count'] as int?,
|
||||||
|
totalValue: (json['totalValue'] != null)
|
||||||
|
? (json['totalValue'] as num).toDouble()
|
||||||
|
: null,
|
||||||
|
percentage: (json['percentage'] != null)
|
||||||
|
? (json['percentage'] as num).toDouble()
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'count': count,
|
||||||
|
'totalValue': totalValue,
|
||||||
|
'percentage': percentage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TopSupplier {
|
||||||
|
final String? id;
|
||||||
|
final String? name;
|
||||||
|
final int? count;
|
||||||
|
final double? totalValue;
|
||||||
|
final double? percentage;
|
||||||
|
|
||||||
|
TopSupplier({
|
||||||
|
this.id,
|
||||||
|
this.name,
|
||||||
|
this.count,
|
||||||
|
this.totalValue,
|
||||||
|
this.percentage,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory TopSupplier.fromJson(Map<String, dynamic> json) {
|
||||||
|
return TopSupplier(
|
||||||
|
id: json['id'] as String?,
|
||||||
|
name: json['name'] as String?,
|
||||||
|
count: json['count'] as int?,
|
||||||
|
totalValue: (json['totalValue'] != null)
|
||||||
|
? (json['totalValue'] as num).toDouble()
|
||||||
|
: null,
|
||||||
|
percentage: (json['percentage'] != null)
|
||||||
|
? (json['percentage'] as num).toDouble()
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'count': count,
|
||||||
|
'totalValue': totalValue,
|
||||||
|
'percentage': percentage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,6 +11,8 @@ import 'package:on_field_work/helpers/utils/permission_constants.dart';
|
|||||||
import 'package:on_field_work/helpers/widgets/avatar.dart';
|
import 'package:on_field_work/helpers/widgets/avatar.dart';
|
||||||
import 'package:on_field_work/helpers/widgets/dashbaord/expense_breakdown_chart.dart';
|
import 'package:on_field_work/helpers/widgets/dashbaord/expense_breakdown_chart.dart';
|
||||||
import 'package:on_field_work/helpers/widgets/dashbaord/expense_by_status_widget.dart';
|
import 'package:on_field_work/helpers/widgets/dashbaord/expense_by_status_widget.dart';
|
||||||
|
import 'package:on_field_work/helpers/widgets/dashbaord/collection_dashboard_card.dart';
|
||||||
|
import 'package:on_field_work/helpers/widgets/dashbaord/purchase_invoice_dashboard.dart';
|
||||||
import 'package:on_field_work/helpers/widgets/dashbaord/monthly_expense_dashboard_chart.dart';
|
import 'package:on_field_work/helpers/widgets/dashbaord/monthly_expense_dashboard_chart.dart';
|
||||||
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
|
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
|
||||||
import 'package:on_field_work/helpers/widgets/my_spacing.dart';
|
import 'package:on_field_work/helpers/widgets/my_spacing.dart';
|
||||||
@ -532,7 +534,6 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Build
|
// Build
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -554,6 +555,10 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
_dashboardModules(),
|
_dashboardModules(),
|
||||||
MySpacing.height(20),
|
MySpacing.height(20),
|
||||||
_sectionTitle('Reports & Analytics'),
|
_sectionTitle('Reports & Analytics'),
|
||||||
|
CompactPurchaseInvoiceDashboard(),
|
||||||
|
MySpacing.height(20),
|
||||||
|
CollectionsHealthWidget(),
|
||||||
|
MySpacing.height(20),
|
||||||
_cardWrapper(
|
_cardWrapper(
|
||||||
child: ExpenseTypeReportChart(),
|
child: ExpenseTypeReportChart(),
|
||||||
),
|
),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user