diff --git a/lib/core/constants/api_endpoints.dart b/lib/core/constants/api_endpoints.dart index b192fe4..3b878e9 100644 --- a/lib/core/constants/api_endpoints.dart +++ b/lib/core/constants/api_endpoints.dart @@ -97,6 +97,15 @@ class ApiEndpoints { static String purchaseOrderAmend(String id) => '/purchase-orders/$id/amend'; static String purchaseOrderCancel(String id) => '/purchase-orders/$id/cancel'; static String purchaseOrderPdf(String id) => '/purchase-orders/$id/pdf'; + static String purchaseOrderAttachments(String poId) => + '/purchase-orders/$poId/attachments'; + static String purchaseOrderAttachmentById(String poId, String attachmentId) => + '/purchase-orders/$poId/attachments/$attachmentId'; + static String purchaseOrderAttachmentDownload( + String poId, + String attachmentId, + ) => + '/purchase-orders/$poId/attachments/$attachmentId/download'; // GRN static const String grn = '/grn'; @@ -114,6 +123,12 @@ class ApiEndpoints { static String assetById(String id) => '/assets/$id'; static String assetTransfer(String id) => '/assets/$id/transfer'; static String assetTransferHistory(String id) => '/assets/$id/transfer-history'; + static String assetAttachments(String assetId) => + '/assets/$assetId/attachments'; + static String assetAttachmentById(String assetId, String attachmentId) => + '/assets/$assetId/attachments/$attachmentId'; + static String assetAttachmentDownload(String assetId, String attachmentId) => + '/assets/$assetId/attachments/$attachmentId/download'; static const String assetOptions = '/assets/options'; static const String assetContractTypes = '/assets/contract-types'; static const String assetVisitTypes = '/assets/visit-types'; diff --git a/lib/core/utils/table_search.dart b/lib/core/utils/table_search.dart new file mode 100644 index 0000000..ee3ed86 --- /dev/null +++ b/lib/core/utils/table_search.dart @@ -0,0 +1,63 @@ +import 'dart:async'; + +/// Shared helpers for case-insensitive, trimmed, multi-field table search. +class TableSearch { + TableSearch._(); + + /// Trims leading/trailing whitespace. Does not alter casing. + static String normalize(String? query) => query?.trim() ?? ''; + + /// Trimmed + lowercased query for matching. + static String normalizedQuery(String? query) => normalize(query).toLowerCase(); + + /// Returns true when [query] is empty or any [values] contain the query. + static bool matches(String? query, Iterable values) { + final q = normalizedQuery(query); + if (q.isEmpty) return true; + + for (final value in values) { + if (value == null) continue; + final text = value.toString().trim().toLowerCase(); + if (text.isEmpty) continue; + if (text.contains(q)) return true; + } + return false; + } + + /// Filters [items] to those where any extracted field matches [query]. + static List filter( + Iterable items, + String? query, + Iterable Function(T item) valuesOf, + ) { + final q = normalizedQuery(query); + if (q.isEmpty) { + return items is List ? items : items.toList(); + } + return items.where((item) => matches(q, valuesOf(item))).toList(); + } +} + +/// Debounces search input so API-backed lists are not hit on every keystroke. +class SearchDebouncer { + SearchDebouncer({this.duration = const Duration(milliseconds: 300)}); + + final Duration duration; + Timer? _timer; + + /// Runs [action] after [duration]. Empty queries run immediately. + void run(String query, void Function(String normalized) action) { + final normalized = TableSearch.normalize(query); + _timer?.cancel(); + if (normalized.isEmpty) { + action(normalized); + return; + } + _timer = Timer(duration, () => action(normalized)); + } + + void dispose() { + _timer?.cancel(); + _timer = null; + } +} diff --git a/lib/modules/assets/data/datasources/asset_remote_data_source.dart b/lib/modules/assets/data/datasources/asset_remote_data_source.dart index e031e93..34b726c 100644 --- a/lib/modules/assets/data/datasources/asset_remote_data_source.dart +++ b/lib/modules/assets/data/datasources/asset_remote_data_source.dart @@ -3,6 +3,7 @@ import 'package:dio/dio.dart'; import '../../../../core/constants/api_endpoints.dart'; import '../../../../shared/models/api_response.dart'; import '../../../../shared/models/asset_model.dart'; +import '../../../../shared/models/entity_attachment_model.dart'; class AssetRemoteDataSource { AssetRemoteDataSource({required this.dio}); @@ -40,6 +41,50 @@ class AssetRemoteDataSource { await dio.delete(ApiEndpoints.assetById(id)); } + Future> listAttachments(String assetId) async { + final response = await dio.get(ApiEndpoints.assetAttachments(assetId)); + final data = response.data['data']; + if (data is! List) return const []; + return data + .whereType() + .map((e) => EntityAttachmentModel.fromJson(Map.from(e))) + .toList(); + } + + Future uploadAttachment( + String assetId, { + required List bytes, + required String filename, + String attachmentType = 'DOCUMENT', + }) async { + final formData = FormData.fromMap({ + 'file': MultipartFile.fromBytes(bytes, filename: filename), + 'attachment_type': attachmentType, + }); + final response = await dio.post( + ApiEndpoints.assetAttachments(assetId), + data: formData, + ); + return EntityAttachmentModel.fromJson( + Map.from(response.data['data'] as Map), + ); + } + + Future> downloadAttachment( + String assetId, + String attachmentId, + ) async { + final response = await dio.get>( + ApiEndpoints.assetAttachmentDownload(assetId, attachmentId), + options: Options(responseType: ResponseType.bytes), + ); + return response.data ?? []; + } + + Future deleteAttachment(String assetId, String attachmentId) async { + await dio.delete(ApiEndpoints.assetAttachmentById(assetId, attachmentId)); + } + Future transferAsset(String id, Map data) async { await dio.post(ApiEndpoints.assetTransfer(id), data: data); } @@ -324,7 +369,7 @@ class AssetRemoteDataSource { dynamic body, T Function(Map) fromJson, ) { - if (body is! Map) { + if (body is! Map) { return const PaginatedResponse( items: [], page: 1, @@ -334,22 +379,66 @@ class AssetRemoteDataSource { ); } - final raw = body['data']; - final meta = body['meta'] as Map? ?? {}; + final map = Map.from(body); + final raw = map['data']; + final meta = map['meta'] is Map + ? Map.from(map['meta'] as Map) + : {}; if (raw is List) { - final items = raw.whereType>().map(fromJson).toList(); + final items = raw + .whereType() + .map((e) => fromJson(Map.from(e))) + .toList(); + final limit = (meta['limit'] as num?)?.toInt() ?? items.length; + final total = (meta['total'] as num?)?.toInt() ?? items.length; + final explicitTotalPages = + (meta['totalPages'] as num?)?.toInt() ?? + (meta['total_pages'] as num?)?.toInt(); return PaginatedResponse( items: items, page: (meta['page'] as num?)?.toInt() ?? 1, - limit: (meta['limit'] as num?)?.toInt() ?? items.length, - total: (meta['total'] as num?)?.toInt() ?? items.length, - totalPages: (meta['totalPages'] as num?)?.toInt() ?? 1, + limit: limit, + total: total, + totalPages: explicitTotalPages ?? + (limit > 0 + ? ((total + limit - 1) ~/ limit).clamp(1, 999999) + : 1), ); } - if (raw is Map) { - return PaginatedResponse.fromJson(raw, (json) => fromJson(json! as Map)); + if (raw is Map) { + final nested = Map.from(raw); + final list = nested['items']; + if (list is List) { + final items = list + .whereType() + .map((e) => fromJson(Map.from(e))) + .toList(); + final limit = (meta['limit'] as num?)?.toInt() ?? + (nested['limit'] as num?)?.toInt() ?? + 20; + final total = (meta['total'] as num?)?.toInt() ?? + (nested['total'] as num?)?.toInt() ?? + items.length; + final explicitTotalPages = + (meta['totalPages'] as num?)?.toInt() ?? + (meta['total_pages'] as num?)?.toInt() ?? + (nested['totalPages'] as num?)?.toInt() ?? + (nested['total_pages'] as num?)?.toInt(); + return PaginatedResponse( + items: items, + page: (meta['page'] as num?)?.toInt() ?? + (nested['page'] as num?)?.toInt() ?? + 1, + limit: limit, + total: total, + totalPages: explicitTotalPages ?? + (limit > 0 + ? ((total + limit - 1) ~/ limit).clamp(1, 999999) + : 1), + ); + } } return const PaginatedResponse( diff --git a/lib/modules/assets/data/repositories/asset_repository_impl.dart b/lib/modules/assets/data/repositories/asset_repository_impl.dart index 3519759..bb881c6 100644 --- a/lib/modules/assets/data/repositories/asset_repository_impl.dart +++ b/lib/modules/assets/data/repositories/asset_repository_impl.dart @@ -4,6 +4,7 @@ import '../../../../core/network/api_handler.dart'; import '../../../../core/network/dio_client.dart'; import '../../../../shared/models/api_response.dart'; import '../../../../shared/models/asset_model.dart'; +import '../../../../shared/models/entity_attachment_model.dart'; import '../../domain/repositories/asset_repository.dart'; import '../datasources/asset_remote_data_source.dart'; @@ -252,4 +253,43 @@ class AssetRepositoryImpl implements AssetRepository { ) { return safeApiCall(() => dataSource.renewInsurancePolicy(assetId, policyId, data)); } + + @override + Future>> listAttachments(String assetId) { + return safeApiCall(() => dataSource.listAttachments(assetId)); + } + + @override + Future> uploadAttachment( + String assetId, { + required List bytes, + required String filename, + String attachmentType = 'DOCUMENT', + }) { + return safeApiCall( + () => dataSource.uploadAttachment( + assetId, + bytes: bytes, + filename: filename, + attachmentType: attachmentType, + ), + ); + } + + @override + Future>> downloadAttachment( + String assetId, + String attachmentId, + ) { + return safeApiCall( + () => dataSource.downloadAttachment(assetId, attachmentId), + ); + } + + @override + Future> deleteAttachment(String assetId, String attachmentId) { + return safeApiCall( + () => dataSource.deleteAttachment(assetId, attachmentId), + ); + } } diff --git a/lib/modules/assets/domain/repositories/asset_repository.dart b/lib/modules/assets/domain/repositories/asset_repository.dart index beec77b..c167ba9 100644 --- a/lib/modules/assets/domain/repositories/asset_repository.dart +++ b/lib/modules/assets/domain/repositories/asset_repository.dart @@ -1,6 +1,7 @@ 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'; abstract class AssetRepository { Future>> getAssets(PaginationParams params); @@ -84,4 +85,16 @@ abstract class AssetRepository { String policyId, Map data, ); + Future>> listAttachments(String assetId); + Future> uploadAttachment( + String assetId, { + required List bytes, + required String filename, + String attachmentType = 'DOCUMENT', + }); + Future>> downloadAttachment( + String assetId, + String attachmentId, + ); + Future> deleteAttachment(String assetId, String attachmentId); } diff --git a/lib/modules/assets/presentation/providers/assets_provider.dart b/lib/modules/assets/presentation/providers/assets_provider.dart index db8908e..3c49eab 100644 --- a/lib/modules/assets/presentation/providers/assets_provider.dart +++ b/lib/modules/assets/presentation/providers/assets_provider.dart @@ -4,6 +4,7 @@ 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 '../../data/repositories/asset_repository_impl.dart'; class AssetsListState { @@ -141,24 +142,28 @@ class AssetDetailState { this.amcContracts = const [], this.serviceVisits = const [], this.insurancePolicies = const [], + this.attachments = const [], }); final AssetModel asset; final List amcContracts; final List serviceVisits; final List insurancePolicies; + final List attachments; AssetDetailState copyWith({ AssetModel? asset, List? amcContracts, List? serviceVisits, List? insurancePolicies, + List? attachments, }) { return AssetDetailState( asset: asset ?? this.asset, amcContracts: amcContracts ?? this.amcContracts, serviceVisits: serviceVisits ?? this.serviceVisits, insurancePolicies: insurancePolicies ?? this.insurancePolicies, + attachments: attachments ?? this.attachments, ); } } @@ -177,12 +182,14 @@ class AssetDetailNotifier extends FamilyAsyncNotifier final amcResult = await repository.getAmcContracts(assetId); final visitsResult = await repository.getServiceVisits(assetId); final insuranceResult = await repository.getInsurancePolicies(assetId); + final attachmentsResult = await repository.listAttachments(assetId); return AssetDetailState( asset: assetResult.data!, amcContracts: amcResult.data ?? [], serviceVisits: visitsResult.data ?? [], insurancePolicies: insuranceResult.data ?? [], + attachments: attachmentsResult.data ?? [], ); } @@ -191,6 +198,57 @@ class AssetDetailNotifier extends FamilyAsyncNotifier state = AsyncData(await _loadAll(arg)); } + Future uploadAttachment({ + required List bytes, + required String filename, + String attachmentType = 'DOCUMENT', + }) async { + final repository = ref.read(assetRepositoryProvider); + final result = await repository.uploadAttachment( + arg, + bytes: bytes, + filename: filename, + attachmentType: attachmentType, + ); + if (result.failure != null) throw result.failure!; + final current = state.valueOrNull; + if (current == null) { + await reload(); + } else { + state = AsyncData( + current.copyWith( + attachments: [...current.attachments, result.data!], + ), + ); + } + return result.data!; + } + + Future> downloadAttachment(String attachmentId) async { + final repository = ref.read(assetRepositoryProvider); + final result = await repository.downloadAttachment(arg, attachmentId); + if (result.failure != null) throw result.failure!; + return result.data ?? []; + } + + Future deleteAttachment(String attachmentId) async { + final repository = ref.read(assetRepositoryProvider); + final result = await repository.deleteAttachment(arg, attachmentId); + if (result.failure != null) throw result.failure!; + final current = state.valueOrNull; + if (current == null) { + await reload(); + return; + } + state = AsyncData( + current.copyWith( + attachments: current.attachments + .where((a) => a.id != attachmentId) + .toList(), + ), + ); + } + Future updateAsset(Map data) async { final repository = ref.read(assetRepositoryProvider); final result = await repository.updateAsset(arg, data); diff --git a/lib/modules/assets/presentation/screens/asset_detail_screen.dart b/lib/modules/assets/presentation/screens/asset_detail_screen.dart index d191a23..63e090e 100644 --- a/lib/modules/assets/presentation/screens/asset_detail_screen.dart +++ b/lib/modules/assets/presentation/screens/asset_detail_screen.dart @@ -7,6 +7,7 @@ import '../../../../core/constants/enums.dart'; import '../../../../core/constants/route_constants.dart'; import '../../../../core/errors/failure.dart'; import '../../../../shared/models/asset_model.dart'; +import '../../../../shared/models/entity_attachment_model.dart'; import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/providers/permissions_provider.dart'; import '../../../../shared/widgets/app_card.dart'; @@ -15,6 +16,7 @@ import '../../../../shared/widgets/app_empty_state.dart'; import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_status_chip.dart'; import '../../../../shared/widgets/can_permission.dart'; +import '../../../../shared/widgets/entity_attachments_card.dart'; import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/page_header.dart'; import '../../../../shared/widgets/app_side_panel.dart'; @@ -115,7 +117,11 @@ class _AssetDetailScreenState extends ConsumerState controller: _tabController, children: [ _OverviewTab( + assetId: widget.assetId, asset: state.asset, + attachments: state.attachments, + canUpload: canEdit, + canDelete: canDelete, onOpenTransferHistory: _openTransferHistoryPanel, ), _AmcTab(assetId: widget.assetId, contracts: state.amcContracts), @@ -170,17 +176,25 @@ class _AssetDetailScreenState extends ConsumerState } } -class _OverviewTab extends StatelessWidget { +class _OverviewTab extends ConsumerWidget { const _OverviewTab({ + required this.assetId, required this.asset, + required this.attachments, + required this.canUpload, + required this.canDelete, required this.onOpenTransferHistory, }); + final String assetId; final AssetModel asset; + final List attachments; + final bool canUpload; + final bool canDelete; final VoidCallback onOpenTransferHistory; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); final dateFormat = DateFormat('dd MMM yyyy'); @@ -188,89 +202,124 @@ class _OverviewTab extends StatelessWidget { child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 1200), - child: AppCard( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AppCard( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - 'Asset Details', - style: theme.textTheme.labelLarge?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, + Row( + children: [ + Text( + 'Asset Details', + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + TextButton.icon( + onPressed: onOpenTransferHistory, + icon: const Icon(Icons.history, size: 18), + label: const Text('Transfer History'), + ), + ], + ), + const SizedBox(height: 12), + _AssetInfoGrid( + items: [ + _AssetInfo('Asset Name', asset.assetName), + _AssetInfo('Asset Code', asset.assetCode ?? '—'), + _AssetInfo('Category', asset.assetCategoryName ?? '—'), + _AssetInfo( + 'Subcategory', + asset.assetSubcategoryName ?? '—', + ), + _AssetInfo('Plant', asset.plantName ?? '—'), + _AssetInfo('Serial Number', asset.serialNumber ?? '—'), + _AssetInfo('Brand / Model', asset.brandModel ?? '—'), + _AssetInfo('Manufacturer', asset.manufacturer ?? '—'), + _AssetInfo( + 'Purchase Date', + asset.purchaseDate != null + ? dateFormat.format(asset.purchaseDate!) + : '—', + ), + _AssetInfo( + 'Warranty Expiry', + asset.warrantyExpiryDate != null + ? dateFormat.format(asset.warrantyExpiryDate!) + : '—', + ), + _AssetInfo( + 'Purchase Cost', + asset.purchaseCost != null + ? '₹${asset.purchaseCost}' + : '—', + ), + _AssetInfo( + 'Useful Life', + asset.usefulLifeYears != null + ? '${asset.usefulLifeYears} years' + : '—', + ), + _AssetInfo( + 'Depreciation', + asset.depreciationMethod != null + ? '${asset.depreciationMethod}' + '${asset.depreciationRate != null ? ' (${asset.depreciationRate}%)' : ''}' + : '—', + ), + _AssetInfo( + 'Condition', + assetConditionLabel(asset.condition), + ), + _AssetInfo.widget( + 'Status', + AppStatusChip(status: asset.status ?? 'IN_USE'), + ), + _AssetInfo('Active', asset.isActive ? 'Yes' : 'No'), + ], + ), + if (asset.remarks?.trim().isNotEmpty == true) ...[ + const Padding( + padding: EdgeInsets.symmetric(vertical: 20), + child: Divider(height: 1), ), - ), - const Spacer(), - TextButton.icon( - onPressed: onOpenTransferHistory, - icon: const Icon(Icons.history, size: 18), - label: const Text('Transfer History'), - ), + _AssetInfoGrid( + items: [_AssetInfo('Remarks', asset.remarks!)], + ), + ], ], ), - const SizedBox(height: 12), - _AssetInfoGrid( - items: [ - _AssetInfo('Asset Name', asset.assetName), - _AssetInfo('Asset Code', asset.assetCode ?? '—'), - _AssetInfo('Category', asset.assetCategoryName ?? '—'), - _AssetInfo('Subcategory', asset.assetSubcategoryName ?? '—'), - _AssetInfo('Plant', asset.plantName ?? '—'), - _AssetInfo('Serial Number', asset.serialNumber ?? '—'), - _AssetInfo('Brand / Model', asset.brandModel ?? '—'), - _AssetInfo('Manufacturer', asset.manufacturer ?? '—'), - _AssetInfo( - 'Purchase Date', - asset.purchaseDate != null - ? dateFormat.format(asset.purchaseDate!) - : '—', - ), - _AssetInfo( - 'Warranty Expiry', - asset.warrantyExpiryDate != null - ? dateFormat.format(asset.warrantyExpiryDate!) - : '—', - ), - _AssetInfo( - 'Purchase Cost', - asset.purchaseCost != null ? '₹${asset.purchaseCost}' : '—', - ), - _AssetInfo( - 'Useful Life', - asset.usefulLifeYears != null - ? '${asset.usefulLifeYears} years' - : '—', - ), - _AssetInfo( - 'Depreciation', - asset.depreciationMethod != null - ? '${asset.depreciationMethod}' - '${asset.depreciationRate != null ? ' (${asset.depreciationRate}%)' : ''}' - : '—', - ), - _AssetInfo('Condition', assetConditionLabel(asset.condition)), - _AssetInfo.widget( - 'Status', - AppStatusChip(status: asset.status ?? 'IN_USE'), - ), - _AssetInfo('Active', asset.isActive ? 'Yes' : 'No'), - ], - ), - if (asset.remarks?.trim().isNotEmpty == true) ...[ - const Padding( - padding: EdgeInsets.symmetric(vertical: 20), - child: Divider(height: 1), - ), - _AssetInfoGrid( - items: [_AssetInfo('Remarks', asset.remarks!)], - ), - ], - ], + ), ), - ), + const SizedBox(height: 16), + EntityAttachmentsCard( + attachments: attachments, + canUpload: canUpload, + canDelete: canDelete, + subtitleWhenEditable: + 'PDF, JPEG, PNG, or WebP · invoices, warranty cards, photos', + subtitleWhenReadonly: 'Supporting documents for this asset', + emptyUploadHint: + 'No attachments yet. Upload invoices, warranty cards, or photos.', + onUpload: ({required bytes, required filename}) async { + await ref + .read(assetDetailProvider(assetId).notifier) + .uploadAttachment(bytes: bytes, filename: filename); + }, + onDownload: (id) => ref + .read(assetDetailProvider(assetId).notifier) + .downloadAttachment(id), + onDelete: (id) => ref + .read(assetDetailProvider(assetId).notifier) + .deleteAttachment(id), + ), + ], ), ), ), diff --git a/lib/modules/purchase_orders/data/datasources/purchase_order_remote_data_source.dart b/lib/modules/purchase_orders/data/datasources/purchase_order_remote_data_source.dart index 098a7b3..727930f 100644 --- a/lib/modules/purchase_orders/data/datasources/purchase_order_remote_data_source.dart +++ b/lib/modules/purchase_orders/data/datasources/purchase_order_remote_data_source.dart @@ -2,6 +2,7 @@ import 'package:dio/dio.dart'; import '../../../../core/constants/api_endpoints.dart'; import '../../../../shared/models/api_response.dart'; +import '../../../../shared/models/entity_attachment_model.dart'; import '../../../../shared/models/purchase_order_model.dart'; class PurchaseOrderRemoteDataSource { @@ -127,6 +128,51 @@ class PurchaseOrderRemoteDataSource { return response.data ?? []; } + Future> listAttachments(String poId) async { + final response = + await dio.get(ApiEndpoints.purchaseOrderAttachments(poId)); + final data = response.data['data']; + if (data is! List) return const []; + return data + .whereType() + .map((e) => EntityAttachmentModel.fromJson(Map.from(e))) + .toList(); + } + + Future uploadAttachment( + String poId, { + required List bytes, + required String filename, + }) async { + final formData = FormData.fromMap({ + 'file': MultipartFile.fromBytes(bytes, filename: filename), + }); + final response = await dio.post( + ApiEndpoints.purchaseOrderAttachments(poId), + data: formData, + ); + return EntityAttachmentModel.fromJson( + Map.from(response.data['data'] as Map), + ); + } + + Future> downloadAttachment( + String poId, + String attachmentId, + ) async { + final response = await dio.get>( + ApiEndpoints.purchaseOrderAttachmentDownload(poId, attachmentId), + options: Options(responseType: ResponseType.bytes), + ); + return response.data ?? []; + } + + Future deleteAttachment(String poId, String attachmentId) async { + await dio.delete( + ApiEndpoints.purchaseOrderAttachmentById(poId, attachmentId), + ); + } + Map _queryToMap(PurchaseOrderListQuery query) { return { 'page': query.page, diff --git a/lib/modules/purchase_orders/data/repositories/purchase_order_repository_impl.dart b/lib/modules/purchase_orders/data/repositories/purchase_order_repository_impl.dart index 797f9b9..c852195 100644 --- a/lib/modules/purchase_orders/data/repositories/purchase_order_repository_impl.dart +++ b/lib/modules/purchase_orders/data/repositories/purchase_order_repository_impl.dart @@ -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/entity_attachment_model.dart'; import '../../../../shared/models/purchase_order_model.dart'; import '../../domain/repositories/purchase_order_repository.dart'; import '../datasources/purchase_order_remote_data_source.dart'; @@ -99,4 +100,41 @@ class PurchaseOrderRepositoryImpl implements PurchaseOrderRepository { Future>> downloadPurchaseOrderPdf(String id) { return safeApiCall(() => dataSource.downloadPurchaseOrderPdf(id)); } + + @override + Future>> listAttachments(String poId) { + return safeApiCall(() => dataSource.listAttachments(poId)); + } + + @override + Future> uploadAttachment( + String poId, { + required List bytes, + required String filename, + }) { + return safeApiCall( + () => dataSource.uploadAttachment( + poId, + bytes: bytes, + filename: filename, + ), + ); + } + + @override + Future>> downloadAttachment( + String poId, + String attachmentId, + ) { + return safeApiCall( + () => dataSource.downloadAttachment(poId, attachmentId), + ); + } + + @override + Future> deleteAttachment(String poId, String attachmentId) { + return safeApiCall( + () => dataSource.deleteAttachment(poId, attachmentId), + ); + } } diff --git a/lib/modules/purchase_orders/domain/repositories/purchase_order_repository.dart b/lib/modules/purchase_orders/domain/repositories/purchase_order_repository.dart index 3a84d9d..78c01e0 100644 --- a/lib/modules/purchase_orders/domain/repositories/purchase_order_repository.dart +++ b/lib/modules/purchase_orders/domain/repositories/purchase_order_repository.dart @@ -1,5 +1,6 @@ import '../../../../core/network/api_handler.dart'; import '../../../../shared/models/api_response.dart'; +import '../../../../shared/models/entity_attachment_model.dart'; import '../../../../shared/models/purchase_order_model.dart'; abstract class PurchaseOrderRepository { @@ -25,4 +26,15 @@ abstract class PurchaseOrderRepository { }); Future> cancelPurchaseOrder(String id, {String? remarks}); Future>> downloadPurchaseOrderPdf(String id); + Future>> listAttachments(String poId); + Future> uploadAttachment( + String poId, { + required List bytes, + required String filename, + }); + Future>> downloadAttachment( + String poId, + String attachmentId, + ); + Future> deleteAttachment(String poId, String attachmentId); } diff --git a/lib/modules/purchase_orders/presentation/providers/purchase_orders_provider.dart b/lib/modules/purchase_orders/presentation/providers/purchase_orders_provider.dart index e369095..b66721a 100644 --- a/lib/modules/purchase_orders/presentation/providers/purchase_orders_provider.dart +++ b/lib/modules/purchase_orders/presentation/providers/purchase_orders_provider.dart @@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/utils/table_search.dart'; +import '../../../../shared/models/entity_attachment_model.dart'; import '../../../../shared/models/purchase_order_model.dart'; import '../../../grn/presentation/providers/grn_lookups_provider.dart'; import '../../data/repositories/purchase_order_repository_impl.dart'; @@ -221,6 +222,55 @@ class PurchaseOrderDetailNotifier } } +final purchaseOrderAttachmentsProvider = AsyncNotifierProvider.family< + PurchaseOrderAttachmentsNotifier, List, String>( + PurchaseOrderAttachmentsNotifier.new, +); + +class PurchaseOrderAttachmentsNotifier + extends FamilyAsyncNotifier, String> { + @override + Future> build(String arg) async { + final repository = ref.read(purchaseOrderRepositoryProvider); + final result = await repository.listAttachments(arg); + if (result.failure != null) throw result.failure!; + return result.data ?? []; + } + + Future upload({ + required List bytes, + required String filename, + }) async { + final repository = ref.read(purchaseOrderRepositoryProvider); + final result = await repository.uploadAttachment( + arg, + bytes: bytes, + filename: filename, + ); + if (result.failure != null) throw result.failure!; + final current = state.valueOrNull ?? []; + state = AsyncData([...current, result.data!]); + return result.data!; + } + + Future> download(String attachmentId) async { + final repository = ref.read(purchaseOrderRepositoryProvider); + final result = await repository.downloadAttachment(arg, attachmentId); + if (result.failure != null) throw result.failure!; + return result.data ?? []; + } + + Future delete(String attachmentId) async { + final repository = ref.read(purchaseOrderRepositoryProvider); + final result = await repository.deleteAttachment(arg, attachmentId); + if (result.failure != null) throw result.failure!; + final current = state.valueOrNull ?? []; + state = AsyncData( + current.where((a) => a.id != attachmentId).toList(), + ); + } +} + final purchaseOrderFormProvider = AsyncNotifierProvider.family< PurchaseOrderFormNotifier, PurchaseOrderModel?, String?>( PurchaseOrderFormNotifier.new, diff --git a/lib/modules/purchase_orders/presentation/screens/purchase_order_detail_screen.dart b/lib/modules/purchase_orders/presentation/screens/purchase_order_detail_screen.dart index 54d67c8..39e953a 100644 --- a/lib/modules/purchase_orders/presentation/screens/purchase_order_detail_screen.dart +++ b/lib/modules/purchase_orders/presentation/screens/purchase_order_detail_screen.dart @@ -14,6 +14,7 @@ import '../../../../shared/utils/file_download_helper.dart'; import '../../../../shared/widgets/app_confirmation_dialog.dart'; import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_text_field.dart'; +import '../../../../shared/widgets/entity_attachments_card.dart'; import '../../../../shared/widgets/error_view.dart'; import '../providers/purchase_order_lookups_provider.dart'; import '../providers/purchase_orders_provider.dart'; @@ -118,6 +119,12 @@ class _PurchaseOrderDetailScreenState ); }, ), + const SizedBox(height: 16), + _PoAttachmentsSection( + poId: order.id, + canUpload: canEdit, + canDelete: canDelete, + ), const SizedBox(height: 20), _DetailFooter(order: order), ], @@ -793,6 +800,71 @@ class _DetailField extends StatelessWidget { } } +class _PoAttachmentsSection extends ConsumerWidget { + const _PoAttachmentsSection({ + required this.poId, + required this.canUpload, + required this.canDelete, + }); + + final String poId; + final bool canUpload; + final bool canDelete; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final attachmentsAsync = ref.watch(purchaseOrderAttachmentsProvider(poId)); + + return attachmentsAsync.when( + loading: () => const SizedBox( + height: 88, + child: Center(child: CircularProgressIndicator(strokeWidth: 2)), + ), + error: (e, _) => EntityAttachmentsCard( + attachments: const [], + canUpload: canUpload, + canDelete: canDelete, + subtitleWhenEditable: 'PDF, JPEG, PNG, or WebP', + subtitleWhenReadonly: 'Supporting documents for this purchase order', + emptyReadonlyHint: e is Failure + ? e.message + : 'Failed to load attachments', + onUpload: ({required bytes, required filename}) async { + await ref + .read(purchaseOrderAttachmentsProvider(poId).notifier) + .upload(bytes: bytes, filename: filename); + }, + onDownload: (id) => ref + .read(purchaseOrderAttachmentsProvider(poId).notifier) + .download(id), + onDelete: (id) => ref + .read(purchaseOrderAttachmentsProvider(poId).notifier) + .delete(id), + ), + data: (attachments) => EntityAttachmentsCard( + attachments: attachments, + canUpload: canUpload, + canDelete: canDelete, + subtitleWhenEditable: 'PDF, JPEG, PNG, or WebP', + subtitleWhenReadonly: 'Supporting documents for this purchase order', + emptyUploadHint: + 'No attachments yet. Upload quotes, approvals, or related files.', + onUpload: ({required bytes, required filename}) async { + await ref + .read(purchaseOrderAttachmentsProvider(poId).notifier) + .upload(bytes: bytes, filename: filename); + }, + onDownload: (id) => ref + .read(purchaseOrderAttachmentsProvider(poId).notifier) + .download(id), + onDelete: (id) => ref + .read(purchaseOrderAttachmentsProvider(poId).notifier) + .delete(id), + ), + ); + } +} + class _LineItemsCard extends StatelessWidget { const _LineItemsCard({ required this.order, diff --git a/lib/shared/models/entity_attachment_model.dart b/lib/shared/models/entity_attachment_model.dart new file mode 100644 index 0000000..96f7895 --- /dev/null +++ b/lib/shared/models/entity_attachment_model.dart @@ -0,0 +1,87 @@ +/// Shared attachment metadata for GRN / Asset / Purchase Order files. +class EntityAttachmentModel { + const EntityAttachmentModel({ + required this.id, + this.parentId, + this.fileName, + this.fileType, + this.fileSize, + this.uploadedByName, + this.attachmentType, + this.createdAt, + }); + + final String id; + final String? parentId; + final String? fileName; + final String? fileType; + final int? fileSize; + final String? uploadedByName; + final String? attachmentType; + final DateTime? createdAt; + + factory EntityAttachmentModel.fromJson(Map json) { + return EntityAttachmentModel( + id: _idFromJson(json['id']), + parentId: _idFromJsonNullable( + json['asset_id'] ?? json['po_id'] ?? json['grn_id'], + ), + fileName: json['file_name'] as String?, + fileType: json['file_type'] as String?, + fileSize: _intFromJsonNullable(json['file_size']), + uploadedByName: _readUploadedByName(json), + attachmentType: json['attachment_type'] as String?, + createdAt: _dateFromJsonNullable(json['created_at']), + ); + } + + bool get isPdf => + (fileType ?? '').toLowerCase().contains('pdf') || + (fileName ?? '').toLowerCase().endsWith('.pdf'); + + bool get isImage { + final type = (fileType ?? '').toLowerCase(); + final name = (fileName ?? '').toLowerCase(); + return type.startsWith('image/') || + name.endsWith('.jpg') || + name.endsWith('.jpeg') || + name.endsWith('.png') || + name.endsWith('.webp'); + } +} + +String _idFromJson(Object? value) { + if (value == null) return ''; + return value.toString(); +} + +String? _idFromJsonNullable(Object? value) { + if (value == null) return null; + final s = value.toString().trim(); + return s.isEmpty ? null : s; +} + +int? _intFromJsonNullable(Object? value) { + if (value == null) return null; + if (value is int) return value; + return int.tryParse(value.toString()); +} + +DateTime? _dateFromJsonNullable(Object? value) { + if (value == null) return null; + if (value is DateTime) return value; + return DateTime.tryParse(value.toString()); +} + +String? _readUploadedByName(Map json) { + final flat = json['uploaded_by_name']; + if (flat is String && flat.trim().isNotEmpty) return flat; + final nested = json['uploaded_by_user'] ?? json['uploaded_by']; + if (nested is Map) { + final name = nested['full_name'] ?? nested['name']; + if (name != null && name.toString().trim().isNotEmpty) { + return name.toString().trim(); + } + } + return null; +} diff --git a/lib/shared/widgets/entity_attachments_card.dart b/lib/shared/widgets/entity_attachments_card.dart new file mode 100644 index 0000000..5e4f2e2 --- /dev/null +++ b/lib/shared/widgets/entity_attachments_card.dart @@ -0,0 +1,377 @@ +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; + +import '../../core/errors/failure.dart'; +import '../../core/network/api_handler.dart'; +import '../../core/utils/formatters.dart'; +import '../models/entity_attachment_model.dart'; +import '../utils/file_download_helper.dart'; +import 'app_confirmation_dialog.dart'; + +const kEntityAttachmentExtensions = ['pdf', 'jpg', 'jpeg', 'png', 'webp']; + +String _formatFileSize(int? bytes) { + if (bytes == null || bytes <= 0) return '—'; + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) { + return '${(bytes / 1024).toStringAsFixed(1)} KB'; + } + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; +} + +IconData _fileIcon(EntityAttachmentModel attachment) { + if (attachment.isPdf) return Icons.picture_as_pdf_outlined; + if (attachment.isImage) return Icons.image_outlined; + return Icons.insert_drive_file_outlined; +} + +/// Shared attachments card — list / upload / download / delete. +class EntityAttachmentsCard extends StatefulWidget { + const EntityAttachmentsCard({ + super.key, + required this.attachments, + required this.canUpload, + required this.canDelete, + required this.onUpload, + required this.onDownload, + required this.onDelete, + this.subtitleWhenEditable = + 'PDF, JPEG, PNG, or WebP', + this.subtitleWhenReadonly = 'Supporting documents', + this.emptyUploadHint = 'No attachments yet. Upload a file to get started.', + this.emptyReadonlyHint = 'No attachments', + this.readonlyFooter, + }); + + final List attachments; + final bool canUpload; + final bool canDelete; + final Future Function({ + required List bytes, + required String filename, + }) onUpload; + final Future> Function(String attachmentId) onDownload; + final Future Function(String attachmentId) onDelete; + final String subtitleWhenEditable; + final String subtitleWhenReadonly; + final String emptyUploadHint; + final String emptyReadonlyHint; + final String? readonlyFooter; + + @override + State createState() => _EntityAttachmentsCardState(); +} + +class _EntityAttachmentsCardState extends State { + bool _isUploading = false; + String? _busyAttachmentId; + + Future _upload() async { + if (!widget.canUpload) return; + + final result = await FilePicker.pickFiles( + type: FileType.custom, + allowedExtensions: kEntityAttachmentExtensions, + withData: true, + ); + if (result == null || result.files.isEmpty) return; + + final file = result.files.single; + final bytes = file.bytes; + if (bytes == null || bytes.isEmpty) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not read the selected file')), + ); + return; + } + + final ext = (file.extension ?? '').toLowerCase(); + if (!kEntityAttachmentExtensions.contains(ext)) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Only PDF, JPEG, PNG, and WebP files are allowed'), + ), + ); + return; + } + + setState(() => _isUploading = true); + try { + await widget.onUpload(bytes: bytes, filename: file.name); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${file.name} uploaded')), + ); + } catch (e) { + if (!mounted) return; + final message = + e is Failure ? validationErrorMessage(e) : e.toString(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } finally { + if (mounted) setState(() => _isUploading = false); + } + } + + Future _download(EntityAttachmentModel attachment) async { + setState(() => _busyAttachmentId = attachment.id); + try { + final bytes = await widget.onDownload(attachment.id); + if (bytes.isEmpty) throw Exception('Empty file response'); + await downloadFile( + bytes: bytes, + fileName: attachment.fileName ?? 'attachment-${attachment.id}', + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString())), + ); + } finally { + if (mounted) setState(() => _busyAttachmentId = null); + } + } + + Future _delete(EntityAttachmentModel attachment) async { + if (!widget.canDelete) return; + + final confirmed = await showAppConfirmationDialog( + context: context, + title: 'Delete Attachment', + message: + 'Delete ${attachment.fileName ?? 'this file'}? This cannot be undone.', + confirmLabel: 'Delete', + isDestructive: true, + ); + if (confirmed != true || !mounted) return; + + setState(() => _busyAttachmentId = attachment.id); + try { + await widget.onDelete(attachment.id); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Attachment deleted')), + ); + } catch (e) { + if (!mounted) return; + final message = + e is Failure ? validationErrorMessage(e) : e.toString(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } finally { + if (mounted) setState(() => _busyAttachmentId = null); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final attachments = widget.attachments; + final showUpload = widget.canUpload; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: theme.colorScheme.outline.withValues(alpha: 0.2), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Text( + 'ATTACHMENTS · ${attachments.length}', + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + letterSpacing: 0.8, + ), + ), + const Spacer(), + if (showUpload) + TextButton.icon( + onPressed: _isUploading ? null : _upload, + icon: _isUploading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.upload_file_outlined, size: 18), + label: Text(_isUploading ? 'Uploading…' : 'Upload'), + ), + ], + ), + const SizedBox(height: 4), + Text( + showUpload + ? widget.subtitleWhenEditable + : widget.subtitleWhenReadonly, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 12), + if (attachments.isEmpty) + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: theme.colorScheme.outline.withValues(alpha: 0.15), + ), + ), + child: Text( + showUpload + ? widget.emptyUploadHint + : widget.emptyReadonlyHint, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ) + else + ...attachments.asMap().entries.map((entry) { + final index = entry.key; + final attachment = entry.value; + return Padding( + padding: EdgeInsets.only( + bottom: index == attachments.length - 1 ? 0 : 8, + ), + child: _AttachmentRow( + attachment: attachment, + isBusy: _busyAttachmentId == attachment.id, + canDelete: widget.canDelete, + onDownload: () => _download(attachment), + onDelete: () => _delete(attachment), + ), + ); + }), + if (widget.readonlyFooter != null && + !showUpload && + attachments.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + widget.readonlyFooter!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ); + } +} + +class _AttachmentRow extends StatelessWidget { + const _AttachmentRow({ + required this.attachment, + required this.isBusy, + required this.canDelete, + required this.onDownload, + required this.onDelete, + }); + + final EntityAttachmentModel attachment; + final bool isBusy; + final bool canDelete; + final VoidCallback onDownload; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final borderColor = theme.colorScheme.outline.withValues(alpha: 0.15); + final metaParts = [ + _formatFileSize(attachment.fileSize), + if (attachment.uploadedByName?.trim().isNotEmpty == true) + attachment.uploadedByName!.trim(), + if (attachment.createdAt != null) + DateFormatter.displayDateTime(attachment.createdAt), + ]; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: borderColor), + ), + child: Row( + children: [ + Icon( + _fileIcon(attachment), + size: 22, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + attachment.fileName ?? 'Attachment #${attachment.id}', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (metaParts.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + metaParts.join(' · '), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + if (isBusy) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + else ...[ + IconButton( + tooltip: 'Download', + onPressed: onDownload, + icon: const Icon(Icons.download_outlined, size: 20), + visualDensity: VisualDensity.compact, + ), + if (canDelete) + IconButton( + tooltip: 'Delete', + onPressed: onDelete, + icon: Icon( + Icons.delete_outline, + size: 20, + color: theme.colorScheme.error, + ), + visualDensity: VisualDensity.compact, + ), + ], + ], + ), + ); + } +}