table alignment

This commit is contained in:
Surendiran 2026-07-15 17:12:21 +05:30
parent c462374125
commit 5a28cfc0da
24 changed files with 740 additions and 237 deletions

View File

@ -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';

View File

@ -0,0 +1,31 @@
import 'package:dio/dio.dart';
/// Parses `Content-Disposition` for CSV/Excel downloads with a module fallback.
String exportFileNameFromResponse(
Response<List<int>> 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';
}

View File

@ -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<PaginatedResponse<AssetModel>> getAssets(PaginationParams params) async {
Future<PaginatedResponse<AssetModel>> 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<ExportFileResult> exportAssets(AssetListQuery query) async {
final response = await dio.get<List<int>>(
ApiEndpoints.assetsExport,
queryParameters: _exportQueryToMap(query),
options: Options(responseType: ResponseType.bytes),
);
return ExportFileResult(
bytes: response.data ?? <int>[],
fileName: exportFileNameFromResponse(response, fallbackBase: 'assets'),
);
}
Future<List<EntityAttachmentModel>> listAttachments(String assetId) async {
final response = await dio.get(ApiEndpoints.assetAttachments(assetId));
final data = response.data['data'];
@ -335,6 +345,29 @@ class AssetRemoteDataSource {
);
}
Map<String, dynamic> _queryToMap(AssetListQuery query) {
return {
'page': query.page,
'limit': query.limit,
..._exportQueryToMap(query),
};
}
Map<String, dynamic> _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<String, dynamic> _asStringMap(dynamic value) {
if (value is Map<String, dynamic>) return value;
if (value is Map) return Map<String, dynamic>.from(value);

View File

@ -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<Result<PaginatedResponse<AssetModel>>> getAssets(PaginationParams params) {
return safeApiCall(() => dataSource.getAssets(params));
Future<Result<PaginatedResponse<AssetModel>>> getAssets(AssetListQuery query) {
return safeApiCall(() => dataSource.getAssets(query));
}
@override
Future<Result<ExportFileResult>> exportAssets(AssetListQuery query) {
return safeApiCall(() => dataSource.exportAssets(query));
}
@override

View File

@ -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<Result<PaginatedResponse<AssetModel>>> getAssets(PaginationParams params);
Future<Result<PaginatedResponse<AssetModel>>> getAssets(AssetListQuery query);
Future<Result<ExportFileResult>> exportAssets(AssetListQuery query);
Future<Result<AssetModel>> getAssetById(String id);
Future<Result<AssetModel>> createAsset(Map<String, dynamic> data);
Future<Result<AssetModel>> updateAsset(String id, Map<String, dynamic> data);

View File

@ -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<AssetModel> 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<AssetModel>? 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<AssetsListState> {
@override
Future<AssetsListState> build() async {
return _load(const PaginationParams(limit: 20));
return _load(const AssetListQuery(limit: 20));
}
Future<AssetsListState> _load(PaginationParams query) async {
Future<AssetsListState> _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<AssetsListState> {
}
}
Future<void> applyQuery(PaginationParams query) async {
Future<void> applyQuery(AssetListQuery query) async {
final previous = state.valueOrNull;
if (previous == null) {
state = const AsyncLoading();
@ -97,7 +101,27 @@ class AssetsListNotifier extends AutoDisposeAsyncNotifier<AssetsListState> {
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<AssetsListState> {
applyQuery(current.query.copyWith(limit: limit, page: 1));
}
Future<ExportFileResult?> 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<bool> deleteAsset(String id) async {
final repository = ref.read(assetRepositoryProvider);
final result = await repository.deleteAsset(id);

View File

@ -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<AssetListScreen> {
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<AssetListScreen> {
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 <AssetDropdownOption>[]))
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<AssetListScreen> {
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<AssetListScreen> {
),
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<AssetListScreen> {
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<AssetListScreen> {
else
Expanded(
child: _AssetDataTable(
assets: filteredAssets,
assets: state.assets,
canEdit: canEdit,
canDelete: canDelete,
onView: _viewAsset,
@ -205,17 +184,13 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
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<AssetListScreen> {
);
}
List<AssetModel> _filterAssets(List<AssetModel> 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<String> _categoryOptions(
List<AssetModel> assets,
List<AssetCategoryModel> allCategories,
) {
final names = <String>{};
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<String> _plantOptions(List<AssetModel> assets) {
final names = assets
.map((a) => a.plantName)
.whereType<String>()
.where((n) => n.isNotEmpty)
.toSet()
.toList()
..sort();
return ['All Plants', ...names];
}
List<String> _statusOptions(List<AssetDropdownOption> 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<AssetListScreen> {
openAssetFormPanel(context, ref, assetId: asset.id);
}
Future<void> _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<void> _deleteAsset(AssetModel asset) async {
final confirmed = await showAppConfirmationDialog(
context: context,
@ -325,13 +276,10 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
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<String> categories;
final List<String> plants;
final List<String> statuses;
final Map<String, String> statusLabels;
final AssetListQuery query;
final List<AssetCategoryModel> categories;
final List<FilterOptionModel> plants;
final List<AssetDropdownOption> statuses;
final ValueChanged<String> onSearch;
final ValueChanged<String> onCategoryChanged;
final ValueChanged<String> onPlantChanged;
final ValueChanged<String> onStatusChanged;
final ValueChanged<int?> onCategoryChanged;
final ValueChanged<int?> onPlantChanged;
final ValueChanged<String?> onStatusChanged;
@override
Widget build(BuildContext context) {
@ -367,24 +312,61 @@ class _AssetsFilterBar extends StatelessWidget {
),
);
final categoryOptions = <AppDropdownOption<int?>>[
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 = <AppDropdownOption<int?>>[
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 = <AppDropdownOption<String?>>[
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<int?>(
label: 'Category',
items: categories,
value: query.itemCategoryId,
searchHint: 'Search category...',
isDense: true,
options: categoryOptions,
onChanged: onCategoryChanged,
),
_AssetFilterDropdown(
value: plantFilter,
AppSearchableDropdown<int?>(
label: 'Plant',
items: plants,
value: query.plantId,
searchHint: 'Search plant...',
isDense: true,
options: plantOptions,
onChanged: onPlantChanged,
),
_AssetFilterDropdown(
value: statusFilter,
AppSearchableDropdown<String?>(
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<String> items;
final Map<String, String> itemLabels;
final ValueChanged<String> onChanged;
@override
Widget build(BuildContext context) {
return AppSearchableDropdown<String>(
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,

View File

@ -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<ExportFileResult> exportGrns(GrnListQuery query) async {
final response = await dio.get<List<int>>(
ApiEndpoints.grnExport,
queryParameters: _exportQueryToMap(query),
options: Options(responseType: ResponseType.bytes),
);
return ExportFileResult(
bytes: response.data ?? <int>[],
fileName: exportFileNameFromResponse(response, fallbackBase: 'grn'),
);
}
Future<List<GrnAttachmentModel>> 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<String, dynamic> _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,

View File

@ -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<Result<ExportFileResult>> exportGrns(GrnListQuery query) {
return safeApiCall(() => dataSource.exportGrns(query));
}
@override
Future<Result<GrnModel>> getGrnById(String id) {
return safeApiCall(() => dataSource.getGrnById(id));

View File

@ -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<Result<PaginatedResponse<GrnModel>>> getGrns(GrnListQuery query);
Future<Result<ExportFileResult>> exportGrns(GrnListQuery query);
Future<Result<GrnModel>> getGrnById(String id);
Future<Result<GrnModel>> createGrn(Map<String, dynamic> data);
Future<Result<GrnModel>> updateGrn(String id, Map<String, dynamic> data);

View File

@ -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<GrnListState> {
if (current == null) return;
applyQuery(current.query.copyWith(limit: limit, page: 1));
}
Future<ExportFileResult?> 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 =

View File

@ -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<GrnListScreen> {
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<GrnListScreen> {
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<GrnListScreen> {
);
}
Future<void> _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<String> onSearch;
final ValueChanged<String?> 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,
],
],
);
}

View File

@ -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<ExportFileResult> exportPurchaseOrders(
PurchaseOrderListQuery query,
) async {
final response = await dio.get<List<int>>(
ApiEndpoints.purchaseOrdersExport,
queryParameters: _exportQueryToMap(query),
options: Options(responseType: ResponseType.bytes),
);
return ExportFileResult(
bytes: response.data ?? <int>[],
fileName: exportFileNameFromResponse(
response,
fallbackBase: 'purchase_orders',
),
);
}
Future<List<EntityAttachmentModel>> 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<String, dynamic> _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,

View File

@ -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<Result<ExportFileResult>> exportPurchaseOrders(
PurchaseOrderListQuery query,
) {
return safeApiCall(() => dataSource.exportPurchaseOrders(query));
}
@override
Future<Result<PurchaseOrderModel>> getPurchaseOrderById(String id) {
return safeApiCall(() => dataSource.getPurchaseOrderById(id));

View File

@ -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<Result<PaginatedResponse<PurchaseOrderModel>>> getPurchaseOrders(
PurchaseOrderListQuery query,
);
Future<Result<ExportFileResult>> exportPurchaseOrders(
PurchaseOrderListQuery query,
);
Future<Result<PurchaseOrderModel>> getPurchaseOrderById(String id);
Future<Result<PurchaseOrderModel>> createPurchaseOrder(Map<String, dynamic> data);
Future<Result<PurchaseOrderModel>> updatePurchaseOrder(

View File

@ -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<ExportFileResult?> 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<bool> deletePurchaseOrder(String id) async {
final repository = ref.read(purchaseOrderRepositoryProvider);
final result = await repository.deletePurchaseOrder(id);

View File

@ -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<PurchaseOrderListScree
final ordersAsync = ref.watch(purchaseOrdersListProvider);
final canEdit = ref.can('purchase_orders', PermissionAction.update);
final canDelete = ref.can('purchase_orders', PermissionAction.delete);
final canExport = ref.can('purchase_orders', PermissionAction.export);
ref.listen(purchaseOrdersListProvider, (prev, next) {
final error = next.valueOrNull?.actionError;
@ -108,6 +110,9 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
query: state.query,
statusOptions: _statusFilterOptions(state.orders),
wrapped: constraints.maxWidth < 900,
showExport: canExport,
isExporting: state.isExporting,
onExport: _exportPurchaseOrders,
onSearch: ref.read(purchaseOrdersListProvider.notifier).setSearch,
onStatusChanged:
ref.read(purchaseOrdersListProvider.notifier).setStatusFilter,
@ -186,6 +191,36 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
context.push('${RouteConstants.purchaseOrders}/${order.id}/edit');
}
Future<void> _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<void> _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<String> onSearch;
final ValueChanged<String?> onStatusChanged;
final ValueChanged<String?> 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,
],
],
);
}

View File

@ -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,
),
),
],
),

View File

@ -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<ExportFileResult> exportVendors(VendorListQuery query) async {
final response = await dio.get<List<int>>(
ApiEndpoints.vendorsExport,
queryParameters: _exportQueryToMap(query),
options: Options(responseType: ResponseType.bytes),
);
return ExportFileResult(
bytes: response.data ?? <int>[],
fileName: exportFileNameFromResponse(response, fallbackBase: 'vendors'),
);
}
Future<VendorModel> 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<String, dynamic> _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,

View File

@ -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<Result<ExportFileResult>> exportVendors(VendorListQuery query) {
return safeApiCall(() => dataSource.exportVendors(query));
}
@override
Future<Result<VendorModel>> getVendorById(String id) {
return safeApiCall(() => dataSource.getVendorById(id));

View File

@ -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<Result<PaginatedResponse<VendorModel>>> getVendors(VendorListQuery query);
Future<Result<ExportFileResult>> exportVendors(VendorListQuery query);
Future<Result<VendorModel>> getVendorById(String id);
Future<Result<VendorModel>> createVendor(Map<String, dynamic> data);
Future<Result<VendorModel>> updateVendor(String id, Map<String, dynamic> data);

View File

@ -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<VendorsListState> {
applyQuery(current.query.copyWith(limit: limit, page: 1));
}
Future<ExportFileResult?> 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<bool> deleteVendor(String id) async {
final repository = ref.read(vendorRepositoryProvider);
final result = await repository.deleteVendor(id);

View File

@ -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<VendorListScreen> {
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<VendorListScreen> {
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<VendorListScreen> {
openVendorFormPanel(context, ref, vendorId: vendor.id);
}
Future<void> _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<void> _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<String> onSearch;
final ValueChanged<String?> onStatusChanged;
final ValueChanged<String?> 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,
],
],
);
}

View File

@ -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();