reports fix
This commit is contained in:
parent
b740edf0e8
commit
4b4a51e553
@ -146,7 +146,12 @@ class ApiEndpoints {
|
||||
static const String dashboardKpis = '/dashboard/kpis';
|
||||
static const String dashboardCharts = '/dashboard/charts';
|
||||
|
||||
// Reports
|
||||
// Reports (see https://demo.venbait.in/api-docs/#/Reports)
|
||||
static const String reportAssetDepreciation = '/reports/assets/depreciation';
|
||||
static const String reportAssetDepreciationFilters =
|
||||
'/reports/assets/depreciation/filters';
|
||||
static const String reportAssetDepreciationExport =
|
||||
'/reports/assets/depreciation/export';
|
||||
static const String reportAssetRegister = '/reports/asset-register';
|
||||
static const String reportAllocation = '/reports/allocation';
|
||||
static const String reportMaintenance = '/reports/maintenance';
|
||||
|
||||
@ -77,6 +77,8 @@ class RouteConstants {
|
||||
|
||||
// Reports
|
||||
static const String reports = '/reports';
|
||||
static String reportDetail(String key) => '$reports/$key';
|
||||
static const String reportDepreciation = '/reports/depreciation';
|
||||
|
||||
// Settings
|
||||
static const String settings = '/settings';
|
||||
|
||||
@ -19,6 +19,10 @@ const Map<String, String> permissionModuleAliases = {
|
||||
'purchase_orders': 'PURCHASE_ORDER',
|
||||
'purchase_order': 'PURCHASE_ORDER',
|
||||
'grn': 'GRN',
|
||||
'reports': 'REPORTS',
|
||||
'audit_logs': 'AUDIT_LOGS',
|
||||
'audit': 'AUDIT_LOGS',
|
||||
'settings': 'SETTINGS',
|
||||
};
|
||||
|
||||
/// Modules that are always visible in navigation (no API permission key).
|
||||
@ -85,7 +89,9 @@ bool canSeeMenuModule({
|
||||
required UserRole role,
|
||||
}) {
|
||||
if (alwaysVisibleMenuModules.contains(module)) return true;
|
||||
if (isSuperAdmin(role) || permissions.contains('*')) return true;
|
||||
// Full access only via wildcard — role alone must not reveal menus the
|
||||
// user cannot open (e.g. Reports without REPORTS:view).
|
||||
if (permissions.contains('*')) return true;
|
||||
|
||||
if (module == 'users') {
|
||||
return hasPermission(
|
||||
|
||||
@ -10,6 +10,7 @@ import '../../../../core/utils/validators.dart';
|
||||
import '../../../../shared/models/asset_model.dart';
|
||||
import '../../../../shared/models/user_management_models.dart' show FilterOptionModel;
|
||||
import '../../../../shared/widgets/app_button.dart';
|
||||
import '../../../../shared/widgets/app_date_popup.dart';
|
||||
import '../../../../shared/widgets/app_dropdown.dart';
|
||||
import '../../../../shared/widgets/app_searchable_dropdown.dart';
|
||||
import '../../../../shared/widgets/app_loading_view.dart';
|
||||
@ -364,11 +365,12 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
|
||||
}
|
||||
|
||||
Future<void> _pickDate(void Function(DateTime) onPicked, DateTime? current) async {
|
||||
final picked = await showDatePicker(
|
||||
final picked = await showAppDatePopup(
|
||||
context: context,
|
||||
initialDate: current ?? DateTime.now(),
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime(2100),
|
||||
helpText: 'Select date',
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() => onPicked(picked));
|
||||
|
||||
@ -8,6 +8,7 @@ import '../../../../shared/models/asset_model.dart';
|
||||
import '../../../../shared/widgets/api_feedback.dart';
|
||||
import '../../../../shared/widgets/app_button.dart';
|
||||
import '../../../../shared/widgets/app_card.dart';
|
||||
import '../../../../shared/widgets/app_date_popup.dart';
|
||||
import '../../../../shared/widgets/app_dropdown.dart';
|
||||
import '../../../../shared/widgets/app_empty_state.dart';
|
||||
import '../../../../shared/widgets/app_searchable_dropdown.dart';
|
||||
@ -134,11 +135,12 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
|
||||
required DateTime? current,
|
||||
required void Function(DateTime date) onPicked,
|
||||
}) async {
|
||||
final picked = await showDatePicker(
|
||||
final picked = await showAppDatePopup(
|
||||
context: context,
|
||||
initialDate: current ?? DateTime.now(),
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime(2100),
|
||||
helpText: 'Select date',
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() => onPicked(picked));
|
||||
@ -513,11 +515,12 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
|
||||
required DateTime? current,
|
||||
required void Function(DateTime date) onPicked,
|
||||
}) async {
|
||||
final picked = await showDatePicker(
|
||||
final picked = await showAppDatePopup(
|
||||
context: context,
|
||||
initialDate: current ?? DateTime.now(),
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime(2100),
|
||||
helpText: 'Select date',
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() => onPicked(picked));
|
||||
@ -947,11 +950,12 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
||||
required DateTime? current,
|
||||
required void Function(DateTime date) onPicked,
|
||||
}) async {
|
||||
final picked = await showDatePicker(
|
||||
final picked = await showAppDatePopup(
|
||||
context: context,
|
||||
initialDate: current ?? DateTime.now(),
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime(2100),
|
||||
helpText: 'Select date',
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() => onPicked(picked));
|
||||
@ -1254,11 +1258,12 @@ class _TransferAssetPanelState extends ConsumerState<TransferAssetPanel> {
|
||||
}
|
||||
|
||||
Future<void> _pickDate() async {
|
||||
final picked = await showDatePicker(
|
||||
final picked = await showAppDatePopup(
|
||||
context: context,
|
||||
initialDate: _transferDate,
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime(2100),
|
||||
helpText: 'Transfer date',
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() => _transferDate = picked);
|
||||
|
||||
@ -10,11 +10,12 @@ import '../../../../shared/providers/permissions_provider.dart';
|
||||
import '../../../../shared/utils/file_download_helper.dart';
|
||||
import '../../../../shared/widgets/app_card.dart';
|
||||
import '../../../../shared/widgets/app_data_table.dart';
|
||||
import '../../../../shared/widgets/app_date_range_popup.dart';
|
||||
import '../../../../shared/widgets/app_dropdown.dart';
|
||||
import '../../../../shared/widgets/app_empty_state.dart';
|
||||
import '../../../../shared/widgets/app_filter_date_field.dart';
|
||||
import '../../../../shared/widgets/app_loading_view.dart';
|
||||
import '../../../../shared/widgets/app_pagination.dart';
|
||||
import '../../../../shared/widgets/app_search_field.dart';
|
||||
import '../../../../shared/widgets/app_searchable_dropdown.dart';
|
||||
import '../../../../shared/widgets/app_side_panel.dart';
|
||||
import '../../../../shared/widgets/app_status_chip.dart';
|
||||
@ -81,7 +82,7 @@ class _AuditLogsScreenState extends ConsumerState<AuditLogsScreen> {
|
||||
? DateTimeRange(start: query.dateFrom!, end: query.dateTo!)
|
||||
: null;
|
||||
|
||||
final picked = await showDateRangePicker(
|
||||
final picked = await showAppDateRangePopup(
|
||||
context: context,
|
||||
firstDate: DateTime(now.year - 5),
|
||||
lastDate: DateTime(now.year + 1),
|
||||
@ -258,19 +259,35 @@ class _FiltersBar extends StatelessWidget {
|
||||
final VoidCallback onClearDateRange;
|
||||
final VoidCallback onReset;
|
||||
|
||||
String get _dateLabel {
|
||||
if (query.dateFrom == null && query.dateTo == null) return 'Date range';
|
||||
String get _dateValue {
|
||||
if (query.dateFrom == null && query.dateTo == null) return '';
|
||||
final from = DateFormatter.displayDate(query.dateFrom);
|
||||
final to = DateFormatter.displayDate(query.dateTo);
|
||||
return '$from – $to';
|
||||
}
|
||||
|
||||
bool get _dateEmpty => query.dateFrom == null && query.dateTo == null;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final searchField = AppSearchField(
|
||||
final searchField = TextField(
|
||||
controller: searchController,
|
||||
hint: 'Search table, action, request ID...',
|
||||
onChanged: onSearch,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Search',
|
||||
hintText: 'Search table, action, request ID...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
isDense: true,
|
||||
suffixIcon: searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
searchController.clear();
|
||||
onSearch('');
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
|
||||
final tableDropdown = AppSearchableDropdown<String?>(
|
||||
@ -318,20 +335,16 @@ class _FiltersBar extends StatelessWidget {
|
||||
onChanged: onPerformerChanged,
|
||||
);
|
||||
|
||||
final dateButton = OutlinedButton.icon(
|
||||
onPressed: onPickDateRange,
|
||||
icon: const Icon(Icons.date_range_outlined, size: 18),
|
||||
label: Text(_dateLabel, overflow: TextOverflow.ellipsis),
|
||||
final dateField = AppFilterDateField(
|
||||
label: 'Date range',
|
||||
value: _dateValue,
|
||||
placeholder: 'Select range',
|
||||
icon: Icons.date_range_outlined,
|
||||
isEmpty: _dateEmpty,
|
||||
onTap: onPickDateRange,
|
||||
onClear: _dateEmpty ? null : onClearDateRange,
|
||||
);
|
||||
|
||||
final clearDate = query.dateFrom != null || query.dateTo != null
|
||||
? IconButton(
|
||||
tooltip: 'Clear date range',
|
||||
onPressed: onClearDateRange,
|
||||
icon: const Icon(Icons.clear, size: 18),
|
||||
)
|
||||
: null;
|
||||
|
||||
final resetButton = TextButton(
|
||||
onPressed: query.hasActiveFilter ? onReset : null,
|
||||
child: const Text('Reset'),
|
||||
@ -351,8 +364,7 @@ class _FiltersBar extends StatelessWidget {
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: dateButton),
|
||||
if (clearDate != null) clearDate,
|
||||
Expanded(child: dateField),
|
||||
resetButton,
|
||||
],
|
||||
),
|
||||
@ -360,28 +372,29 @@ class _FiltersBar extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget row(List<Widget> cells) {
|
||||
assert(cells.length == 4);
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
for (var i = 0; i < cells.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 12),
|
||||
Expanded(child: cells[i]),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(flex: 3, child: searchField),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(flex: 2, child: tableDropdown),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(flex: 2, child: actionDropdown),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(flex: 2, child: performerDropdown),
|
||||
],
|
||||
),
|
||||
row([searchField, tableDropdown, actionDropdown, performerDropdown]),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Flexible(child: dateButton),
|
||||
if (clearDate != null) clearDate,
|
||||
const Spacer(),
|
||||
resetButton,
|
||||
],
|
||||
),
|
||||
row([
|
||||
dateField,
|
||||
const SizedBox.shrink(),
|
||||
const SizedBox.shrink(),
|
||||
Align(alignment: Alignment.centerRight, child: resetButton),
|
||||
]),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -112,6 +112,11 @@ final _entries = [
|
||||
_GalleryEntry(title: 'Security', route: RouteConstants.settingsSecurity, group: 'Settings'),
|
||||
// Other
|
||||
_GalleryEntry(title: 'Reports', route: RouteConstants.reports, group: 'Other'),
|
||||
_GalleryEntry(
|
||||
title: 'Depreciation Report',
|
||||
route: RouteConstants.reportDepreciation,
|
||||
group: 'Other',
|
||||
),
|
||||
_GalleryEntry(title: 'Audit Logs', route: RouteConstants.auditLogs, group: 'Other'),
|
||||
];
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@ import '../../../../shared/models/purchase_order_model.dart';
|
||||
import '../../../../shared/models/user_management_models.dart';
|
||||
import '../../../../shared/utils/navigation_utils.dart';
|
||||
import '../../../../shared/widgets/app_button.dart';
|
||||
import '../../../../shared/widgets/app_date_popup.dart';
|
||||
import '../../../../shared/widgets/app_dropdown.dart';
|
||||
import '../../../../shared/widgets/app_loading_view.dart';
|
||||
import '../../../../shared/widgets/app_searchable_dropdown.dart';
|
||||
@ -271,11 +272,12 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
required DateTime? current,
|
||||
required ValueChanged<DateTime?> onPicked,
|
||||
}) async {
|
||||
final picked = await showDatePicker(
|
||||
final picked = await showAppDatePopup(
|
||||
context: context,
|
||||
initialDate: current ?? DateTime.now(),
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2100),
|
||||
helpText: 'Select date',
|
||||
);
|
||||
if (picked != null) onPicked(picked);
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
|
||||
import '../../../../core/utils/formatters.dart';
|
||||
import '../../../../shared/models/grn_model.dart';
|
||||
import '../../../../shared/models/purchase_order_model.dart';
|
||||
import '../../../../shared/widgets/app_date_popup.dart';
|
||||
import '../../../../shared/widgets/app_side_panel.dart';
|
||||
import '../../../../shared/widgets/app_text_field.dart';
|
||||
|
||||
@ -245,11 +246,12 @@ class _GrnLineItemCardState extends State<_GrnLineItemCard> {
|
||||
required DateTime? current,
|
||||
required ValueChanged<DateTime?> onPicked,
|
||||
}) async {
|
||||
final picked = await showDatePicker(
|
||||
final picked = await showAppDatePopup(
|
||||
context: context,
|
||||
initialDate: current ?? DateTime.now(),
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2100),
|
||||
helpText: 'Select date',
|
||||
);
|
||||
if (picked != null) onPicked(picked);
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ import '../../../../shared/models/purchase_order_model.dart';
|
||||
import '../../../../shared/models/user_management_models.dart';
|
||||
import '../../../../shared/utils/navigation_utils.dart';
|
||||
import '../../../../shared/widgets/app_button.dart';
|
||||
import '../../../../shared/widgets/app_date_popup.dart';
|
||||
import '../../../../shared/widgets/app_dropdown.dart';
|
||||
import '../../../../shared/widgets/app_loading_view.dart';
|
||||
import '../../../../shared/widgets/app_searchable_dropdown.dart';
|
||||
@ -312,11 +313,12 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
required DateTime? current,
|
||||
required ValueChanged<DateTime?> onPicked,
|
||||
}) async {
|
||||
final picked = await showDatePicker(
|
||||
final picked = await showAppDatePopup(
|
||||
context: context,
|
||||
initialDate: current ?? DateTime.now(),
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2100),
|
||||
helpText: 'Select date',
|
||||
);
|
||||
if (picked != null) onPicked(picked);
|
||||
}
|
||||
|
||||
@ -0,0 +1,142 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../../core/constants/api_endpoints.dart';
|
||||
import '../../../../core/utils/formatters.dart';
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
import '../../domain/entities/depreciation_report.dart';
|
||||
|
||||
class ReportsRemoteDataSource {
|
||||
ReportsRemoteDataSource({required this.dio});
|
||||
|
||||
final Dio dio;
|
||||
|
||||
Future<DepreciationReportFilters> getAssetReportFilters() async {
|
||||
final response = await dio.get(ApiEndpoints.reportAssetDepreciationFilters);
|
||||
final data = response.data['data'];
|
||||
if (data is Map<String, dynamic>) {
|
||||
return DepreciationReportFilters.fromJson(data);
|
||||
}
|
||||
if (data is Map) {
|
||||
return DepreciationReportFilters.fromJson(
|
||||
Map<String, dynamic>.from(data),
|
||||
);
|
||||
}
|
||||
return const DepreciationReportFilters();
|
||||
}
|
||||
|
||||
Future<DepreciationReportPage> getDepreciationReport(
|
||||
DepreciationReportQuery query,
|
||||
) async {
|
||||
final response = await dio.get(
|
||||
ApiEndpoints.reportAssetDepreciation,
|
||||
queryParameters: _queryToMap(query, includePagination: true),
|
||||
);
|
||||
final body = response.data as Map<String, dynamic>;
|
||||
final rawData = body['data'];
|
||||
final meta = body['meta'] is Map
|
||||
? Map<String, dynamic>.from(body['meta'] as Map)
|
||||
: <String, dynamic>{};
|
||||
|
||||
final rawItems = rawData is List ? rawData : const [];
|
||||
final items = rawItems
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(item) => DepreciationReportRow.fromJson(
|
||||
Map<String, dynamic>.from(item),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
final page = (meta['page'] as num?)?.toInt() ?? query.page;
|
||||
final limit = (meta['limit'] as num?)?.toInt() ?? query.limit;
|
||||
final total = (meta['total'] as num?)?.toInt() ?? items.length;
|
||||
final totalPages = limit > 0
|
||||
? ((total + limit - 1) ~/ limit).clamp(1, 999999)
|
||||
: 1;
|
||||
|
||||
final summaryRaw = meta['summary'];
|
||||
final summary = summaryRaw is Map
|
||||
? DepreciationReportSummary.fromJson(
|
||||
Map<String, dynamic>.from(summaryRaw),
|
||||
)
|
||||
: const DepreciationReportSummary();
|
||||
|
||||
final asOfRaw = meta['as_of_date'] ?? meta['asOfDate'];
|
||||
final asOfDate = asOfRaw == null ? null : DateTime.tryParse(asOfRaw.toString());
|
||||
|
||||
return DepreciationReportPage(
|
||||
items: items,
|
||||
page: page,
|
||||
limit: limit,
|
||||
total: total,
|
||||
totalPages: totalPages,
|
||||
asOfDate: asOfDate,
|
||||
summary: summary,
|
||||
);
|
||||
}
|
||||
|
||||
Future<ExportFileResult> exportAssetReport(
|
||||
DepreciationReportQuery query,
|
||||
) async {
|
||||
final response = await dio.get<List<int>>(
|
||||
ApiEndpoints.reportAssetDepreciationExport,
|
||||
queryParameters: _queryToMap(query, includePagination: false),
|
||||
options: Options(responseType: ResponseType.bytes),
|
||||
);
|
||||
return ExportFileResult(
|
||||
bytes: response.data ?? <int>[],
|
||||
fileName: _fileNameFromResponse(response),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _queryToMap(
|
||||
DepreciationReportQuery query, {
|
||||
required bool includePagination,
|
||||
}) {
|
||||
return {
|
||||
if (includePagination) 'page': query.page,
|
||||
if (includePagination) 'limit': query.limit,
|
||||
if (query.search != null && query.search!.isNotEmpty) 'search': query.search,
|
||||
if (query.status != null && query.status!.isNotEmpty) 'status': query.status,
|
||||
if (query.depreciationMethod != null &&
|
||||
query.depreciationMethod!.isNotEmpty)
|
||||
'depreciation_method': query.depreciationMethod,
|
||||
if (query.itemCategoryId != null && query.itemCategoryId!.isNotEmpty)
|
||||
'item_category_id': query.itemCategoryId,
|
||||
if (query.itemSubcategoryId != null &&
|
||||
query.itemSubcategoryId!.isNotEmpty)
|
||||
'item_subcategory_id': query.itemSubcategoryId,
|
||||
if (query.plantId != null && query.plantId!.isNotEmpty)
|
||||
'plant_id': query.plantId,
|
||||
if (query.departmentId != null && query.departmentId!.isNotEmpty)
|
||||
'department_id': query.departmentId,
|
||||
if (query.isActive != null) 'is_active': query.isActive,
|
||||
if (query.asOfDate != null)
|
||||
'as_of_date': DateFormatter.toApiDate(query.asOfDate!),
|
||||
if (query.purchaseDateFrom != null)
|
||||
'purchase_date_from': DateFormatter.toApiDate(query.purchaseDateFrom!),
|
||||
if (query.purchaseDateTo != null)
|
||||
'purchase_date_to': DateFormatter.toApiDate(query.purchaseDateTo!),
|
||||
};
|
||||
}
|
||||
|
||||
String _fileNameFromResponse(Response<List<int>> response) {
|
||||
final disposition = response.headers.value('content-disposition');
|
||||
if (disposition != null) {
|
||||
final utf8Match = RegExp(
|
||||
r"filename\*=UTF-8''([^;\n]+)",
|
||||
caseSensitive: false,
|
||||
).firstMatch(disposition);
|
||||
if (utf8Match != null) {
|
||||
return Uri.decodeComponent(utf8Match.group(1)!);
|
||||
}
|
||||
|
||||
final match = RegExp(r'filename="?([^";\n]+)"?').firstMatch(disposition);
|
||||
if (match != null) {
|
||||
return match.group(1)!.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return 'depreciation_report.csv';
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/network/api_handler.dart';
|
||||
import '../../../../core/network/dio_client.dart';
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
import '../../domain/entities/depreciation_report.dart';
|
||||
import '../../domain/repositories/reports_repository.dart';
|
||||
import '../datasources/reports_remote_data_source.dart';
|
||||
|
||||
final reportsRemoteDataSourceProvider = Provider<ReportsRemoteDataSource>((ref) {
|
||||
return ReportsRemoteDataSource(dio: ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
final reportsRepositoryProvider = Provider<ReportsRepository>((ref) {
|
||||
return ReportsRepositoryImpl(remote: ref.watch(reportsRemoteDataSourceProvider));
|
||||
});
|
||||
|
||||
class ReportsRepositoryImpl implements ReportsRepository {
|
||||
ReportsRepositoryImpl({required this.remote});
|
||||
|
||||
final ReportsRemoteDataSource remote;
|
||||
|
||||
@override
|
||||
Future<Result<DepreciationReportFilters>> getAssetReportFilters() =>
|
||||
safeApiCall(remote.getAssetReportFilters);
|
||||
|
||||
@override
|
||||
Future<Result<DepreciationReportPage>> getDepreciationReport(
|
||||
DepreciationReportQuery query,
|
||||
) =>
|
||||
safeApiCall(() => remote.getDepreciationReport(query));
|
||||
|
||||
@override
|
||||
Future<Result<ExportFileResult>> exportAssetReport(
|
||||
DepreciationReportQuery query,
|
||||
) =>
|
||||
safeApiCall(() => remote.exportAssetReport(query));
|
||||
}
|
||||
424
lib/modules/reports/domain/entities/depreciation_report.dart
Normal file
424
lib/modules/reports/domain/entities/depreciation_report.dart
Normal file
@ -0,0 +1,424 @@
|
||||
import '../../../../shared/models/asset_model.dart';
|
||||
|
||||
class ReportFilterOption {
|
||||
const ReportFilterOption({
|
||||
required this.value,
|
||||
required this.label,
|
||||
this.parentId,
|
||||
});
|
||||
|
||||
final String value;
|
||||
final String label;
|
||||
final String? parentId;
|
||||
|
||||
factory ReportFilterOption.fromDynamic(dynamic item) {
|
||||
if (item is Map) {
|
||||
final map = Map<String, dynamic>.from(item);
|
||||
final value = (map['value'] ?? map['id'] ?? map['code'] ?? map['key'] ?? '')
|
||||
.toString()
|
||||
.trim();
|
||||
final label = (map['label'] ?? map['name'] ?? map['display'] ?? value)
|
||||
.toString()
|
||||
.trim();
|
||||
final code = map['code']?.toString().trim();
|
||||
final display = (code != null &&
|
||||
code.isNotEmpty &&
|
||||
label.isNotEmpty &&
|
||||
code != label)
|
||||
? '$label ($code)'
|
||||
: (label.isEmpty ? value : label);
|
||||
final parentId = (map['item_category_id'] ?? map['parent_id'])
|
||||
?.toString()
|
||||
.trim();
|
||||
return ReportFilterOption(
|
||||
value: value,
|
||||
label: display,
|
||||
parentId: parentId?.isEmpty == true ? null : parentId,
|
||||
);
|
||||
}
|
||||
|
||||
final option = AssetDropdownOption.fromDynamic(item);
|
||||
return ReportFilterOption(value: option.value, label: option.label);
|
||||
}
|
||||
}
|
||||
|
||||
class DepreciationReportFilters {
|
||||
const DepreciationReportFilters({
|
||||
this.plants = const [],
|
||||
this.categories = const [],
|
||||
this.subcategories = const [],
|
||||
this.departments = const [],
|
||||
this.statuses = const [],
|
||||
this.depreciationMethods = const [],
|
||||
});
|
||||
|
||||
final List<ReportFilterOption> plants;
|
||||
final List<ReportFilterOption> categories;
|
||||
final List<ReportFilterOption> subcategories;
|
||||
final List<ReportFilterOption> departments;
|
||||
final List<ReportFilterOption> statuses;
|
||||
final List<ReportFilterOption> depreciationMethods;
|
||||
|
||||
factory DepreciationReportFilters.fromJson(Map<String, dynamic> json) {
|
||||
List<ReportFilterOption> parse(List<String> keys) {
|
||||
for (final key in keys) {
|
||||
final raw = json[key];
|
||||
if (raw is List && raw.isNotEmpty) {
|
||||
return raw
|
||||
.map(ReportFilterOption.fromDynamic)
|
||||
.where((o) => o.value.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
return DepreciationReportFilters(
|
||||
plants: parse(['plants', 'plant_options', 'plant']),
|
||||
categories: parse([
|
||||
'item_categories',
|
||||
'categories',
|
||||
'asset_categories',
|
||||
'category_options',
|
||||
]),
|
||||
subcategories: parse([
|
||||
'item_subcategories',
|
||||
'subcategories',
|
||||
'asset_subcategories',
|
||||
]),
|
||||
departments: parse(['departments', 'department_options', 'department']),
|
||||
statuses: parse(['statuses', 'status_options', 'status']),
|
||||
depreciationMethods: parse([
|
||||
'depreciation_methods',
|
||||
'methods',
|
||||
'depreciationMethods',
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DepreciationReportSummary {
|
||||
const DepreciationReportSummary({
|
||||
this.assetCount = 0,
|
||||
this.totalPurchaseCost = 0,
|
||||
this.totalAccumulatedDepreciation = 0,
|
||||
this.totalBookValue = 0,
|
||||
this.totalAnnualDepreciation = 0,
|
||||
});
|
||||
|
||||
final int assetCount;
|
||||
final double totalPurchaseCost;
|
||||
final double totalAccumulatedDepreciation;
|
||||
final double totalBookValue;
|
||||
final double totalAnnualDepreciation;
|
||||
|
||||
factory DepreciationReportSummary.fromJson(Map<String, dynamic>? json) {
|
||||
if (json == null) return const DepreciationReportSummary();
|
||||
|
||||
double toDouble(dynamic value) {
|
||||
if (value is num) return value.toDouble();
|
||||
return double.tryParse(value?.toString() ?? '') ?? 0;
|
||||
}
|
||||
|
||||
int toInt(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
return int.tryParse(value?.toString() ?? '') ?? 0;
|
||||
}
|
||||
|
||||
return DepreciationReportSummary(
|
||||
assetCount: toInt(json['asset_count'] ?? json['assetCount']),
|
||||
totalPurchaseCost:
|
||||
toDouble(json['total_purchase_cost'] ?? json['totalPurchaseCost']),
|
||||
totalAccumulatedDepreciation: toDouble(
|
||||
json['total_accumulated_depreciation'] ??
|
||||
json['totalAccumulatedDepreciation'],
|
||||
),
|
||||
totalBookValue:
|
||||
toDouble(json['total_book_value'] ?? json['totalBookValue']),
|
||||
totalAnnualDepreciation: toDouble(
|
||||
json['total_annual_depreciation'] ?? json['totalAnnualDepreciation'],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DepreciationReportRow {
|
||||
const DepreciationReportRow({
|
||||
required this.id,
|
||||
this.assetCode,
|
||||
this.assetName,
|
||||
this.categoryName,
|
||||
this.subcategoryName,
|
||||
this.plantName,
|
||||
this.departmentName,
|
||||
this.status,
|
||||
this.purchaseDate,
|
||||
this.purchaseCost,
|
||||
this.depreciationMethod,
|
||||
this.depreciationRate,
|
||||
this.usefulLifeYears,
|
||||
this.yearsElapsed,
|
||||
this.salvageValue,
|
||||
this.annualDepreciation,
|
||||
this.accumulatedDepreciation,
|
||||
this.bookValue,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String? assetCode;
|
||||
final String? assetName;
|
||||
final String? categoryName;
|
||||
final String? subcategoryName;
|
||||
final String? plantName;
|
||||
final String? departmentName;
|
||||
final String? status;
|
||||
final DateTime? purchaseDate;
|
||||
final double? purchaseCost;
|
||||
final String? depreciationMethod;
|
||||
final double? depreciationRate;
|
||||
final int? usefulLifeYears;
|
||||
final double? yearsElapsed;
|
||||
final double? salvageValue;
|
||||
final double? annualDepreciation;
|
||||
final double? accumulatedDepreciation;
|
||||
final double? bookValue;
|
||||
|
||||
factory DepreciationReportRow.fromJson(Map<String, dynamic> json) {
|
||||
double? toDouble(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is num) return value.toDouble();
|
||||
return double.tryParse(value.toString());
|
||||
}
|
||||
|
||||
int? toInt(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
return int.tryParse(value.toString());
|
||||
}
|
||||
|
||||
DateTime? toDate(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is DateTime) return value;
|
||||
return DateTime.tryParse(value.toString());
|
||||
}
|
||||
|
||||
String? readString(List<String> keys, [Map<String, dynamic>? source]) {
|
||||
final map = source ?? json;
|
||||
for (final key in keys) {
|
||||
final value = map[key];
|
||||
if (value == null) continue;
|
||||
final text = value.toString().trim();
|
||||
if (text.isNotEmpty) return text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? readNestedName(String key) {
|
||||
final nested = json[key];
|
||||
if (nested is Map) {
|
||||
final map = Map<String, dynamic>.from(nested);
|
||||
final name = map['name'] ?? map['label'] ?? map['title'];
|
||||
if (name != null && name.toString().trim().isNotEmpty) {
|
||||
return name.toString().trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
final depreciation = json['depreciation'];
|
||||
final depMap = depreciation is Map
|
||||
? Map<String, dynamic>.from(depreciation)
|
||||
: <String, dynamic>{};
|
||||
|
||||
final id = (json['id'] ??
|
||||
json['asset_id'] ??
|
||||
json['assetId'] ??
|
||||
json['asset_code'] ??
|
||||
'')
|
||||
.toString();
|
||||
|
||||
return DepreciationReportRow(
|
||||
id: id,
|
||||
assetCode: readString(['asset_code', 'assetCode', 'code']),
|
||||
assetName: readString(['asset_name', 'assetName', 'name']),
|
||||
categoryName: readString([
|
||||
'category_name',
|
||||
'asset_category_name',
|
||||
'item_category_name',
|
||||
]) ??
|
||||
readNestedName('item_category') ??
|
||||
readNestedName('category') ??
|
||||
readNestedName('asset_category'),
|
||||
subcategoryName: readString([
|
||||
'subcategory_name',
|
||||
'item_subcategory_name',
|
||||
]) ??
|
||||
readNestedName('item_subcategory') ??
|
||||
readNestedName('subcategory'),
|
||||
plantName:
|
||||
readString(['plant_name', 'plantName']) ?? readNestedName('plant'),
|
||||
departmentName: readString(['department_name', 'departmentName']) ??
|
||||
readNestedName('department'),
|
||||
status: readString(['status']),
|
||||
purchaseDate: toDate(json['purchase_date'] ?? json['purchaseDate']),
|
||||
purchaseCost: toDouble(
|
||||
json['purchase_cost'] ?? json['purchaseCost'],
|
||||
) ??
|
||||
toDouble(depMap['purchase_cost']),
|
||||
depreciationMethod: readString([
|
||||
'depreciation_method',
|
||||
'depreciationMethod',
|
||||
'method',
|
||||
]) ??
|
||||
readString(['depreciation_method', 'depreciationMethod'], depMap),
|
||||
depreciationRate: toDouble(
|
||||
json['depreciation_rate'] ?? json['depreciationRate'],
|
||||
) ??
|
||||
toDouble(depMap['depreciation_rate']),
|
||||
usefulLifeYears: toInt(
|
||||
json['useful_life_years'] ?? json['usefulLifeYears'],
|
||||
) ??
|
||||
toInt(depMap['useful_life_years']),
|
||||
yearsElapsed: toDouble(
|
||||
json['years_elapsed'] ?? json['yearsElapsed'],
|
||||
) ??
|
||||
toDouble(depMap['years_elapsed']),
|
||||
salvageValue: toDouble(
|
||||
json['salvage_value'] ?? json['salvageValue'],
|
||||
) ??
|
||||
toDouble(depMap['salvage_value']),
|
||||
annualDepreciation: toDouble(
|
||||
json['annual_depreciation'] ?? json['annualDepreciation'],
|
||||
) ??
|
||||
toDouble(depMap['annual_depreciation']),
|
||||
accumulatedDepreciation: toDouble(
|
||||
json['accumulated_depreciation'] ?? json['accumulatedDepreciation'],
|
||||
) ??
|
||||
toDouble(depMap['accumulated_depreciation']),
|
||||
bookValue: toDouble(
|
||||
json['book_value'] ?? json['bookValue'] ?? json['nbv'],
|
||||
) ??
|
||||
toDouble(depMap['book_value']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DepreciationReportQuery {
|
||||
const DepreciationReportQuery({
|
||||
this.page = 1,
|
||||
this.limit = 20,
|
||||
this.search,
|
||||
this.plantId,
|
||||
this.itemCategoryId,
|
||||
this.itemSubcategoryId,
|
||||
this.departmentId,
|
||||
this.status,
|
||||
this.depreciationMethod,
|
||||
this.isActive,
|
||||
this.asOfDate,
|
||||
this.purchaseDateFrom,
|
||||
this.purchaseDateTo,
|
||||
});
|
||||
|
||||
final int page;
|
||||
final int limit;
|
||||
final String? search;
|
||||
final String? plantId;
|
||||
final String? itemCategoryId;
|
||||
final String? itemSubcategoryId;
|
||||
final String? departmentId;
|
||||
final String? status;
|
||||
final String? depreciationMethod;
|
||||
final bool? isActive;
|
||||
final DateTime? asOfDate;
|
||||
final DateTime? purchaseDateFrom;
|
||||
final DateTime? purchaseDateTo;
|
||||
|
||||
bool get hasActiveFilter =>
|
||||
(search?.isNotEmpty ?? false) ||
|
||||
(plantId?.isNotEmpty ?? false) ||
|
||||
(itemCategoryId?.isNotEmpty ?? false) ||
|
||||
(itemSubcategoryId?.isNotEmpty ?? false) ||
|
||||
(departmentId?.isNotEmpty ?? false) ||
|
||||
(status?.isNotEmpty ?? false) ||
|
||||
(depreciationMethod?.isNotEmpty ?? false) ||
|
||||
isActive != null ||
|
||||
asOfDate != null ||
|
||||
purchaseDateFrom != null ||
|
||||
purchaseDateTo != null;
|
||||
|
||||
DepreciationReportQuery copyWith({
|
||||
int? page,
|
||||
int? limit,
|
||||
String? search,
|
||||
String? plantId,
|
||||
String? itemCategoryId,
|
||||
String? itemSubcategoryId,
|
||||
String? departmentId,
|
||||
String? status,
|
||||
String? depreciationMethod,
|
||||
bool? isActive,
|
||||
DateTime? asOfDate,
|
||||
DateTime? purchaseDateFrom,
|
||||
DateTime? purchaseDateTo,
|
||||
bool clearSearch = false,
|
||||
bool clearPlantId = false,
|
||||
bool clearItemCategoryId = false,
|
||||
bool clearItemSubcategoryId = false,
|
||||
bool clearDepartmentId = false,
|
||||
bool clearStatus = false,
|
||||
bool clearDepreciationMethod = false,
|
||||
bool clearIsActive = false,
|
||||
bool clearAsOfDate = false,
|
||||
bool clearPurchaseDateFrom = false,
|
||||
bool clearPurchaseDateTo = false,
|
||||
}) {
|
||||
return DepreciationReportQuery(
|
||||
page: page ?? this.page,
|
||||
limit: limit ?? this.limit,
|
||||
search: clearSearch ? null : search ?? this.search,
|
||||
plantId: clearPlantId ? null : plantId ?? this.plantId,
|
||||
itemCategoryId: clearItemCategoryId
|
||||
? null
|
||||
: itemCategoryId ?? this.itemCategoryId,
|
||||
itemSubcategoryId: clearItemSubcategoryId
|
||||
? null
|
||||
: itemSubcategoryId ?? this.itemSubcategoryId,
|
||||
departmentId:
|
||||
clearDepartmentId ? null : departmentId ?? this.departmentId,
|
||||
status: clearStatus ? null : status ?? this.status,
|
||||
depreciationMethod: clearDepreciationMethod
|
||||
? null
|
||||
: depreciationMethod ?? this.depreciationMethod,
|
||||
isActive: clearIsActive ? null : isActive ?? this.isActive,
|
||||
asOfDate: clearAsOfDate ? null : asOfDate ?? this.asOfDate,
|
||||
purchaseDateFrom: clearPurchaseDateFrom
|
||||
? null
|
||||
: purchaseDateFrom ?? this.purchaseDateFrom,
|
||||
purchaseDateTo:
|
||||
clearPurchaseDateTo ? null : purchaseDateTo ?? this.purchaseDateTo,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DepreciationReportPage {
|
||||
const DepreciationReportPage({
|
||||
required this.items,
|
||||
required this.page,
|
||||
required this.limit,
|
||||
required this.total,
|
||||
required this.totalPages,
|
||||
this.asOfDate,
|
||||
this.summary = const DepreciationReportSummary(),
|
||||
});
|
||||
|
||||
final List<DepreciationReportRow> items;
|
||||
final int page;
|
||||
final int limit;
|
||||
final int total;
|
||||
final int totalPages;
|
||||
final DateTime? asOfDate;
|
||||
final DepreciationReportSummary summary;
|
||||
}
|
||||
62
lib/modules/reports/domain/entities/report_definition.dart
Normal file
62
lib/modules/reports/domain/entities/report_definition.dart
Normal file
@ -0,0 +1,62 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Catalog entry for a report on the Reports hub.
|
||||
/// Add new reports here — hub + router pick them up automatically.
|
||||
class ReportDefinition {
|
||||
const ReportDefinition({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.category,
|
||||
required this.routeKey,
|
||||
required this.icon,
|
||||
this.accentColor = const Color(0xFF2563EB),
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
final String description;
|
||||
final String category;
|
||||
final String routeKey;
|
||||
final IconData icon;
|
||||
final Color accentColor;
|
||||
}
|
||||
|
||||
ReportDefinition? reportDefinitionByRouteKey(String routeKey) {
|
||||
for (final def in reportDefinitions) {
|
||||
if (def.routeKey == routeKey) return def;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<String> get reportCategories {
|
||||
final seen = <String>{};
|
||||
final categories = <String>[];
|
||||
for (final def in reportDefinitions) {
|
||||
if (seen.add(def.category)) categories.add(def.category);
|
||||
}
|
||||
return categories;
|
||||
}
|
||||
|
||||
const reportDefinitions = <ReportDefinition>[
|
||||
ReportDefinition(
|
||||
id: 'depreciation',
|
||||
title: 'Depreciation Report',
|
||||
description:
|
||||
'Track purchase cost, accumulated depreciation, and current book value by asset.',
|
||||
category: 'Assets',
|
||||
routeKey: 'depreciation',
|
||||
icon: Icons.trending_down_outlined,
|
||||
accentColor: Color(0xFF2563EB),
|
||||
),
|
||||
// Future reports (uncomment / add when ready):
|
||||
// ReportDefinition(
|
||||
// id: 'asset-register',
|
||||
// title: 'Asset Register',
|
||||
// description: 'Full inventory of assets with status and location.',
|
||||
// category: 'Assets',
|
||||
// routeKey: 'asset-register',
|
||||
// icon: Icons.inventory_2_outlined,
|
||||
// accentColor: Color(0xFF16A34A),
|
||||
// ),
|
||||
];
|
||||
@ -0,0 +1,15 @@
|
||||
import '../../../../core/network/api_handler.dart';
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
import '../entities/depreciation_report.dart';
|
||||
|
||||
abstract class ReportsRepository {
|
||||
Future<Result<DepreciationReportFilters>> getAssetReportFilters();
|
||||
|
||||
Future<Result<DepreciationReportPage>> getDepreciationReport(
|
||||
DepreciationReportQuery query,
|
||||
);
|
||||
|
||||
Future<Result<ExportFileResult>> exportAssetReport(
|
||||
DepreciationReportQuery query,
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,306 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/constants/app_constants.dart';
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
import '../../data/repositories/reports_repository_impl.dart';
|
||||
import '../../domain/entities/depreciation_report.dart';
|
||||
|
||||
class DepreciationReportState {
|
||||
const DepreciationReportState({
|
||||
this.items = const [],
|
||||
this.filters = const DepreciationReportFilters(),
|
||||
this.query = const DepreciationReportQuery(
|
||||
limit: AppConstants.defaultPageSize,
|
||||
),
|
||||
this.total = 0,
|
||||
this.totalPages = 1,
|
||||
this.asOfDate,
|
||||
this.summary = const DepreciationReportSummary(),
|
||||
this.isRefreshing = false,
|
||||
this.isExporting = false,
|
||||
this.actionError,
|
||||
this.actionSuccess,
|
||||
});
|
||||
|
||||
final List<DepreciationReportRow> items;
|
||||
final DepreciationReportFilters filters;
|
||||
final DepreciationReportQuery query;
|
||||
final int total;
|
||||
final int totalPages;
|
||||
final DateTime? asOfDate;
|
||||
final DepreciationReportSummary summary;
|
||||
final bool isRefreshing;
|
||||
final bool isExporting;
|
||||
final String? actionError;
|
||||
final String? actionSuccess;
|
||||
|
||||
DepreciationReportState copyWith({
|
||||
List<DepreciationReportRow>? items,
|
||||
DepreciationReportFilters? filters,
|
||||
DepreciationReportQuery? query,
|
||||
int? total,
|
||||
int? totalPages,
|
||||
DateTime? asOfDate,
|
||||
DepreciationReportSummary? summary,
|
||||
bool? isRefreshing,
|
||||
bool? isExporting,
|
||||
String? actionError,
|
||||
String? actionSuccess,
|
||||
bool clearMessages = false,
|
||||
bool clearAsOfDate = false,
|
||||
}) {
|
||||
return DepreciationReportState(
|
||||
items: items ?? this.items,
|
||||
filters: filters ?? this.filters,
|
||||
query: query ?? this.query,
|
||||
total: total ?? this.total,
|
||||
totalPages: totalPages ?? this.totalPages,
|
||||
asOfDate: clearAsOfDate ? null : asOfDate ?? this.asOfDate,
|
||||
summary: summary ?? this.summary,
|
||||
isRefreshing: isRefreshing ?? this.isRefreshing,
|
||||
isExporting: isExporting ?? this.isExporting,
|
||||
actionError: clearMessages ? null : actionError ?? this.actionError,
|
||||
actionSuccess: clearMessages ? null : actionSuccess ?? this.actionSuccess,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final depreciationReportProvider = AsyncNotifierProvider<
|
||||
DepreciationReportNotifier, DepreciationReportState>(
|
||||
DepreciationReportNotifier.new,
|
||||
);
|
||||
|
||||
class DepreciationReportNotifier
|
||||
extends AsyncNotifier<DepreciationReportState> {
|
||||
@override
|
||||
Future<DepreciationReportState> build() async {
|
||||
ref.keepAlive();
|
||||
return _load(
|
||||
const DepreciationReportQuery(limit: AppConstants.defaultPageSize),
|
||||
);
|
||||
}
|
||||
|
||||
Future<DepreciationReportState> _load(DepreciationReportQuery query) async {
|
||||
final repository = ref.read(reportsRepositoryProvider);
|
||||
final filtersResult = await repository.getAssetReportFilters();
|
||||
final listResult = await repository.getDepreciationReport(query);
|
||||
|
||||
if (listResult.failure != null) throw listResult.failure!;
|
||||
|
||||
final page = listResult.data!;
|
||||
return DepreciationReportState(
|
||||
items: page.items,
|
||||
filters: filtersResult.data ?? const DepreciationReportFilters(),
|
||||
query: query.copyWith(page: page.page, limit: page.limit),
|
||||
total: page.total,
|
||||
totalPages: page.totalPages,
|
||||
asOfDate: page.asOfDate,
|
||||
summary: page.summary,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
final current = state.valueOrNull ?? const DepreciationReportState();
|
||||
state = AsyncData(current.copyWith(isRefreshing: true, clearMessages: true));
|
||||
try {
|
||||
state = AsyncData(await _load(current.query));
|
||||
} catch (e, st) {
|
||||
state = AsyncError(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> applyQuery(DepreciationReportQuery query) async {
|
||||
final previous = state.valueOrNull;
|
||||
if (previous == null) {
|
||||
state = const AsyncLoading();
|
||||
} else {
|
||||
state = AsyncData(previous.copyWith(clearMessages: true));
|
||||
}
|
||||
|
||||
try {
|
||||
final next = await _load(query);
|
||||
state = AsyncData(next);
|
||||
} catch (e, st) {
|
||||
if (previous != null) {
|
||||
state = AsyncData(
|
||||
previous.copyWith(actionError: e.toString()),
|
||||
);
|
||||
} else {
|
||||
state = AsyncError(e, st);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setSearch(String? value) async {
|
||||
final current = state.valueOrNull?.query ??
|
||||
const DepreciationReportQuery(limit: AppConstants.defaultPageSize);
|
||||
final trimmed = value?.trim();
|
||||
await applyQuery(
|
||||
current.copyWith(
|
||||
page: 1,
|
||||
search: trimmed,
|
||||
clearSearch: trimmed == null || trimmed.isEmpty,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setPlantId(String? value) async {
|
||||
final current = state.valueOrNull?.query ??
|
||||
const DepreciationReportQuery(limit: AppConstants.defaultPageSize);
|
||||
await applyQuery(
|
||||
current.copyWith(
|
||||
page: 1,
|
||||
plantId: value,
|
||||
clearPlantId: value == null || value.isEmpty,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setItemCategoryId(String? value) async {
|
||||
final current = state.valueOrNull?.query ??
|
||||
const DepreciationReportQuery(limit: AppConstants.defaultPageSize);
|
||||
await applyQuery(
|
||||
current.copyWith(
|
||||
page: 1,
|
||||
itemCategoryId: value,
|
||||
clearItemCategoryId: value == null || value.isEmpty,
|
||||
clearItemSubcategoryId: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setItemSubcategoryId(String? value) async {
|
||||
final current = state.valueOrNull?.query ??
|
||||
const DepreciationReportQuery(limit: AppConstants.defaultPageSize);
|
||||
await applyQuery(
|
||||
current.copyWith(
|
||||
page: 1,
|
||||
itemSubcategoryId: value,
|
||||
clearItemSubcategoryId: value == null || value.isEmpty,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setDepartmentId(String? value) async {
|
||||
final current = state.valueOrNull?.query ??
|
||||
const DepreciationReportQuery(limit: AppConstants.defaultPageSize);
|
||||
await applyQuery(
|
||||
current.copyWith(
|
||||
page: 1,
|
||||
departmentId: value,
|
||||
clearDepartmentId: value == null || value.isEmpty,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setStatus(String? value) async {
|
||||
final current = state.valueOrNull?.query ??
|
||||
const DepreciationReportQuery(limit: AppConstants.defaultPageSize);
|
||||
await applyQuery(
|
||||
current.copyWith(
|
||||
page: 1,
|
||||
status: value,
|
||||
clearStatus: value == null || value.isEmpty,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setDepreciationMethod(String? value) async {
|
||||
final current = state.valueOrNull?.query ??
|
||||
const DepreciationReportQuery(limit: AppConstants.defaultPageSize);
|
||||
await applyQuery(
|
||||
current.copyWith(
|
||||
page: 1,
|
||||
depreciationMethod: value,
|
||||
clearDepreciationMethod: value == null || value.isEmpty,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setIsActive(bool? value) async {
|
||||
final current = state.valueOrNull?.query ??
|
||||
const DepreciationReportQuery(limit: AppConstants.defaultPageSize);
|
||||
await applyQuery(
|
||||
current.copyWith(
|
||||
page: 1,
|
||||
isActive: value,
|
||||
clearIsActive: value == null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setAsOfDate(DateTime? value) async {
|
||||
final current = state.valueOrNull?.query ??
|
||||
const DepreciationReportQuery(limit: AppConstants.defaultPageSize);
|
||||
await applyQuery(
|
||||
current.copyWith(
|
||||
page: 1,
|
||||
asOfDate: value,
|
||||
clearAsOfDate: value == null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setPurchaseDateRange(DateTime? from, DateTime? to) async {
|
||||
final current = state.valueOrNull?.query ??
|
||||
const DepreciationReportQuery(limit: AppConstants.defaultPageSize);
|
||||
await applyQuery(
|
||||
current.copyWith(
|
||||
page: 1,
|
||||
purchaseDateFrom: from,
|
||||
purchaseDateTo: to,
|
||||
clearPurchaseDateFrom: from == null,
|
||||
clearPurchaseDateTo: to == null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setPage(int page) async {
|
||||
final current = state.valueOrNull?.query ??
|
||||
const DepreciationReportQuery(limit: AppConstants.defaultPageSize);
|
||||
await applyQuery(current.copyWith(page: page));
|
||||
}
|
||||
|
||||
Future<void> setPageSize(int limit) async {
|
||||
final current = state.valueOrNull?.query ??
|
||||
const DepreciationReportQuery(limit: AppConstants.defaultPageSize);
|
||||
await applyQuery(current.copyWith(page: 1, limit: limit));
|
||||
}
|
||||
|
||||
Future<void> resetFilters() async {
|
||||
await applyQuery(
|
||||
DepreciationReportQuery(
|
||||
limit: state.valueOrNull?.query.limit ?? AppConstants.defaultPageSize,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<ExportFileResult?> exportReport() async {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return null;
|
||||
|
||||
state = AsyncData(current.copyWith(isExporting: true, clearMessages: true));
|
||||
final result = await ref
|
||||
.read(reportsRepositoryProvider)
|
||||
.exportAssetReport(current.query);
|
||||
|
||||
final latest = state.valueOrNull ?? current;
|
||||
if (result.failure != null) {
|
||||
state = AsyncData(
|
||||
latest.copyWith(
|
||||
isExporting: false,
|
||||
actionError: result.failure!.message,
|
||||
),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
state = AsyncData(
|
||||
latest.copyWith(
|
||||
isExporting: false,
|
||||
actionSuccess: 'Export ready',
|
||||
),
|
||||
);
|
||||
return result.data;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,744 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/constants/enums.dart';
|
||||
import '../../../../core/constants/route_constants.dart';
|
||||
import '../../../../core/errors/failure.dart';
|
||||
import '../../../../core/utils/formatters.dart';
|
||||
import '../../../../core/utils/responsive_utils.dart';
|
||||
import '../../../../shared/providers/permissions_provider.dart';
|
||||
import '../../../../shared/utils/file_download_helper.dart';
|
||||
import '../../../rbac/presentation/widgets/rbac_widgets.dart';
|
||||
import '../../../../shared/widgets/app_data_table.dart';
|
||||
import '../../../../shared/widgets/app_date_popup.dart';
|
||||
import '../../../../shared/widgets/app_date_range_popup.dart';
|
||||
import '../../../../shared/widgets/app_dropdown.dart';
|
||||
import '../../../../shared/widgets/app_empty_state.dart';
|
||||
import '../../../../shared/widgets/app_filter_date_field.dart';
|
||||
import '../../../../shared/widgets/app_loading_view.dart';
|
||||
import '../../../../shared/widgets/app_pagination.dart';
|
||||
import '../../../../shared/widgets/app_searchable_dropdown.dart';
|
||||
import '../../../../shared/widgets/app_table_shell.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
import '../../../../shared/widgets/page_header.dart';
|
||||
import '../../domain/entities/depreciation_report.dart';
|
||||
import '../providers/depreciation_report_provider.dart';
|
||||
|
||||
class DepreciationReportScreen extends ConsumerStatefulWidget {
|
||||
const DepreciationReportScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DepreciationReportScreen> createState() =>
|
||||
_DepreciationReportScreenState();
|
||||
}
|
||||
|
||||
class _DepreciationReportScreenState
|
||||
extends ConsumerState<DepreciationReportScreen> {
|
||||
final _searchController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _export() async {
|
||||
final file =
|
||||
await ref.read(depreciationReportProvider.notifier).exportReport();
|
||||
if (!mounted) return;
|
||||
|
||||
if (file == null) {
|
||||
final error =
|
||||
ref.read(depreciationReportProvider).valueOrNull?.actionError;
|
||||
if (error != null) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(error)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final saved = await downloadFile(
|
||||
bytes: file.bytes,
|
||||
fileName: file.fileName,
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
saved ? 'Downloaded ${file.fileName}' : 'Export cancelled',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickAsOfDate(DepreciationReportQuery query) async {
|
||||
final now = DateTime.now();
|
||||
final picked = await showAppDatePopup(
|
||||
context: context,
|
||||
initialDate: query.asOfDate ?? now,
|
||||
firstDate: DateTime(now.year - 20),
|
||||
lastDate: DateTime(now.year + 1),
|
||||
helpText: 'As of date',
|
||||
);
|
||||
if (picked == null) return;
|
||||
ref.read(depreciationReportProvider.notifier).setAsOfDate(picked);
|
||||
}
|
||||
|
||||
Future<void> _pickPurchaseDateRange(DepreciationReportQuery query) async {
|
||||
final now = DateTime.now();
|
||||
final initial = (query.purchaseDateFrom != null &&
|
||||
query.purchaseDateTo != null)
|
||||
? DateTimeRange(
|
||||
start: query.purchaseDateFrom!,
|
||||
end: query.purchaseDateTo!,
|
||||
)
|
||||
: null;
|
||||
|
||||
final picked = await showAppDateRangePopup(
|
||||
context: context,
|
||||
firstDate: DateTime(now.year - 30),
|
||||
lastDate: DateTime(now.year + 1),
|
||||
initialDateRange: initial,
|
||||
helpText: 'Purchase date range',
|
||||
);
|
||||
if (picked == null) return;
|
||||
|
||||
ref.read(depreciationReportProvider.notifier).setPurchaseDateRange(
|
||||
DateTime(picked.start.year, picked.start.month, picked.start.day),
|
||||
DateTime(picked.end.year, picked.end.month, picked.end.day),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final reportAsync = ref.watch(depreciationReportProvider);
|
||||
final canExport = ref.can('reports', PermissionAction.export);
|
||||
|
||||
ref.listen(depreciationReportProvider, (prev, next) {
|
||||
final error = next.valueOrNull?.actionError;
|
||||
if (error != null && error != prev?.valueOrNull?.actionError) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(error)));
|
||||
}
|
||||
});
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: reportAsync.when(
|
||||
loading: () =>
|
||||
const AppLoadingView(message: 'Loading depreciation report...'),
|
||||
error: (error, _) => ErrorView.fromFailure(
|
||||
error is Failure ? error : Failure.unknown(message: error.toString()),
|
||||
onRetry: () => ref.invalidate(depreciationReportProvider),
|
||||
),
|
||||
data: (state) {
|
||||
final notifier = ref.read(depreciationReportProvider.notifier);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
PageHeader(
|
||||
title: 'Depreciation Report',
|
||||
subtitle:
|
||||
'Purchase cost, accumulated depreciation, and book value',
|
||||
leading: IconButton(
|
||||
tooltip: 'Back to reports',
|
||||
onPressed: () => context.go(RouteConstants.reports),
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
),
|
||||
actions: [
|
||||
if (canExport)
|
||||
OutlinedButton.icon(
|
||||
onPressed: state.isExporting ? null : _export,
|
||||
icon: state.isExporting
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.download_outlined),
|
||||
label: Text(
|
||||
state.isExporting ? 'Exporting...' : 'Export CSV',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
_SummaryStrip(summary: state.summary, asOfDate: state.asOfDate),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: AppTableShell(
|
||||
toolbar: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return _FiltersBar(
|
||||
searchController: _searchController,
|
||||
filters: state.filters,
|
||||
query: state.query,
|
||||
wrapped: constraints.maxWidth < 1100,
|
||||
onSearch: notifier.setSearch,
|
||||
onPlantChanged: notifier.setPlantId,
|
||||
onCategoryChanged: notifier.setItemCategoryId,
|
||||
onSubcategoryChanged: notifier.setItemSubcategoryId,
|
||||
onDepartmentChanged: notifier.setDepartmentId,
|
||||
onStatusChanged: notifier.setStatus,
|
||||
onMethodChanged: notifier.setDepreciationMethod,
|
||||
onIsActiveChanged: notifier.setIsActive,
|
||||
onPickAsOfDate: () => _pickAsOfDate(state.query),
|
||||
onClearAsOfDate: () => notifier.setAsOfDate(null),
|
||||
onPickPurchaseRange: () =>
|
||||
_pickPurchaseDateRange(state.query),
|
||||
onClearPurchaseRange: () =>
|
||||
notifier.setPurchaseDateRange(null, null),
|
||||
onReset: () {
|
||||
_searchController.clear();
|
||||
notifier.resetFilters();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
footer: AppPagination(
|
||||
currentPage: state.query.page,
|
||||
totalPages: state.totalPages,
|
||||
totalItems: state.total,
|
||||
pageSize: state.query.limit,
|
||||
itemLabel: 'assets',
|
||||
onPageChanged: notifier.setPage,
|
||||
onPageSizeChanged: notifier.setPageSize,
|
||||
),
|
||||
child: RefreshIndicator(
|
||||
onRefresh: notifier.refresh,
|
||||
child: state.items.isEmpty
|
||||
? ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: const [
|
||||
SizedBox(
|
||||
height: 260,
|
||||
child: AppEmptyState(
|
||||
title: 'No depreciation data',
|
||||
description:
|
||||
'Try adjusting filters or the as-of date.',
|
||||
icon: Icons.trending_down_outlined,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: context.isMobile
|
||||
? _MobileList(items: state.items)
|
||||
: _ReportTable(items: state.items),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SummaryStrip extends StatelessWidget {
|
||||
const _SummaryStrip({required this.summary, this.asOfDate});
|
||||
|
||||
final DepreciationReportSummary summary;
|
||||
final DateTime? asOfDate;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final cards = [
|
||||
(
|
||||
label: 'Assets',
|
||||
value: summary.assetCount.toString(),
|
||||
icon: Icons.inventory_2_outlined,
|
||||
color: const Color(0xFF2563EB),
|
||||
),
|
||||
(
|
||||
label: 'Purchase Cost',
|
||||
value: CurrencyFormatter.format(summary.totalPurchaseCost),
|
||||
icon: Icons.payments_outlined,
|
||||
color: const Color(0xFF16A34A),
|
||||
),
|
||||
(
|
||||
label: 'Accumulated',
|
||||
value: CurrencyFormatter.format(summary.totalAccumulatedDepreciation),
|
||||
icon: Icons.trending_down_outlined,
|
||||
color: const Color(0xFFEA580C),
|
||||
),
|
||||
(
|
||||
label: 'Book Value',
|
||||
value: CurrencyFormatter.format(summary.totalBookValue),
|
||||
icon: Icons.account_balance_wallet_outlined,
|
||||
color: const Color(0xFF7C3AED),
|
||||
),
|
||||
(
|
||||
label: 'Annual',
|
||||
value: CurrencyFormatter.format(summary.totalAnnualDepreciation),
|
||||
icon: Icons.calendar_today_outlined,
|
||||
color: const Color(0xFF0891B2),
|
||||
),
|
||||
];
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (asOfDate != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
'As of ${DateFormatter.displayDate(asOfDate)}',
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isCompact = constraints.maxWidth < 900;
|
||||
final cardWidth = isCompact
|
||||
? constraints.maxWidth
|
||||
: (constraints.maxWidth - 48) / 5;
|
||||
|
||||
return Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: cards
|
||||
.map(
|
||||
(card) => SizedBox(
|
||||
width: cardWidth.clamp(160, constraints.maxWidth),
|
||||
child: RbacStatCard(
|
||||
label: card.label,
|
||||
value: card.value,
|
||||
icon: card.icon,
|
||||
color: card.color,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FiltersBar extends StatelessWidget {
|
||||
const _FiltersBar({
|
||||
required this.searchController,
|
||||
required this.filters,
|
||||
required this.query,
|
||||
required this.wrapped,
|
||||
required this.onSearch,
|
||||
required this.onPlantChanged,
|
||||
required this.onCategoryChanged,
|
||||
required this.onSubcategoryChanged,
|
||||
required this.onDepartmentChanged,
|
||||
required this.onStatusChanged,
|
||||
required this.onMethodChanged,
|
||||
required this.onIsActiveChanged,
|
||||
required this.onPickAsOfDate,
|
||||
required this.onClearAsOfDate,
|
||||
required this.onPickPurchaseRange,
|
||||
required this.onClearPurchaseRange,
|
||||
required this.onReset,
|
||||
});
|
||||
|
||||
final TextEditingController searchController;
|
||||
final DepreciationReportFilters filters;
|
||||
final DepreciationReportQuery query;
|
||||
final bool wrapped;
|
||||
final ValueChanged<String> onSearch;
|
||||
final ValueChanged<String?> onPlantChanged;
|
||||
final ValueChanged<String?> onCategoryChanged;
|
||||
final ValueChanged<String?> onSubcategoryChanged;
|
||||
final ValueChanged<String?> onDepartmentChanged;
|
||||
final ValueChanged<String?> onStatusChanged;
|
||||
final ValueChanged<String?> onMethodChanged;
|
||||
final ValueChanged<bool?> onIsActiveChanged;
|
||||
final VoidCallback onPickAsOfDate;
|
||||
final VoidCallback onClearAsOfDate;
|
||||
final VoidCallback onPickPurchaseRange;
|
||||
final VoidCallback onClearPurchaseRange;
|
||||
final VoidCallback onReset;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final subcategoryOptions = query.itemCategoryId == null
|
||||
? filters.subcategories
|
||||
: filters.subcategories
|
||||
.where((o) => o.parentId == query.itemCategoryId)
|
||||
.toList();
|
||||
|
||||
final searchField = TextField(
|
||||
controller: searchController,
|
||||
onChanged: onSearch,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Search',
|
||||
hintText: 'Search asset code or name...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
isDense: true,
|
||||
suffixIcon: searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
searchController.clear();
|
||||
onSearch('');
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
|
||||
Widget dropdown({
|
||||
required String label,
|
||||
required String? value,
|
||||
required List<ReportFilterOption> options,
|
||||
required ValueChanged<String?> onChanged,
|
||||
}) {
|
||||
return AppSearchableDropdown<String?>(
|
||||
label: label,
|
||||
value: value,
|
||||
searchHint: 'Search $label...',
|
||||
isDense: true,
|
||||
options: [
|
||||
const AppDropdownOption(value: null, label: 'All'),
|
||||
...options.map(
|
||||
(option) => AppDropdownOption(
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: onChanged,
|
||||
);
|
||||
}
|
||||
|
||||
final plant = dropdown(
|
||||
label: 'Plant',
|
||||
value: query.plantId,
|
||||
options: filters.plants,
|
||||
onChanged: onPlantChanged,
|
||||
);
|
||||
final category = dropdown(
|
||||
label: 'Category',
|
||||
value: query.itemCategoryId,
|
||||
options: filters.categories,
|
||||
onChanged: onCategoryChanged,
|
||||
);
|
||||
final subcategory = dropdown(
|
||||
label: 'Subcategory',
|
||||
value: query.itemSubcategoryId,
|
||||
options: subcategoryOptions,
|
||||
onChanged: onSubcategoryChanged,
|
||||
);
|
||||
final department = dropdown(
|
||||
label: 'Department',
|
||||
value: query.departmentId,
|
||||
options: filters.departments,
|
||||
onChanged: onDepartmentChanged,
|
||||
);
|
||||
final status = dropdown(
|
||||
label: 'Status',
|
||||
value: query.status,
|
||||
options: filters.statuses,
|
||||
onChanged: onStatusChanged,
|
||||
);
|
||||
final method = dropdown(
|
||||
label: 'Method',
|
||||
value: query.depreciationMethod,
|
||||
options: filters.depreciationMethods,
|
||||
onChanged: onMethodChanged,
|
||||
);
|
||||
|
||||
final active = AppSearchableDropdown<bool?>(
|
||||
label: 'Active',
|
||||
value: query.isActive,
|
||||
searchHint: 'Filter active...',
|
||||
isDense: true,
|
||||
options: const [
|
||||
AppDropdownOption(value: null, label: 'All'),
|
||||
AppDropdownOption(value: true, label: 'Active'),
|
||||
AppDropdownOption(value: false, label: 'Inactive'),
|
||||
],
|
||||
onChanged: onIsActiveChanged,
|
||||
);
|
||||
|
||||
final asOfEmpty = query.asOfDate == null;
|
||||
final asOfValue = asOfEmpty
|
||||
? ''
|
||||
: DateFormatter.displayDate(query.asOfDate);
|
||||
|
||||
final purchaseEmpty =
|
||||
query.purchaseDateFrom == null && query.purchaseDateTo == null;
|
||||
final purchaseValue = purchaseEmpty
|
||||
? ''
|
||||
: '${DateFormatter.displayDate(query.purchaseDateFrom)} – ${DateFormatter.displayDate(query.purchaseDateTo)}';
|
||||
|
||||
final asOfField = AppFilterDateField(
|
||||
label: 'As of date',
|
||||
value: asOfValue,
|
||||
placeholder: 'Select date',
|
||||
icon: Icons.calendar_today_outlined,
|
||||
isEmpty: asOfEmpty,
|
||||
onTap: onPickAsOfDate,
|
||||
onClear: asOfEmpty ? null : onClearAsOfDate,
|
||||
);
|
||||
|
||||
final purchaseField = AppFilterDateField(
|
||||
label: 'Purchase dates',
|
||||
value: purchaseValue,
|
||||
placeholder: 'Select range',
|
||||
icon: Icons.date_range_outlined,
|
||||
isEmpty: purchaseEmpty,
|
||||
onTap: onPickPurchaseRange,
|
||||
onClear: purchaseEmpty ? null : onClearPurchaseRange,
|
||||
);
|
||||
|
||||
final reset = TextButton(
|
||||
onPressed: query.hasActiveFilter ? onReset : null,
|
||||
child: const Text('Reset'),
|
||||
);
|
||||
|
||||
if (wrapped) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
searchField,
|
||||
const SizedBox(height: 12),
|
||||
plant,
|
||||
const SizedBox(height: 12),
|
||||
category,
|
||||
const SizedBox(height: 12),
|
||||
subcategory,
|
||||
const SizedBox(height: 12),
|
||||
department,
|
||||
const SizedBox(height: 12),
|
||||
status,
|
||||
const SizedBox(height: 12),
|
||||
method,
|
||||
const SizedBox(height: 12),
|
||||
active,
|
||||
const SizedBox(height: 12),
|
||||
asOfField,
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: purchaseField),
|
||||
reset,
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Consistent 4-column grid so every row lines up vertically.
|
||||
Widget row(List<Widget> cells) {
|
||||
assert(cells.length == 4);
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
for (var i = 0; i < cells.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 12),
|
||||
Expanded(child: cells[i]),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
row([searchField, plant, category, subcategory]),
|
||||
const SizedBox(height: 12),
|
||||
row([department, status, method, active]),
|
||||
const SizedBox(height: 12),
|
||||
row([
|
||||
asOfField,
|
||||
purchaseField,
|
||||
const SizedBox.shrink(),
|
||||
Align(alignment: Alignment.centerRight, child: reset),
|
||||
]),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReportTable extends StatelessWidget {
|
||||
const _ReportTable({required this.items});
|
||||
|
||||
final List<DepreciationReportRow> items;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppDataTable<DepreciationReportRow>(
|
||||
wrapInCard: false,
|
||||
rows: items,
|
||||
columns: [
|
||||
AppDataColumn(
|
||||
label: 'Asset Code',
|
||||
flex: 2,
|
||||
cellBuilder: (_, row) => AppTableCell.text(row.assetCode),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Asset Name',
|
||||
flex: 3,
|
||||
cellBuilder: (_, row) => AppTableCell.text(row.assetName),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Category',
|
||||
flex: 2,
|
||||
cellBuilder: (_, row) => AppTableCell.text(row.categoryName),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Plant',
|
||||
flex: 2,
|
||||
cellBuilder: (_, row) => AppTableCell.text(row.plantName),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Purchase Date',
|
||||
flex: 2,
|
||||
cellBuilder: (_, row) =>
|
||||
AppTableCell.text(DateFormatter.displayDate(row.purchaseDate)),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Purchase Cost',
|
||||
flex: 2,
|
||||
alignment: Alignment.centerRight,
|
||||
cellBuilder: (_, row) => AppTableCell.text(
|
||||
CurrencyFormatter.format(row.purchaseCost),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Method',
|
||||
flex: 2,
|
||||
cellBuilder: (_, row) => AppTableCell.text(row.depreciationMethod),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Rate %',
|
||||
flex: 1,
|
||||
alignment: Alignment.centerRight,
|
||||
cellBuilder: (_, row) => AppTableCell.text(
|
||||
row.depreciationRate?.toStringAsFixed(2),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Annual',
|
||||
flex: 2,
|
||||
alignment: Alignment.centerRight,
|
||||
cellBuilder: (_, row) => AppTableCell.text(
|
||||
CurrencyFormatter.format(row.annualDepreciation),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Accumulated',
|
||||
flex: 2,
|
||||
alignment: Alignment.centerRight,
|
||||
cellBuilder: (_, row) => AppTableCell.text(
|
||||
CurrencyFormatter.format(row.accumulatedDepreciation),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Book Value',
|
||||
flex: 2,
|
||||
alignment: Alignment.centerRight,
|
||||
cellBuilder: (_, row) => AppTableCell.text(
|
||||
CurrencyFormatter.format(row.bookValue),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MobileList extends StatelessWidget {
|
||||
const _MobileList({required this.items});
|
||||
|
||||
final List<DepreciationReportRow> items;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return ListView.separated(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final row = items[index];
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(
|
||||
color: theme.colorScheme.outline.withValues(alpha: 0.12),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
row.assetName ?? row.assetCode ?? 'Asset',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (row.assetCode != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
row.assetCode!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_metric(
|
||||
context,
|
||||
'Cost',
|
||||
CurrencyFormatter.format(row.purchaseCost),
|
||||
),
|
||||
_metric(
|
||||
context,
|
||||
'Accumulated',
|
||||
CurrencyFormatter.format(row.accumulatedDepreciation),
|
||||
),
|
||||
_metric(
|
||||
context,
|
||||
'Book Value',
|
||||
CurrencyFormatter.format(row.bookValue),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _metric(BuildContext context, String label, String value) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
Text(value, style: theme.textTheme.bodyMedium),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
178
lib/modules/reports/presentation/screens/reports_hub_screen.dart
Normal file
178
lib/modules/reports/presentation/screens/reports_hub_screen.dart
Normal file
@ -0,0 +1,178 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/constants/enums.dart';
|
||||
import '../../../../core/constants/route_constants.dart';
|
||||
import '../../../../shared/providers/permissions_provider.dart';
|
||||
import '../../../../shared/widgets/app_hover_effect.dart';
|
||||
import '../../domain/entities/report_definition.dart';
|
||||
|
||||
/// Reports landing — same hub flow as Master Data, different visual design.
|
||||
/// Uses full-width list rows (icon + title + description) instead of square tiles.
|
||||
class ReportsHubScreen extends ConsumerWidget {
|
||||
const ReportsHubScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final canView = ref.can('reports', PermissionAction.read);
|
||||
final categories = reportCategories;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Reports',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Browse asset and operational reports by category.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
if (!canView)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 48),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'You do not have permission to view reports.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
...categories.map((category) {
|
||||
final items = reportDefinitions
|
||||
.where((def) => def.category == category)
|
||||
.toList();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 28),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
category.toUpperCase(),
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.8,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
...items.map(
|
||||
(def) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: _ReportListCard(
|
||||
definition: def,
|
||||
onTap: () => context.push(
|
||||
RouteConstants.reportDetail(def.routeKey),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReportListCard extends StatelessWidget {
|
||||
const _ReportListCard({
|
||||
required this.definition,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final ReportDefinition definition;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final accent = definition.accentColor;
|
||||
|
||||
return AppHoverEffect(
|
||||
onTap: onTap,
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
width: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: accent,
|
||||
borderRadius: const BorderRadius.horizontal(
|
||||
left: Radius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(definition.icon, color: accent, size: 22),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
definition.title,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
definition.description,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,6 +1 @@
|
||||
import '../../../../shared/widgets/placeholder_screen.dart';
|
||||
|
||||
class ReportsScreen extends PlaceholderScreen {
|
||||
const ReportsScreen({super.key})
|
||||
: super(title: 'Reports', description: 'Asset and operational reports');
|
||||
}
|
||||
export 'reports_hub_screen.dart';
|
||||
|
||||
@ -82,6 +82,7 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
|
||||
final SettingsRepository _repository;
|
||||
final FaviconStore _faviconStore;
|
||||
final Future<void> Function(String? logoUrl, String? companyName) _syncAppLogo;
|
||||
Future<Failure?>? _companyProfileInFlight;
|
||||
|
||||
Future<void> _syncMainLogo(CompanyProfileSettings profile) async {
|
||||
final logo = resolveMediaUrl(profile.logoUrl);
|
||||
@ -98,13 +99,25 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
|
||||
await _syncMainLogo(state.companyProfile);
|
||||
}
|
||||
|
||||
Future<Failure?> refreshCompanyProfile() async {
|
||||
final result = await _repository.fetchCompanyProfile();
|
||||
if (result.failure == null && result.data != null) {
|
||||
state = state.copyWith(companyProfile: result.data!);
|
||||
await _syncMainLogo(result.data!);
|
||||
}
|
||||
return result.failure;
|
||||
Future<Failure?> refreshCompanyProfile() {
|
||||
final existing = _companyProfileInFlight;
|
||||
if (existing != null) return existing;
|
||||
|
||||
final future = () async {
|
||||
try {
|
||||
final result = await _repository.fetchCompanyProfile();
|
||||
if (result.failure == null && result.data != null) {
|
||||
state = state.copyWith(companyProfile: result.data!);
|
||||
await _syncMainLogo(result.data!);
|
||||
}
|
||||
return result.failure;
|
||||
} finally {
|
||||
_companyProfileInFlight = null;
|
||||
}
|
||||
}();
|
||||
|
||||
_companyProfileInFlight = future;
|
||||
return future;
|
||||
}
|
||||
|
||||
Future<Failure?> refreshEmailSettings() async {
|
||||
|
||||
@ -14,42 +14,45 @@ class SettingsScreen extends StatelessWidget {
|
||||
final visiblePhase1 = visiblePhase1SettingsSections;
|
||||
final visiblePhase2 = visiblePhase2SettingsSections;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: context.contentMaxWidth),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const PageHeader(
|
||||
title: 'Settings',
|
||||
subtitle:
|
||||
'Configure company, security, assets, and system behavior',
|
||||
),
|
||||
if (visiblePhase1.isNotEmpty)
|
||||
SettingsSectionGrid(
|
||||
sections: visiblePhase1,
|
||||
onSectionTap: (section) => context.go(section.route),
|
||||
),
|
||||
if (visiblePhase2.isNotEmpty) ...[
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Coming in Phase 2',
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SettingsSectionGrid(
|
||||
sections: visiblePhase2,
|
||||
enabled: false,
|
||||
badge: 'Phase 2',
|
||||
onSectionTap: (_) {},
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: context.contentMaxWidth),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const PageHeader(
|
||||
title: 'Settings',
|
||||
subtitle:
|
||||
'Configure company, security, assets, and system behavior',
|
||||
),
|
||||
if (visiblePhase1.isNotEmpty)
|
||||
SettingsSectionGrid(
|
||||
sections: visiblePhase1,
|
||||
onSectionTap: (section) => context.go(section.route),
|
||||
),
|
||||
if (visiblePhase2.isNotEmpty) ...[
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Coming in Phase 2',
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SettingsSectionGrid(
|
||||
sections: visiblePhase2,
|
||||
enabled: false,
|
||||
badge: 'Phase 2',
|
||||
onSectionTap: (_) {},
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -24,32 +24,37 @@ class SettingsPageLayout extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 900),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
tooltip: 'Back to Settings',
|
||||
onPressed: () => context.go('/settings'),
|
||||
),
|
||||
Expanded(
|
||||
child: PageHeader(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
actions: actions,
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Material(
|
||||
color: theme.colorScheme.surface,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 900),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
tooltip: 'Back to Settings',
|
||||
onPressed: () => context.go('/settings'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child,
|
||||
],
|
||||
Expanded(
|
||||
child: PageHeader(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
actions: actions,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -16,7 +16,9 @@ import '../../modules/auth/presentation/screens/verify_otp_screen.dart';
|
||||
import '../../modules/master_data/domain/entities/master_definition.dart';
|
||||
import '../../modules/master_data/presentation/screens/master_list_screen.dart';
|
||||
import '../../modules/master_data/presentation/screens/masters_hub_screen.dart';
|
||||
import '../../modules/reports/presentation/screens/reports_screen.dart';
|
||||
import '../../modules/reports/domain/entities/report_definition.dart';
|
||||
import '../../modules/reports/presentation/screens/depreciation_report_screen.dart';
|
||||
import '../../modules/reports/presentation/screens/reports_hub_screen.dart';
|
||||
import '../../modules/audit/presentation/screens/audit_logs_screen.dart';
|
||||
import '../../modules/branch/presentation/screens/branch_form_screen.dart';
|
||||
import '../../modules/branch/presentation/screens/branch_list_screen.dart';
|
||||
@ -310,7 +312,22 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
GoRoute(
|
||||
path: RouteConstants.reports,
|
||||
pageBuilder: (context, state) =>
|
||||
shellPage(state, const ReportsScreen()),
|
||||
shellPage(state, const ReportsHubScreen()),
|
||||
routes: [
|
||||
...reportDefinitions.map(
|
||||
(def) => GoRoute(
|
||||
path: def.routeKey,
|
||||
pageBuilder: (context, state) {
|
||||
// Map known report keys to screens; extend as reports grow.
|
||||
final Widget screen = switch (def.id) {
|
||||
'depreciation' => const DepreciationReportScreen(),
|
||||
_ => const ReportsHubScreen(),
|
||||
};
|
||||
return shellPage(state, screen);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: RouteConstants.auditLogs,
|
||||
@ -321,40 +338,47 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
path: RouteConstants.settings,
|
||||
pageBuilder: (context, state) =>
|
||||
shellPage(state, const SettingsScreen()),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'general',
|
||||
builder: (context, state) => const GeneralSettingsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'company-profile',
|
||||
builder: (context, state) => const CompanyProfileSettingsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'appearance',
|
||||
builder: (context, state) => const AppearanceSettingsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'roles',
|
||||
builder: (context, state) => const RolesPermissionsSettingsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'asset',
|
||||
builder: (context, state) => const AssetSettingsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'notifications',
|
||||
builder: (context, state) => const NotificationSettingsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'email',
|
||||
builder: (context, state) => const EmailConfigurationScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'security',
|
||||
builder: (context, state) => const SecuritySettingsScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Sibling routes (not nested) so Settings hub is not stacked underneath.
|
||||
GoRoute(
|
||||
path: RouteConstants.settingsGeneral,
|
||||
pageBuilder: (context, state) =>
|
||||
shellPage(state, const GeneralSettingsScreen()),
|
||||
),
|
||||
GoRoute(
|
||||
path: RouteConstants.settingsCompanyProfile,
|
||||
pageBuilder: (context, state) =>
|
||||
shellPage(state, const CompanyProfileSettingsScreen()),
|
||||
),
|
||||
GoRoute(
|
||||
path: RouteConstants.settingsAppearance,
|
||||
pageBuilder: (context, state) =>
|
||||
shellPage(state, const AppearanceSettingsScreen()),
|
||||
),
|
||||
GoRoute(
|
||||
path: RouteConstants.settingsRoles,
|
||||
pageBuilder: (context, state) =>
|
||||
shellPage(state, const RolesPermissionsSettingsScreen()),
|
||||
),
|
||||
GoRoute(
|
||||
path: RouteConstants.settingsAsset,
|
||||
pageBuilder: (context, state) =>
|
||||
shellPage(state, const AssetSettingsScreen()),
|
||||
),
|
||||
GoRoute(
|
||||
path: RouteConstants.settingsNotifications,
|
||||
pageBuilder: (context, state) =>
|
||||
shellPage(state, const NotificationSettingsScreen()),
|
||||
),
|
||||
GoRoute(
|
||||
path: RouteConstants.settingsEmail,
|
||||
pageBuilder: (context, state) =>
|
||||
shellPage(state, const EmailConfigurationScreen()),
|
||||
),
|
||||
GoRoute(
|
||||
path: RouteConstants.settingsSecurity,
|
||||
pageBuilder: (context, state) =>
|
||||
shellPage(state, const SecuritySettingsScreen()),
|
||||
),
|
||||
if (DevConfig.screenPreviewEnabled)
|
||||
GoRoute(
|
||||
|
||||
@ -116,7 +116,9 @@ List<MenuItem> getVisibleMenuItems({
|
||||
required List<String> permissions,
|
||||
required UserRole role,
|
||||
}) {
|
||||
if (isSuperAdmin(role) || permissions.contains('*')) {
|
||||
// Only '*' grants every menu. Do not unlock all items by role alone —
|
||||
// otherwise items like Reports appear without REPORTS:view.
|
||||
if (permissions.contains('*')) {
|
||||
return appMenuItems;
|
||||
}
|
||||
|
||||
|
||||
@ -89,30 +89,42 @@ class AppDataTable<T> extends StatelessWidget {
|
||||
return AppCard(clipBehavior: Clip.antiAlias, child: empty);
|
||||
}
|
||||
|
||||
final table = LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
shrinkWrap: shrinkWrap,
|
||||
physics: shrinkWrap ? const NeverScrollableScrollPhysics() : null,
|
||||
children: [
|
||||
_TableHeaderRow<T>(
|
||||
columns: columns,
|
||||
sortColumn: sortColumn,
|
||||
sortAscending: sortAscending,
|
||||
onSort: onSort,
|
||||
),
|
||||
...rows.map(
|
||||
(row) => _TableDataRow<T>(
|
||||
columns: columns,
|
||||
row: row,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
final header = _TableHeaderRow<T>(
|
||||
columns: columns,
|
||||
sortColumn: sortColumn,
|
||||
sortAscending: sortAscending,
|
||||
onSort: onSort,
|
||||
);
|
||||
|
||||
final body = ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
shrinkWrap: shrinkWrap,
|
||||
physics: shrinkWrap ? const NeverScrollableScrollPhysics() : null,
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (context, index) => _TableDataRow<T>(
|
||||
columns: columns,
|
||||
row: rows[index],
|
||||
),
|
||||
);
|
||||
|
||||
// Header stays fixed; only body rows scroll (when not shrink-wrapped).
|
||||
final table = shrinkWrap
|
||||
? Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
header,
|
||||
body,
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
header,
|
||||
Expanded(child: body),
|
||||
],
|
||||
);
|
||||
|
||||
if (!wrapInCard) return table;
|
||||
|
||||
return AppCard(
|
||||
@ -142,8 +154,19 @@ class _TableHeaderRow<T> extends StatelessWidget {
|
||||
return SizedBox(
|
||||
height: kAppTableRowHeight,
|
||||
width: double.infinity,
|
||||
child: ColoredBox(
|
||||
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
// Opaque so scrolling rows never show through the sticky header.
|
||||
color: Color.alphaBlend(
|
||||
theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.55),
|
||||
theme.colorScheme.surface,
|
||||
),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: theme.colorScheme.outline.withValues(alpha: 0.12),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
|
||||
285
lib/shared/widgets/app_date_popup.dart
Normal file
285
lib/shared/widgets/app_date_popup.dart
Normal file
@ -0,0 +1,285 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Compact dialog date picker (avoids the full-page Material picker on web).
|
||||
Future<DateTime?> showAppDatePopup({
|
||||
required BuildContext context,
|
||||
DateTime? initialDate,
|
||||
DateTime? firstDate,
|
||||
DateTime? lastDate,
|
||||
String helpText = 'Select date',
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
return showDialog<DateTime>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (context) => _AppDateDialog(
|
||||
helpText: helpText,
|
||||
initialDate: initialDate ?? now,
|
||||
firstDate: firstDate ?? DateTime(now.year - 30),
|
||||
lastDate: lastDate ?? DateTime(now.year + 10),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _AppDateDialog extends StatefulWidget {
|
||||
const _AppDateDialog({
|
||||
required this.helpText,
|
||||
required this.initialDate,
|
||||
required this.firstDate,
|
||||
required this.lastDate,
|
||||
});
|
||||
|
||||
final String helpText;
|
||||
final DateTime initialDate;
|
||||
final DateTime firstDate;
|
||||
final DateTime lastDate;
|
||||
|
||||
@override
|
||||
State<_AppDateDialog> createState() => _AppDateDialogState();
|
||||
}
|
||||
|
||||
class _AppDateDialogState extends State<_AppDateDialog> {
|
||||
late DateTime _selected;
|
||||
late DateTime _displayedMonth;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selected = DateTime(
|
||||
widget.initialDate.year,
|
||||
widget.initialDate.month,
|
||||
widget.initialDate.day,
|
||||
);
|
||||
_displayedMonth = DateTime(_selected.year, _selected.month);
|
||||
}
|
||||
|
||||
void _shiftMonth(int delta) {
|
||||
setState(() {
|
||||
_displayedMonth = DateTime(
|
||||
_displayedMonth.year,
|
||||
_displayedMonth.month + delta,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 360),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.helpText,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Close',
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_MonthHeader(
|
||||
month: _displayedMonth,
|
||||
onPrev: () => _shiftMonth(-1),
|
||||
onNext: () => _shiftMonth(1),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_CalendarGrid(
|
||||
month: _displayedMonth,
|
||||
firstDate: widget.firstDate,
|
||||
lastDate: widget.lastDate,
|
||||
selected: _selected,
|
||||
onSelected: (day) => setState(() => _selected = day),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(_selected),
|
||||
child: const Text('Apply'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MonthHeader extends StatelessWidget {
|
||||
const _MonthHeader({
|
||||
required this.month,
|
||||
required this.onPrev,
|
||||
required this.onNext,
|
||||
});
|
||||
|
||||
final DateTime month;
|
||||
final VoidCallback onPrev;
|
||||
final VoidCallback onNext;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
const months = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
];
|
||||
final title = '${months[month.month - 1]} ${month.year}';
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: onPrev,
|
||||
icon: const Icon(Icons.chevron_left),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: onNext,
|
||||
icon: const Icon(Icons.chevron_right),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CalendarGrid extends StatelessWidget {
|
||||
const _CalendarGrid({
|
||||
required this.month,
|
||||
required this.firstDate,
|
||||
required this.lastDate,
|
||||
required this.selected,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
final DateTime month;
|
||||
final DateTime firstDate;
|
||||
final DateTime lastDate;
|
||||
final DateTime selected;
|
||||
final ValueChanged<DateTime> onSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final firstOfMonth = DateTime(month.year, month.month);
|
||||
final daysInMonth = DateTime(month.year, month.month + 1, 0).day;
|
||||
final leading = firstOfMonth.weekday % 7;
|
||||
const weekdays = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
children: weekdays
|
||||
.map(
|
||||
(d) => Expanded(
|
||||
child: Center(
|
||||
child: Text(
|
||||
d,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: leading + daysInMonth,
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 7,
|
||||
mainAxisSpacing: 4,
|
||||
crossAxisSpacing: 4,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
if (index < leading) return const SizedBox.shrink();
|
||||
final day = index - leading + 1;
|
||||
final date = DateTime(month.year, month.month, day);
|
||||
final enabled = !date.isBefore(
|
||||
DateTime(firstDate.year, firstDate.month, firstDate.day),
|
||||
) &&
|
||||
!date.isAfter(
|
||||
DateTime(lastDate.year, lastDate.month, lastDate.day),
|
||||
);
|
||||
final isSelected = date.year == selected.year &&
|
||||
date.month == selected.month &&
|
||||
date.day == selected.day;
|
||||
|
||||
return Material(
|
||||
color: isSelected
|
||||
? theme.colorScheme.primary
|
||||
: Colors.transparent,
|
||||
shape: const CircleBorder(),
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: enabled ? () => onSelected(date) : null,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'$day',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: !enabled
|
||||
? theme.disabledColor
|
||||
: isSelected
|
||||
? theme.colorScheme.onPrimary
|
||||
: theme.colorScheme.onSurface,
|
||||
fontWeight:
|
||||
isSelected ? FontWeight.w700 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
343
lib/shared/widgets/app_date_range_popup.dart
Normal file
343
lib/shared/widgets/app_date_range_popup.dart
Normal file
@ -0,0 +1,343 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/utils/formatters.dart';
|
||||
|
||||
/// Compact dialog date-range picker (avoids the full-page Material picker on web).
|
||||
Future<DateTimeRange?> showAppDateRangePopup({
|
||||
required BuildContext context,
|
||||
DateTimeRange? initialDateRange,
|
||||
DateTime? firstDate,
|
||||
DateTime? lastDate,
|
||||
String helpText = 'Select date range',
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
return showDialog<DateTimeRange>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (context) => _AppDateRangeDialog(
|
||||
helpText: helpText,
|
||||
initialDateRange: initialDateRange,
|
||||
firstDate: firstDate ?? DateTime(now.year - 5),
|
||||
lastDate: lastDate ?? DateTime(now.year + 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _AppDateRangeDialog extends StatefulWidget {
|
||||
const _AppDateRangeDialog({
|
||||
required this.helpText,
|
||||
required this.firstDate,
|
||||
required this.lastDate,
|
||||
this.initialDateRange,
|
||||
});
|
||||
|
||||
final String helpText;
|
||||
final DateTime firstDate;
|
||||
final DateTime lastDate;
|
||||
final DateTimeRange? initialDateRange;
|
||||
|
||||
@override
|
||||
State<_AppDateRangeDialog> createState() => _AppDateRangeDialogState();
|
||||
}
|
||||
|
||||
class _AppDateRangeDialogState extends State<_AppDateRangeDialog> {
|
||||
DateTime? _start;
|
||||
DateTime? _end;
|
||||
late DateTime _displayedMonth;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_start = widget.initialDateRange?.start;
|
||||
_end = widget.initialDateRange?.end;
|
||||
_displayedMonth = DateTime(
|
||||
(_start ?? DateTime.now()).year,
|
||||
(_start ?? DateTime.now()).month,
|
||||
);
|
||||
}
|
||||
|
||||
void _onDaySelected(DateTime day) {
|
||||
final selected = DateTime(day.year, day.month, day.day);
|
||||
setState(() {
|
||||
if (_start == null || (_start != null && _end != null)) {
|
||||
_start = selected;
|
||||
_end = null;
|
||||
} else if (selected.isBefore(_start!)) {
|
||||
_end = _start;
|
||||
_start = selected;
|
||||
} else {
|
||||
_end = selected;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bool _isInRange(DateTime day) {
|
||||
if (_start == null || _end == null) return false;
|
||||
final d = DateTime(day.year, day.month, day.day);
|
||||
return !d.isBefore(_start!) && !d.isAfter(_end!);
|
||||
}
|
||||
|
||||
bool _isEndpoint(DateTime day) {
|
||||
final d = DateTime(day.year, day.month, day.day);
|
||||
return (_start != null && d == _start) || (_end != null && d == _end);
|
||||
}
|
||||
|
||||
void _shiftMonth(int delta) {
|
||||
setState(() {
|
||||
_displayedMonth = DateTime(
|
||||
_displayedMonth.year,
|
||||
_displayedMonth.month + delta,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final canApply = _start != null && _end != null;
|
||||
final rangeLabel = _start == null
|
||||
? 'Select start date'
|
||||
: _end == null
|
||||
? '${DateFormatter.displayDate(_start)} – Select end date'
|
||||
: '${DateFormatter.displayDate(_start)} – ${DateFormatter.displayDate(_end)}';
|
||||
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 360),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.helpText,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Close',
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
rangeLabel,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_MonthHeader(
|
||||
month: _displayedMonth,
|
||||
onPrev: () => _shiftMonth(-1),
|
||||
onNext: () => _shiftMonth(1),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_CalendarGrid(
|
||||
month: _displayedMonth,
|
||||
firstDate: widget.firstDate,
|
||||
lastDate: widget.lastDate,
|
||||
isInRange: _isInRange,
|
||||
isEndpoint: _isEndpoint,
|
||||
onSelected: _onDaySelected,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => setState(() {
|
||||
_start = null;
|
||||
_end = null;
|
||||
}),
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
onPressed: canApply
|
||||
? () => Navigator.of(context).pop(
|
||||
DateTimeRange(start: _start!, end: _end!),
|
||||
)
|
||||
: null,
|
||||
child: const Text('Apply'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MonthHeader extends StatelessWidget {
|
||||
const _MonthHeader({
|
||||
required this.month,
|
||||
required this.onPrev,
|
||||
required this.onNext,
|
||||
});
|
||||
|
||||
final DateTime month;
|
||||
final VoidCallback onPrev;
|
||||
final VoidCallback onNext;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
const months = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
];
|
||||
final title = '${months[month.month - 1]} ${month.year}';
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: onPrev,
|
||||
icon: const Icon(Icons.chevron_left),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: onNext,
|
||||
icon: const Icon(Icons.chevron_right),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CalendarGrid extends StatelessWidget {
|
||||
const _CalendarGrid({
|
||||
required this.month,
|
||||
required this.firstDate,
|
||||
required this.lastDate,
|
||||
required this.isInRange,
|
||||
required this.isEndpoint,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
final DateTime month;
|
||||
final DateTime firstDate;
|
||||
final DateTime lastDate;
|
||||
final bool Function(DateTime day) isInRange;
|
||||
final bool Function(DateTime day) isEndpoint;
|
||||
final ValueChanged<DateTime> onSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final firstOfMonth = DateTime(month.year, month.month);
|
||||
final daysInMonth = DateTime(month.year, month.month + 1, 0).day;
|
||||
// DateTime.weekday: Mon=1..Sun=7 → make Sunday-first grid
|
||||
final leading = firstOfMonth.weekday % 7;
|
||||
|
||||
final weekdays = const ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
children: weekdays
|
||||
.map(
|
||||
(d) => Expanded(
|
||||
child: Center(
|
||||
child: Text(
|
||||
d,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: leading + daysInMonth,
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 7,
|
||||
mainAxisSpacing: 4,
|
||||
crossAxisSpacing: 4,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
if (index < leading) return const SizedBox.shrink();
|
||||
final day = index - leading + 1;
|
||||
final date = DateTime(month.year, month.month, day);
|
||||
final enabled = !date.isBefore(
|
||||
DateTime(firstDate.year, firstDate.month, firstDate.day),
|
||||
) &&
|
||||
!date.isAfter(
|
||||
DateTime(lastDate.year, lastDate.month, lastDate.day),
|
||||
);
|
||||
final endpoint = isEndpoint(date);
|
||||
final inRange = isInRange(date);
|
||||
|
||||
return Material(
|
||||
color: endpoint
|
||||
? theme.colorScheme.primary
|
||||
: inRange
|
||||
? theme.colorScheme.primary.withValues(alpha: 0.12)
|
||||
: Colors.transparent,
|
||||
shape: const CircleBorder(),
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: enabled ? () => onSelected(date) : null,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'$day',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: !enabled
|
||||
? theme.disabledColor
|
||||
: endpoint
|
||||
? theme.colorScheme.onPrimary
|
||||
: theme.colorScheme.onSurface,
|
||||
fontWeight: endpoint ? FontWeight.w700 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
83
lib/shared/widgets/app_filter_date_field.dart
Normal file
83
lib/shared/widgets/app_filter_date_field.dart
Normal file
@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Outlined filter date / date-range field matching dropdown filter styling.
|
||||
///
|
||||
/// Height stays constant whether empty or filled (clear icon is always reserved).
|
||||
class AppFilterDateField extends StatelessWidget {
|
||||
const AppFilterDateField({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.onTap,
|
||||
this.icon = Icons.calendar_today_outlined,
|
||||
this.placeholder = 'Select date',
|
||||
this.onClear,
|
||||
this.isEmpty = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final VoidCallback onTap;
|
||||
final IconData icon;
|
||||
final String placeholder;
|
||||
final VoidCallback? onClear;
|
||||
final bool isEmpty;
|
||||
|
||||
static const _suffixConstraints = BoxConstraints(
|
||||
minWidth: 40,
|
||||
minHeight: 40,
|
||||
maxWidth: 40,
|
||||
maxHeight: 40,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final showClear = onClear != null && !isEmpty;
|
||||
|
||||
return InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.fromLTRB(12, 8, 4, 8),
|
||||
suffixIconConstraints: _suffixConstraints,
|
||||
// Always reserve trailing space so height matches empty ↔ filled
|
||||
// and lines up with dense searchable dropdowns.
|
||||
suffixIcon: showClear
|
||||
? IconButton(
|
||||
tooltip: 'Clear',
|
||||
onPressed: onClear,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: _suffixConstraints,
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(Icons.close, size: 18),
|
||||
)
|
||||
: const SizedBox(width: 40, height: 40),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: SizedBox(
|
||||
height: 24,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 16, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
isEmpty ? placeholder : value,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
height: 1.2,
|
||||
color: isEmpty ? theme.colorScheme.onSurfaceVariant : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -7,8 +7,8 @@ class AppHoverStyle {
|
||||
static const Duration duration = Duration(milliseconds: 180);
|
||||
static const Curve curve = Curves.easeOutCubic;
|
||||
static const double borderRadius = 12;
|
||||
static const double idleBorderWidth = 1;
|
||||
static const double hoverBorderWidth = 1.5;
|
||||
/// Fixed for idle and hover so decoration never changes widget size.
|
||||
static const double borderWidth = 1;
|
||||
|
||||
static Color idleBorderColor(ColorScheme scheme) =>
|
||||
scheme.outline.withValues(alpha: 0.18);
|
||||
@ -16,15 +16,23 @@ class AppHoverStyle {
|
||||
static Color hoverBorderColor(ColorScheme scheme) =>
|
||||
scheme.primary.withValues(alpha: 0.45);
|
||||
|
||||
static List<BoxShadow> hoverShadow(ColorScheme scheme) => [
|
||||
static List<BoxShadow> _shadow({required Color color}) => [
|
||||
BoxShadow(
|
||||
color: scheme.primary.withValues(alpha: 0.2),
|
||||
color: color,
|
||||
blurRadius: 14,
|
||||
spreadRadius: 0,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
];
|
||||
|
||||
static List<BoxShadow> hoverShadow(ColorScheme scheme) =>
|
||||
_shadow(color: scheme.primary.withValues(alpha: 0.2));
|
||||
|
||||
/// Same geometry as [hoverShadow] but invisible — keeps layout stable while
|
||||
/// [AnimatedContainer] interpolates shadow color on hover.
|
||||
static List<BoxShadow> idleShadow() =>
|
||||
_shadow(color: Colors.transparent);
|
||||
|
||||
static BoxDecoration decoration(
|
||||
ThemeData theme, {
|
||||
required bool hovered,
|
||||
@ -40,10 +48,12 @@ class AppHoverStyle {
|
||||
border: showBorder
|
||||
? Border.all(
|
||||
color: hovered ? hoverBorderColor(scheme) : idleBorderColor(scheme),
|
||||
width: hovered ? hoverBorderWidth : idleBorderWidth,
|
||||
width: borderWidth,
|
||||
)
|
||||
: null,
|
||||
boxShadow: hovered && showShadow ? hoverShadow(scheme) : null,
|
||||
boxShadow: showShadow
|
||||
? (hovered ? hoverShadow(scheme) : idleShadow())
|
||||
: const [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,7 +25,7 @@ const _sidebarChildIndent = 28.0;
|
||||
|
||||
/// Set to `true` to show the Light/Dark toggle in the sidebar again.
|
||||
/// Kept hidden for now — do not delete `_buildThemeToggle`.
|
||||
const showSidebarThemeToggle = false;
|
||||
const showSidebarThemeToggle = true;
|
||||
|
||||
class AppSidebar extends ConsumerStatefulWidget {
|
||||
const AppSidebar({
|
||||
@ -153,9 +153,7 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildHeader(context, isNarrow: isNarrow),
|
||||
// NOTE: Light/Dark theme toggle is temporarily hidden from the
|
||||
// sidebar. Do not remove `_buildThemeToggle` — restore by
|
||||
// setting [showSidebarThemeToggle] to true.
|
||||
// Light/Dark theme toggle. Hide with [showSidebarThemeToggle] = false.
|
||||
if (!isNarrow && showSidebarThemeToggle) ...[
|
||||
const SizedBox(height: 16),
|
||||
_buildThemeToggle(context, isLightActive),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user