table alignment
This commit is contained in:
parent
c462374125
commit
5a28cfc0da
@ -73,6 +73,7 @@ class ApiEndpoints {
|
|||||||
|
|
||||||
// Vendors
|
// Vendors
|
||||||
static const String vendors = '/vendors';
|
static const String vendors = '/vendors';
|
||||||
|
static const String vendorsExport = '/vendors/export';
|
||||||
static const String vendorGstTreatments = '/vendors/gst-treatments';
|
static const String vendorGstTreatments = '/vendors/gst-treatments';
|
||||||
static const String vendorSourceOfSupply = '/vendors/source-of-supply';
|
static const String vendorSourceOfSupply = '/vendors/source-of-supply';
|
||||||
static String vendorById(String id) => '/vendors/$id';
|
static String vendorById(String id) => '/vendors/$id';
|
||||||
@ -93,6 +94,7 @@ class ApiEndpoints {
|
|||||||
|
|
||||||
// Purchase Orders
|
// Purchase Orders
|
||||||
static const String purchaseOrders = '/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 purchaseOrderById(String id) => '/purchase-orders/$id';
|
||||||
static String purchaseOrderSubmit(String id) => '/purchase-orders/$id/submit';
|
static String purchaseOrderSubmit(String id) => '/purchase-orders/$id/submit';
|
||||||
static String purchaseOrderApprove(String id) => '/purchase-orders/$id/approve';
|
static String purchaseOrderApprove(String id) => '/purchase-orders/$id/approve';
|
||||||
@ -112,6 +114,7 @@ class ApiEndpoints {
|
|||||||
|
|
||||||
// GRN
|
// GRN
|
||||||
static const String grn = '/grn';
|
static const String grn = '/grn';
|
||||||
|
static const String grnExport = '/grn/export';
|
||||||
static String grnById(String id) => '/grn/$id';
|
static String grnById(String id) => '/grn/$id';
|
||||||
static String grnCancel(String id) => '/grn/$id/cancel';
|
static String grnCancel(String id) => '/grn/$id/cancel';
|
||||||
static String grnPdf(String id) => '/grn/$id/pdf';
|
static String grnPdf(String id) => '/grn/$id/pdf';
|
||||||
@ -123,6 +126,7 @@ class ApiEndpoints {
|
|||||||
|
|
||||||
// Assets
|
// Assets
|
||||||
static const String assets = '/assets';
|
static const String assets = '/assets';
|
||||||
|
static const String assetsExport = '/assets/export';
|
||||||
static String assetById(String id) => '/assets/$id';
|
static String assetById(String id) => '/assets/$id';
|
||||||
static String assetTransfer(String id) => '/assets/$id/transfer';
|
static String assetTransfer(String id) => '/assets/$id/transfer';
|
||||||
static String assetTransferHistory(String id) => '/assets/$id/transfer-history';
|
static String assetTransferHistory(String id) => '/assets/$id/transfer-history';
|
||||||
|
|||||||
31
lib/core/utils/export_file_name.dart
Normal file
31
lib/core/utils/export_file_name.dart
Normal 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';
|
||||||
|
}
|
||||||
@ -1,23 +1,21 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
import '../../../../core/constants/api_endpoints.dart';
|
import '../../../../core/constants/api_endpoints.dart';
|
||||||
|
import '../../../../core/utils/export_file_name.dart';
|
||||||
import '../../../../shared/models/api_response.dart';
|
import '../../../../shared/models/api_response.dart';
|
||||||
import '../../../../shared/models/asset_model.dart';
|
import '../../../../shared/models/asset_model.dart';
|
||||||
import '../../../../shared/models/entity_attachment_model.dart';
|
import '../../../../shared/models/entity_attachment_model.dart';
|
||||||
|
import '../../../../shared/models/export_file_result.dart';
|
||||||
|
|
||||||
class AssetRemoteDataSource {
|
class AssetRemoteDataSource {
|
||||||
AssetRemoteDataSource({required this.dio});
|
AssetRemoteDataSource({required this.dio});
|
||||||
|
|
||||||
final Dio dio;
|
final Dio dio;
|
||||||
|
|
||||||
Future<PaginatedResponse<AssetModel>> getAssets(PaginationParams params) async {
|
Future<PaginatedResponse<AssetModel>> getAssets(AssetListQuery query) async {
|
||||||
final response = await dio.get(
|
final response = await dio.get(
|
||||||
ApiEndpoints.assets,
|
ApiEndpoints.assets,
|
||||||
queryParameters: {
|
queryParameters: _queryToMap(query),
|
||||||
'page': params.page,
|
|
||||||
'limit': params.limit,
|
|
||||||
if (params.search != null && params.search!.isNotEmpty) 'search': params.search,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
return _parsePaginated(response.data, AssetModel.fromJson);
|
return _parsePaginated(response.data, AssetModel.fromJson);
|
||||||
}
|
}
|
||||||
@ -41,6 +39,18 @@ class AssetRemoteDataSource {
|
|||||||
await dio.delete(ApiEndpoints.assetById(id));
|
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 {
|
Future<List<EntityAttachmentModel>> listAttachments(String assetId) async {
|
||||||
final response = await dio.get(ApiEndpoints.assetAttachments(assetId));
|
final response = await dio.get(ApiEndpoints.assetAttachments(assetId));
|
||||||
final data = response.data['data'];
|
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) {
|
Map<String, dynamic> _asStringMap(dynamic value) {
|
||||||
if (value is Map<String, dynamic>) return value;
|
if (value is Map<String, dynamic>) return value;
|
||||||
if (value is Map) return Map<String, dynamic>.from(value);
|
if (value is Map) return Map<String, dynamic>.from(value);
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import '../../../../core/network/dio_client.dart';
|
|||||||
import '../../../../shared/models/api_response.dart';
|
import '../../../../shared/models/api_response.dart';
|
||||||
import '../../../../shared/models/asset_model.dart';
|
import '../../../../shared/models/asset_model.dart';
|
||||||
import '../../../../shared/models/entity_attachment_model.dart';
|
import '../../../../shared/models/entity_attachment_model.dart';
|
||||||
|
import '../../../../shared/models/export_file_result.dart';
|
||||||
import '../../domain/repositories/asset_repository.dart';
|
import '../../domain/repositories/asset_repository.dart';
|
||||||
import '../datasources/asset_remote_data_source.dart';
|
import '../datasources/asset_remote_data_source.dart';
|
||||||
|
|
||||||
@ -22,8 +23,13 @@ class AssetRepositoryImpl implements AssetRepository {
|
|||||||
final AssetRemoteDataSource dataSource;
|
final AssetRemoteDataSource dataSource;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Result<PaginatedResponse<AssetModel>>> getAssets(PaginationParams params) {
|
Future<Result<PaginatedResponse<AssetModel>>> getAssets(AssetListQuery query) {
|
||||||
return safeApiCall(() => dataSource.getAssets(params));
|
return safeApiCall(() => dataSource.getAssets(query));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Result<ExportFileResult>> exportAssets(AssetListQuery query) {
|
||||||
|
return safeApiCall(() => dataSource.exportAssets(query));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@ -2,9 +2,11 @@ import '../../../../core/network/api_handler.dart';
|
|||||||
import '../../../../shared/models/api_response.dart';
|
import '../../../../shared/models/api_response.dart';
|
||||||
import '../../../../shared/models/asset_model.dart';
|
import '../../../../shared/models/asset_model.dart';
|
||||||
import '../../../../shared/models/entity_attachment_model.dart';
|
import '../../../../shared/models/entity_attachment_model.dart';
|
||||||
|
import '../../../../shared/models/export_file_result.dart';
|
||||||
|
|
||||||
abstract class AssetRepository {
|
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>> getAssetById(String id);
|
||||||
Future<Result<AssetModel>> createAsset(Map<String, dynamic> data);
|
Future<Result<AssetModel>> createAsset(Map<String, dynamic> data);
|
||||||
Future<Result<AssetModel>> updateAsset(String id, Map<String, dynamic> data);
|
Future<Result<AssetModel>> updateAsset(String id, Map<String, dynamic> data);
|
||||||
|
|||||||
@ -2,36 +2,39 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
|
|
||||||
import '../../../../core/utils/table_search.dart';
|
import '../../../../core/utils/table_search.dart';
|
||||||
|
|
||||||
import '../../../../shared/models/api_response.dart';
|
|
||||||
import '../../../../shared/models/asset_model.dart';
|
import '../../../../shared/models/asset_model.dart';
|
||||||
import '../../../../shared/models/entity_attachment_model.dart';
|
import '../../../../shared/models/entity_attachment_model.dart';
|
||||||
|
import '../../../../shared/models/export_file_result.dart';
|
||||||
import '../../data/repositories/asset_repository_impl.dart';
|
import '../../data/repositories/asset_repository_impl.dart';
|
||||||
|
|
||||||
class AssetsListState {
|
class AssetsListState {
|
||||||
const AssetsListState({
|
const AssetsListState({
|
||||||
this.assets = const [],
|
this.assets = const [],
|
||||||
this.query = const PaginationParams(limit: 20),
|
this.query = const AssetListQuery(limit: 20),
|
||||||
this.total = 0,
|
this.total = 0,
|
||||||
this.totalPages = 1,
|
this.totalPages = 1,
|
||||||
this.isRefreshing = false,
|
this.isRefreshing = false,
|
||||||
|
this.isExporting = false,
|
||||||
this.actionError,
|
this.actionError,
|
||||||
this.actionSuccess,
|
this.actionSuccess,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<AssetModel> assets;
|
final List<AssetModel> assets;
|
||||||
final PaginationParams query;
|
final AssetListQuery query;
|
||||||
final int total;
|
final int total;
|
||||||
final int totalPages;
|
final int totalPages;
|
||||||
final bool isRefreshing;
|
final bool isRefreshing;
|
||||||
|
final bool isExporting;
|
||||||
final String? actionError;
|
final String? actionError;
|
||||||
final String? actionSuccess;
|
final String? actionSuccess;
|
||||||
|
|
||||||
AssetsListState copyWith({
|
AssetsListState copyWith({
|
||||||
List<AssetModel>? assets,
|
List<AssetModel>? assets,
|
||||||
PaginationParams? query,
|
AssetListQuery? query,
|
||||||
int? total,
|
int? total,
|
||||||
int? totalPages,
|
int? totalPages,
|
||||||
bool? isRefreshing,
|
bool? isRefreshing,
|
||||||
|
bool? isExporting,
|
||||||
String? actionError,
|
String? actionError,
|
||||||
String? actionSuccess,
|
String? actionSuccess,
|
||||||
bool clearMessages = false,
|
bool clearMessages = false,
|
||||||
@ -42,6 +45,7 @@ class AssetsListState {
|
|||||||
total: total ?? this.total,
|
total: total ?? this.total,
|
||||||
totalPages: totalPages ?? this.totalPages,
|
totalPages: totalPages ?? this.totalPages,
|
||||||
isRefreshing: isRefreshing ?? this.isRefreshing,
|
isRefreshing: isRefreshing ?? this.isRefreshing,
|
||||||
|
isExporting: isExporting ?? this.isExporting,
|
||||||
actionError: clearMessages ? null : actionError ?? this.actionError,
|
actionError: clearMessages ? null : actionError ?? this.actionError,
|
||||||
actionSuccess: clearMessages ? null : actionSuccess ?? this.actionSuccess,
|
actionSuccess: clearMessages ? null : actionSuccess ?? this.actionSuccess,
|
||||||
);
|
);
|
||||||
@ -56,10 +60,10 @@ final assetsListProvider =
|
|||||||
class AssetsListNotifier extends AutoDisposeAsyncNotifier<AssetsListState> {
|
class AssetsListNotifier extends AutoDisposeAsyncNotifier<AssetsListState> {
|
||||||
@override
|
@override
|
||||||
Future<AssetsListState> build() async {
|
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 repository = ref.read(assetRepositoryProvider);
|
||||||
final result = await repository.getAssets(query);
|
final result = await repository.getAssets(query);
|
||||||
if (result.failure != null) throw result.failure!;
|
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;
|
final previous = state.valueOrNull;
|
||||||
if (previous == null) {
|
if (previous == null) {
|
||||||
state = const AsyncLoading();
|
state = const AsyncLoading();
|
||||||
@ -97,7 +101,27 @@ class AssetsListNotifier extends AutoDisposeAsyncNotifier<AssetsListState> {
|
|||||||
void setSearch(String search) {
|
void setSearch(String search) {
|
||||||
final current = state.valueOrNull;
|
final current = state.valueOrNull;
|
||||||
if (current == null) return;
|
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) {
|
void setPage(int page) {
|
||||||
@ -112,6 +136,30 @@ class AssetsListNotifier extends AutoDisposeAsyncNotifier<AssetsListState> {
|
|||||||
applyQuery(current.query.copyWith(limit: limit, page: 1));
|
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 {
|
Future<bool> deleteAsset(String id) async {
|
||||||
final repository = ref.read(assetRepositoryProvider);
|
final repository = ref.read(assetRepositoryProvider);
|
||||||
final result = await repository.deleteAsset(id);
|
final result = await repository.deleteAsset(id);
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import '../../../../core/errors/failure.dart';
|
|||||||
import '../../../../core/utils/formatters.dart';
|
import '../../../../core/utils/formatters.dart';
|
||||||
import '../../../../core/utils/responsive_utils.dart';
|
import '../../../../core/utils/responsive_utils.dart';
|
||||||
import '../../../../shared/models/asset_model.dart';
|
import '../../../../shared/models/asset_model.dart';
|
||||||
|
import '../../../../shared/models/user_management_models.dart';
|
||||||
import '../../../../shared/providers/permissions_provider.dart';
|
import '../../../../shared/providers/permissions_provider.dart';
|
||||||
import '../../../../shared/widgets/app_card.dart';
|
import '../../../../shared/widgets/app_card.dart';
|
||||||
import '../../../../shared/widgets/app_confirmation_dialog.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/can_permission.dart';
|
||||||
import '../../../../shared/widgets/error_view.dart';
|
import '../../../../shared/widgets/error_view.dart';
|
||||||
import '../../../../shared/widgets/page_header.dart';
|
import '../../../../shared/widgets/page_header.dart';
|
||||||
|
import '../../../../shared/utils/file_download_helper.dart';
|
||||||
import '../providers/asset_categories_provider.dart';
|
import '../providers/asset_categories_provider.dart';
|
||||||
import '../providers/asset_form_lookups_provider.dart';
|
import '../providers/asset_form_lookups_provider.dart';
|
||||||
import '../providers/assets_provider.dart';
|
import '../providers/assets_provider.dart';
|
||||||
@ -36,15 +38,12 @@ class AssetListScreen extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
||||||
String? _selectedCategory;
|
|
||||||
String? _selectedPlant;
|
|
||||||
String? _selectedStatus;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final assetsAsync = ref.watch(assetsListProvider);
|
final assetsAsync = ref.watch(assetsListProvider);
|
||||||
final canEdit = ref.can('assets', PermissionAction.update);
|
final canEdit = ref.can('assets', PermissionAction.update);
|
||||||
final canDelete = ref.can('assets', PermissionAction.delete);
|
final canDelete = ref.can('assets', PermissionAction.delete);
|
||||||
|
final canExport = ref.can('assets', PermissionAction.export);
|
||||||
|
|
||||||
ref.listen(assetsListProvider, (prev, next) {
|
ref.listen(assetsListProvider, (prev, next) {
|
||||||
final error = next.valueOrNull?.actionError;
|
final error = next.valueOrNull?.actionError;
|
||||||
@ -66,26 +65,11 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
|||||||
onRetry: () => ref.invalidate(assetsListProvider),
|
onRetry: () => ref.invalidate(assetsListProvider),
|
||||||
),
|
),
|
||||||
data: (state) {
|
data: (state) {
|
||||||
final allCategories = ref.watch(itemCategoriesProvider).valueOrNull ?? [];
|
final allCategories =
|
||||||
final filteredAssets = _filterAssets(state.assets);
|
ref.watch(itemCategoriesProvider).valueOrNull ?? [];
|
||||||
final categories = _categoryOptions(state.assets, allCategories);
|
final lookups = ref.watch(assetFormLookupsProvider).valueOrNull;
|
||||||
final plants = _plantOptions(state.assets);
|
final allPlants = lookups?.plants ?? const [];
|
||||||
final statuses = _statusOptions(
|
final notifier = ref.read(assetsListProvider.notifier);
|
||||||
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;
|
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@ -94,6 +78,19 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
|||||||
title: 'Asset Master',
|
title: 'Asset Master',
|
||||||
subtitle: 'Manage plant assets, AMC, service & insurance',
|
subtitle: 'Manage plant assets, AMC, service & insurance',
|
||||||
actions: [
|
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(
|
OutlinedButton.icon(
|
||||||
onPressed: () => context.go(RouteConstants.assetAlerts),
|
onPressed: () => context.go(RouteConstants.assetAlerts),
|
||||||
icon: const Icon(Icons.notifications_outlined),
|
icon: const Icon(Icons.notifications_outlined),
|
||||||
@ -113,10 +110,10 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
|||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: RefreshIndicator(
|
child: RefreshIndicator(
|
||||||
onRefresh: () => ref.read(assetsListProvider.notifier).refresh(),
|
onRefresh: () => notifier.refresh(),
|
||||||
child: context.isMobile
|
child: context.isMobile
|
||||||
? _AssetMobileList(
|
? _AssetMobileList(
|
||||||
assets: filteredAssets,
|
assets: state.assets,
|
||||||
onView: _viewAsset,
|
onView: _viewAsset,
|
||||||
onEdit: canEdit ? _editAsset : null,
|
onEdit: canEdit ? _editAsset : null,
|
||||||
onDelete: canDelete ? _deleteAsset : null,
|
onDelete: canDelete ? _deleteAsset : null,
|
||||||
@ -142,39 +139,21 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
|||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
return _AssetsFilterBar(
|
return _AssetsFilterBar(
|
||||||
wrapped: constraints.maxWidth < 1000,
|
wrapped: constraints.maxWidth < 1000,
|
||||||
categoryFilter: categoryFilter,
|
query: state.query,
|
||||||
plantFilter: plantFilter,
|
categories: allCategories,
|
||||||
statusFilter: statusFilter,
|
plants: allPlants,
|
||||||
categories: categories,
|
statuses: lookups?.statuses ?? const [],
|
||||||
plants: plants,
|
onSearch: notifier.setSearch,
|
||||||
statuses: statuses,
|
onCategoryChanged:
|
||||||
statusLabels: statusLabels,
|
notifier.setCategoryFilter,
|
||||||
onSearch:
|
onPlantChanged: notifier.setPlantFilter,
|
||||||
ref.read(assetsListProvider.notifier).setSearch,
|
onStatusChanged: notifier.setStatusFilter,
|
||||||
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;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
if (filteredAssets.isEmpty)
|
if (state.assets.isEmpty)
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
@ -193,7 +172,7 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
|||||||
else
|
else
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _AssetDataTable(
|
child: _AssetDataTable(
|
||||||
assets: filteredAssets,
|
assets: state.assets,
|
||||||
canEdit: canEdit,
|
canEdit: canEdit,
|
||||||
canDelete: canDelete,
|
canDelete: canDelete,
|
||||||
onView: _viewAsset,
|
onView: _viewAsset,
|
||||||
@ -205,17 +184,13 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: AppPagination(
|
child: AppPagination(
|
||||||
currentPage: page,
|
currentPage: state.query.page,
|
||||||
totalPages: state.totalPages,
|
totalPages: state.totalPages,
|
||||||
totalItems: total,
|
totalItems: state.total,
|
||||||
pageSize: pageSize,
|
pageSize: state.query.limit,
|
||||||
itemLabel: 'assets',
|
itemLabel: 'assets',
|
||||||
onPageChanged: ref
|
onPageChanged: notifier.setPage,
|
||||||
.read(assetsListProvider.notifier)
|
onPageSizeChanged: notifier.setPageSize,
|
||||||
.setPage,
|
|
||||||
onPageSizeChanged: ref
|
|
||||||
.read(assetsListProvider.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) {
|
void _viewAsset(AssetModel asset) {
|
||||||
context.push('${RouteConstants.assets}/${asset.id}');
|
context.push('${RouteConstants.assets}/${asset.id}');
|
||||||
}
|
}
|
||||||
@ -290,6 +213,34 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
|||||||
openAssetFormPanel(context, ref, assetId: asset.id);
|
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 {
|
Future<void> _deleteAsset(AssetModel asset) async {
|
||||||
final confirmed = await showAppConfirmationDialog(
|
final confirmed = await showAppConfirmationDialog(
|
||||||
context: context,
|
context: context,
|
||||||
@ -325,13 +276,10 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
|||||||
class _AssetsFilterBar extends StatelessWidget {
|
class _AssetsFilterBar extends StatelessWidget {
|
||||||
const _AssetsFilterBar({
|
const _AssetsFilterBar({
|
||||||
required this.wrapped,
|
required this.wrapped,
|
||||||
required this.categoryFilter,
|
required this.query,
|
||||||
required this.plantFilter,
|
|
||||||
required this.statusFilter,
|
|
||||||
required this.categories,
|
required this.categories,
|
||||||
required this.plants,
|
required this.plants,
|
||||||
required this.statuses,
|
required this.statuses,
|
||||||
required this.statusLabels,
|
|
||||||
required this.onSearch,
|
required this.onSearch,
|
||||||
required this.onCategoryChanged,
|
required this.onCategoryChanged,
|
||||||
required this.onPlantChanged,
|
required this.onPlantChanged,
|
||||||
@ -339,17 +287,14 @@ class _AssetsFilterBar extends StatelessWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final bool wrapped;
|
final bool wrapped;
|
||||||
final String categoryFilter;
|
final AssetListQuery query;
|
||||||
final String plantFilter;
|
final List<AssetCategoryModel> categories;
|
||||||
final String statusFilter;
|
final List<FilterOptionModel> plants;
|
||||||
final List<String> categories;
|
final List<AssetDropdownOption> statuses;
|
||||||
final List<String> plants;
|
|
||||||
final List<String> statuses;
|
|
||||||
final Map<String, String> statusLabels;
|
|
||||||
final ValueChanged<String> onSearch;
|
final ValueChanged<String> onSearch;
|
||||||
final ValueChanged<String> onCategoryChanged;
|
final ValueChanged<int?> onCategoryChanged;
|
||||||
final ValueChanged<String> onPlantChanged;
|
final ValueChanged<int?> onPlantChanged;
|
||||||
final ValueChanged<String> onStatusChanged;
|
final ValueChanged<String?> onStatusChanged;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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 = [
|
final filters = [
|
||||||
_AssetFilterDropdown(
|
AppSearchableDropdown<int?>(
|
||||||
value: categoryFilter,
|
|
||||||
label: 'Category',
|
label: 'Category',
|
||||||
items: categories,
|
value: query.itemCategoryId,
|
||||||
|
searchHint: 'Search category...',
|
||||||
|
isDense: true,
|
||||||
|
options: categoryOptions,
|
||||||
onChanged: onCategoryChanged,
|
onChanged: onCategoryChanged,
|
||||||
),
|
),
|
||||||
_AssetFilterDropdown(
|
AppSearchableDropdown<int?>(
|
||||||
value: plantFilter,
|
|
||||||
label: 'Plant',
|
label: 'Plant',
|
||||||
items: plants,
|
value: query.plantId,
|
||||||
|
searchHint: 'Search plant...',
|
||||||
|
isDense: true,
|
||||||
|
options: plantOptions,
|
||||||
onChanged: onPlantChanged,
|
onChanged: onPlantChanged,
|
||||||
),
|
),
|
||||||
_AssetFilterDropdown(
|
AppSearchableDropdown<String?>(
|
||||||
value: statusFilter,
|
|
||||||
label: 'Status',
|
label: 'Status',
|
||||||
items: statuses,
|
value: query.status,
|
||||||
itemLabels: statusLabels,
|
searchHint: 'Search status...',
|
||||||
|
isDense: true,
|
||||||
|
options: statusOptions,
|
||||||
onChanged: onStatusChanged,
|
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 {
|
class _AssetDataTable extends StatelessWidget {
|
||||||
const _AssetDataTable({
|
const _AssetDataTable({
|
||||||
required this.assets,
|
required this.assets,
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
import '../../../../core/constants/api_endpoints.dart';
|
import '../../../../core/constants/api_endpoints.dart';
|
||||||
|
import '../../../../core/utils/export_file_name.dart';
|
||||||
import '../../../../shared/models/api_response.dart';
|
import '../../../../shared/models/api_response.dart';
|
||||||
|
import '../../../../shared/models/export_file_result.dart';
|
||||||
import '../../../../shared/models/grn_model.dart';
|
import '../../../../shared/models/grn_model.dart';
|
||||||
|
|
||||||
class GrnRemoteDataSource {
|
class GrnRemoteDataSource {
|
||||||
@ -48,6 +50,18 @@ class GrnRemoteDataSource {
|
|||||||
return response.data ?? [];
|
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 {
|
Future<List<GrnAttachmentModel>> listAttachments(String grnId) async {
|
||||||
final response = await dio.get(ApiEndpoints.grnAttachments(grnId));
|
final response = await dio.get(ApiEndpoints.grnAttachments(grnId));
|
||||||
final data = response.data['data'];
|
final data = response.data['data'];
|
||||||
@ -106,6 +120,12 @@ class GrnRemoteDataSource {
|
|||||||
return {
|
return {
|
||||||
'page': query.page,
|
'page': query.page,
|
||||||
'limit': query.limit,
|
'limit': query.limit,
|
||||||
|
..._exportQueryToMap(query),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _exportQueryToMap(GrnListQuery query) {
|
||||||
|
return {
|
||||||
if (query.search != null && query.search!.isNotEmpty) 'search': query.search,
|
if (query.search != null && query.search!.isNotEmpty) 'search': query.search,
|
||||||
if (query.status != null) 'status': query.status,
|
if (query.status != null) 'status': query.status,
|
||||||
if (query.poId != null) 'po_id': query.poId,
|
if (query.poId != null) 'po_id': query.poId,
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../../../../core/network/api_handler.dart';
|
import '../../../../core/network/api_handler.dart';
|
||||||
import '../../../../core/network/dio_client.dart';
|
import '../../../../core/network/dio_client.dart';
|
||||||
import '../../../../shared/models/api_response.dart';
|
import '../../../../shared/models/api_response.dart';
|
||||||
|
import '../../../../shared/models/export_file_result.dart';
|
||||||
import '../../../../shared/models/grn_model.dart';
|
import '../../../../shared/models/grn_model.dart';
|
||||||
import '../../domain/repositories/grn_repository.dart';
|
import '../../domain/repositories/grn_repository.dart';
|
||||||
import '../datasources/grn_remote_data_source.dart';
|
import '../datasources/grn_remote_data_source.dart';
|
||||||
@ -25,6 +26,11 @@ class GrnRepositoryImpl implements GrnRepository {
|
|||||||
return safeApiCall(() => dataSource.getGrns(query));
|
return safeApiCall(() => dataSource.getGrns(query));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Result<ExportFileResult>> exportGrns(GrnListQuery query) {
|
||||||
|
return safeApiCall(() => dataSource.exportGrns(query));
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Result<GrnModel>> getGrnById(String id) {
|
Future<Result<GrnModel>> getGrnById(String id) {
|
||||||
return safeApiCall(() => dataSource.getGrnById(id));
|
return safeApiCall(() => dataSource.getGrnById(id));
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
import '../../../../core/network/api_handler.dart';
|
import '../../../../core/network/api_handler.dart';
|
||||||
import '../../../../shared/models/api_response.dart';
|
import '../../../../shared/models/api_response.dart';
|
||||||
|
import '../../../../shared/models/export_file_result.dart';
|
||||||
import '../../../../shared/models/grn_model.dart';
|
import '../../../../shared/models/grn_model.dart';
|
||||||
|
|
||||||
abstract class GrnRepository {
|
abstract class GrnRepository {
|
||||||
Future<Result<PaginatedResponse<GrnModel>>> getGrns(GrnListQuery query);
|
Future<Result<PaginatedResponse<GrnModel>>> getGrns(GrnListQuery query);
|
||||||
|
Future<Result<ExportFileResult>> exportGrns(GrnListQuery query);
|
||||||
Future<Result<GrnModel>> getGrnById(String id);
|
Future<Result<GrnModel>> getGrnById(String id);
|
||||||
Future<Result<GrnModel>> createGrn(Map<String, dynamic> data);
|
Future<Result<GrnModel>> createGrn(Map<String, dynamic> data);
|
||||||
Future<Result<GrnModel>> updateGrn(String id, Map<String, dynamic> data);
|
Future<Result<GrnModel>> updateGrn(String id, Map<String, dynamic> data);
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
|
|
||||||
import '../../../../core/utils/table_search.dart';
|
import '../../../../core/utils/table_search.dart';
|
||||||
|
|
||||||
|
import '../../../../shared/models/export_file_result.dart';
|
||||||
import '../../../../shared/models/grn_model.dart';
|
import '../../../../shared/models/grn_model.dart';
|
||||||
import '../../data/repositories/grn_repository_impl.dart';
|
import '../../data/repositories/grn_repository_impl.dart';
|
||||||
|
|
||||||
@ -12,6 +13,7 @@ class GrnListState {
|
|||||||
this.total = 0,
|
this.total = 0,
|
||||||
this.totalPages = 1,
|
this.totalPages = 1,
|
||||||
this.isRefreshing = false,
|
this.isRefreshing = false,
|
||||||
|
this.isExporting = false,
|
||||||
this.actionError,
|
this.actionError,
|
||||||
this.actionSuccess,
|
this.actionSuccess,
|
||||||
});
|
});
|
||||||
@ -21,6 +23,7 @@ class GrnListState {
|
|||||||
final int total;
|
final int total;
|
||||||
final int totalPages;
|
final int totalPages;
|
||||||
final bool isRefreshing;
|
final bool isRefreshing;
|
||||||
|
final bool isExporting;
|
||||||
final String? actionError;
|
final String? actionError;
|
||||||
final String? actionSuccess;
|
final String? actionSuccess;
|
||||||
|
|
||||||
@ -30,6 +33,7 @@ class GrnListState {
|
|||||||
int? total,
|
int? total,
|
||||||
int? totalPages,
|
int? totalPages,
|
||||||
bool? isRefreshing,
|
bool? isRefreshing,
|
||||||
|
bool? isExporting,
|
||||||
String? actionError,
|
String? actionError,
|
||||||
String? actionSuccess,
|
String? actionSuccess,
|
||||||
bool clearMessages = false,
|
bool clearMessages = false,
|
||||||
@ -40,6 +44,7 @@ class GrnListState {
|
|||||||
total: total ?? this.total,
|
total: total ?? this.total,
|
||||||
totalPages: totalPages ?? this.totalPages,
|
totalPages: totalPages ?? this.totalPages,
|
||||||
isRefreshing: isRefreshing ?? this.isRefreshing,
|
isRefreshing: isRefreshing ?? this.isRefreshing,
|
||||||
|
isExporting: isExporting ?? this.isExporting,
|
||||||
actionError: clearMessages ? null : actionError ?? this.actionError,
|
actionError: clearMessages ? null : actionError ?? this.actionError,
|
||||||
actionSuccess: clearMessages ? null : actionSuccess ?? this.actionSuccess,
|
actionSuccess: clearMessages ? null : actionSuccess ?? this.actionSuccess,
|
||||||
);
|
);
|
||||||
@ -115,6 +120,30 @@ class GrnListNotifier extends AutoDisposeAsyncNotifier<GrnListState> {
|
|||||||
if (current == null) return;
|
if (current == null) return;
|
||||||
applyQuery(current.query.copyWith(limit: limit, page: 1));
|
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 =
|
final grnDetailProvider =
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import '../../../../shared/widgets/app_table_action_icon.dart';
|
|||||||
import '../../../../shared/widgets/app_table_shell.dart';
|
import '../../../../shared/widgets/app_table_shell.dart';
|
||||||
import '../../../../shared/widgets/error_view.dart';
|
import '../../../../shared/widgets/error_view.dart';
|
||||||
import '../../../../shared/widgets/page_header.dart';
|
import '../../../../shared/widgets/page_header.dart';
|
||||||
|
import '../../../../shared/utils/file_download_helper.dart';
|
||||||
import '../providers/grn_provider.dart';
|
import '../providers/grn_provider.dart';
|
||||||
import '../widgets/grn_status_chip.dart';
|
import '../widgets/grn_status_chip.dart';
|
||||||
import '../../../../shared/widgets/app_toast.dart';
|
import '../../../../shared/widgets/app_toast.dart';
|
||||||
@ -45,6 +46,14 @@ class _GrnListScreenState extends ConsumerState<GrnListScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final listAsync = ref.watch(grnListProvider);
|
final listAsync = ref.watch(grnListProvider);
|
||||||
final canEdit = ref.can('grn', PermissionAction.update);
|
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(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
@ -80,6 +89,9 @@ class _GrnListScreenState extends ConsumerState<GrnListScreen> {
|
|||||||
searchController: _searchController,
|
searchController: _searchController,
|
||||||
query: state.query,
|
query: state.query,
|
||||||
wrapped: constraints.maxWidth < 900,
|
wrapped: constraints.maxWidth < 900,
|
||||||
|
showExport: canExport,
|
||||||
|
isExporting: state.isExporting,
|
||||||
|
onExport: _exportGrns,
|
||||||
onSearch: ref.read(grnListProvider.notifier).setSearch,
|
onSearch: ref.read(grnListProvider.notifier).setSearch,
|
||||||
onStatusChanged:
|
onStatusChanged:
|
||||||
ref.read(grnListProvider.notifier).setStatusFilter,
|
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) {
|
void _viewGrn(GrnModel grn) {
|
||||||
context.push('${RouteConstants.grn}/${grn.id}');
|
context.push('${RouteConstants.grn}/${grn.id}');
|
||||||
}
|
}
|
||||||
@ -154,6 +194,9 @@ class _FiltersBar extends StatelessWidget {
|
|||||||
required this.wrapped,
|
required this.wrapped,
|
||||||
required this.onSearch,
|
required this.onSearch,
|
||||||
required this.onStatusChanged,
|
required this.onStatusChanged,
|
||||||
|
this.showExport = false,
|
||||||
|
this.isExporting = false,
|
||||||
|
this.onExport,
|
||||||
});
|
});
|
||||||
|
|
||||||
final TextEditingController searchController;
|
final TextEditingController searchController;
|
||||||
@ -161,6 +204,9 @@ class _FiltersBar extends StatelessWidget {
|
|||||||
final bool wrapped;
|
final bool wrapped;
|
||||||
final ValueChanged<String> onSearch;
|
final ValueChanged<String> onSearch;
|
||||||
final ValueChanged<String?> onStatusChanged;
|
final ValueChanged<String?> onStatusChanged;
|
||||||
|
final bool showExport;
|
||||||
|
final bool isExporting;
|
||||||
|
final VoidCallback? onExport;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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) {
|
if (wrapped) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
@ -206,6 +264,10 @@ class _FiltersBar extends StatelessWidget {
|
|||||||
searchField,
|
searchField,
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
statusFilter,
|
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),
|
Expanded(flex: 3, child: searchField),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(flex: 2, child: statusFilter),
|
Expanded(flex: 2, child: statusFilter),
|
||||||
|
if (showExport) ...[
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
exportButton,
|
||||||
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
import '../../../../core/constants/api_endpoints.dart';
|
import '../../../../core/constants/api_endpoints.dart';
|
||||||
|
import '../../../../core/utils/export_file_name.dart';
|
||||||
import '../../../../shared/models/api_response.dart';
|
import '../../../../shared/models/api_response.dart';
|
||||||
import '../../../../shared/models/entity_attachment_model.dart';
|
import '../../../../shared/models/entity_attachment_model.dart';
|
||||||
|
import '../../../../shared/models/export_file_result.dart';
|
||||||
import '../../../../shared/models/purchase_order_model.dart';
|
import '../../../../shared/models/purchase_order_model.dart';
|
||||||
|
|
||||||
class PurchaseOrderRemoteDataSource {
|
class PurchaseOrderRemoteDataSource {
|
||||||
@ -128,6 +130,23 @@ class PurchaseOrderRemoteDataSource {
|
|||||||
return response.data ?? [];
|
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 {
|
Future<List<EntityAttachmentModel>> listAttachments(String poId) async {
|
||||||
final response =
|
final response =
|
||||||
await dio.get(ApiEndpoints.purchaseOrderAttachments(poId));
|
await dio.get(ApiEndpoints.purchaseOrderAttachments(poId));
|
||||||
@ -177,6 +196,12 @@ class PurchaseOrderRemoteDataSource {
|
|||||||
return {
|
return {
|
||||||
'page': query.page,
|
'page': query.page,
|
||||||
'limit': query.limit,
|
'limit': query.limit,
|
||||||
|
..._exportQueryToMap(query),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _exportQueryToMap(PurchaseOrderListQuery query) {
|
||||||
|
return {
|
||||||
if (query.search != null && query.search!.isNotEmpty) 'search': query.search,
|
if (query.search != null && query.search!.isNotEmpty) 'search': query.search,
|
||||||
if (query.status != null) 'status': query.status,
|
if (query.status != null) 'status': query.status,
|
||||||
if (query.poType != null) 'po_type': query.poType,
|
if (query.poType != null) 'po_type': query.poType,
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import '../../../../core/network/api_handler.dart';
|
|||||||
import '../../../../core/network/dio_client.dart';
|
import '../../../../core/network/dio_client.dart';
|
||||||
import '../../../../shared/models/api_response.dart';
|
import '../../../../shared/models/api_response.dart';
|
||||||
import '../../../../shared/models/entity_attachment_model.dart';
|
import '../../../../shared/models/entity_attachment_model.dart';
|
||||||
|
import '../../../../shared/models/export_file_result.dart';
|
||||||
import '../../../../shared/models/purchase_order_model.dart';
|
import '../../../../shared/models/purchase_order_model.dart';
|
||||||
import '../../domain/repositories/purchase_order_repository.dart';
|
import '../../domain/repositories/purchase_order_repository.dart';
|
||||||
import '../datasources/purchase_order_remote_data_source.dart';
|
import '../datasources/purchase_order_remote_data_source.dart';
|
||||||
@ -31,6 +32,13 @@ class PurchaseOrderRepositoryImpl implements PurchaseOrderRepository {
|
|||||||
return safeApiCall(() => dataSource.getPurchaseOrders(query));
|
return safeApiCall(() => dataSource.getPurchaseOrders(query));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Result<ExportFileResult>> exportPurchaseOrders(
|
||||||
|
PurchaseOrderListQuery query,
|
||||||
|
) {
|
||||||
|
return safeApiCall(() => dataSource.exportPurchaseOrders(query));
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Result<PurchaseOrderModel>> getPurchaseOrderById(String id) {
|
Future<Result<PurchaseOrderModel>> getPurchaseOrderById(String id) {
|
||||||
return safeApiCall(() => dataSource.getPurchaseOrderById(id));
|
return safeApiCall(() => dataSource.getPurchaseOrderById(id));
|
||||||
|
|||||||
@ -1,12 +1,16 @@
|
|||||||
import '../../../../core/network/api_handler.dart';
|
import '../../../../core/network/api_handler.dart';
|
||||||
import '../../../../shared/models/api_response.dart';
|
import '../../../../shared/models/api_response.dart';
|
||||||
import '../../../../shared/models/entity_attachment_model.dart';
|
import '../../../../shared/models/entity_attachment_model.dart';
|
||||||
|
import '../../../../shared/models/export_file_result.dart';
|
||||||
import '../../../../shared/models/purchase_order_model.dart';
|
import '../../../../shared/models/purchase_order_model.dart';
|
||||||
|
|
||||||
abstract class PurchaseOrderRepository {
|
abstract class PurchaseOrderRepository {
|
||||||
Future<Result<PaginatedResponse<PurchaseOrderModel>>> getPurchaseOrders(
|
Future<Result<PaginatedResponse<PurchaseOrderModel>>> getPurchaseOrders(
|
||||||
PurchaseOrderListQuery query,
|
PurchaseOrderListQuery query,
|
||||||
);
|
);
|
||||||
|
Future<Result<ExportFileResult>> exportPurchaseOrders(
|
||||||
|
PurchaseOrderListQuery query,
|
||||||
|
);
|
||||||
Future<Result<PurchaseOrderModel>> getPurchaseOrderById(String id);
|
Future<Result<PurchaseOrderModel>> getPurchaseOrderById(String id);
|
||||||
Future<Result<PurchaseOrderModel>> createPurchaseOrder(Map<String, dynamic> data);
|
Future<Result<PurchaseOrderModel>> createPurchaseOrder(Map<String, dynamic> data);
|
||||||
Future<Result<PurchaseOrderModel>> updatePurchaseOrder(
|
Future<Result<PurchaseOrderModel>> updatePurchaseOrder(
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../../../../core/utils/table_search.dart';
|
import '../../../../core/utils/table_search.dart';
|
||||||
|
|
||||||
import '../../../../shared/models/entity_attachment_model.dart';
|
import '../../../../shared/models/entity_attachment_model.dart';
|
||||||
|
import '../../../../shared/models/export_file_result.dart';
|
||||||
import '../../../../shared/models/purchase_order_model.dart';
|
import '../../../../shared/models/purchase_order_model.dart';
|
||||||
import '../../../grn/presentation/providers/grn_lookups_provider.dart';
|
import '../../../grn/presentation/providers/grn_lookups_provider.dart';
|
||||||
import '../../data/repositories/purchase_order_repository_impl.dart';
|
import '../../data/repositories/purchase_order_repository_impl.dart';
|
||||||
@ -14,6 +15,7 @@ class PurchaseOrdersListState {
|
|||||||
this.total = 0,
|
this.total = 0,
|
||||||
this.totalPages = 1,
|
this.totalPages = 1,
|
||||||
this.isRefreshing = false,
|
this.isRefreshing = false,
|
||||||
|
this.isExporting = false,
|
||||||
this.actionError,
|
this.actionError,
|
||||||
this.actionSuccess,
|
this.actionSuccess,
|
||||||
});
|
});
|
||||||
@ -23,6 +25,7 @@ class PurchaseOrdersListState {
|
|||||||
final int total;
|
final int total;
|
||||||
final int totalPages;
|
final int totalPages;
|
||||||
final bool isRefreshing;
|
final bool isRefreshing;
|
||||||
|
final bool isExporting;
|
||||||
final String? actionError;
|
final String? actionError;
|
||||||
final String? actionSuccess;
|
final String? actionSuccess;
|
||||||
|
|
||||||
@ -32,6 +35,7 @@ class PurchaseOrdersListState {
|
|||||||
int? total,
|
int? total,
|
||||||
int? totalPages,
|
int? totalPages,
|
||||||
bool? isRefreshing,
|
bool? isRefreshing,
|
||||||
|
bool? isExporting,
|
||||||
String? actionError,
|
String? actionError,
|
||||||
String? actionSuccess,
|
String? actionSuccess,
|
||||||
bool clearMessages = false,
|
bool clearMessages = false,
|
||||||
@ -42,6 +46,7 @@ class PurchaseOrdersListState {
|
|||||||
total: total ?? this.total,
|
total: total ?? this.total,
|
||||||
totalPages: totalPages ?? this.totalPages,
|
totalPages: totalPages ?? this.totalPages,
|
||||||
isRefreshing: isRefreshing ?? this.isRefreshing,
|
isRefreshing: isRefreshing ?? this.isRefreshing,
|
||||||
|
isExporting: isExporting ?? this.isExporting,
|
||||||
actionError: clearMessages ? null : actionError ?? this.actionError,
|
actionError: clearMessages ? null : actionError ?? this.actionError,
|
||||||
actionSuccess: clearMessages ? null : actionSuccess ?? this.actionSuccess,
|
actionSuccess: clearMessages ? null : actionSuccess ?? this.actionSuccess,
|
||||||
);
|
);
|
||||||
@ -125,6 +130,31 @@ class PurchaseOrdersListNotifier
|
|||||||
applyQuery(current.query.copyWith(limit: limit, page: 1));
|
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 {
|
Future<bool> deletePurchaseOrder(String id) async {
|
||||||
final repository = ref.read(purchaseOrderRepositoryProvider);
|
final repository = ref.read(purchaseOrderRepositoryProvider);
|
||||||
final result = await repository.deletePurchaseOrder(id);
|
final result = await repository.deletePurchaseOrder(id);
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import '../../../../shared/widgets/app_table_action_icon.dart';
|
|||||||
import '../../../../shared/widgets/app_table_shell.dart';
|
import '../../../../shared/widgets/app_table_shell.dart';
|
||||||
import '../../../../shared/widgets/error_view.dart';
|
import '../../../../shared/widgets/error_view.dart';
|
||||||
import '../../../../shared/widgets/page_header.dart';
|
import '../../../../shared/widgets/page_header.dart';
|
||||||
|
import '../../../../shared/utils/file_download_helper.dart';
|
||||||
import '../providers/purchase_orders_provider.dart';
|
import '../providers/purchase_orders_provider.dart';
|
||||||
import '../widgets/po_status_chip.dart';
|
import '../widgets/po_status_chip.dart';
|
||||||
import '../../../../shared/widgets/app_toast.dart';
|
import '../../../../shared/widgets/app_toast.dart';
|
||||||
@ -59,6 +60,7 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
|
|||||||
final ordersAsync = ref.watch(purchaseOrdersListProvider);
|
final ordersAsync = ref.watch(purchaseOrdersListProvider);
|
||||||
final canEdit = ref.can('purchase_orders', PermissionAction.update);
|
final canEdit = ref.can('purchase_orders', PermissionAction.update);
|
||||||
final canDelete = ref.can('purchase_orders', PermissionAction.delete);
|
final canDelete = ref.can('purchase_orders', PermissionAction.delete);
|
||||||
|
final canExport = ref.can('purchase_orders', PermissionAction.export);
|
||||||
|
|
||||||
ref.listen(purchaseOrdersListProvider, (prev, next) {
|
ref.listen(purchaseOrdersListProvider, (prev, next) {
|
||||||
final error = next.valueOrNull?.actionError;
|
final error = next.valueOrNull?.actionError;
|
||||||
@ -108,6 +110,9 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
|
|||||||
query: state.query,
|
query: state.query,
|
||||||
statusOptions: _statusFilterOptions(state.orders),
|
statusOptions: _statusFilterOptions(state.orders),
|
||||||
wrapped: constraints.maxWidth < 900,
|
wrapped: constraints.maxWidth < 900,
|
||||||
|
showExport: canExport,
|
||||||
|
isExporting: state.isExporting,
|
||||||
|
onExport: _exportPurchaseOrders,
|
||||||
onSearch: ref.read(purchaseOrdersListProvider.notifier).setSearch,
|
onSearch: ref.read(purchaseOrdersListProvider.notifier).setSearch,
|
||||||
onStatusChanged:
|
onStatusChanged:
|
||||||
ref.read(purchaseOrdersListProvider.notifier).setStatusFilter,
|
ref.read(purchaseOrdersListProvider.notifier).setStatusFilter,
|
||||||
@ -186,6 +191,36 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
|
|||||||
context.push('${RouteConstants.purchaseOrders}/${order.id}/edit');
|
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 {
|
Future<void> _deleteOrder(PurchaseOrderModel order) async {
|
||||||
if (!order.canDelete) {
|
if (!order.canDelete) {
|
||||||
showAppToastFromSnackBar(context,
|
showAppToastFromSnackBar(context,
|
||||||
@ -244,6 +279,9 @@ class _FiltersBar extends StatelessWidget {
|
|||||||
required this.onSearch,
|
required this.onSearch,
|
||||||
required this.onStatusChanged,
|
required this.onStatusChanged,
|
||||||
required this.onPoTypeChanged,
|
required this.onPoTypeChanged,
|
||||||
|
this.showExport = false,
|
||||||
|
this.isExporting = false,
|
||||||
|
this.onExport,
|
||||||
});
|
});
|
||||||
|
|
||||||
final TextEditingController searchController;
|
final TextEditingController searchController;
|
||||||
@ -253,6 +291,9 @@ class _FiltersBar extends StatelessWidget {
|
|||||||
final ValueChanged<String> onSearch;
|
final ValueChanged<String> onSearch;
|
||||||
final ValueChanged<String?> onStatusChanged;
|
final ValueChanged<String?> onStatusChanged;
|
||||||
final ValueChanged<String?> onPoTypeChanged;
|
final ValueChanged<String?> onPoTypeChanged;
|
||||||
|
final bool showExport;
|
||||||
|
final bool isExporting;
|
||||||
|
final VoidCallback? onExport;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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) {
|
if (wrapped) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
@ -295,6 +348,10 @@ class _FiltersBar extends StatelessWidget {
|
|||||||
filters[0],
|
filters[0],
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
filters[1],
|
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]),
|
Expanded(flex: 2, child: filters[0]),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(flex: 2, child: filters[1]),
|
Expanded(flex: 2, child: filters[1]),
|
||||||
|
if (showExport) ...[
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
exportButton,
|
||||||
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -313,28 +313,30 @@ class _CompanyProfileSettingsScreenState
|
|||||||
SettingsFormCard(
|
SettingsFormCard(
|
||||||
title: 'Company Information',
|
title: 'Company Information',
|
||||||
children: [
|
children: [
|
||||||
AppTextField(
|
SidePanelFormRow(
|
||||||
controller: _nameController,
|
left: AppTextField(
|
||||||
label: 'Company Name',
|
controller: _nameController,
|
||||||
validator: (v) =>
|
label: 'Company Name',
|
||||||
Validators.required(v, fieldName: 'Company Name'),
|
validator: (v) =>
|
||||||
|
Validators.required(v, fieldName: 'Company Name'),
|
||||||
|
),
|
||||||
|
right: AppTextField(
|
||||||
|
controller: _codeController,
|
||||||
|
label: 'Company Code',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
AppTextField(
|
SidePanelFormRow(
|
||||||
controller: _codeController,
|
left: AppTextField(
|
||||||
label: 'Company Code',
|
controller: _registrationController,
|
||||||
),
|
label: 'Registration Number',
|
||||||
const SizedBox(height: 16),
|
),
|
||||||
AppTextField(
|
right: AppTextField(
|
||||||
controller: _registrationController,
|
controller: _gstController,
|
||||||
label: 'Registration Number',
|
label: 'GSTIN',
|
||||||
),
|
validator: Validators.optionalGstin,
|
||||||
const SizedBox(height: 16),
|
inputFormatters: Validators.gstinInput,
|
||||||
AppTextField(
|
),
|
||||||
controller: _gstController,
|
|
||||||
label: 'GSTIN',
|
|
||||||
validator: Validators.optionalGstin,
|
|
||||||
inputFormatters: Validators.gstinInput,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
AppTextField(
|
AppTextField(
|
||||||
@ -354,31 +356,33 @@ class _CompanyProfileSettingsScreenState
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
AppTextField(
|
SidePanelFormRow(
|
||||||
controller: _pincodeController,
|
left: AppTextField(
|
||||||
label: 'Pincode',
|
controller: _pincodeController,
|
||||||
keyboardType: TextInputType.number,
|
label: 'Pincode',
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
),
|
||||||
|
right: AppTextField(
|
||||||
|
controller: _emailController,
|
||||||
|
label: 'Email',
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
validator: Validators.optionalEmail,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
AppTextField(
|
SidePanelFormRow(
|
||||||
controller: _emailController,
|
left: AppTextField(
|
||||||
label: 'Email',
|
controller: _phoneController,
|
||||||
keyboardType: TextInputType.emailAddress,
|
label: 'Mobile',
|
||||||
validator: Validators.optionalEmail,
|
keyboardType: TextInputType.phone,
|
||||||
),
|
validator: Validators.optionalMobile,
|
||||||
const SizedBox(height: 16),
|
inputFormatters: Validators.mobileInput,
|
||||||
AppTextField(
|
),
|
||||||
controller: _phoneController,
|
right: AppTextField(
|
||||||
label: 'Mobile',
|
controller: _websiteController,
|
||||||
keyboardType: TextInputType.phone,
|
label: 'Website',
|
||||||
validator: Validators.optionalMobile,
|
keyboardType: TextInputType.url,
|
||||||
inputFormatters: Validators.mobileInput,
|
),
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
AppTextField(
|
|
||||||
controller: _websiteController,
|
|
||||||
label: 'Website',
|
|
||||||
keyboardType: TextInputType.url,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
import '../../../../core/constants/api_endpoints.dart';
|
import '../../../../core/constants/api_endpoints.dart';
|
||||||
|
import '../../../../core/utils/export_file_name.dart';
|
||||||
import '../../../../shared/models/api_response.dart';
|
import '../../../../shared/models/api_response.dart';
|
||||||
|
import '../../../../shared/models/export_file_result.dart';
|
||||||
import '../../../../shared/models/user_management_models.dart';
|
import '../../../../shared/models/user_management_models.dart';
|
||||||
import '../../../../shared/models/vendor_model.dart';
|
import '../../../../shared/models/vendor_model.dart';
|
||||||
|
|
||||||
@ -37,6 +39,18 @@ class VendorRemoteDataSource {
|
|||||||
await dio.delete(ApiEndpoints.vendorById(id));
|
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 {
|
Future<VendorModel> updateVendorStatus(String id, String status) async {
|
||||||
final response = await dio.patch(
|
final response = await dio.patch(
|
||||||
ApiEndpoints.vendorStatus(id),
|
ApiEndpoints.vendorStatus(id),
|
||||||
@ -190,6 +204,12 @@ class VendorRemoteDataSource {
|
|||||||
return {
|
return {
|
||||||
'page': query.page,
|
'page': query.page,
|
||||||
'limit': query.limit,
|
'limit': query.limit,
|
||||||
|
..._exportQueryToMap(query),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _exportQueryToMap(VendorListQuery query) {
|
||||||
|
return {
|
||||||
if (query.search != null && query.search!.isNotEmpty) 'search': query.search,
|
if (query.search != null && query.search!.isNotEmpty) 'search': query.search,
|
||||||
if (query.status != null) 'status': query.status,
|
if (query.status != null) 'status': query.status,
|
||||||
if (query.vendorType != null) 'vendor_type': query.vendorType,
|
if (query.vendorType != null) 'vendor_type': query.vendorType,
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../../../../core/network/api_handler.dart';
|
import '../../../../core/network/api_handler.dart';
|
||||||
import '../../../../core/network/dio_client.dart';
|
import '../../../../core/network/dio_client.dart';
|
||||||
import '../../../../shared/models/api_response.dart';
|
import '../../../../shared/models/api_response.dart';
|
||||||
|
import '../../../../shared/models/export_file_result.dart';
|
||||||
import '../../../../shared/models/vendor_model.dart';
|
import '../../../../shared/models/vendor_model.dart';
|
||||||
import '../../domain/repositories/vendor_repository.dart';
|
import '../../domain/repositories/vendor_repository.dart';
|
||||||
import '../datasources/vendor_remote_data_source.dart';
|
import '../datasources/vendor_remote_data_source.dart';
|
||||||
@ -25,6 +26,11 @@ class VendorRepositoryImpl implements VendorRepository {
|
|||||||
return safeApiCall(() => dataSource.getVendors(query));
|
return safeApiCall(() => dataSource.getVendors(query));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Result<ExportFileResult>> exportVendors(VendorListQuery query) {
|
||||||
|
return safeApiCall(() => dataSource.exportVendors(query));
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Result<VendorModel>> getVendorById(String id) {
|
Future<Result<VendorModel>> getVendorById(String id) {
|
||||||
return safeApiCall(() => dataSource.getVendorById(id));
|
return safeApiCall(() => dataSource.getVendorById(id));
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
import '../../../../core/network/api_handler.dart';
|
import '../../../../core/network/api_handler.dart';
|
||||||
import '../../../../shared/models/api_response.dart';
|
import '../../../../shared/models/api_response.dart';
|
||||||
|
import '../../../../shared/models/export_file_result.dart';
|
||||||
import '../../../../shared/models/vendor_model.dart';
|
import '../../../../shared/models/vendor_model.dart';
|
||||||
|
|
||||||
abstract class VendorRepository {
|
abstract class VendorRepository {
|
||||||
Future<Result<PaginatedResponse<VendorModel>>> getVendors(VendorListQuery query);
|
Future<Result<PaginatedResponse<VendorModel>>> getVendors(VendorListQuery query);
|
||||||
|
Future<Result<ExportFileResult>> exportVendors(VendorListQuery query);
|
||||||
Future<Result<VendorModel>> getVendorById(String id);
|
Future<Result<VendorModel>> getVendorById(String id);
|
||||||
Future<Result<VendorModel>> createVendor(Map<String, dynamic> data);
|
Future<Result<VendorModel>> createVendor(Map<String, dynamic> data);
|
||||||
Future<Result<VendorModel>> updateVendor(String id, Map<String, dynamic> data);
|
Future<Result<VendorModel>> updateVendor(String id, Map<String, dynamic> data);
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
|
|
||||||
import '../../../../core/utils/table_search.dart';
|
import '../../../../core/utils/table_search.dart';
|
||||||
|
|
||||||
|
import '../../../../shared/models/export_file_result.dart';
|
||||||
import '../../../../shared/models/vendor_model.dart';
|
import '../../../../shared/models/vendor_model.dart';
|
||||||
import '../../data/repositories/vendor_repository_impl.dart';
|
import '../../data/repositories/vendor_repository_impl.dart';
|
||||||
|
|
||||||
@ -12,6 +13,7 @@ class VendorsListState {
|
|||||||
this.total = 0,
|
this.total = 0,
|
||||||
this.totalPages = 1,
|
this.totalPages = 1,
|
||||||
this.isRefreshing = false,
|
this.isRefreshing = false,
|
||||||
|
this.isExporting = false,
|
||||||
this.actionError,
|
this.actionError,
|
||||||
this.actionSuccess,
|
this.actionSuccess,
|
||||||
});
|
});
|
||||||
@ -21,6 +23,7 @@ class VendorsListState {
|
|||||||
final int total;
|
final int total;
|
||||||
final int totalPages;
|
final int totalPages;
|
||||||
final bool isRefreshing;
|
final bool isRefreshing;
|
||||||
|
final bool isExporting;
|
||||||
final String? actionError;
|
final String? actionError;
|
||||||
final String? actionSuccess;
|
final String? actionSuccess;
|
||||||
|
|
||||||
@ -30,6 +33,7 @@ class VendorsListState {
|
|||||||
int? total,
|
int? total,
|
||||||
int? totalPages,
|
int? totalPages,
|
||||||
bool? isRefreshing,
|
bool? isRefreshing,
|
||||||
|
bool? isExporting,
|
||||||
String? actionError,
|
String? actionError,
|
||||||
String? actionSuccess,
|
String? actionSuccess,
|
||||||
bool clearMessages = false,
|
bool clearMessages = false,
|
||||||
@ -40,6 +44,7 @@ class VendorsListState {
|
|||||||
total: total ?? this.total,
|
total: total ?? this.total,
|
||||||
totalPages: totalPages ?? this.totalPages,
|
totalPages: totalPages ?? this.totalPages,
|
||||||
isRefreshing: isRefreshing ?? this.isRefreshing,
|
isRefreshing: isRefreshing ?? this.isRefreshing,
|
||||||
|
isExporting: isExporting ?? this.isExporting,
|
||||||
actionError: clearMessages ? null : actionError ?? this.actionError,
|
actionError: clearMessages ? null : actionError ?? this.actionError,
|
||||||
actionSuccess: clearMessages ? null : actionSuccess ?? this.actionSuccess,
|
actionSuccess: clearMessages ? null : actionSuccess ?? this.actionSuccess,
|
||||||
);
|
);
|
||||||
@ -122,6 +127,30 @@ class VendorsListNotifier extends AutoDisposeAsyncNotifier<VendorsListState> {
|
|||||||
applyQuery(current.query.copyWith(limit: limit, page: 1));
|
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 {
|
Future<bool> deleteVendor(String id) async {
|
||||||
final repository = ref.read(vendorRepositoryProvider);
|
final repository = ref.read(vendorRepositoryProvider);
|
||||||
final result = await repository.deleteVendor(id);
|
final result = await repository.deleteVendor(id);
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import '../../../../shared/widgets/can_permission.dart';
|
|||||||
import '../../../../shared/widgets/app_table_shell.dart';
|
import '../../../../shared/widgets/app_table_shell.dart';
|
||||||
import '../../../../shared/widgets/error_view.dart';
|
import '../../../../shared/widgets/error_view.dart';
|
||||||
import '../../../../shared/widgets/page_header.dart';
|
import '../../../../shared/widgets/page_header.dart';
|
||||||
|
import '../../../../shared/utils/file_download_helper.dart';
|
||||||
import '../providers/vendors_provider.dart';
|
import '../providers/vendors_provider.dart';
|
||||||
import '../widgets/vendor_form_panel.dart';
|
import '../widgets/vendor_form_panel.dart';
|
||||||
import '../../../../shared/widgets/app_toast.dart';
|
import '../../../../shared/widgets/app_toast.dart';
|
||||||
@ -48,6 +49,7 @@ class _VendorListScreenState extends ConsumerState<VendorListScreen> {
|
|||||||
final vendorsAsync = ref.watch(vendorsListProvider);
|
final vendorsAsync = ref.watch(vendorsListProvider);
|
||||||
final canEdit = ref.can('vendors', PermissionAction.update);
|
final canEdit = ref.can('vendors', PermissionAction.update);
|
||||||
final canDelete = ref.can('vendors', PermissionAction.delete);
|
final canDelete = ref.can('vendors', PermissionAction.delete);
|
||||||
|
final canExport = ref.can('vendors', PermissionAction.export);
|
||||||
|
|
||||||
ref.listen(vendorsListProvider, (prev, next) {
|
ref.listen(vendorsListProvider, (prev, next) {
|
||||||
final error = next.valueOrNull?.actionError;
|
final error = next.valueOrNull?.actionError;
|
||||||
@ -94,6 +96,9 @@ class _VendorListScreenState extends ConsumerState<VendorListScreen> {
|
|||||||
searchController: _searchController,
|
searchController: _searchController,
|
||||||
query: state.query,
|
query: state.query,
|
||||||
wrapped: constraints.maxWidth < 900,
|
wrapped: constraints.maxWidth < 900,
|
||||||
|
showExport: canExport,
|
||||||
|
isExporting: state.isExporting,
|
||||||
|
onExport: _exportVendors,
|
||||||
onSearch: ref.read(vendorsListProvider.notifier).setSearch,
|
onSearch: ref.read(vendorsListProvider.notifier).setSearch,
|
||||||
onStatusChanged:
|
onStatusChanged:
|
||||||
ref.read(vendorsListProvider.notifier).setStatusFilter,
|
ref.read(vendorsListProvider.notifier).setStatusFilter,
|
||||||
@ -159,6 +164,34 @@ class _VendorListScreenState extends ConsumerState<VendorListScreen> {
|
|||||||
openVendorFormPanel(context, ref, vendorId: vendor.id);
|
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 {
|
Future<void> _deleteVendor(VendorModel vendor) async {
|
||||||
final confirmed = await showAppConfirmationDialog(
|
final confirmed = await showAppConfirmationDialog(
|
||||||
context: context,
|
context: context,
|
||||||
@ -180,6 +213,9 @@ class _FiltersBar extends StatelessWidget {
|
|||||||
required this.onSearch,
|
required this.onSearch,
|
||||||
required this.onStatusChanged,
|
required this.onStatusChanged,
|
||||||
required this.onVendorTypeChanged,
|
required this.onVendorTypeChanged,
|
||||||
|
this.showExport = false,
|
||||||
|
this.isExporting = false,
|
||||||
|
this.onExport,
|
||||||
});
|
});
|
||||||
|
|
||||||
final TextEditingController searchController;
|
final TextEditingController searchController;
|
||||||
@ -188,6 +224,9 @@ class _FiltersBar extends StatelessWidget {
|
|||||||
final ValueChanged<String> onSearch;
|
final ValueChanged<String> onSearch;
|
||||||
final ValueChanged<String?> onStatusChanged;
|
final ValueChanged<String?> onStatusChanged;
|
||||||
final ValueChanged<String?> onVendorTypeChanged;
|
final ValueChanged<String?> onVendorTypeChanged;
|
||||||
|
final bool showExport;
|
||||||
|
final bool isExporting;
|
||||||
|
final VoidCallback? onExport;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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) {
|
if (wrapped) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
@ -235,6 +286,10 @@ class _FiltersBar extends StatelessWidget {
|
|||||||
filters[0],
|
filters[0],
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
filters[1],
|
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]),
|
Expanded(flex: 2, child: filters[0]),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(flex: 2, child: filters[1]),
|
Expanded(flex: 2, child: filters[1]),
|
||||||
|
if (showExport) ...[
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
exportButton,
|
||||||
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user