diff --git a/lib/core/constants/api_endpoints.dart b/lib/core/constants/api_endpoints.dart index 92facc7..787a77e 100644 --- a/lib/core/constants/api_endpoints.dart +++ b/lib/core/constants/api_endpoints.dart @@ -73,6 +73,7 @@ class ApiEndpoints { // Vendors static const String vendors = '/vendors'; + static const String vendorsExport = '/vendors/export'; static const String vendorGstTreatments = '/vendors/gst-treatments'; static const String vendorSourceOfSupply = '/vendors/source-of-supply'; static String vendorById(String id) => '/vendors/$id'; @@ -93,6 +94,7 @@ class ApiEndpoints { // Purchase Orders static const String purchaseOrders = '/purchase-orders'; + static const String purchaseOrdersExport = '/purchase-orders/export'; static String purchaseOrderById(String id) => '/purchase-orders/$id'; static String purchaseOrderSubmit(String id) => '/purchase-orders/$id/submit'; static String purchaseOrderApprove(String id) => '/purchase-orders/$id/approve'; @@ -112,6 +114,7 @@ class ApiEndpoints { // GRN static const String grn = '/grn'; + static const String grnExport = '/grn/export'; static String grnById(String id) => '/grn/$id'; static String grnCancel(String id) => '/grn/$id/cancel'; static String grnPdf(String id) => '/grn/$id/pdf'; @@ -123,6 +126,7 @@ class ApiEndpoints { // Assets static const String assets = '/assets'; + static const String assetsExport = '/assets/export'; static String assetById(String id) => '/assets/$id'; static String assetTransfer(String id) => '/assets/$id/transfer'; static String assetTransferHistory(String id) => '/assets/$id/transfer-history'; diff --git a/lib/core/utils/export_file_name.dart b/lib/core/utils/export_file_name.dart new file mode 100644 index 0000000..eec2484 --- /dev/null +++ b/lib/core/utils/export_file_name.dart @@ -0,0 +1,31 @@ +import 'package:dio/dio.dart'; + +/// Parses `Content-Disposition` for CSV/Excel downloads with a module fallback. +String exportFileNameFromResponse( + Response> response, { + required String fallbackBase, +}) { + 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(); + } + } + + final contentType = + response.headers.value('content-type')?.toLowerCase() ?? ''; + if (contentType.contains('csv')) return '$fallbackBase.csv'; + if (contentType.contains('sheet') || contentType.contains('excel')) { + return '$fallbackBase.xlsx'; + } + return '$fallbackBase.csv'; +} diff --git a/lib/modules/assets/data/datasources/asset_remote_data_source.dart b/lib/modules/assets/data/datasources/asset_remote_data_source.dart index 34b726c..50e83d0 100644 --- a/lib/modules/assets/data/datasources/asset_remote_data_source.dart +++ b/lib/modules/assets/data/datasources/asset_remote_data_source.dart @@ -1,23 +1,21 @@ import 'package:dio/dio.dart'; import '../../../../core/constants/api_endpoints.dart'; +import '../../../../core/utils/export_file_name.dart'; import '../../../../shared/models/api_response.dart'; import '../../../../shared/models/asset_model.dart'; import '../../../../shared/models/entity_attachment_model.dart'; +import '../../../../shared/models/export_file_result.dart'; class AssetRemoteDataSource { AssetRemoteDataSource({required this.dio}); final Dio dio; - Future> getAssets(PaginationParams params) async { + Future> getAssets(AssetListQuery query) async { final response = await dio.get( ApiEndpoints.assets, - queryParameters: { - 'page': params.page, - 'limit': params.limit, - if (params.search != null && params.search!.isNotEmpty) 'search': params.search, - }, + queryParameters: _queryToMap(query), ); return _parsePaginated(response.data, AssetModel.fromJson); } @@ -41,6 +39,18 @@ class AssetRemoteDataSource { await dio.delete(ApiEndpoints.assetById(id)); } + Future exportAssets(AssetListQuery query) async { + final response = await dio.get>( + ApiEndpoints.assetsExport, + queryParameters: _exportQueryToMap(query), + options: Options(responseType: ResponseType.bytes), + ); + return ExportFileResult( + bytes: response.data ?? [], + fileName: exportFileNameFromResponse(response, fallbackBase: 'assets'), + ); + } + Future> listAttachments(String assetId) async { final response = await dio.get(ApiEndpoints.assetAttachments(assetId)); final data = response.data['data']; @@ -335,6 +345,29 @@ class AssetRemoteDataSource { ); } + Map _queryToMap(AssetListQuery query) { + return { + 'page': query.page, + 'limit': query.limit, + ..._exportQueryToMap(query), + }; + } + + Map _exportQueryToMap(AssetListQuery query) { + return { + if (query.search != null && query.search!.isNotEmpty) 'search': query.search, + if (query.status != null && query.status!.isNotEmpty) 'status': query.status, + if (query.condition != null && query.condition!.isNotEmpty) + 'condition': query.condition, + if (query.itemCategoryId != null) 'item_category_id': query.itemCategoryId, + if (query.itemSubcategoryId != null) + 'item_subcategory_id': query.itemSubcategoryId, + if (query.plantId != null) 'plant_id': query.plantId, + if (query.departmentId != null) 'department_id': query.departmentId, + if (query.isActive != null) 'is_active': query.isActive, + }; + } + Map _asStringMap(dynamic value) { if (value is Map) return value; if (value is Map) return Map.from(value); diff --git a/lib/modules/assets/data/repositories/asset_repository_impl.dart b/lib/modules/assets/data/repositories/asset_repository_impl.dart index bb881c6..30316cb 100644 --- a/lib/modules/assets/data/repositories/asset_repository_impl.dart +++ b/lib/modules/assets/data/repositories/asset_repository_impl.dart @@ -5,6 +5,7 @@ import '../../../../core/network/dio_client.dart'; import '../../../../shared/models/api_response.dart'; import '../../../../shared/models/asset_model.dart'; import '../../../../shared/models/entity_attachment_model.dart'; +import '../../../../shared/models/export_file_result.dart'; import '../../domain/repositories/asset_repository.dart'; import '../datasources/asset_remote_data_source.dart'; @@ -22,8 +23,13 @@ class AssetRepositoryImpl implements AssetRepository { final AssetRemoteDataSource dataSource; @override - Future>> getAssets(PaginationParams params) { - return safeApiCall(() => dataSource.getAssets(params)); + Future>> getAssets(AssetListQuery query) { + return safeApiCall(() => dataSource.getAssets(query)); + } + + @override + Future> exportAssets(AssetListQuery query) { + return safeApiCall(() => dataSource.exportAssets(query)); } @override diff --git a/lib/modules/assets/domain/repositories/asset_repository.dart b/lib/modules/assets/domain/repositories/asset_repository.dart index c167ba9..380cc05 100644 --- a/lib/modules/assets/domain/repositories/asset_repository.dart +++ b/lib/modules/assets/domain/repositories/asset_repository.dart @@ -2,9 +2,11 @@ import '../../../../core/network/api_handler.dart'; import '../../../../shared/models/api_response.dart'; import '../../../../shared/models/asset_model.dart'; import '../../../../shared/models/entity_attachment_model.dart'; +import '../../../../shared/models/export_file_result.dart'; abstract class AssetRepository { - Future>> getAssets(PaginationParams params); + Future>> getAssets(AssetListQuery query); + Future> exportAssets(AssetListQuery query); Future> getAssetById(String id); Future> createAsset(Map data); Future> updateAsset(String id, Map data); diff --git a/lib/modules/assets/presentation/providers/assets_provider.dart b/lib/modules/assets/presentation/providers/assets_provider.dart index 3c49eab..4e0c2c9 100644 --- a/lib/modules/assets/presentation/providers/assets_provider.dart +++ b/lib/modules/assets/presentation/providers/assets_provider.dart @@ -2,36 +2,39 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/utils/table_search.dart'; -import '../../../../shared/models/api_response.dart'; import '../../../../shared/models/asset_model.dart'; import '../../../../shared/models/entity_attachment_model.dart'; +import '../../../../shared/models/export_file_result.dart'; import '../../data/repositories/asset_repository_impl.dart'; class AssetsListState { const AssetsListState({ this.assets = const [], - this.query = const PaginationParams(limit: 20), + this.query = const AssetListQuery(limit: 20), this.total = 0, this.totalPages = 1, this.isRefreshing = false, + this.isExporting = false, this.actionError, this.actionSuccess, }); final List assets; - final PaginationParams query; + final AssetListQuery query; final int total; final int totalPages; final bool isRefreshing; + final bool isExporting; final String? actionError; final String? actionSuccess; AssetsListState copyWith({ List? assets, - PaginationParams? query, + AssetListQuery? query, int? total, int? totalPages, bool? isRefreshing, + bool? isExporting, String? actionError, String? actionSuccess, bool clearMessages = false, @@ -42,6 +45,7 @@ class AssetsListState { total: total ?? this.total, totalPages: totalPages ?? this.totalPages, isRefreshing: isRefreshing ?? this.isRefreshing, + isExporting: isExporting ?? this.isExporting, actionError: clearMessages ? null : actionError ?? this.actionError, actionSuccess: clearMessages ? null : actionSuccess ?? this.actionSuccess, ); @@ -56,10 +60,10 @@ final assetsListProvider = class AssetsListNotifier extends AutoDisposeAsyncNotifier { @override Future build() async { - return _load(const PaginationParams(limit: 20)); + return _load(const AssetListQuery(limit: 20)); } - Future _load(PaginationParams query) async { + Future _load(AssetListQuery query) async { final repository = ref.read(assetRepositoryProvider); final result = await repository.getAssets(query); if (result.failure != null) throw result.failure!; @@ -82,7 +86,7 @@ class AssetsListNotifier extends AutoDisposeAsyncNotifier { } } - Future applyQuery(PaginationParams query) async { + Future applyQuery(AssetListQuery query) async { final previous = state.valueOrNull; if (previous == null) { state = const AsyncLoading(); @@ -97,7 +101,27 @@ class AssetsListNotifier extends AutoDisposeAsyncNotifier { void setSearch(String search) { final current = state.valueOrNull; if (current == null) return; - applyQuery(current.query.copyWith(search: TableSearch.normalize(search), page: 1)); + applyQuery( + current.query.copyWith(search: TableSearch.normalize(search), page: 1), + ); + } + + void setStatusFilter(String? status) { + final current = state.valueOrNull; + if (current == null) return; + applyQuery(current.query.copyWith(status: status, page: 1)); + } + + void setCategoryFilter(int? itemCategoryId) { + final current = state.valueOrNull; + if (current == null) return; + applyQuery(current.query.copyWith(itemCategoryId: itemCategoryId, page: 1)); + } + + void setPlantFilter(int? plantId) { + final current = state.valueOrNull; + if (current == null) return; + applyQuery(current.query.copyWith(plantId: plantId, page: 1)); } void setPage(int page) { @@ -112,6 +136,30 @@ class AssetsListNotifier extends AutoDisposeAsyncNotifier { applyQuery(current.query.copyWith(limit: limit, page: 1)); } + Future exportAssets() async { + final current = state.valueOrNull; + if (current == null) return null; + + state = AsyncData(current.copyWith(isExporting: true, clearMessages: true)); + + final result = + await ref.read(assetRepositoryProvider).exportAssets(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)); + return result.data; + } + Future deleteAsset(String id) async { final repository = ref.read(assetRepositoryProvider); final result = await repository.deleteAsset(id); diff --git a/lib/modules/assets/presentation/screens/asset_list_screen.dart b/lib/modules/assets/presentation/screens/asset_list_screen.dart index ad48efc..232e244 100644 --- a/lib/modules/assets/presentation/screens/asset_list_screen.dart +++ b/lib/modules/assets/presentation/screens/asset_list_screen.dart @@ -8,6 +8,7 @@ import '../../../../core/errors/failure.dart'; import '../../../../core/utils/formatters.dart'; import '../../../../core/utils/responsive_utils.dart'; import '../../../../shared/models/asset_model.dart'; +import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/providers/permissions_provider.dart'; import '../../../../shared/widgets/app_card.dart'; import '../../../../shared/widgets/app_confirmation_dialog.dart'; @@ -22,6 +23,7 @@ import '../../../../shared/widgets/app_table_action_icon.dart'; import '../../../../shared/widgets/can_permission.dart'; import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/page_header.dart'; +import '../../../../shared/utils/file_download_helper.dart'; import '../providers/asset_categories_provider.dart'; import '../providers/asset_form_lookups_provider.dart'; import '../providers/assets_provider.dart'; @@ -36,15 +38,12 @@ class AssetListScreen extends ConsumerStatefulWidget { } class _AssetListScreenState extends ConsumerState { - String? _selectedCategory; - String? _selectedPlant; - String? _selectedStatus; - @override Widget build(BuildContext context) { final assetsAsync = ref.watch(assetsListProvider); final canEdit = ref.can('assets', PermissionAction.update); final canDelete = ref.can('assets', PermissionAction.delete); + final canExport = ref.can('assets', PermissionAction.export); ref.listen(assetsListProvider, (prev, next) { final error = next.valueOrNull?.actionError; @@ -66,26 +65,11 @@ class _AssetListScreenState extends ConsumerState { onRetry: () => ref.invalidate(assetsListProvider), ), data: (state) { - final allCategories = ref.watch(itemCategoriesProvider).valueOrNull ?? []; - final filteredAssets = _filterAssets(state.assets); - final categories = _categoryOptions(state.assets, allCategories); - final plants = _plantOptions(state.assets); - final statuses = _statusOptions( - ref.watch(assetFormLookupsProvider).valueOrNull?.statuses ?? const [], - ); - final categoryFilter = _selectedCategory ?? 'All Categories'; - final plantFilter = _selectedPlant ?? 'All Plants'; - final statusFilter = _selectedStatus ?? 'All Statuses'; - final statusLabels = { - for (final option - in (ref.watch(assetFormLookupsProvider).valueOrNull?.statuses ?? - const [])) - option.value: option.label, - 'All Statuses': 'All Statuses', - }; - final page = state.query.page; - final pageSize = state.query.limit; - final total = state.total; + final allCategories = + ref.watch(itemCategoriesProvider).valueOrNull ?? []; + final lookups = ref.watch(assetFormLookupsProvider).valueOrNull; + final allPlants = lookups?.plants ?? const []; + final notifier = ref.read(assetsListProvider.notifier); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -94,6 +78,19 @@ class _AssetListScreenState extends ConsumerState { title: 'Asset Master', subtitle: 'Manage plant assets, AMC, service & insurance', actions: [ + if (canExport) + OutlinedButton.icon( + onPressed: state.isExporting ? null : _exportAssets, + icon: state.isExporting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.download_outlined), + label: Text(state.isExporting ? 'Exporting...' : 'Export'), + ), + if (canExport) const SizedBox(width: 8), OutlinedButton.icon( onPressed: () => context.go(RouteConstants.assetAlerts), icon: const Icon(Icons.notifications_outlined), @@ -113,10 +110,10 @@ class _AssetListScreenState extends ConsumerState { ), Expanded( child: RefreshIndicator( - onRefresh: () => ref.read(assetsListProvider.notifier).refresh(), + onRefresh: () => notifier.refresh(), child: context.isMobile ? _AssetMobileList( - assets: filteredAssets, + assets: state.assets, onView: _viewAsset, onEdit: canEdit ? _editAsset : null, onDelete: canDelete ? _deleteAsset : null, @@ -142,39 +139,21 @@ class _AssetListScreenState extends ConsumerState { builder: (context, constraints) { return _AssetsFilterBar( wrapped: constraints.maxWidth < 1000, - categoryFilter: categoryFilter, - plantFilter: plantFilter, - statusFilter: statusFilter, - categories: categories, - plants: plants, - statuses: statuses, - statusLabels: statusLabels, - onSearch: - ref.read(assetsListProvider.notifier).setSearch, - onCategoryChanged: (value) { - setState(() { - _selectedCategory = - value == 'All Categories' ? null : value; - }); - }, - onPlantChanged: (value) { - setState(() { - _selectedPlant = - value == 'All Plants' ? null : value; - }); - }, - onStatusChanged: (value) { - setState(() { - _selectedStatus = - value == 'All Statuses' ? null : value; - }); - }, + query: state.query, + categories: allCategories, + plants: allPlants, + statuses: lookups?.statuses ?? const [], + onSearch: notifier.setSearch, + onCategoryChanged: + notifier.setCategoryFilter, + onPlantChanged: notifier.setPlantFilter, + onStatusChanged: notifier.setStatusFilter, ); }, ), ), const Divider(height: 1), - if (filteredAssets.isEmpty) + if (state.assets.isEmpty) Expanded( child: Center( child: Text( @@ -193,7 +172,7 @@ class _AssetListScreenState extends ConsumerState { else Expanded( child: _AssetDataTable( - assets: filteredAssets, + assets: state.assets, canEdit: canEdit, canDelete: canDelete, onView: _viewAsset, @@ -205,17 +184,13 @@ class _AssetListScreenState extends ConsumerState { Padding( padding: const EdgeInsets.all(16), child: AppPagination( - currentPage: page, + currentPage: state.query.page, totalPages: state.totalPages, - totalItems: total, - pageSize: pageSize, + totalItems: state.total, + pageSize: state.query.limit, itemLabel: 'assets', - onPageChanged: ref - .read(assetsListProvider.notifier) - .setPage, - onPageSizeChanged: ref - .read(assetsListProvider.notifier) - .setPageSize, + onPageChanged: notifier.setPage, + onPageSizeChanged: notifier.setPageSize, ), ), ], @@ -230,58 +205,6 @@ class _AssetListScreenState extends ConsumerState { ); } - List _filterAssets(List assets) { - return assets.where((asset) { - if (_selectedCategory != null && - asset.assetCategoryName != _selectedCategory) { - return false; - } - if (_selectedPlant != null && asset.plantName != _selectedPlant) { - return false; - } - if (_selectedStatus != null) { - final status = asset.status?.trim().toUpperCase().replaceAll(' ', '_'); - if (status != _selectedStatus) return false; - } - return true; - }).toList(); - } - - List _categoryOptions( - List assets, - List allCategories, - ) { - final names = {}; - for (final asset in assets) { - final name = asset.assetCategoryName; - if (name != null && name.isNotEmpty) names.add(name); - } - for (final category in allCategories) { - names.add(category.name); - } - final sorted = names.toList()..sort(); - return ['All Categories', ...sorted]; - } - - List _plantOptions(List assets) { - final names = assets - .map((a) => a.plantName) - .whereType() - .where((n) => n.isNotEmpty) - .toSet() - .toList() - ..sort(); - return ['All Plants', ...names]; - } - - List _statusOptions(List apiStatuses) { - final labels = apiStatuses - .map((option) => option.value) - .where((value) => value.trim().isNotEmpty) - .toList(); - return ['All Statuses', ...labels]; - } - void _viewAsset(AssetModel asset) { context.push('${RouteConstants.assets}/${asset.id}'); } @@ -290,6 +213,34 @@ class _AssetListScreenState extends ConsumerState { openAssetFormPanel(context, ref, assetId: asset.id); } + Future _exportAssets() async { + final file = await ref.read(assetsListProvider.notifier).exportAssets(); + if (!mounted) return; + + if (file == null) { + final error = ref.read(assetsListProvider).valueOrNull?.actionError; + if (error != null) { + showAppToastFromSnackBar(context, SnackBar(content: Text(error))); + } + return; + } + + final saved = await downloadFile( + bytes: file.bytes, + fileName: file.fileName, + ); + if (!mounted) return; + + showAppToastFromSnackBar( + context, + SnackBar( + content: Text( + saved ? 'Downloaded ${file.fileName}' : 'Export cancelled', + ), + ), + ); + } + Future _deleteAsset(AssetModel asset) async { final confirmed = await showAppConfirmationDialog( context: context, @@ -325,13 +276,10 @@ class _AssetListScreenState extends ConsumerState { class _AssetsFilterBar extends StatelessWidget { const _AssetsFilterBar({ required this.wrapped, - required this.categoryFilter, - required this.plantFilter, - required this.statusFilter, + required this.query, required this.categories, required this.plants, required this.statuses, - required this.statusLabels, required this.onSearch, required this.onCategoryChanged, required this.onPlantChanged, @@ -339,17 +287,14 @@ class _AssetsFilterBar extends StatelessWidget { }); final bool wrapped; - final String categoryFilter; - final String plantFilter; - final String statusFilter; - final List categories; - final List plants; - final List statuses; - final Map statusLabels; + final AssetListQuery query; + final List categories; + final List plants; + final List statuses; final ValueChanged onSearch; - final ValueChanged onCategoryChanged; - final ValueChanged onPlantChanged; - final ValueChanged onStatusChanged; + final ValueChanged onCategoryChanged; + final ValueChanged onPlantChanged; + final ValueChanged onStatusChanged; @override Widget build(BuildContext context) { @@ -367,24 +312,61 @@ class _AssetsFilterBar extends StatelessWidget { ), ); + final categoryOptions = >[ + const AppDropdownOption(value: null, label: 'All Categories'), + for (final category in categories) + if (int.tryParse(category.id) != null) + AppDropdownOption( + value: int.parse(category.id), + label: category.name, + ), + ]; + + final plantOptions = >[ + const AppDropdownOption(value: null, label: 'All Plants'), + for (final plant in plants) + if (int.tryParse(plant.id) != null) + AppDropdownOption( + value: int.parse(plant.id), + label: plant.name, + ), + ]; + + final statusOptions = >[ + const AppDropdownOption(value: null, label: 'All Statuses'), + for (final status in statuses) + if (status.value.trim().isNotEmpty) + AppDropdownOption( + value: status.value, + label: status.label.isNotEmpty + ? status.label + : status.value.replaceAll('_', ' '), + ), + ]; + final filters = [ - _AssetFilterDropdown( - value: categoryFilter, + AppSearchableDropdown( label: 'Category', - items: categories, + value: query.itemCategoryId, + searchHint: 'Search category...', + isDense: true, + options: categoryOptions, onChanged: onCategoryChanged, ), - _AssetFilterDropdown( - value: plantFilter, + AppSearchableDropdown( label: 'Plant', - items: plants, + value: query.plantId, + searchHint: 'Search plant...', + isDense: true, + options: plantOptions, onChanged: onPlantChanged, ), - _AssetFilterDropdown( - value: statusFilter, + AppSearchableDropdown( label: 'Status', - items: statuses, - itemLabels: statusLabels, + value: query.status, + searchHint: 'Search status...', + isDense: true, + options: statusOptions, onChanged: onStatusChanged, ), ]; @@ -416,43 +398,6 @@ class _AssetsFilterBar extends StatelessWidget { } } -class _AssetFilterDropdown extends StatelessWidget { - const _AssetFilterDropdown({ - required this.value, - required this.label, - required this.items, - required this.onChanged, - this.itemLabels = const {}, - }); - - final String value; - final String label; - final List items; - final Map itemLabels; - final ValueChanged onChanged; - - @override - Widget build(BuildContext context) { - return AppSearchableDropdown( - label: label, - value: value, - isDense: true, - searchHint: 'Search $label...', - options: items - .map( - (item) => AppDropdownOption( - value: item, - label: itemLabels[item] ?? item.replaceAll('_', ' '), - ), - ) - .toList(), - onChanged: (v) { - if (v != null) onChanged(v); - }, - ); - } -} - class _AssetDataTable extends StatelessWidget { const _AssetDataTable({ required this.assets, diff --git a/lib/modules/grn/data/datasources/grn_remote_data_source.dart b/lib/modules/grn/data/datasources/grn_remote_data_source.dart index 9aa1abb..58bc8fc 100644 --- a/lib/modules/grn/data/datasources/grn_remote_data_source.dart +++ b/lib/modules/grn/data/datasources/grn_remote_data_source.dart @@ -1,7 +1,9 @@ import 'package:dio/dio.dart'; import '../../../../core/constants/api_endpoints.dart'; +import '../../../../core/utils/export_file_name.dart'; import '../../../../shared/models/api_response.dart'; +import '../../../../shared/models/export_file_result.dart'; import '../../../../shared/models/grn_model.dart'; class GrnRemoteDataSource { @@ -48,6 +50,18 @@ class GrnRemoteDataSource { return response.data ?? []; } + Future exportGrns(GrnListQuery query) async { + final response = await dio.get>( + ApiEndpoints.grnExport, + queryParameters: _exportQueryToMap(query), + options: Options(responseType: ResponseType.bytes), + ); + return ExportFileResult( + bytes: response.data ?? [], + fileName: exportFileNameFromResponse(response, fallbackBase: 'grn'), + ); + } + Future> listAttachments(String grnId) async { final response = await dio.get(ApiEndpoints.grnAttachments(grnId)); final data = response.data['data']; @@ -106,6 +120,12 @@ class GrnRemoteDataSource { return { 'page': query.page, 'limit': query.limit, + ..._exportQueryToMap(query), + }; + } + + Map _exportQueryToMap(GrnListQuery query) { + return { if (query.search != null && query.search!.isNotEmpty) 'search': query.search, if (query.status != null) 'status': query.status, if (query.poId != null) 'po_id': query.poId, diff --git a/lib/modules/grn/data/repositories/grn_repository_impl.dart b/lib/modules/grn/data/repositories/grn_repository_impl.dart index 483c6d3..710aea0 100644 --- a/lib/modules/grn/data/repositories/grn_repository_impl.dart +++ b/lib/modules/grn/data/repositories/grn_repository_impl.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/network/api_handler.dart'; import '../../../../core/network/dio_client.dart'; import '../../../../shared/models/api_response.dart'; +import '../../../../shared/models/export_file_result.dart'; import '../../../../shared/models/grn_model.dart'; import '../../domain/repositories/grn_repository.dart'; import '../datasources/grn_remote_data_source.dart'; @@ -25,6 +26,11 @@ class GrnRepositoryImpl implements GrnRepository { return safeApiCall(() => dataSource.getGrns(query)); } + @override + Future> exportGrns(GrnListQuery query) { + return safeApiCall(() => dataSource.exportGrns(query)); + } + @override Future> getGrnById(String id) { return safeApiCall(() => dataSource.getGrnById(id)); diff --git a/lib/modules/grn/domain/repositories/grn_repository.dart b/lib/modules/grn/domain/repositories/grn_repository.dart index 16cc484..d9a6241 100644 --- a/lib/modules/grn/domain/repositories/grn_repository.dart +++ b/lib/modules/grn/domain/repositories/grn_repository.dart @@ -1,9 +1,11 @@ import '../../../../core/network/api_handler.dart'; import '../../../../shared/models/api_response.dart'; +import '../../../../shared/models/export_file_result.dart'; import '../../../../shared/models/grn_model.dart'; abstract class GrnRepository { Future>> getGrns(GrnListQuery query); + Future> exportGrns(GrnListQuery query); Future> getGrnById(String id); Future> createGrn(Map data); Future> updateGrn(String id, Map data); diff --git a/lib/modules/grn/presentation/providers/grn_provider.dart b/lib/modules/grn/presentation/providers/grn_provider.dart index 82bcc27..7d210ac 100644 --- a/lib/modules/grn/presentation/providers/grn_provider.dart +++ b/lib/modules/grn/presentation/providers/grn_provider.dart @@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/utils/table_search.dart'; +import '../../../../shared/models/export_file_result.dart'; import '../../../../shared/models/grn_model.dart'; import '../../data/repositories/grn_repository_impl.dart'; @@ -12,6 +13,7 @@ class GrnListState { this.total = 0, this.totalPages = 1, this.isRefreshing = false, + this.isExporting = false, this.actionError, this.actionSuccess, }); @@ -21,6 +23,7 @@ class GrnListState { final int total; final int totalPages; final bool isRefreshing; + final bool isExporting; final String? actionError; final String? actionSuccess; @@ -30,6 +33,7 @@ class GrnListState { int? total, int? totalPages, bool? isRefreshing, + bool? isExporting, String? actionError, String? actionSuccess, bool clearMessages = false, @@ -40,6 +44,7 @@ class GrnListState { total: total ?? this.total, totalPages: totalPages ?? this.totalPages, isRefreshing: isRefreshing ?? this.isRefreshing, + isExporting: isExporting ?? this.isExporting, actionError: clearMessages ? null : actionError ?? this.actionError, actionSuccess: clearMessages ? null : actionSuccess ?? this.actionSuccess, ); @@ -115,6 +120,30 @@ class GrnListNotifier extends AutoDisposeAsyncNotifier { if (current == null) return; applyQuery(current.query.copyWith(limit: limit, page: 1)); } + + Future exportGrns() async { + final current = state.valueOrNull; + if (current == null) return null; + + state = AsyncData(current.copyWith(isExporting: true, clearMessages: true)); + + final result = + await ref.read(grnRepositoryProvider).exportGrns(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)); + return result.data; + } } final grnDetailProvider = diff --git a/lib/modules/grn/presentation/screens/grn_list_screen.dart b/lib/modules/grn/presentation/screens/grn_list_screen.dart index e098c0a..3258e82 100644 --- a/lib/modules/grn/presentation/screens/grn_list_screen.dart +++ b/lib/modules/grn/presentation/screens/grn_list_screen.dart @@ -21,6 +21,7 @@ import '../../../../shared/widgets/app_table_action_icon.dart'; import '../../../../shared/widgets/app_table_shell.dart'; import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/page_header.dart'; +import '../../../../shared/utils/file_download_helper.dart'; import '../providers/grn_provider.dart'; import '../widgets/grn_status_chip.dart'; import '../../../../shared/widgets/app_toast.dart'; @@ -45,6 +46,14 @@ class _GrnListScreenState extends ConsumerState { Widget build(BuildContext context) { final listAsync = ref.watch(grnListProvider); final canEdit = ref.can('grn', PermissionAction.update); + final canExport = ref.can('grn', PermissionAction.export); + + ref.listen(grnListProvider, (prev, next) { + final error = next.valueOrNull?.actionError; + if (error != null && error != prev?.valueOrNull?.actionError) { + showAppToastFromSnackBar(context, SnackBar(content: Text(error))); + } + }); return Padding( padding: const EdgeInsets.all(24), @@ -80,6 +89,9 @@ class _GrnListScreenState extends ConsumerState { searchController: _searchController, query: state.query, wrapped: constraints.maxWidth < 900, + showExport: canExport, + isExporting: state.isExporting, + onExport: _exportGrns, onSearch: ref.read(grnListProvider.notifier).setSearch, onStatusChanged: ref.read(grnListProvider.notifier).setStatusFilter, @@ -132,6 +144,34 @@ class _GrnListScreenState extends ConsumerState { ); } + Future _exportGrns() async { + final file = await ref.read(grnListProvider.notifier).exportGrns(); + if (!mounted) return; + + if (file == null) { + final error = ref.read(grnListProvider).valueOrNull?.actionError; + if (error != null) { + showAppToastFromSnackBar(context, SnackBar(content: Text(error))); + } + return; + } + + final saved = await downloadFile( + bytes: file.bytes, + fileName: file.fileName, + ); + if (!mounted) return; + + showAppToastFromSnackBar( + context, + SnackBar( + content: Text( + saved ? 'Downloaded ${file.fileName}' : 'Export cancelled', + ), + ), + ); + } + void _viewGrn(GrnModel grn) { context.push('${RouteConstants.grn}/${grn.id}'); } @@ -154,6 +194,9 @@ class _FiltersBar extends StatelessWidget { required this.wrapped, required this.onSearch, required this.onStatusChanged, + this.showExport = false, + this.isExporting = false, + this.onExport, }); final TextEditingController searchController; @@ -161,6 +204,9 @@ class _FiltersBar extends StatelessWidget { final bool wrapped; final ValueChanged onSearch; final ValueChanged onStatusChanged; + final bool showExport; + final bool isExporting; + final VoidCallback? onExport; @override Widget build(BuildContext context) { @@ -199,6 +245,18 @@ class _FiltersBar extends StatelessWidget { ), ); + final exportButton = OutlinedButton.icon( + onPressed: isExporting ? null : onExport, + icon: isExporting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.download_outlined, size: 18), + label: Text(isExporting ? 'Exporting...' : 'Export'), + ); + if (wrapped) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -206,6 +264,10 @@ class _FiltersBar extends StatelessWidget { searchField, const SizedBox(height: 12), statusFilter, + if (showExport) ...[ + const SizedBox(height: 12), + Align(alignment: Alignment.centerRight, child: exportButton), + ], ], ); } @@ -216,6 +278,10 @@ class _FiltersBar extends StatelessWidget { Expanded(flex: 3, child: searchField), const SizedBox(width: 12), Expanded(flex: 2, child: statusFilter), + if (showExport) ...[ + const SizedBox(width: 12), + exportButton, + ], ], ); } diff --git a/lib/modules/purchase_orders/data/datasources/purchase_order_remote_data_source.dart b/lib/modules/purchase_orders/data/datasources/purchase_order_remote_data_source.dart index 727930f..052a711 100644 --- a/lib/modules/purchase_orders/data/datasources/purchase_order_remote_data_source.dart +++ b/lib/modules/purchase_orders/data/datasources/purchase_order_remote_data_source.dart @@ -1,8 +1,10 @@ import 'package:dio/dio.dart'; import '../../../../core/constants/api_endpoints.dart'; +import '../../../../core/utils/export_file_name.dart'; import '../../../../shared/models/api_response.dart'; import '../../../../shared/models/entity_attachment_model.dart'; +import '../../../../shared/models/export_file_result.dart'; import '../../../../shared/models/purchase_order_model.dart'; class PurchaseOrderRemoteDataSource { @@ -128,6 +130,23 @@ class PurchaseOrderRemoteDataSource { return response.data ?? []; } + Future exportPurchaseOrders( + PurchaseOrderListQuery query, + ) async { + final response = await dio.get>( + ApiEndpoints.purchaseOrdersExport, + queryParameters: _exportQueryToMap(query), + options: Options(responseType: ResponseType.bytes), + ); + return ExportFileResult( + bytes: response.data ?? [], + fileName: exportFileNameFromResponse( + response, + fallbackBase: 'purchase_orders', + ), + ); + } + Future> listAttachments(String poId) async { final response = await dio.get(ApiEndpoints.purchaseOrderAttachments(poId)); @@ -177,6 +196,12 @@ class PurchaseOrderRemoteDataSource { return { 'page': query.page, 'limit': query.limit, + ..._exportQueryToMap(query), + }; + } + + Map _exportQueryToMap(PurchaseOrderListQuery query) { + return { if (query.search != null && query.search!.isNotEmpty) 'search': query.search, if (query.status != null) 'status': query.status, if (query.poType != null) 'po_type': query.poType, diff --git a/lib/modules/purchase_orders/data/repositories/purchase_order_repository_impl.dart b/lib/modules/purchase_orders/data/repositories/purchase_order_repository_impl.dart index c852195..b93b9a0 100644 --- a/lib/modules/purchase_orders/data/repositories/purchase_order_repository_impl.dart +++ b/lib/modules/purchase_orders/data/repositories/purchase_order_repository_impl.dart @@ -4,6 +4,7 @@ import '../../../../core/network/api_handler.dart'; import '../../../../core/network/dio_client.dart'; import '../../../../shared/models/api_response.dart'; import '../../../../shared/models/entity_attachment_model.dart'; +import '../../../../shared/models/export_file_result.dart'; import '../../../../shared/models/purchase_order_model.dart'; import '../../domain/repositories/purchase_order_repository.dart'; import '../datasources/purchase_order_remote_data_source.dart'; @@ -31,6 +32,13 @@ class PurchaseOrderRepositoryImpl implements PurchaseOrderRepository { return safeApiCall(() => dataSource.getPurchaseOrders(query)); } + @override + Future> exportPurchaseOrders( + PurchaseOrderListQuery query, + ) { + return safeApiCall(() => dataSource.exportPurchaseOrders(query)); + } + @override Future> getPurchaseOrderById(String id) { return safeApiCall(() => dataSource.getPurchaseOrderById(id)); diff --git a/lib/modules/purchase_orders/domain/repositories/purchase_order_repository.dart b/lib/modules/purchase_orders/domain/repositories/purchase_order_repository.dart index 78c01e0..7e535a4 100644 --- a/lib/modules/purchase_orders/domain/repositories/purchase_order_repository.dart +++ b/lib/modules/purchase_orders/domain/repositories/purchase_order_repository.dart @@ -1,12 +1,16 @@ import '../../../../core/network/api_handler.dart'; import '../../../../shared/models/api_response.dart'; import '../../../../shared/models/entity_attachment_model.dart'; +import '../../../../shared/models/export_file_result.dart'; import '../../../../shared/models/purchase_order_model.dart'; abstract class PurchaseOrderRepository { Future>> getPurchaseOrders( PurchaseOrderListQuery query, ); + Future> exportPurchaseOrders( + PurchaseOrderListQuery query, + ); Future> getPurchaseOrderById(String id); Future> createPurchaseOrder(Map data); Future> updatePurchaseOrder( diff --git a/lib/modules/purchase_orders/presentation/providers/purchase_orders_provider.dart b/lib/modules/purchase_orders/presentation/providers/purchase_orders_provider.dart index b66721a..60b274a 100644 --- a/lib/modules/purchase_orders/presentation/providers/purchase_orders_provider.dart +++ b/lib/modules/purchase_orders/presentation/providers/purchase_orders_provider.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/utils/table_search.dart'; import '../../../../shared/models/entity_attachment_model.dart'; +import '../../../../shared/models/export_file_result.dart'; import '../../../../shared/models/purchase_order_model.dart'; import '../../../grn/presentation/providers/grn_lookups_provider.dart'; import '../../data/repositories/purchase_order_repository_impl.dart'; @@ -14,6 +15,7 @@ class PurchaseOrdersListState { this.total = 0, this.totalPages = 1, this.isRefreshing = false, + this.isExporting = false, this.actionError, this.actionSuccess, }); @@ -23,6 +25,7 @@ class PurchaseOrdersListState { final int total; final int totalPages; final bool isRefreshing; + final bool isExporting; final String? actionError; final String? actionSuccess; @@ -32,6 +35,7 @@ class PurchaseOrdersListState { int? total, int? totalPages, bool? isRefreshing, + bool? isExporting, String? actionError, String? actionSuccess, bool clearMessages = false, @@ -42,6 +46,7 @@ class PurchaseOrdersListState { total: total ?? this.total, totalPages: totalPages ?? this.totalPages, isRefreshing: isRefreshing ?? this.isRefreshing, + isExporting: isExporting ?? this.isExporting, actionError: clearMessages ? null : actionError ?? this.actionError, actionSuccess: clearMessages ? null : actionSuccess ?? this.actionSuccess, ); @@ -125,6 +130,31 @@ class PurchaseOrdersListNotifier applyQuery(current.query.copyWith(limit: limit, page: 1)); } + Future exportPurchaseOrders() async { + final current = state.valueOrNull; + if (current == null) return null; + + state = AsyncData(current.copyWith(isExporting: true, clearMessages: true)); + + final result = await ref + .read(purchaseOrderRepositoryProvider) + .exportPurchaseOrders(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)); + return result.data; + } + Future deletePurchaseOrder(String id) async { final repository = ref.read(purchaseOrderRepositoryProvider); final result = await repository.deletePurchaseOrder(id); diff --git a/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart b/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart index 8c449b8..5121981 100644 --- a/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart +++ b/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart @@ -23,6 +23,7 @@ import '../../../../shared/widgets/app_table_action_icon.dart'; import '../../../../shared/widgets/app_table_shell.dart'; import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/page_header.dart'; +import '../../../../shared/utils/file_download_helper.dart'; import '../providers/purchase_orders_provider.dart'; import '../widgets/po_status_chip.dart'; import '../../../../shared/widgets/app_toast.dart'; @@ -59,6 +60,7 @@ class _PurchaseOrderListScreenState extends ConsumerState _exportPurchaseOrders() async { + final file = + await ref.read(purchaseOrdersListProvider.notifier).exportPurchaseOrders(); + if (!mounted) return; + + if (file == null) { + final error = + ref.read(purchaseOrdersListProvider).valueOrNull?.actionError; + if (error != null) { + showAppToastFromSnackBar(context, SnackBar(content: Text(error))); + } + return; + } + + final saved = await downloadFile( + bytes: file.bytes, + fileName: file.fileName, + ); + if (!mounted) return; + + showAppToastFromSnackBar( + context, + SnackBar( + content: Text( + saved ? 'Downloaded ${file.fileName}' : 'Export cancelled', + ), + ), + ); + } + Future _deleteOrder(PurchaseOrderModel order) async { if (!order.canDelete) { showAppToastFromSnackBar(context, @@ -244,6 +279,9 @@ class _FiltersBar extends StatelessWidget { required this.onSearch, required this.onStatusChanged, required this.onPoTypeChanged, + this.showExport = false, + this.isExporting = false, + this.onExport, }); final TextEditingController searchController; @@ -253,6 +291,9 @@ class _FiltersBar extends StatelessWidget { final ValueChanged onSearch; final ValueChanged onStatusChanged; final ValueChanged onPoTypeChanged; + final bool showExport; + final bool isExporting; + final VoidCallback? onExport; @override Widget build(BuildContext context) { @@ -286,6 +327,18 @@ class _FiltersBar extends StatelessWidget { ), ]; + final exportButton = OutlinedButton.icon( + onPressed: isExporting ? null : onExport, + icon: isExporting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.download_outlined, size: 18), + label: Text(isExporting ? 'Exporting...' : 'Export'), + ); + if (wrapped) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -295,6 +348,10 @@ class _FiltersBar extends StatelessWidget { filters[0], const SizedBox(height: 12), filters[1], + if (showExport) ...[ + const SizedBox(height: 12), + Align(alignment: Alignment.centerRight, child: exportButton), + ], ], ); } @@ -307,6 +364,10 @@ class _FiltersBar extends StatelessWidget { Expanded(flex: 2, child: filters[0]), const SizedBox(width: 12), Expanded(flex: 2, child: filters[1]), + if (showExport) ...[ + const SizedBox(width: 12), + exportButton, + ], ], ); } diff --git a/lib/modules/settings/presentation/screens/company_profile_settings_screen.dart b/lib/modules/settings/presentation/screens/company_profile_settings_screen.dart index 87113c3..f450cfe 100644 --- a/lib/modules/settings/presentation/screens/company_profile_settings_screen.dart +++ b/lib/modules/settings/presentation/screens/company_profile_settings_screen.dart @@ -313,28 +313,30 @@ class _CompanyProfileSettingsScreenState SettingsFormCard( title: 'Company Information', children: [ - AppTextField( - controller: _nameController, - label: 'Company Name', - validator: (v) => - Validators.required(v, fieldName: 'Company Name'), + SidePanelFormRow( + left: AppTextField( + controller: _nameController, + label: 'Company Name', + validator: (v) => + Validators.required(v, fieldName: 'Company Name'), + ), + right: AppTextField( + controller: _codeController, + label: 'Company Code', + ), ), const SizedBox(height: 16), - AppTextField( - controller: _codeController, - label: 'Company Code', - ), - const SizedBox(height: 16), - AppTextField( - controller: _registrationController, - label: 'Registration Number', - ), - const SizedBox(height: 16), - AppTextField( - controller: _gstController, - label: 'GSTIN', - validator: Validators.optionalGstin, - inputFormatters: Validators.gstinInput, + SidePanelFormRow( + left: AppTextField( + controller: _registrationController, + label: 'Registration Number', + ), + right: AppTextField( + controller: _gstController, + label: 'GSTIN', + validator: Validators.optionalGstin, + inputFormatters: Validators.gstinInput, + ), ), const SizedBox(height: 16), AppTextField( @@ -354,31 +356,33 @@ class _CompanyProfileSettingsScreenState ), ), const SizedBox(height: 16), - AppTextField( - controller: _pincodeController, - label: 'Pincode', - keyboardType: TextInputType.number, + SidePanelFormRow( + left: AppTextField( + controller: _pincodeController, + label: 'Pincode', + keyboardType: TextInputType.number, + ), + right: AppTextField( + controller: _emailController, + label: 'Email', + keyboardType: TextInputType.emailAddress, + validator: Validators.optionalEmail, + ), ), const SizedBox(height: 16), - AppTextField( - controller: _emailController, - label: 'Email', - keyboardType: TextInputType.emailAddress, - validator: Validators.optionalEmail, - ), - const SizedBox(height: 16), - AppTextField( - controller: _phoneController, - label: 'Mobile', - keyboardType: TextInputType.phone, - validator: Validators.optionalMobile, - inputFormatters: Validators.mobileInput, - ), - const SizedBox(height: 16), - AppTextField( - controller: _websiteController, - label: 'Website', - keyboardType: TextInputType.url, + SidePanelFormRow( + left: AppTextField( + controller: _phoneController, + label: 'Mobile', + keyboardType: TextInputType.phone, + validator: Validators.optionalMobile, + inputFormatters: Validators.mobileInput, + ), + right: AppTextField( + controller: _websiteController, + label: 'Website', + keyboardType: TextInputType.url, + ), ), ], ), diff --git a/lib/modules/vendors/data/datasources/vendor_remote_data_source.dart b/lib/modules/vendors/data/datasources/vendor_remote_data_source.dart index 3fa3af0..7aa689a 100644 --- a/lib/modules/vendors/data/datasources/vendor_remote_data_source.dart +++ b/lib/modules/vendors/data/datasources/vendor_remote_data_source.dart @@ -1,7 +1,9 @@ import 'package:dio/dio.dart'; import '../../../../core/constants/api_endpoints.dart'; +import '../../../../core/utils/export_file_name.dart'; import '../../../../shared/models/api_response.dart'; +import '../../../../shared/models/export_file_result.dart'; import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/models/vendor_model.dart'; @@ -37,6 +39,18 @@ class VendorRemoteDataSource { await dio.delete(ApiEndpoints.vendorById(id)); } + Future exportVendors(VendorListQuery query) async { + final response = await dio.get>( + ApiEndpoints.vendorsExport, + queryParameters: _exportQueryToMap(query), + options: Options(responseType: ResponseType.bytes), + ); + return ExportFileResult( + bytes: response.data ?? [], + fileName: exportFileNameFromResponse(response, fallbackBase: 'vendors'), + ); + } + Future updateVendorStatus(String id, String status) async { final response = await dio.patch( ApiEndpoints.vendorStatus(id), @@ -190,6 +204,12 @@ class VendorRemoteDataSource { return { 'page': query.page, 'limit': query.limit, + ..._exportQueryToMap(query), + }; + } + + Map _exportQueryToMap(VendorListQuery query) { + return { if (query.search != null && query.search!.isNotEmpty) 'search': query.search, if (query.status != null) 'status': query.status, if (query.vendorType != null) 'vendor_type': query.vendorType, diff --git a/lib/modules/vendors/data/repositories/vendor_repository_impl.dart b/lib/modules/vendors/data/repositories/vendor_repository_impl.dart index 0ec5055..bf71507 100644 --- a/lib/modules/vendors/data/repositories/vendor_repository_impl.dart +++ b/lib/modules/vendors/data/repositories/vendor_repository_impl.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/network/api_handler.dart'; import '../../../../core/network/dio_client.dart'; import '../../../../shared/models/api_response.dart'; +import '../../../../shared/models/export_file_result.dart'; import '../../../../shared/models/vendor_model.dart'; import '../../domain/repositories/vendor_repository.dart'; import '../datasources/vendor_remote_data_source.dart'; @@ -25,6 +26,11 @@ class VendorRepositoryImpl implements VendorRepository { return safeApiCall(() => dataSource.getVendors(query)); } + @override + Future> exportVendors(VendorListQuery query) { + return safeApiCall(() => dataSource.exportVendors(query)); + } + @override Future> getVendorById(String id) { return safeApiCall(() => dataSource.getVendorById(id)); diff --git a/lib/modules/vendors/domain/repositories/vendor_repository.dart b/lib/modules/vendors/domain/repositories/vendor_repository.dart index 00cccff..179d1cb 100644 --- a/lib/modules/vendors/domain/repositories/vendor_repository.dart +++ b/lib/modules/vendors/domain/repositories/vendor_repository.dart @@ -1,9 +1,11 @@ import '../../../../core/network/api_handler.dart'; import '../../../../shared/models/api_response.dart'; +import '../../../../shared/models/export_file_result.dart'; import '../../../../shared/models/vendor_model.dart'; abstract class VendorRepository { Future>> getVendors(VendorListQuery query); + Future> exportVendors(VendorListQuery query); Future> getVendorById(String id); Future> createVendor(Map data); Future> updateVendor(String id, Map data); diff --git a/lib/modules/vendors/presentation/providers/vendors_provider.dart b/lib/modules/vendors/presentation/providers/vendors_provider.dart index 317c9f6..5fdaf5c 100644 --- a/lib/modules/vendors/presentation/providers/vendors_provider.dart +++ b/lib/modules/vendors/presentation/providers/vendors_provider.dart @@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/utils/table_search.dart'; +import '../../../../shared/models/export_file_result.dart'; import '../../../../shared/models/vendor_model.dart'; import '../../data/repositories/vendor_repository_impl.dart'; @@ -12,6 +13,7 @@ class VendorsListState { this.total = 0, this.totalPages = 1, this.isRefreshing = false, + this.isExporting = false, this.actionError, this.actionSuccess, }); @@ -21,6 +23,7 @@ class VendorsListState { final int total; final int totalPages; final bool isRefreshing; + final bool isExporting; final String? actionError; final String? actionSuccess; @@ -30,6 +33,7 @@ class VendorsListState { int? total, int? totalPages, bool? isRefreshing, + bool? isExporting, String? actionError, String? actionSuccess, bool clearMessages = false, @@ -40,6 +44,7 @@ class VendorsListState { total: total ?? this.total, totalPages: totalPages ?? this.totalPages, isRefreshing: isRefreshing ?? this.isRefreshing, + isExporting: isExporting ?? this.isExporting, actionError: clearMessages ? null : actionError ?? this.actionError, actionSuccess: clearMessages ? null : actionSuccess ?? this.actionSuccess, ); @@ -122,6 +127,30 @@ class VendorsListNotifier extends AutoDisposeAsyncNotifier { applyQuery(current.query.copyWith(limit: limit, page: 1)); } + Future exportVendors() async { + final current = state.valueOrNull; + if (current == null) return null; + + state = AsyncData(current.copyWith(isExporting: true, clearMessages: true)); + + final result = + await ref.read(vendorRepositoryProvider).exportVendors(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)); + return result.data; + } + Future deleteVendor(String id) async { final repository = ref.read(vendorRepositoryProvider); final result = await repository.deleteVendor(id); diff --git a/lib/modules/vendors/presentation/screens/vendor_list_screen.dart b/lib/modules/vendors/presentation/screens/vendor_list_screen.dart index 345db28..270d722 100644 --- a/lib/modules/vendors/presentation/screens/vendor_list_screen.dart +++ b/lib/modules/vendors/presentation/screens/vendor_list_screen.dart @@ -23,6 +23,7 @@ import '../../../../shared/widgets/can_permission.dart'; import '../../../../shared/widgets/app_table_shell.dart'; import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/page_header.dart'; +import '../../../../shared/utils/file_download_helper.dart'; import '../providers/vendors_provider.dart'; import '../widgets/vendor_form_panel.dart'; import '../../../../shared/widgets/app_toast.dart'; @@ -48,6 +49,7 @@ class _VendorListScreenState extends ConsumerState { final vendorsAsync = ref.watch(vendorsListProvider); final canEdit = ref.can('vendors', PermissionAction.update); final canDelete = ref.can('vendors', PermissionAction.delete); + final canExport = ref.can('vendors', PermissionAction.export); ref.listen(vendorsListProvider, (prev, next) { final error = next.valueOrNull?.actionError; @@ -94,6 +96,9 @@ class _VendorListScreenState extends ConsumerState { searchController: _searchController, query: state.query, wrapped: constraints.maxWidth < 900, + showExport: canExport, + isExporting: state.isExporting, + onExport: _exportVendors, onSearch: ref.read(vendorsListProvider.notifier).setSearch, onStatusChanged: ref.read(vendorsListProvider.notifier).setStatusFilter, @@ -159,6 +164,34 @@ class _VendorListScreenState extends ConsumerState { openVendorFormPanel(context, ref, vendorId: vendor.id); } + Future _exportVendors() async { + final file = await ref.read(vendorsListProvider.notifier).exportVendors(); + if (!mounted) return; + + if (file == null) { + final error = ref.read(vendorsListProvider).valueOrNull?.actionError; + if (error != null) { + showAppToastFromSnackBar(context, SnackBar(content: Text(error))); + } + return; + } + + final saved = await downloadFile( + bytes: file.bytes, + fileName: file.fileName, + ); + if (!mounted) return; + + showAppToastFromSnackBar( + context, + SnackBar( + content: Text( + saved ? 'Downloaded ${file.fileName}' : 'Export cancelled', + ), + ), + ); + } + Future _deleteVendor(VendorModel vendor) async { final confirmed = await showAppConfirmationDialog( context: context, @@ -180,6 +213,9 @@ class _FiltersBar extends StatelessWidget { required this.onSearch, required this.onStatusChanged, required this.onVendorTypeChanged, + this.showExport = false, + this.isExporting = false, + this.onExport, }); final TextEditingController searchController; @@ -188,6 +224,9 @@ class _FiltersBar extends StatelessWidget { final ValueChanged onSearch; final ValueChanged onStatusChanged; final ValueChanged onVendorTypeChanged; + final bool showExport; + final bool isExporting; + final VoidCallback? onExport; @override Widget build(BuildContext context) { @@ -226,6 +265,18 @@ class _FiltersBar extends StatelessWidget { ), ]; + final exportButton = OutlinedButton.icon( + onPressed: isExporting ? null : onExport, + icon: isExporting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.download_outlined, size: 18), + label: Text(isExporting ? 'Exporting...' : 'Export'), + ); + if (wrapped) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -235,6 +286,10 @@ class _FiltersBar extends StatelessWidget { filters[0], const SizedBox(height: 12), filters[1], + if (showExport) ...[ + const SizedBox(height: 12), + Align(alignment: Alignment.centerRight, child: exportButton), + ], ], ); } @@ -247,6 +302,10 @@ class _FiltersBar extends StatelessWidget { Expanded(flex: 2, child: filters[0]), const SizedBox(width: 12), Expanded(flex: 2, child: filters[1]), + if (showExport) ...[ + const SizedBox(width: 12), + exportButton, + ], ], ); } diff --git a/lib/shared/models/asset_model.dart b/lib/shared/models/asset_model.dart index 2c6a2ec..6333635 100644 --- a/lib/shared/models/asset_model.dart +++ b/lib/shared/models/asset_model.dart @@ -545,3 +545,66 @@ class AssetDepreciationPreviewModel { ); } } + +/// List/export filters for assets (matches GET /assets and /assets/export). +class AssetListQuery { + const AssetListQuery({ + this.page = 1, + this.limit = 20, + this.search, + this.status, + this.condition, + this.itemCategoryId, + this.itemSubcategoryId, + this.plantId, + this.departmentId, + this.isActive, + }); + + final int page; + final int limit; + final String? search; + final String? status; + final String? condition; + final int? itemCategoryId; + final int? itemSubcategoryId; + final int? plantId; + final int? departmentId; + final bool? isActive; + + AssetListQuery copyWith({ + int? page, + int? limit, + Object? search = _unset, + Object? status = _unset, + Object? condition = _unset, + Object? itemCategoryId = _unset, + Object? itemSubcategoryId = _unset, + Object? plantId = _unset, + Object? departmentId = _unset, + Object? isActive = _unset, + }) { + return AssetListQuery( + page: page ?? this.page, + limit: limit ?? this.limit, + search: identical(search, _unset) ? this.search : search as String?, + status: identical(status, _unset) ? this.status : status as String?, + condition: + identical(condition, _unset) ? this.condition : condition as String?, + itemCategoryId: identical(itemCategoryId, _unset) + ? this.itemCategoryId + : itemCategoryId as int?, + itemSubcategoryId: identical(itemSubcategoryId, _unset) + ? this.itemSubcategoryId + : itemSubcategoryId as int?, + plantId: identical(plantId, _unset) ? this.plantId : plantId as int?, + departmentId: identical(departmentId, _unset) + ? this.departmentId + : departmentId as int?, + isActive: + identical(isActive, _unset) ? this.isActive : isActive as bool?, + ); + } +} + +const _unset = Object();