Bug And Changes screen
This commit is contained in:
parent
83c73e31ae
commit
b740edf0e8
@ -37,12 +37,6 @@ class ApiEndpoints {
|
||||
static String rolePermissionMatrix(String roleId) =>
|
||||
'/roles/$roleId/permission-matrix';
|
||||
|
||||
// Asset Categories
|
||||
static const String assetCategories = '/masters/asset-categories';
|
||||
static String assetCategoryById(String id) => '/masters/asset-categories/$id';
|
||||
static const String assetSubcategories = '/masters/asset-subcategories';
|
||||
static String assetSubcategoryById(String id) => '/masters/asset-subcategories/$id';
|
||||
|
||||
// Masters
|
||||
static const String departments = '/masters/departments';
|
||||
static String departmentById(String id) => '/masters/departments/$id';
|
||||
@ -69,6 +63,8 @@ class ApiEndpoints {
|
||||
static String paymentTermById(String id) => '/masters/payment-terms/$id';
|
||||
static const String gstRates = '/masters/gst-rates';
|
||||
static String gstRateById(String id) => '/masters/gst-rates/$id';
|
||||
static const String hsnCodes = '/masters/hsn-codes';
|
||||
static String hsnCodeById(String id) => '/masters/hsn-codes/$id';
|
||||
static const String warehouses = '/masters/warehouses';
|
||||
static String warehouseById(String id) => '/masters/warehouses/$id';
|
||||
|
||||
@ -107,6 +103,11 @@ class ApiEndpoints {
|
||||
static String grnById(String id) => '/grn/$id';
|
||||
static String grnCancel(String id) => '/grn/$id/cancel';
|
||||
static String grnPdf(String id) => '/grn/$id/pdf';
|
||||
static String grnAttachments(String grnId) => '/grn/$grnId/attachments';
|
||||
static String grnAttachmentById(String grnId, String attachmentId) =>
|
||||
'/grn/$grnId/attachments/$attachmentId';
|
||||
static String grnAttachmentDownload(String grnId, String attachmentId) =>
|
||||
'/grn/$grnId/attachments/$attachmentId/download';
|
||||
|
||||
// Assets
|
||||
static const String assets = '/assets';
|
||||
@ -165,6 +166,9 @@ class ApiEndpoints {
|
||||
|
||||
// Audit
|
||||
static const String auditLogs = '/audit-logs';
|
||||
static const String auditLogsFilters = '/audit-logs/filters';
|
||||
static const String auditLogsExport = '/audit-logs/export';
|
||||
static String auditLogById(String id) => '/audit-logs/$id';
|
||||
|
||||
// Notifications
|
||||
static const String notifications = '/notifications';
|
||||
|
||||
@ -10,4 +10,7 @@ class AppConstants {
|
||||
|
||||
static const Duration animationDuration = Duration(milliseconds: 300);
|
||||
static const Duration snackBarDuration = Duration(seconds: 3);
|
||||
|
||||
/// Sidebar / top-bar Notifications entry. Kept in code; set true to show again.
|
||||
static const bool showNotificationsMenu = false;
|
||||
}
|
||||
|
||||
@ -62,7 +62,6 @@ class RouteConstants {
|
||||
static const String assetAdd = '/assets/add';
|
||||
static const String assetEdit = '/assets/:id/edit';
|
||||
static const String assetDetail = '/assets/:id';
|
||||
static const String assetCategories = '/assets/categories';
|
||||
static const String assetAlerts = '/assets/alerts';
|
||||
|
||||
// Master Data
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../shared/widgets/app_data_table.dart';
|
||||
import 'app_colors.dart';
|
||||
import 'app_typography.dart';
|
||||
import 'branding_config.dart';
|
||||
@ -202,6 +203,9 @@ class AppTheme {
|
||||
headingRowColor: WidgetStateProperty.all(
|
||||
colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
),
|
||||
headingRowHeight: kAppTableRowHeight,
|
||||
dataRowMinHeight: kAppTableRowHeight,
|
||||
dataRowMaxHeight: kAppTableRowHeight,
|
||||
headingTextStyle: textTheme.labelLarge,
|
||||
dataTextStyle: textTheme.bodyMedium,
|
||||
),
|
||||
|
||||
20
lib/core/utils/media_url.dart
Normal file
20
lib/core/utils/media_url.dart
Normal file
@ -0,0 +1,20 @@
|
||||
import '../config/environment.dart';
|
||||
|
||||
/// Turns API-relative media paths into absolute URLs the UI can load.
|
||||
///
|
||||
/// Leaves `http(s)://` and `data:` URIs unchanged.
|
||||
String? resolveMediaUrl(String? path) {
|
||||
if (path == null) return null;
|
||||
final trimmed = path.trim();
|
||||
if (trimmed.isEmpty) return null;
|
||||
if (trimmed.startsWith('data:') ||
|
||||
trimmed.startsWith('http://') ||
|
||||
trimmed.startsWith('https://') ||
|
||||
trimmed.startsWith('blob:')) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
final origin = Uri.parse(Environment.apiBaseUrl).origin;
|
||||
if (trimmed.startsWith('/')) return '$origin$trimmed';
|
||||
return '$origin/$trimmed';
|
||||
}
|
||||
@ -321,6 +321,49 @@ class Validators {
|
||||
return normalizedKey == 'code' || normalizedKey == 'item_code';
|
||||
}
|
||||
|
||||
/// HSN/SAC codes are 4–8 digits.
|
||||
static final RegExp _hsnCodePattern = RegExp(r'^\d{4,8}$');
|
||||
|
||||
static String? hsnCode(String? value, {String fieldName = 'HSN/SAC Code'}) {
|
||||
final requiredError = required(value, fieldName: fieldName);
|
||||
if (requiredError != null) return requiredError;
|
||||
|
||||
final trimmed = value!.trim();
|
||||
if (!_hsnCodePattern.hasMatch(trimmed)) {
|
||||
return '$fieldName must be 4–8 digits';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? uniqueHsnCode(
|
||||
String? value, {
|
||||
required Iterable<Map<String, dynamic>> existingRecords,
|
||||
String? currentRecordId,
|
||||
String fieldName = 'HSN/SAC Code',
|
||||
}) {
|
||||
final formatError = hsnCode(value, fieldName: fieldName);
|
||||
if (formatError != null) return formatError;
|
||||
|
||||
final normalized = value!.trim();
|
||||
for (final record in existingRecords) {
|
||||
final recordId = record['id']?.toString();
|
||||
if (currentRecordId != null && recordId == currentRecordId) continue;
|
||||
|
||||
final existingCode = record['code']?.toString().trim();
|
||||
if (existingCode != null &&
|
||||
existingCode.isNotEmpty &&
|
||||
existingCode == normalized) {
|
||||
return '$fieldName must be unique';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<TextInputFormatter> get hsnCodeInput => [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(8),
|
||||
];
|
||||
|
||||
/// Required master code — allowed: A-Z a-z 0-9 - _ /
|
||||
static String? masterCode(String? value, {String fieldName = 'Code'}) {
|
||||
final requiredError = required(value, fieldName: fieldName);
|
||||
|
||||
@ -51,7 +51,7 @@ class AssetRemoteDataSource {
|
||||
|
||||
Future<List<AssetCategoryModel>> getCategories() async {
|
||||
final response = await dio.get(
|
||||
ApiEndpoints.assetCategories,
|
||||
ApiEndpoints.itemCategories,
|
||||
queryParameters: const {'limit': 100, 'is_active': true},
|
||||
);
|
||||
return _parseList(response.data, AssetCategoryModel.fromJson);
|
||||
|
||||
@ -3,9 +3,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../data/repositories/asset_repository_impl.dart';
|
||||
import '../../../../shared/models/asset_model.dart';
|
||||
|
||||
final assetCategoriesProvider = FutureProvider<List<AssetCategoryModel>>((ref) async {
|
||||
/// Item categories from `/masters/item-categories` (shared for items and assets).
|
||||
final itemCategoriesProvider = FutureProvider<List<AssetCategoryModel>>((ref) async {
|
||||
final repository = ref.watch(assetRepositoryProvider);
|
||||
final result = await repository.getCategories();
|
||||
if (result.failure != null) throw result.failure!;
|
||||
return result.data ?? [];
|
||||
});
|
||||
|
||||
@Deprecated('Use itemCategoriesProvider')
|
||||
final assetCategoriesProvider = itemCategoriesProvider;
|
||||
|
||||
@ -1,58 +0,0 @@
|
||||
import '../../../../shared/widgets/app_card.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../providers/asset_categories_provider.dart';
|
||||
import '../../../../shared/widgets/page_header.dart';
|
||||
|
||||
class AssetCategoriesScreen extends ConsumerWidget {
|
||||
const AssetCategoriesScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final categories = ref.watch(assetCategoriesProvider);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const PageHeader(
|
||||
title: 'Asset Categories',
|
||||
subtitle: 'Predefined and custom categories',
|
||||
),
|
||||
Expanded(
|
||||
child: categories.when(
|
||||
data: (items) {
|
||||
if (items.isEmpty) {
|
||||
return const Center(child: Text('No categories found'));
|
||||
}
|
||||
return ListView.separated(
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final category = items[index];
|
||||
return AppCard(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.category_outlined),
|
||||
title: Text(category.name),
|
||||
subtitle: Text(
|
||||
'${category.code}'
|
||||
'${category.defaultDepreciationMethod != null ? ' · ${category.defaultDepreciationMethod}' : ''}',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, __) => const Center(
|
||||
child: Text('Failed to load categories from API'),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -65,7 +65,7 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
||||
onRetry: () => ref.invalidate(assetsListProvider),
|
||||
),
|
||||
data: (state) {
|
||||
final allCategories = ref.watch(assetCategoriesProvider).valueOrNull ?? [];
|
||||
final allCategories = ref.watch(itemCategoriesProvider).valueOrNull ?? [];
|
||||
final filteredAssets = _filterAssets(state.assets);
|
||||
final categories = _categoryOptions(state.assets, allCategories);
|
||||
final plants = _plantOptions(state.assets);
|
||||
@ -651,7 +651,7 @@ class _AssetCodeBadge extends StatelessWidget {
|
||||
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.7),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
child: AppTableCell.text(
|
||||
code,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
|
||||
@ -306,8 +306,8 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
|
||||
Map<String, dynamic> _buildPayload() {
|
||||
final payload = <String, dynamic>{
|
||||
'asset_name': _nameController.text.trim(),
|
||||
'asset_category_id': _categoryId,
|
||||
'asset_subcategory_id': _subcategoryId,
|
||||
'item_category_id': _categoryId,
|
||||
'item_subcategory_id': _subcategoryId,
|
||||
'plant_id': _plantId,
|
||||
'is_active': _isActive,
|
||||
};
|
||||
@ -461,7 +461,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final categoriesAsync = ref.watch(assetCategoriesProvider);
|
||||
final categoriesAsync = ref.watch(itemCategoriesProvider);
|
||||
final plantsAsync = ref.watch(assetPlantsProvider);
|
||||
|
||||
if (widget.isEditing) {
|
||||
@ -518,7 +518,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
|
||||
AsyncValue<List<AssetCategoryModel>> categoriesAsync,
|
||||
AsyncValue<List<FilterOptionModel>> plantsAsync,
|
||||
) {
|
||||
final subcategoriesAsync = ref.watch(assetSubcategoriesProvider(_categoryId));
|
||||
final subcategoriesAsync = ref.watch(itemSubcategoriesProvider(_categoryId));
|
||||
final lookupsAsync = ref.watch(assetFormLookupsProvider);
|
||||
final grnItemsAsync = ref.watch(assetGrnItemsProvider(_grnId));
|
||||
final options = lookupsAsync.valueOrNull?.options ?? const AssetDropdownOptionsModel();
|
||||
@ -1291,9 +1291,9 @@ final assetPlantsProvider = FutureProvider<List<FilterOptionModel>>((ref) async
|
||||
return dataSource.listPlants();
|
||||
});
|
||||
|
||||
final assetSubcategoriesProvider =
|
||||
final itemSubcategoriesProvider =
|
||||
FutureProvider.family<List<FilterOptionModel>, int?>((ref, categoryId) async {
|
||||
if (categoryId == null) return [];
|
||||
final dataSource = ref.watch(masterRemoteDataSourceProvider);
|
||||
return dataSource.listAssetSubcategories(assetCategoryId: categoryId);
|
||||
return dataSource.listItemSubcategories(itemCategoryId: categoryId);
|
||||
});
|
||||
|
||||
118
lib/modules/audit/data/datasources/audit_remote_data_source.dart
Normal file
118
lib/modules/audit/data/datasources/audit_remote_data_source.dart
Normal file
@ -0,0 +1,118 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../../core/constants/api_endpoints.dart';
|
||||
import '../../../../shared/models/audit_log_model.dart';
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
|
||||
class AuditRemoteDataSource {
|
||||
AuditRemoteDataSource({required this.dio});
|
||||
|
||||
final Dio dio;
|
||||
|
||||
Future<AuditLogFilterOptions> getFilters() async {
|
||||
final response = await dio.get(ApiEndpoints.auditLogsFilters);
|
||||
final data = response.data['data'] as Map<String, dynamic>? ?? {};
|
||||
return AuditLogFilterOptions.fromJson(data);
|
||||
}
|
||||
|
||||
Future<AuditLogListResult> getAuditLogs(AuditLogListQuery query) async {
|
||||
final response = await dio.get(
|
||||
ApiEndpoints.auditLogs,
|
||||
queryParameters: _queryToMap(query),
|
||||
);
|
||||
final body = response.data as Map<String, dynamic>;
|
||||
final rawItems = body['data'];
|
||||
final items = rawItems is List
|
||||
? rawItems
|
||||
.map(
|
||||
(item) =>
|
||||
AuditLogEntryModel.fromJson(item as Map<String, dynamic>),
|
||||
)
|
||||
.toList()
|
||||
: <AuditLogEntryModel>[];
|
||||
|
||||
final meta = body['meta'] as Map<String, dynamic>? ?? {};
|
||||
final page = (meta['page'] as num?)?.toInt() ?? query.page;
|
||||
final limit = (meta['limit'] as num?)?.toInt() ?? query.limit;
|
||||
final total = (meta['total'] as num?)?.toInt() ?? items.length;
|
||||
final totalPages = limit > 0
|
||||
? ((total + limit - 1) ~/ limit).clamp(1, 999999)
|
||||
: 1;
|
||||
final filtersRequired = meta['filters_required'] == true;
|
||||
|
||||
return AuditLogListResult(
|
||||
items: items,
|
||||
page: page,
|
||||
limit: limit,
|
||||
total: total,
|
||||
totalPages: totalPages,
|
||||
filtersRequired: filtersRequired,
|
||||
);
|
||||
}
|
||||
|
||||
Future<AuditLogDetailModel> getAuditLogById(String id) async {
|
||||
final response = await dio.get(ApiEndpoints.auditLogById(id));
|
||||
return AuditLogDetailModel.fromJson(
|
||||
response.data['data'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
Future<ExportFileResult> exportAuditLogs(AuditLogListQuery query) async {
|
||||
final response = await dio.get<List<int>>(
|
||||
ApiEndpoints.auditLogsExport,
|
||||
queryParameters: _exportQueryToMap(query),
|
||||
options: Options(responseType: ResponseType.bytes),
|
||||
);
|
||||
final bytes = response.data ?? <int>[];
|
||||
return ExportFileResult(
|
||||
bytes: bytes,
|
||||
fileName: _fileNameFromResponse(response),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _queryToMap(AuditLogListQuery query) {
|
||||
return {
|
||||
'page': query.page,
|
||||
'limit': query.limit,
|
||||
..._exportQueryToMap(query),
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> _exportQueryToMap(AuditLogListQuery query) {
|
||||
return {
|
||||
if (query.tableName != null && query.tableName!.isNotEmpty)
|
||||
'table_name': query.tableName,
|
||||
if (query.recordId != null) 'record_id': query.recordId,
|
||||
if (query.action != null && query.action!.isNotEmpty) 'action': query.action,
|
||||
if (query.performedBy != null) 'performed_by': query.performedBy,
|
||||
if (query.requestId != null && query.requestId!.isNotEmpty)
|
||||
'request_id': query.requestId,
|
||||
if (query.dateFrom != null) 'date_from': query.dateFrom!.toUtc().toIso8601String(),
|
||||
if (query.dateTo != null) 'date_to': query.dateTo!.toUtc().toIso8601String(),
|
||||
if (query.search != null && query.search!.isNotEmpty) 'search': query.search,
|
||||
};
|
||||
}
|
||||
|
||||
String _fileNameFromResponse(Response<List<int>> response) {
|
||||
final disposition = response.headers.value('content-disposition');
|
||||
if (disposition != null) {
|
||||
final utf8Match = RegExp(
|
||||
r"filename\*=UTF-8''([^;\n]+)",
|
||||
caseSensitive: false,
|
||||
).firstMatch(disposition);
|
||||
if (utf8Match != null) {
|
||||
return Uri.decodeComponent(utf8Match.group(1)!);
|
||||
}
|
||||
|
||||
final match = RegExp(r'filename="?([^";\n]+)"?').firstMatch(disposition);
|
||||
if (match != null) {
|
||||
return match.group(1)!.trim();
|
||||
}
|
||||
}
|
||||
|
||||
final contentType =
|
||||
response.headers.value('content-type')?.toLowerCase() ?? '';
|
||||
if (contentType.contains('csv')) return 'audit_logs_export.csv';
|
||||
return 'audit_logs_export.csv';
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/network/api_handler.dart';
|
||||
import '../../../../core/network/dio_client.dart';
|
||||
import '../../../../shared/models/audit_log_model.dart';
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
import '../../domain/repositories/audit_repository.dart';
|
||||
import '../datasources/audit_remote_data_source.dart';
|
||||
|
||||
final auditRemoteDataSourceProvider = Provider<AuditRemoteDataSource>((ref) {
|
||||
return AuditRemoteDataSource(dio: ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
final auditRepositoryProvider = Provider<AuditRepository>((ref) {
|
||||
return AuditRepositoryImpl(remote: ref.watch(auditRemoteDataSourceProvider));
|
||||
});
|
||||
|
||||
class AuditRepositoryImpl implements AuditRepository {
|
||||
AuditRepositoryImpl({required this.remote});
|
||||
|
||||
final AuditRemoteDataSource remote;
|
||||
|
||||
@override
|
||||
Future<Result<AuditLogFilterOptions>> getFilters() =>
|
||||
safeApiCall(remote.getFilters);
|
||||
|
||||
@override
|
||||
Future<Result<AuditLogListResult>> getAuditLogs(AuditLogListQuery query) =>
|
||||
safeApiCall(() => remote.getAuditLogs(query));
|
||||
|
||||
@override
|
||||
Future<Result<AuditLogDetailModel>> getAuditLogById(String id) =>
|
||||
safeApiCall(() => remote.getAuditLogById(id));
|
||||
|
||||
@override
|
||||
Future<Result<ExportFileResult>> exportAuditLogs(AuditLogListQuery query) =>
|
||||
safeApiCall(() => remote.exportAuditLogs(query));
|
||||
}
|
||||
10
lib/modules/audit/domain/repositories/audit_repository.dart
Normal file
10
lib/modules/audit/domain/repositories/audit_repository.dart
Normal file
@ -0,0 +1,10 @@
|
||||
import '../../../../core/network/api_handler.dart';
|
||||
import '../../../../shared/models/audit_log_model.dart';
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
|
||||
abstract class AuditRepository {
|
||||
Future<Result<AuditLogFilterOptions>> getFilters();
|
||||
Future<Result<AuditLogListResult>> getAuditLogs(AuditLogListQuery query);
|
||||
Future<Result<AuditLogDetailModel>> getAuditLogById(String id);
|
||||
Future<Result<ExportFileResult>> exportAuditLogs(AuditLogListQuery query);
|
||||
}
|
||||
39
lib/modules/audit/domain/usecases/audit_usecases.dart
Normal file
39
lib/modules/audit/domain/usecases/audit_usecases.dart
Normal file
@ -0,0 +1,39 @@
|
||||
import '../../../../core/network/api_handler.dart';
|
||||
import '../../../../shared/models/audit_log_model.dart';
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
import '../repositories/audit_repository.dart';
|
||||
|
||||
class GetAuditFiltersUseCase {
|
||||
GetAuditFiltersUseCase(this._repository);
|
||||
|
||||
final AuditRepository _repository;
|
||||
|
||||
Future<Result<AuditLogFilterOptions>> call() => _repository.getFilters();
|
||||
}
|
||||
|
||||
class GetAuditLogsUseCase {
|
||||
GetAuditLogsUseCase(this._repository);
|
||||
|
||||
final AuditRepository _repository;
|
||||
|
||||
Future<Result<AuditLogListResult>> call(AuditLogListQuery query) =>
|
||||
_repository.getAuditLogs(query);
|
||||
}
|
||||
|
||||
class GetAuditLogByIdUseCase {
|
||||
GetAuditLogByIdUseCase(this._repository);
|
||||
|
||||
final AuditRepository _repository;
|
||||
|
||||
Future<Result<AuditLogDetailModel>> call(String id) =>
|
||||
_repository.getAuditLogById(id);
|
||||
}
|
||||
|
||||
class ExportAuditLogsUseCase {
|
||||
ExportAuditLogsUseCase(this._repository);
|
||||
|
||||
final AuditRepository _repository;
|
||||
|
||||
Future<Result<ExportFileResult>> call(AuditLogListQuery query) =>
|
||||
_repository.exportAuditLogs(query);
|
||||
}
|
||||
239
lib/modules/audit/presentation/providers/audit_provider.dart
Normal file
239
lib/modules/audit/presentation/providers/audit_provider.dart
Normal file
@ -0,0 +1,239 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../shared/models/audit_log_model.dart';
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
import '../../data/repositories/audit_repository_impl.dart';
|
||||
import '../../domain/usecases/audit_usecases.dart';
|
||||
|
||||
class AuditLogsListState {
|
||||
const AuditLogsListState({
|
||||
this.items = const [],
|
||||
this.filters = const AuditLogFilterOptions(),
|
||||
this.query = const AuditLogListQuery(),
|
||||
this.total = 0,
|
||||
this.totalPages = 1,
|
||||
this.filtersRequired = true,
|
||||
this.isRefreshing = false,
|
||||
this.isExporting = false,
|
||||
this.actionError,
|
||||
this.actionSuccess,
|
||||
});
|
||||
|
||||
final List<AuditLogEntryModel> items;
|
||||
final AuditLogFilterOptions filters;
|
||||
final AuditLogListQuery query;
|
||||
final int total;
|
||||
final int totalPages;
|
||||
final bool filtersRequired;
|
||||
final bool isRefreshing;
|
||||
final bool isExporting;
|
||||
final String? actionError;
|
||||
final String? actionSuccess;
|
||||
|
||||
AuditLogsListState copyWith({
|
||||
List<AuditLogEntryModel>? items,
|
||||
AuditLogFilterOptions? filters,
|
||||
AuditLogListQuery? query,
|
||||
int? total,
|
||||
int? totalPages,
|
||||
bool? filtersRequired,
|
||||
bool? isRefreshing,
|
||||
bool? isExporting,
|
||||
String? actionError,
|
||||
String? actionSuccess,
|
||||
bool clearMessages = false,
|
||||
}) {
|
||||
return AuditLogsListState(
|
||||
items: items ?? this.items,
|
||||
filters: filters ?? this.filters,
|
||||
query: query ?? this.query,
|
||||
total: total ?? this.total,
|
||||
totalPages: totalPages ?? this.totalPages,
|
||||
filtersRequired: filtersRequired ?? this.filtersRequired,
|
||||
isRefreshing: isRefreshing ?? this.isRefreshing,
|
||||
isExporting: isExporting ?? this.isExporting,
|
||||
actionError: clearMessages ? null : actionError ?? this.actionError,
|
||||
actionSuccess: clearMessages ? null : actionSuccess ?? this.actionSuccess,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final getAuditFiltersUseCaseProvider = Provider((ref) {
|
||||
return GetAuditFiltersUseCase(ref.watch(auditRepositoryProvider));
|
||||
});
|
||||
|
||||
final getAuditLogsUseCaseProvider = Provider((ref) {
|
||||
return GetAuditLogsUseCase(ref.watch(auditRepositoryProvider));
|
||||
});
|
||||
|
||||
final getAuditLogByIdUseCaseProvider = Provider((ref) {
|
||||
return GetAuditLogByIdUseCase(ref.watch(auditRepositoryProvider));
|
||||
});
|
||||
|
||||
final exportAuditLogsUseCaseProvider = Provider((ref) {
|
||||
return ExportAuditLogsUseCase(ref.watch(auditRepositoryProvider));
|
||||
});
|
||||
|
||||
final auditLogsListProvider =
|
||||
AsyncNotifierProvider<AuditLogsListNotifier, AuditLogsListState>(
|
||||
AuditLogsListNotifier.new,
|
||||
);
|
||||
|
||||
class AuditLogsListNotifier extends AsyncNotifier<AuditLogsListState> {
|
||||
@override
|
||||
Future<AuditLogsListState> build() async {
|
||||
ref.keepAlive();
|
||||
return _loadAll(const AuditLogListQuery(limit: 20));
|
||||
}
|
||||
|
||||
Future<AuditLogsListState> _loadAll(AuditLogListQuery query) async {
|
||||
final filtersResult = await ref.read(getAuditFiltersUseCaseProvider)();
|
||||
final listResult = await ref.read(getAuditLogsUseCaseProvider)(query);
|
||||
|
||||
if (listResult.failure != null) throw listResult.failure!;
|
||||
|
||||
final page = listResult.data!;
|
||||
return AuditLogsListState(
|
||||
items: page.items,
|
||||
filters: filtersResult.data ?? const AuditLogFilterOptions(),
|
||||
query: query,
|
||||
total: page.total,
|
||||
totalPages: page.totalPages,
|
||||
filtersRequired: page.filtersRequired || !query.hasActiveFilter,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
final current = state.valueOrNull ?? const AuditLogsListState();
|
||||
state = AsyncData(current.copyWith(isRefreshing: true, clearMessages: true));
|
||||
try {
|
||||
state = AsyncData(await _loadAll(current.query));
|
||||
} catch (e, st) {
|
||||
state = AsyncError(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> applyQuery(AuditLogListQuery query) async {
|
||||
final previous = state.valueOrNull;
|
||||
if (previous == null) {
|
||||
state = const AsyncLoading();
|
||||
} else {
|
||||
state = AsyncData(previous.copyWith(query: query, clearMessages: true));
|
||||
}
|
||||
try {
|
||||
final filters = previous?.filters ?? const AuditLogFilterOptions();
|
||||
final listResult = await ref.read(getAuditLogsUseCaseProvider)(query);
|
||||
if (listResult.failure != null) throw listResult.failure!;
|
||||
final page = listResult.data!;
|
||||
state = AsyncData(
|
||||
AuditLogsListState(
|
||||
items: page.items,
|
||||
filters: filters,
|
||||
query: query,
|
||||
total: page.total,
|
||||
totalPages: page.totalPages,
|
||||
filtersRequired: page.filtersRequired || !query.hasActiveFilter,
|
||||
),
|
||||
);
|
||||
} catch (e, st) {
|
||||
state = AsyncError(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
void setSearch(String search) {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
applyQuery(current.query.copyWith(search: search, page: 1));
|
||||
}
|
||||
|
||||
void setPage(int page) {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
applyQuery(current.query.copyWith(page: page));
|
||||
}
|
||||
|
||||
void setPageSize(int limit) {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
applyQuery(current.query.copyWith(limit: limit, page: 1));
|
||||
}
|
||||
|
||||
void setTableName(String? tableName) {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
applyQuery(current.query.copyWith(tableName: tableName, page: 1));
|
||||
}
|
||||
|
||||
void setAction(String? action) {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
applyQuery(current.query.copyWith(action: action, page: 1));
|
||||
}
|
||||
|
||||
void setPerformedBy(int? performedBy) {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
applyQuery(current.query.copyWith(performedBy: performedBy, page: 1));
|
||||
}
|
||||
|
||||
void setDateRange(DateTime? dateFrom, DateTime? dateTo) {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
applyQuery(
|
||||
current.query.copyWith(dateFrom: dateFrom, dateTo: dateTo, page: 1),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> resetFilters() async {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
await applyQuery(AuditLogListQuery(limit: current.query.limit));
|
||||
}
|
||||
|
||||
Future<ExportFileResult?> exportLogs() async {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return null;
|
||||
|
||||
if (!current.query.hasActiveFilter) {
|
||||
state = AsyncData(
|
||||
current.copyWith(
|
||||
actionError: 'Apply at least one filter before exporting.',
|
||||
),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
state = AsyncData(current.copyWith(isExporting: true, clearMessages: true));
|
||||
|
||||
final result = await ref.read(exportAuditLogsUseCaseProvider)(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 auditLogDetailProvider = AsyncNotifierProvider.family<
|
||||
AuditLogDetailNotifier, AuditLogDetailModel, String>(
|
||||
AuditLogDetailNotifier.new,
|
||||
);
|
||||
|
||||
class AuditLogDetailNotifier
|
||||
extends FamilyAsyncNotifier<AuditLogDetailModel, String> {
|
||||
@override
|
||||
Future<AuditLogDetailModel> build(String arg) async {
|
||||
final result = await ref.read(getAuditLogByIdUseCaseProvider)(arg);
|
||||
if (result.failure != null) throw result.failure!;
|
||||
return result.data!;
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,525 @@
|
||||
import '../../../../shared/widgets/placeholder_screen.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class AuditLogsScreen extends PlaceholderScreen {
|
||||
const AuditLogsScreen({super.key})
|
||||
: super(title: 'Audit Logs', description: 'System activity and audit trail');
|
||||
import '../../../../core/constants/enums.dart';
|
||||
import '../../../../core/errors/failure.dart';
|
||||
import '../../../../core/utils/formatters.dart';
|
||||
import '../../../../core/utils/responsive_utils.dart';
|
||||
import '../../../../shared/models/audit_log_model.dart';
|
||||
import '../../../../shared/providers/permissions_provider.dart';
|
||||
import '../../../../shared/utils/file_download_helper.dart';
|
||||
import '../../../../shared/widgets/app_card.dart';
|
||||
import '../../../../shared/widgets/app_data_table.dart';
|
||||
import '../../../../shared/widgets/app_dropdown.dart';
|
||||
import '../../../../shared/widgets/app_empty_state.dart';
|
||||
import '../../../../shared/widgets/app_loading_view.dart';
|
||||
import '../../../../shared/widgets/app_pagination.dart';
|
||||
import '../../../../shared/widgets/app_search_field.dart';
|
||||
import '../../../../shared/widgets/app_searchable_dropdown.dart';
|
||||
import '../../../../shared/widgets/app_side_panel.dart';
|
||||
import '../../../../shared/widgets/app_status_chip.dart';
|
||||
import '../../../../shared/widgets/app_table_action_icon.dart';
|
||||
import '../../../../shared/widgets/app_table_shell.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
import '../../../../shared/widgets/page_header.dart';
|
||||
import '../providers/audit_provider.dart';
|
||||
import '../widgets/audit_log_detail_panel.dart';
|
||||
|
||||
class AuditLogsScreen extends ConsumerStatefulWidget {
|
||||
const AuditLogsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<AuditLogsScreen> createState() => _AuditLogsScreenState();
|
||||
}
|
||||
|
||||
class _AuditLogsScreenState extends ConsumerState<AuditLogsScreen> {
|
||||
final _searchController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _exportLogs() async {
|
||||
final file = await ref.read(auditLogsListProvider.notifier).exportLogs();
|
||||
if (!mounted) return;
|
||||
|
||||
if (file == null) {
|
||||
final error = ref.read(auditLogsListProvider).valueOrNull?.actionError;
|
||||
if (error != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final saved = await downloadFile(
|
||||
bytes: file.bytes,
|
||||
fileName: file.fileName,
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(saved ? 'Downloaded ${file.fileName}' : 'Export cancelled'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _viewLog(AuditLogEntryModel log) async {
|
||||
ref.invalidate(auditLogDetailProvider(log.id));
|
||||
await showSidePanel(
|
||||
context,
|
||||
AuditLogDetailPanel(logId: log.id),
|
||||
width: 560,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickDateRange(AuditLogListQuery query) async {
|
||||
final now = DateTime.now();
|
||||
final initial = (query.dateFrom != null && query.dateTo != null)
|
||||
? DateTimeRange(start: query.dateFrom!, end: query.dateTo!)
|
||||
: null;
|
||||
|
||||
final picked = await showDateRangePicker(
|
||||
context: context,
|
||||
firstDate: DateTime(now.year - 5),
|
||||
lastDate: DateTime(now.year + 1),
|
||||
initialDateRange: initial,
|
||||
helpText: 'Filter by date range',
|
||||
);
|
||||
if (picked == null) return;
|
||||
|
||||
final start = DateTime(picked.start.year, picked.start.month, picked.start.day);
|
||||
final end = DateTime(
|
||||
picked.end.year,
|
||||
picked.end.month,
|
||||
picked.end.day,
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
);
|
||||
ref.read(auditLogsListProvider.notifier).setDateRange(start, end);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final logsAsync = ref.watch(auditLogsListProvider);
|
||||
final canExport = ref.can('audit_logs', PermissionAction.export);
|
||||
|
||||
ref.listen(auditLogsListProvider, (prev, next) {
|
||||
final error = next.valueOrNull?.actionError;
|
||||
if (error != null && error != prev?.valueOrNull?.actionError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error)));
|
||||
}
|
||||
});
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: logsAsync.when(
|
||||
loading: () => const AppLoadingView(message: 'Loading audit logs...'),
|
||||
error: (error, _) => ErrorView.fromFailure(
|
||||
error is Failure ? error : Failure.unknown(message: error.toString()),
|
||||
onRetry: () => ref.invalidate(auditLogsListProvider),
|
||||
),
|
||||
data: (state) {
|
||||
final notifier = ref.read(auditLogsListProvider.notifier);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
PageHeader(
|
||||
title: 'Audit Logs',
|
||||
subtitle: 'System activity and change history',
|
||||
actions: [
|
||||
if (canExport)
|
||||
OutlinedButton.icon(
|
||||
onPressed: state.isExporting || !state.query.hasActiveFilter
|
||||
? null
|
||||
: _exportLogs,
|
||||
icon: state.isExporting
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.download_outlined),
|
||||
label: Text(state.isExporting ? 'Exporting...' : 'Export'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: AppTableShell(
|
||||
toolbar: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return _FiltersBar(
|
||||
searchController: _searchController,
|
||||
filters: state.filters,
|
||||
query: state.query,
|
||||
wrapped: constraints.maxWidth < 1100,
|
||||
onSearch: notifier.setSearch,
|
||||
onTableChanged: notifier.setTableName,
|
||||
onActionChanged: notifier.setAction,
|
||||
onPerformerChanged: notifier.setPerformedBy,
|
||||
onPickDateRange: () => _pickDateRange(state.query),
|
||||
onClearDateRange: () => notifier.setDateRange(null, null),
|
||||
onReset: () {
|
||||
_searchController.clear();
|
||||
notifier.resetFilters();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
footer: AppPagination(
|
||||
currentPage: state.query.page,
|
||||
totalPages: state.totalPages,
|
||||
totalItems: state.total,
|
||||
pageSize: state.query.limit,
|
||||
itemLabel: 'audit logs',
|
||||
onPageChanged: notifier.setPage,
|
||||
onPageSizeChanged: notifier.setPageSize,
|
||||
),
|
||||
child: RefreshIndicator(
|
||||
onRefresh: notifier.refresh,
|
||||
child: state.filtersRequired && state.items.isEmpty
|
||||
? ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: const [
|
||||
SizedBox(
|
||||
height: 260,
|
||||
child: AppEmptyState(
|
||||
title: 'Apply a filter to view logs',
|
||||
description:
|
||||
'Select a table, action, user, or date range to load audit history.',
|
||||
icon: Icons.filter_alt_outlined,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: state.items.isEmpty
|
||||
? ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: const [
|
||||
SizedBox(
|
||||
height: 260,
|
||||
child: AppEmptyState(
|
||||
title: 'No audit logs found',
|
||||
description:
|
||||
'Try adjusting filters or expanding the date range.',
|
||||
icon: Icons.history_outlined,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: context.isMobile
|
||||
? _AuditCardList(
|
||||
items: state.items,
|
||||
onView: _viewLog,
|
||||
)
|
||||
: _AuditDataTable(
|
||||
items: state.items,
|
||||
onView: _viewLog,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FiltersBar extends StatelessWidget {
|
||||
const _FiltersBar({
|
||||
required this.searchController,
|
||||
required this.filters,
|
||||
required this.query,
|
||||
required this.wrapped,
|
||||
required this.onSearch,
|
||||
required this.onTableChanged,
|
||||
required this.onActionChanged,
|
||||
required this.onPerformerChanged,
|
||||
required this.onPickDateRange,
|
||||
required this.onClearDateRange,
|
||||
required this.onReset,
|
||||
});
|
||||
|
||||
final TextEditingController searchController;
|
||||
final AuditLogFilterOptions filters;
|
||||
final AuditLogListQuery query;
|
||||
final bool wrapped;
|
||||
final ValueChanged<String> onSearch;
|
||||
final ValueChanged<String?> onTableChanged;
|
||||
final ValueChanged<String?> onActionChanged;
|
||||
final ValueChanged<int?> onPerformerChanged;
|
||||
final VoidCallback onPickDateRange;
|
||||
final VoidCallback onClearDateRange;
|
||||
final VoidCallback onReset;
|
||||
|
||||
String get _dateLabel {
|
||||
if (query.dateFrom == null && query.dateTo == null) return 'Date range';
|
||||
final from = DateFormatter.displayDate(query.dateFrom);
|
||||
final to = DateFormatter.displayDate(query.dateTo);
|
||||
return '$from – $to';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final searchField = AppSearchField(
|
||||
controller: searchController,
|
||||
hint: 'Search table, action, request ID...',
|
||||
onChanged: onSearch,
|
||||
);
|
||||
|
||||
final tableDropdown = AppSearchableDropdown<String?>(
|
||||
label: 'Table',
|
||||
value: query.tableName,
|
||||
searchHint: 'Search table...',
|
||||
isDense: true,
|
||||
options: [
|
||||
const AppDropdownOption(value: null, label: 'All tables'),
|
||||
...filters.tableNames.map(
|
||||
(name) => AppDropdownOption(value: name, label: name),
|
||||
),
|
||||
],
|
||||
onChanged: onTableChanged,
|
||||
);
|
||||
|
||||
final actionDropdown = AppSearchableDropdown<String?>(
|
||||
label: 'Action',
|
||||
value: query.action,
|
||||
searchHint: 'Search action...',
|
||||
isDense: true,
|
||||
options: [
|
||||
const AppDropdownOption(value: null, label: 'All actions'),
|
||||
...filters.actions.map(
|
||||
(action) => AppDropdownOption(value: action, label: action),
|
||||
),
|
||||
],
|
||||
onChanged: onActionChanged,
|
||||
);
|
||||
|
||||
final performerDropdown = AppSearchableDropdown<int?>(
|
||||
label: 'Performed by',
|
||||
value: query.performedBy,
|
||||
searchHint: 'Search user...',
|
||||
isDense: true,
|
||||
options: [
|
||||
const AppDropdownOption(value: null, label: 'All users'),
|
||||
...filters.performers.map(
|
||||
(user) => AppDropdownOption(
|
||||
value: int.tryParse(user.id),
|
||||
label: user.label,
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: onPerformerChanged,
|
||||
);
|
||||
|
||||
final dateButton = OutlinedButton.icon(
|
||||
onPressed: onPickDateRange,
|
||||
icon: const Icon(Icons.date_range_outlined, size: 18),
|
||||
label: Text(_dateLabel, overflow: TextOverflow.ellipsis),
|
||||
);
|
||||
|
||||
final clearDate = query.dateFrom != null || query.dateTo != null
|
||||
? IconButton(
|
||||
tooltip: 'Clear date range',
|
||||
onPressed: onClearDateRange,
|
||||
icon: const Icon(Icons.clear, size: 18),
|
||||
)
|
||||
: null;
|
||||
|
||||
final resetButton = TextButton(
|
||||
onPressed: query.hasActiveFilter ? onReset : null,
|
||||
child: const Text('Reset'),
|
||||
);
|
||||
|
||||
if (wrapped) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
searchField,
|
||||
const SizedBox(height: 12),
|
||||
tableDropdown,
|
||||
const SizedBox(height: 12),
|
||||
actionDropdown,
|
||||
const SizedBox(height: 12),
|
||||
performerDropdown,
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: dateButton),
|
||||
if (clearDate != null) clearDate,
|
||||
resetButton,
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(flex: 3, child: searchField),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(flex: 2, child: tableDropdown),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(flex: 2, child: actionDropdown),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(flex: 2, child: performerDropdown),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Flexible(child: dateButton),
|
||||
if (clearDate != null) clearDate,
|
||||
const Spacer(),
|
||||
resetButton,
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AuditDataTable extends StatelessWidget {
|
||||
const _AuditDataTable({
|
||||
required this.items,
|
||||
required this.onView,
|
||||
});
|
||||
|
||||
final List<AuditLogEntryModel> items;
|
||||
final void Function(AuditLogEntryModel log) onView;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppDataTable<AuditLogEntryModel>(
|
||||
wrapInCard: false,
|
||||
rows: items,
|
||||
emptyMessage: 'No audit logs found',
|
||||
columns: [
|
||||
AppDataColumn(
|
||||
label: 'When',
|
||||
flex: 2,
|
||||
cellBuilder: (_, row) => AppTableCell.text(
|
||||
DateFormatter.displayDateTime(row.performedAt),
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Action',
|
||||
flex: 1,
|
||||
cellBuilder: (_, row) => AppTableCell.child(
|
||||
AppStatusChip(status: row.action, compact: true),
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Table',
|
||||
flex: 2,
|
||||
cellBuilder: (_, row) => AppTableCell.text(row.tableName),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Record',
|
||||
flex: 1,
|
||||
cellBuilder: (_, row) => AppTableCell.text(row.recordId),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Performed by',
|
||||
flex: 2,
|
||||
cellBuilder: (_, row) => AppTableCell.text(row.performerLabel),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Request ID',
|
||||
flex: 2,
|
||||
cellBuilder: (_, row) => AppTableCell.text(row.requestId),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Changes',
|
||||
flex: 1,
|
||||
cellBuilder: (_, row) {
|
||||
final parts = <String>[];
|
||||
if (row.hasOldValue) parts.add('old');
|
||||
if (row.hasNewValue) parts.add('new');
|
||||
return AppTableCell.text(
|
||||
parts.isEmpty ? '—' : parts.join(' / '),
|
||||
);
|
||||
},
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Actions',
|
||||
flex: 1,
|
||||
alignment: Alignment.centerRight,
|
||||
cellBuilder: (_, row) => AppTableActions(
|
||||
children: [
|
||||
AppTableActionIcon(
|
||||
tooltip: 'View',
|
||||
icon: Icons.visibility_outlined,
|
||||
onPressed: () => onView(row),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AuditCardList extends StatelessWidget {
|
||||
const _AuditCardList({
|
||||
required this.items,
|
||||
required this.onView,
|
||||
});
|
||||
|
||||
final List<AuditLogEntryModel> items;
|
||||
final void Function(AuditLogEntryModel log) onView;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView.separated(
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final log = items[index];
|
||||
return AppCard(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
log.tableName,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
AppStatusChip(status: log.action, compact: true),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(DateFormatter.displayDateTime(log.performedAt)),
|
||||
Text('Record: ${log.recordId ?? '—'}'),
|
||||
Text(log.performerLabel),
|
||||
if (log.requestId != null) Text('Request: ${log.requestId}'),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => onView(log),
|
||||
icon: const Icon(Icons.visibility_outlined, size: 18),
|
||||
label: const Text('View'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,476 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../../core/errors/failure.dart';
|
||||
import '../../../../core/utils/formatters.dart';
|
||||
import '../../../../shared/models/audit_log_model.dart';
|
||||
import '../../../../shared/widgets/app_loading_view.dart';
|
||||
import '../../../../shared/widgets/app_side_panel.dart';
|
||||
import '../../../../shared/widgets/app_status_chip.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
import '../providers/audit_provider.dart';
|
||||
|
||||
class AuditLogDetailPanel extends ConsumerWidget {
|
||||
const AuditLogDetailPanel({super.key, required this.logId});
|
||||
|
||||
final String logId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final detailAsync = ref.watch(auditLogDetailProvider(logId));
|
||||
|
||||
return SidePanelScaffold(
|
||||
title: 'Audit Log Detail',
|
||||
child: detailAsync.when(
|
||||
loading: () => const SizedBox(
|
||||
height: 240,
|
||||
child: AppLoadingView(message: 'Loading audit log...'),
|
||||
),
|
||||
error: (error, _) => ErrorView.fromFailure(
|
||||
error is Failure ? error : Failure.unknown(message: error.toString()),
|
||||
onRetry: () => ref.invalidate(auditLogDetailProvider(logId)),
|
||||
),
|
||||
data: (detail) => _DetailBody(detail: detail),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DetailBody extends StatelessWidget {
|
||||
const _DetailBody({required this.detail});
|
||||
|
||||
final AuditLogDetailModel detail;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SidePanelSection(
|
||||
title: 'SUMMARY',
|
||||
children: [
|
||||
_DetailRow(
|
||||
label: 'Action',
|
||||
child: AppStatusChip(status: detail.action, compact: true),
|
||||
),
|
||||
_DetailRow(label: 'Table', value: _humanizeKey(detail.tableName)),
|
||||
_DetailRow(label: 'Record ID', value: detail.recordId ?? '—'),
|
||||
_DetailRow(
|
||||
label: 'Performed At',
|
||||
value: DateFormatter.displayDateTime(detail.performedAt),
|
||||
),
|
||||
_DetailRow(label: 'Performed By', value: detail.performerLabel),
|
||||
if (detail.performedByUser?.email != null)
|
||||
_DetailRow(
|
||||
label: 'Email',
|
||||
value: detail.performedByUser!.email!,
|
||||
),
|
||||
_DetailRow(label: 'Request ID', value: detail.requestId ?? '—'),
|
||||
],
|
||||
),
|
||||
if (detail.oldValue != null || detail.hasOldValue)
|
||||
_ReadableValueSection(
|
||||
title: 'OLD VALUE',
|
||||
value: detail.oldValue,
|
||||
emptyLabel: 'No previous value recorded',
|
||||
),
|
||||
if (detail.newValue != null || detail.hasNewValue)
|
||||
_ReadableValueSection(
|
||||
title: 'NEW VALUE',
|
||||
value: detail.newValue,
|
||||
emptyLabel: 'No new value recorded',
|
||||
),
|
||||
if ((detail.oldValue != null || detail.hasOldValue) &&
|
||||
(detail.newValue != null || detail.hasNewValue))
|
||||
_ChangedFieldsSection(
|
||||
oldValue: detail.oldValue,
|
||||
newValue: detail.newValue,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DetailRow extends StatelessWidget {
|
||||
const _DetailRow({
|
||||
required this.label,
|
||||
this.value,
|
||||
this.child,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String? value;
|
||||
final Widget? child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 140,
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: child ??
|
||||
SelectableText(
|
||||
value ?? '—',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReadableValueSection extends StatelessWidget {
|
||||
const _ReadableValueSection({
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.emptyLabel,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final Map<String, dynamic>? value;
|
||||
final String emptyLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final rows = value == null ? const <_FieldRow>[] : _flattenFields(value!);
|
||||
|
||||
return SidePanelSection(
|
||||
title: title,
|
||||
children: [
|
||||
if (rows.isEmpty)
|
||||
Text(
|
||||
emptyLabel,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.fromLTRB(14, 14, 14, 2),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.35),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outline.withValues(alpha: 0.12),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
for (final row in rows)
|
||||
_DetailRow(label: row.label, value: row.value),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChangedFieldsSection extends StatelessWidget {
|
||||
const _ChangedFieldsSection({
|
||||
required this.oldValue,
|
||||
required this.newValue,
|
||||
});
|
||||
|
||||
final Map<String, dynamic>? oldValue;
|
||||
final Map<String, dynamic>? newValue;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final oldRows = {
|
||||
for (final row in _flattenFields(oldValue ?? const {})) row.label: row.value,
|
||||
};
|
||||
final newRows = {
|
||||
for (final row in _flattenFields(newValue ?? const {})) row.label: row.value,
|
||||
};
|
||||
final labels = <String>{...oldRows.keys, ...newRows.keys}.toList()..sort();
|
||||
final changes = labels
|
||||
.where((label) => (oldRows[label] ?? '—') != (newRows[label] ?? '—'))
|
||||
.toList();
|
||||
|
||||
if (changes.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return SidePanelSection(
|
||||
title: 'CHANGED FIELDS',
|
||||
children: [
|
||||
...changes.map(
|
||||
(label) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _ChangeValue(
|
||||
label: 'Before',
|
||||
value: oldRows[label] ?? '—',
|
||||
tone: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 18),
|
||||
child: Icon(
|
||||
Icons.arrow_forward,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _ChangeValue(
|
||||
label: 'After',
|
||||
value: newRows[label] ?? '—',
|
||||
tone: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChangeValue extends StatelessWidget {
|
||||
const _ChangeValue({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.tone,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final Color tone;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: tone.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: tone.withValues(alpha: 0.2)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: tone,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
SelectableText(
|
||||
value,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FieldRow {
|
||||
const _FieldRow({required this.label, required this.value});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
}
|
||||
|
||||
List<_FieldRow> _flattenFields(
|
||||
Map<String, dynamic> map, {
|
||||
String? prefix,
|
||||
}) {
|
||||
final rows = <_FieldRow>[];
|
||||
final entries = map.entries.toList()
|
||||
..sort((a, b) => a.key.toString().compareTo(b.key.toString()));
|
||||
|
||||
for (final entry in entries) {
|
||||
final key = entry.key.toString();
|
||||
final label = prefix == null
|
||||
? _humanizeKey(key)
|
||||
: '${_humanizeKey(prefix)} › ${_humanizeKey(key)}';
|
||||
final value = entry.value;
|
||||
|
||||
if (value is Map) {
|
||||
final nested = Map<String, dynamic>.from(value);
|
||||
final summary = _nestedSummary(nested);
|
||||
if (summary != null) {
|
||||
rows.add(_FieldRow(label: _humanizeKey(key), value: summary));
|
||||
} else {
|
||||
rows.addAll(
|
||||
_flattenFields(
|
||||
nested,
|
||||
prefix: prefix == null ? key : '$prefix.$key',
|
||||
),
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value is List) {
|
||||
rows.add(_FieldRow(label: label, value: _formatList(value)));
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.add(_FieldRow(label: label, value: _formatValue(key, value)));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
String? _nestedSummary(Map<String, dynamic> nested) {
|
||||
const preferredKeys = [
|
||||
'vendor_name',
|
||||
'full_name',
|
||||
'name',
|
||||
'label',
|
||||
'title',
|
||||
'code',
|
||||
'employee_code',
|
||||
'contract_no',
|
||||
'email',
|
||||
];
|
||||
|
||||
for (final key in preferredKeys) {
|
||||
final value = nested[key];
|
||||
if (value == null) continue;
|
||||
final text = value.toString().trim();
|
||||
if (text.isEmpty) continue;
|
||||
|
||||
final id = nested['id']?.toString().trim();
|
||||
if (id != null && id.isNotEmpty && key != 'id') {
|
||||
return '$text (ID: $id)';
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
if (nested.length <= 2) {
|
||||
return nested.entries
|
||||
.map((e) => '${_humanizeKey(e.key.toString())}: ${_formatValue(e.key.toString(), e.value)}')
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
String _formatList(List<dynamic> values) {
|
||||
if (values.isEmpty) return '—';
|
||||
return values.map((item) {
|
||||
if (item is Map) {
|
||||
final summary = _nestedSummary(Map<String, dynamic>.from(item));
|
||||
return summary ?? item.toString();
|
||||
}
|
||||
return _formatValue('', item);
|
||||
}).join(', ');
|
||||
}
|
||||
|
||||
String _formatValue(String key, Object? value) {
|
||||
if (value == null) return '—';
|
||||
|
||||
if (value is bool) return value ? 'Yes' : 'No';
|
||||
|
||||
if (value is num) {
|
||||
final lower = key.toLowerCase();
|
||||
if (lower.contains('amount') ||
|
||||
lower.contains('cost') ||
|
||||
lower.contains('price') ||
|
||||
lower.contains('rate') ||
|
||||
lower.contains('premium')) {
|
||||
return CurrencyFormatter.format(value.toDouble());
|
||||
}
|
||||
if (value is double || value.toString().contains('.')) {
|
||||
return NumberFormat('#,##0.##').format(value);
|
||||
}
|
||||
return NumberFormat('#,##0').format(value);
|
||||
}
|
||||
|
||||
final text = value.toString().trim();
|
||||
if (text.isEmpty) return '—';
|
||||
|
||||
final lower = key.toLowerCase();
|
||||
final looksLikeDate = lower.contains('date') ||
|
||||
lower.contains('_at') ||
|
||||
lower.endsWith('at') ||
|
||||
RegExp(r'^\d{4}-\d{2}-\d{2}').hasMatch(text);
|
||||
|
||||
if (looksLikeDate) {
|
||||
final parsed = DateTime.tryParse(text);
|
||||
if (parsed != null) {
|
||||
if (text.contains('T') || text.contains(':')) {
|
||||
return DateFormatter.displayDateTime(parsed);
|
||||
}
|
||||
return DateFormatter.displayDate(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
if (text == 'true') return 'Yes';
|
||||
if (text == 'false') return 'No';
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
String _humanizeKey(String key) {
|
||||
final cleaned = key.trim().replaceAll(RegExp(r'[_\-.]+'), ' ');
|
||||
if (cleaned.isEmpty) return key;
|
||||
|
||||
const acronyms = {
|
||||
'id': 'ID',
|
||||
'amc': 'AMC',
|
||||
'gst': 'GST',
|
||||
'hsn': 'HSN',
|
||||
'uom': 'UOM',
|
||||
'po': 'PO',
|
||||
'grn': 'GRN',
|
||||
'url': 'URL',
|
||||
'api': 'API',
|
||||
};
|
||||
|
||||
return cleaned
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((part) => part.isNotEmpty)
|
||||
.map((part) {
|
||||
final lower = part.toLowerCase();
|
||||
if (acronyms.containsKey(lower)) return acronyms[lower]!;
|
||||
return '${lower[0].toUpperCase()}${lower.substring(1)}';
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
@ -78,8 +78,6 @@ final _entries = [
|
||||
// Assets
|
||||
_GalleryEntry(title: 'Asset List', route: RouteConstants.assets, group: 'Assets'),
|
||||
_GalleryEntry(title: 'Asset Detail', route: '/assets/demo-asset', group: 'Assets'),
|
||||
_GalleryEntry(title: 'Asset Categories', route: RouteConstants.assetCategories, group: 'Assets'),
|
||||
_GalleryEntry(title: 'Categories', route: RouteConstants.assetCategories, group: 'Assets'),
|
||||
_GalleryEntry(title: 'Alerts', route: RouteConstants.assetAlerts, group: 'Assets'),
|
||||
// Master data
|
||||
_GalleryEntry(title: 'Master Data Hub', route: RouteConstants.masterData, group: 'Master Data'),
|
||||
|
||||
@ -48,6 +48,60 @@ class GrnRemoteDataSource {
|
||||
return response.data ?? [];
|
||||
}
|
||||
|
||||
Future<List<GrnAttachmentModel>> listAttachments(String grnId) async {
|
||||
final response = await dio.get(ApiEndpoints.grnAttachments(grnId));
|
||||
final data = response.data['data'];
|
||||
if (data is! List) return const [];
|
||||
return data
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(GrnAttachmentModel.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<GrnAttachmentModel> uploadAttachment(
|
||||
String grnId, {
|
||||
required List<int> bytes,
|
||||
required String filename,
|
||||
}) async {
|
||||
final formData = FormData.fromMap({
|
||||
'file': MultipartFile.fromBytes(bytes, filename: filename),
|
||||
});
|
||||
final response = await dio.post(
|
||||
ApiEndpoints.grnAttachments(grnId),
|
||||
data: formData,
|
||||
);
|
||||
return GrnAttachmentModel.fromJson(
|
||||
response.data['data'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
Future<GrnAttachmentModel> getAttachment(
|
||||
String grnId,
|
||||
String attachmentId,
|
||||
) async {
|
||||
final response = await dio.get(
|
||||
ApiEndpoints.grnAttachmentById(grnId, attachmentId),
|
||||
);
|
||||
return GrnAttachmentModel.fromJson(
|
||||
response.data['data'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<int>> downloadAttachment(
|
||||
String grnId,
|
||||
String attachmentId,
|
||||
) async {
|
||||
final response = await dio.get<List<int>>(
|
||||
ApiEndpoints.grnAttachmentDownload(grnId, attachmentId),
|
||||
options: Options(responseType: ResponseType.bytes),
|
||||
);
|
||||
return response.data ?? [];
|
||||
}
|
||||
|
||||
Future<void> deleteAttachment(String grnId, String attachmentId) async {
|
||||
await dio.delete(ApiEndpoints.grnAttachmentById(grnId, attachmentId));
|
||||
}
|
||||
|
||||
Map<String, dynamic> _queryToMap(GrnListQuery query) {
|
||||
return {
|
||||
'page': query.page,
|
||||
|
||||
@ -54,4 +54,41 @@ class GrnRepositoryImpl implements GrnRepository {
|
||||
Future<Result<List<int>>> downloadGrnPdf(String id) {
|
||||
return safeApiCall(() => dataSource.downloadGrnPdf(id));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<List<GrnAttachmentModel>>> listAttachments(String grnId) {
|
||||
return safeApiCall(() => dataSource.listAttachments(grnId));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<GrnAttachmentModel>> uploadAttachment(
|
||||
String grnId, {
|
||||
required List<int> bytes,
|
||||
required String filename,
|
||||
}) {
|
||||
return safeApiCall(
|
||||
() => dataSource.uploadAttachment(
|
||||
grnId,
|
||||
bytes: bytes,
|
||||
filename: filename,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<List<int>>> downloadAttachment(
|
||||
String grnId,
|
||||
String attachmentId,
|
||||
) {
|
||||
return safeApiCall(
|
||||
() => dataSource.downloadAttachment(grnId, attachmentId),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> deleteAttachment(String grnId, String attachmentId) {
|
||||
return safeApiCall(
|
||||
() => dataSource.deleteAttachment(grnId, attachmentId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,4 +9,16 @@ abstract class GrnRepository {
|
||||
Future<Result<GrnModel>> updateGrn(String id, Map<String, dynamic> data);
|
||||
Future<Result<GrnModel>> cancelGrn(String id, {required String cancellationReason});
|
||||
Future<Result<List<int>>> downloadGrnPdf(String id);
|
||||
|
||||
Future<Result<List<GrnAttachmentModel>>> listAttachments(String grnId);
|
||||
Future<Result<GrnAttachmentModel>> uploadAttachment(
|
||||
String grnId, {
|
||||
required List<int> bytes,
|
||||
required String filename,
|
||||
});
|
||||
Future<Result<List<int>>> downloadAttachment(
|
||||
String grnId,
|
||||
String attachmentId,
|
||||
);
|
||||
Future<Result<void>> deleteAttachment(String grnId, String attachmentId);
|
||||
}
|
||||
|
||||
@ -11,13 +11,11 @@ class GrnLookups {
|
||||
const GrnLookups({
|
||||
this.warehouses = const [],
|
||||
this.receivablePurchaseOrders = const [],
|
||||
this.assetCategories = const [],
|
||||
this.users = const [],
|
||||
});
|
||||
|
||||
final List<FilterOptionModel> warehouses;
|
||||
final List<PurchaseOrderModel> receivablePurchaseOrders;
|
||||
final List<FilterOptionModel> assetCategories;
|
||||
final List<FilterOptionModel> users;
|
||||
}
|
||||
|
||||
@ -26,7 +24,6 @@ final grnLookupsProvider = FutureProvider.autoDispose<GrnLookups>((ref) async {
|
||||
final poRepo = ref.watch(purchaseOrderRepositoryProvider);
|
||||
|
||||
final warehouses = await _safeOptions(master.listWarehouses);
|
||||
final assetCategories = await _safeOptions(master.listAssetCategories);
|
||||
final users = await _safeUserOptions(ref);
|
||||
|
||||
final receivablePos = <PurchaseOrderModel>[];
|
||||
@ -46,7 +43,6 @@ final grnLookupsProvider = FutureProvider.autoDispose<GrnLookups>((ref) async {
|
||||
return GrnLookups(
|
||||
warehouses: warehouses,
|
||||
receivablePurchaseOrders: receivablePos,
|
||||
assetCategories: assetCategories,
|
||||
users: users,
|
||||
);
|
||||
});
|
||||
@ -89,17 +85,6 @@ Future<List<FilterOptionModel>> _safeUserOptions(Ref ref) async {
|
||||
}
|
||||
}
|
||||
|
||||
final grnAssetSubcategoriesProvider =
|
||||
FutureProvider.autoDispose.family<List<FilterOptionModel>, int?>(
|
||||
(ref, categoryId) async {
|
||||
if (categoryId == null) return const [];
|
||||
final master = ref.watch(masterRemoteDataSourceProvider);
|
||||
return _safeOptions(
|
||||
() => master.listAssetSubcategories(assetCategoryId: categoryId),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
final grnPurchaseOrderProvider =
|
||||
FutureProvider.autoDispose.family<PurchaseOrderModel?, String>((ref, poId) async {
|
||||
final result =
|
||||
|
||||
@ -152,6 +152,55 @@ class GrnDetailNotifier extends FamilyAsyncNotifier<GrnModel, String> {
|
||||
if (result.failure != null) throw result.failure!;
|
||||
return result.data ?? [];
|
||||
}
|
||||
|
||||
Future<GrnAttachmentModel> uploadAttachment({
|
||||
required List<int> bytes,
|
||||
required String filename,
|
||||
}) async {
|
||||
final repository = ref.read(grnRepositoryProvider);
|
||||
final result = await repository.uploadAttachment(
|
||||
arg,
|
||||
bytes: bytes,
|
||||
filename: filename,
|
||||
);
|
||||
if (result.failure != null) throw result.failure!;
|
||||
final current = state.valueOrNull;
|
||||
if (current != null) {
|
||||
state = AsyncData(
|
||||
current.copyWith(
|
||||
attachments: [...current.attachments, result.data!],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await reload();
|
||||
}
|
||||
return result.data!;
|
||||
}
|
||||
|
||||
Future<List<int>> downloadAttachment(String attachmentId) async {
|
||||
final repository = ref.read(grnRepositoryProvider);
|
||||
final result = await repository.downloadAttachment(arg, attachmentId);
|
||||
if (result.failure != null) throw result.failure!;
|
||||
return result.data ?? [];
|
||||
}
|
||||
|
||||
Future<void> deleteAttachment(String attachmentId) async {
|
||||
final repository = ref.read(grnRepositoryProvider);
|
||||
final result = await repository.deleteAttachment(arg, attachmentId);
|
||||
if (result.failure != null) throw result.failure!;
|
||||
final current = state.valueOrNull;
|
||||
if (current != null) {
|
||||
state = AsyncData(
|
||||
current.copyWith(
|
||||
attachments: current.attachments
|
||||
.where((a) => a.id != attachmentId)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await reload();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final grnFormProvider =
|
||||
|
||||
@ -10,13 +10,12 @@ import '../../../../shared/models/grn_model.dart';
|
||||
import '../../../../shared/models/user_management_models.dart';
|
||||
import '../../../../shared/providers/permissions_provider.dart';
|
||||
import '../../../../shared/utils/file_download_helper.dart';
|
||||
import '../../../../shared/widgets/app_card.dart';
|
||||
import '../../../../shared/widgets/app_loading_view.dart';
|
||||
import '../../../../shared/widgets/app_text_field.dart';
|
||||
import '../../../../shared/widgets/app_loading_view.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
import '../../../../shared/widgets/page_header.dart';
|
||||
import '../providers/grn_lookups_provider.dart';
|
||||
import '../providers/grn_provider.dart';
|
||||
import '../widgets/grn_attachments_card.dart';
|
||||
import '../widgets/grn_line_items_editor.dart';
|
||||
import '../widgets/grn_status_chip.dart';
|
||||
|
||||
@ -35,120 +34,93 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final detailAsync = ref.watch(grnDetailProvider(widget.grnId));
|
||||
final lookupsAsync = ref.watch(grnLookupsProvider);
|
||||
final canEdit = ref.can('grn', PermissionAction.update);
|
||||
final canDelete = ref.can('grn', PermissionAction.delete);
|
||||
final canExport = ref.can('grn', PermissionAction.export);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.go(RouteConstants.grn),
|
||||
),
|
||||
title: detailAsync.maybeWhen(
|
||||
data: (grn) => Text(grn.grnNumber ?? 'GRN #${grn.id}'),
|
||||
orElse: () => const Text('GRN'),
|
||||
),
|
||||
),
|
||||
body: detailAsync.when(
|
||||
loading: () => const AppLoadingView(message: 'Loading GRN...'),
|
||||
error: (e, _) => ErrorView.fromFailure(
|
||||
e is Failure ? e : Failure.unknown(message: e.toString()),
|
||||
onRetry: () => ref.invalidate(grnDetailProvider(widget.grnId)),
|
||||
),
|
||||
data: (grn) => SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1200),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
PageHeader(
|
||||
title: grn.grnNumber ?? 'GRN #${grn.id}',
|
||||
subtitle:
|
||||
'PO ${grn.poNumber ?? '—'} · ${grn.vendorName ?? '—'}',
|
||||
actions: [
|
||||
if (canExport)
|
||||
OutlinedButton.icon(
|
||||
onPressed: _isWorking ? null : () => _downloadPdf(grn),
|
||||
icon: const Icon(Icons.picture_as_pdf_outlined),
|
||||
label: const Text('PDF'),
|
||||
),
|
||||
if (canEdit && grn.canEdit) ...[
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _isWorking
|
||||
? null
|
||||
: () => context.push(
|
||||
'${RouteConstants.grn}/${grn.id}/edit',
|
||||
),
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
label: const Text('Edit'),
|
||||
),
|
||||
],
|
||||
if (canEdit && grn.canCancel) ...[
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _isWorking ? null : () => _cancel(grn),
|
||||
icon: const Icon(Icons.block_outlined),
|
||||
label: const Text('Cancel'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
GrnStatusChip(status: grn.status),
|
||||
if (grn.cancellationReason != null &&
|
||||
grn.cancellationReason!.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Cancellation reason: ${grn.cancellationReason}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
_OverviewCard(grn: grn),
|
||||
const SizedBox(height: 16),
|
||||
AppCard(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Line Items',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
GrnItemsTable(items: grn.items),
|
||||
],
|
||||
data: (grn) {
|
||||
final lookups = lookupsAsync.asData?.value;
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1200),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_DetailHeader(
|
||||
grn: grn,
|
||||
isWorking: _isWorking,
|
||||
canEdit: canEdit,
|
||||
canExport: canExport,
|
||||
onBack: () => context.go(RouteConstants.grn),
|
||||
onPdf: () => _downloadPdf(grn),
|
||||
onEdit: () => context.push(
|
||||
'${RouteConstants.grn}/${grn.id}/edit',
|
||||
),
|
||||
onCancel: () => _cancel(grn),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (grn.cancellationReason != null &&
|
||||
grn.cancellationReason!.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
_CancellationBanner(reason: grn.cancellationReason!),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
_ReceiptDetailsCard(grn: grn, lookups: lookups),
|
||||
const SizedBox(height: 16),
|
||||
_LineItemsCard(grn: grn),
|
||||
const SizedBox(height: 16),
|
||||
GrnAttachmentsCard(
|
||||
grn: grn,
|
||||
canUpload: canEdit,
|
||||
canDelete: canDelete,
|
||||
),
|
||||
if (grn.remarks?.trim().isNotEmpty == true) ...[
|
||||
const SizedBox(height: 16),
|
||||
_SectionCard(
|
||||
title: 'REMARKS',
|
||||
child: Text(
|
||||
grn.remarks!.trim(),
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
_DetailFooter(grn: grn),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _runWorkflow(Future<void> Function() action, String success) async {
|
||||
Future<void> _runWorkflow(
|
||||
Future<void> Function() action,
|
||||
String success,
|
||||
) async {
|
||||
setState(() => _isWorking = true);
|
||||
try {
|
||||
await action();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(success)));
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(success)));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(e.toString())));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isWorking = false);
|
||||
@ -164,7 +136,9 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Cancel ${grn.grnNumber ?? grn.id}? This will reverse PO receipts.'),
|
||||
Text(
|
||||
'Cancel ${grn.grnNumber ?? grn.id}? This will reverse PO receipts.',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
label: 'Cancellation reason *',
|
||||
@ -201,8 +175,9 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
|
||||
|
||||
Future<void> _downloadPdf(GrnModel grn) async {
|
||||
await _runWorkflow(() async {
|
||||
final bytes =
|
||||
await ref.read(grnDetailProvider(widget.grnId).notifier).downloadPdf();
|
||||
final bytes = await ref
|
||||
.read(grnDetailProvider(widget.grnId).notifier)
|
||||
.downloadPdf();
|
||||
await downloadFile(
|
||||
bytes: bytes,
|
||||
fileName: '${grn.grnNumber ?? 'GRN-${grn.id}'}.pdf',
|
||||
@ -211,122 +186,381 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
class _OverviewCard extends ConsumerWidget {
|
||||
const _OverviewCard({required this.grn});
|
||||
|
||||
final GrnModel grn;
|
||||
|
||||
String _userLabel(List<FilterOptionModel> users, int? userId) {
|
||||
if (userId == null) return '—';
|
||||
final match = users.where((u) => int.tryParse(u.id) == userId);
|
||||
if (match.isNotEmpty) return match.first.name;
|
||||
return 'User #$userId';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final users = ref.watch(grnLookupsProvider).maybeWhen(
|
||||
data: (lookups) => lookups.users,
|
||||
orElse: () => const <FilterOptionModel>[],
|
||||
);
|
||||
|
||||
return AppCard(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Overview',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_GrnInfoGrid(
|
||||
columns: 4,
|
||||
items: [
|
||||
_GrnInfo('GRN Date', DateFormatter.displayDate(grn.grnDate)),
|
||||
_GrnInfo('PO Number', grn.poNumber ?? '—'),
|
||||
_GrnInfo('Vendor', grn.vendorName ?? '—'),
|
||||
_GrnInfo('Warehouse', grn.warehouseName ?? '—'),
|
||||
_GrnInfo('Vendor Invoice No', grn.vendorInvoiceNo ?? '—'),
|
||||
_GrnInfo(
|
||||
'Vendor Invoice Date',
|
||||
DateFormatter.displayDate(grn.vendorInvoiceDate),
|
||||
),
|
||||
_GrnInfo(
|
||||
'Vendor Invoice Amount',
|
||||
grn.vendorInvoiceAmount != null
|
||||
? CurrencyFormatter.format(grn.vendorInvoiceAmount!)
|
||||
: '—',
|
||||
),
|
||||
_GrnInfo('Vehicle No', grn.vehicleNo ?? '—'),
|
||||
_GrnInfo('LR No', grn.lrNo ?? '—'),
|
||||
_GrnInfo('LR Date', DateFormatter.displayDate(grn.lrDate)),
|
||||
_GrnInfo('Received By', _userLabel(users, grn.receivedBy)),
|
||||
_GrnInfo(
|
||||
'Quality Checked By',
|
||||
_userLabel(users, grn.qualityCheckedBy),
|
||||
),
|
||||
_GrnInfo('Remarks', grn.remarks?.trim().isNotEmpty == true
|
||||
? grn.remarks!
|
||||
: '—'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
String _userLabel(List<FilterOptionModel>? users, int? userId) {
|
||||
if (userId == null || users == null) return '—';
|
||||
for (final user in users) {
|
||||
if (int.tryParse(user.id) == userId) return user.name;
|
||||
}
|
||||
return 'User #$userId';
|
||||
}
|
||||
|
||||
class _GrnInfoGrid extends StatelessWidget {
|
||||
const _GrnInfoGrid({
|
||||
required this.items,
|
||||
this.columns = 4,
|
||||
String _displayOrDash(String? value) {
|
||||
final trimmed = value?.trim();
|
||||
if (trimmed == null || trimmed.isEmpty) return '—';
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
class _DetailHeader extends StatelessWidget {
|
||||
const _DetailHeader({
|
||||
required this.grn,
|
||||
required this.isWorking,
|
||||
required this.canEdit,
|
||||
required this.canExport,
|
||||
required this.onBack,
|
||||
required this.onPdf,
|
||||
required this.onEdit,
|
||||
required this.onCancel,
|
||||
});
|
||||
|
||||
final List<_GrnInfo> items;
|
||||
final int columns;
|
||||
final GrnModel grn;
|
||||
final bool isWorking;
|
||||
final bool canEdit;
|
||||
final bool canExport;
|
||||
final VoidCallback onBack;
|
||||
final VoidCallback onPdf;
|
||||
final VoidCallback onEdit;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final subtitleParts = [
|
||||
if (grn.poNumber?.trim().isNotEmpty == true) 'PO ${grn.poNumber!.trim()}',
|
||||
if (grn.vendorName?.trim().isNotEmpty == true) grn.vendorName!.trim(),
|
||||
if (grn.warehouseName?.trim().isNotEmpty == true)
|
||||
grn.warehouseName!.trim(),
|
||||
];
|
||||
|
||||
final actions = Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
alignment: WrapAlignment.end,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
if (canExport)
|
||||
_HeaderActionButton(
|
||||
label: 'PDF',
|
||||
icon: Icons.description_outlined,
|
||||
onPressed: isWorking ? null : onPdf,
|
||||
),
|
||||
if (canEdit && grn.canEdit)
|
||||
_HeaderActionButton(
|
||||
label: 'Edit',
|
||||
icon: Icons.edit_outlined,
|
||||
onPressed: isWorking ? null : onEdit,
|
||||
),
|
||||
if (canEdit && grn.canCancel)
|
||||
_HeaderActionButton(
|
||||
label: 'Cancel',
|
||||
icon: Icons.block_outlined,
|
||||
destructive: true,
|
||||
onPressed: isWorking ? null : onCancel,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final titleBlock = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
grn.grnNumber ?? 'GRN #${grn.id}',
|
||||
style: theme.textTheme.headlineSmall,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
GrnStatusChip(status: grn.status, compact: true),
|
||||
],
|
||||
),
|
||||
if (subtitleParts.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitleParts.join(' · '),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxWidth = constraints.maxWidth;
|
||||
final cols = maxWidth < 600
|
||||
? 1
|
||||
: maxWidth < 900
|
||||
? 2
|
||||
: columns;
|
||||
const spacing = 16.0;
|
||||
final colWidth = (maxWidth - spacing * (cols - 1)) / cols;
|
||||
|
||||
return Wrap(
|
||||
spacing: spacing,
|
||||
runSpacing: 16,
|
||||
children: items
|
||||
.map(
|
||||
(item) => SizedBox(
|
||||
width: colWidth,
|
||||
child: _GrnDetailTile(
|
||||
label: item.label,
|
||||
value: item.value,
|
||||
final stack = constraints.maxWidth < 800;
|
||||
if (stack) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Back',
|
||||
onPressed: onBack,
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(child: titleBlock),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
actions,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Back',
|
||||
onPressed: onBack,
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(child: titleBlock),
|
||||
const SizedBox(width: 12),
|
||||
actions,
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GrnDetailTile extends StatelessWidget {
|
||||
const _GrnDetailTile({
|
||||
class _HeaderActionButton extends StatelessWidget {
|
||||
const _HeaderActionButton({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.onPressed,
|
||||
this.destructive = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final VoidCallback? onPressed;
|
||||
final bool destructive;
|
||||
|
||||
static const double _height = 40;
|
||||
static const double _radius = 8;
|
||||
static const EdgeInsets _padding =
|
||||
EdgeInsets.symmetric(horizontal: 14, vertical: 0);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final error = theme.colorScheme.error;
|
||||
|
||||
final style = ButtonStyle(
|
||||
minimumSize: const WidgetStatePropertyAll(Size(0, _height)),
|
||||
fixedSize: const WidgetStatePropertyAll(Size.fromHeight(_height)),
|
||||
padding: const WidgetStatePropertyAll(_padding),
|
||||
shape: WidgetStatePropertyAll(
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(_radius)),
|
||||
),
|
||||
visualDensity: VisualDensity.standard,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
);
|
||||
|
||||
final child = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text(label),
|
||||
],
|
||||
);
|
||||
|
||||
if (destructive) {
|
||||
return OutlinedButton(
|
||||
onPressed: onPressed,
|
||||
style: style.copyWith(
|
||||
foregroundColor: WidgetStatePropertyAll(error),
|
||||
side: WidgetStatePropertyAll(BorderSide(color: error)),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
return OutlinedButton(
|
||||
onPressed: onPressed,
|
||||
style: style,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CancellationBanner extends StatelessWidget {
|
||||
const _CancellationBanner({required this.reason});
|
||||
|
||||
final String reason;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.errorContainer.withValues(alpha: 0.35),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.error.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: theme.colorScheme.error, size: 20),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Cancellation reason: $reason',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionCard extends StatelessWidget {
|
||||
const _SectionCard({
|
||||
required this.title,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
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: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.8,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReceiptDetailsCard extends StatelessWidget {
|
||||
const _ReceiptDetailsCard({
|
||||
required this.grn,
|
||||
required this.lookups,
|
||||
});
|
||||
|
||||
final GrnModel grn;
|
||||
final GrnLookups? lookups;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _SectionCard(
|
||||
title: 'RECEIPT DETAILS',
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final cols = constraints.maxWidth < 600
|
||||
? 1
|
||||
: constraints.maxWidth < 900
|
||||
? 2
|
||||
: 4;
|
||||
const spacing = 20.0;
|
||||
final width = (constraints.maxWidth - spacing * (cols - 1)) / cols;
|
||||
final items = [
|
||||
_DetailField(
|
||||
label: 'GRN date',
|
||||
value: DateFormatter.displayDate(grn.grnDate),
|
||||
),
|
||||
_DetailField(
|
||||
label: 'PO number',
|
||||
value: _displayOrDash(grn.poNumber),
|
||||
),
|
||||
_DetailField(
|
||||
label: 'Vendor',
|
||||
value: _displayOrDash(grn.vendorName),
|
||||
),
|
||||
_DetailField(
|
||||
label: 'Warehouse',
|
||||
value: _displayOrDash(grn.warehouseName),
|
||||
),
|
||||
_DetailField(
|
||||
label: 'Vendor invoice no',
|
||||
value: _displayOrDash(grn.vendorInvoiceNo),
|
||||
),
|
||||
_DetailField(
|
||||
label: 'Vendor invoice date',
|
||||
value: DateFormatter.displayDate(grn.vendorInvoiceDate),
|
||||
),
|
||||
_DetailField(
|
||||
label: 'Vendor invoice amount',
|
||||
value: grn.vendorInvoiceAmount != null
|
||||
? CurrencyFormatter.format(grn.vendorInvoiceAmount!)
|
||||
: '—',
|
||||
),
|
||||
_DetailField(
|
||||
label: 'Vehicle no',
|
||||
value: _displayOrDash(grn.vehicleNo),
|
||||
),
|
||||
_DetailField(
|
||||
label: 'LR no',
|
||||
value: _displayOrDash(grn.lrNo),
|
||||
),
|
||||
_DetailField(
|
||||
label: 'LR date',
|
||||
value: DateFormatter.displayDate(grn.lrDate),
|
||||
),
|
||||
_DetailField(
|
||||
label: 'Received by',
|
||||
value: _userLabel(lookups?.users, grn.receivedBy),
|
||||
),
|
||||
_DetailField(
|
||||
label: 'Quality checked by',
|
||||
value: _userLabel(lookups?.users, grn.qualityCheckedBy),
|
||||
),
|
||||
];
|
||||
|
||||
return Wrap(
|
||||
spacing: spacing,
|
||||
runSpacing: 16,
|
||||
children: items
|
||||
.map((item) => SizedBox(width: width, child: item))
|
||||
.toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DetailField extends StatelessWidget {
|
||||
const _DetailField({
|
||||
required this.label,
|
||||
required this.value,
|
||||
});
|
||||
@ -337,7 +571,6 @@ class _GrnDetailTile extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@ -349,15 +582,53 @@ class _GrnDetailTile extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(value, style: theme.textTheme.bodyLarge),
|
||||
Text(
|
||||
value,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GrnInfo {
|
||||
const _GrnInfo(this.label, this.value);
|
||||
class _LineItemsCard extends StatelessWidget {
|
||||
const _LineItemsCard({required this.grn});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final GrnModel grn;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _SectionCard(
|
||||
title: 'LINE ITEMS · ${grn.items.length}',
|
||||
child: GrnItemsTable(items: grn.items),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DetailFooter extends StatelessWidget {
|
||||
const _DetailFooter({required this.grn});
|
||||
|
||||
final GrnModel grn;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final parts = <String>[
|
||||
if (grn.createdAt != null)
|
||||
'Created ${DateFormatter.displayDateTime(grn.createdAt)}',
|
||||
if (grn.updatedAt != null)
|
||||
'Updated ${DateFormatter.displayDateTime(grn.updatedAt)}',
|
||||
];
|
||||
|
||||
if (parts.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Text(
|
||||
parts.join(' · '),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,13 +14,13 @@ import '../../../../shared/widgets/app_button.dart';
|
||||
import '../../../../shared/widgets/app_dropdown.dart';
|
||||
import '../../../../shared/widgets/app_loading_view.dart';
|
||||
import '../../../../shared/widgets/app_searchable_dropdown.dart';
|
||||
import '../../../../shared/widgets/app_side_panel.dart';
|
||||
import '../../../../shared/widgets/app_text_field.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
import '../../../../shared/widgets/app_side_panel.dart';
|
||||
import '../../../../shared/widgets/page_header.dart';
|
||||
import '../providers/grn_lookups_provider.dart';
|
||||
import '../providers/grn_provider.dart';
|
||||
import '../widgets/grn_line_items_editor.dart';
|
||||
import '../widgets/grn_status_chip.dart';
|
||||
|
||||
class GrnFormScreen extends ConsumerStatefulWidget {
|
||||
const GrnFormScreen({super.key, this.grnId});
|
||||
@ -58,6 +58,9 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
super.initState();
|
||||
if (!widget.isEditing) {
|
||||
_grnDate = DateTime.now();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) ref.invalidate(grnLookupsProvider);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -90,8 +93,8 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
_vehicleNoController.text = grn.vehicleNo ?? '';
|
||||
_lrNoController.text = grn.lrNo ?? '';
|
||||
_lrDate = grn.lrDate;
|
||||
_receivedById = grn.receivedBy;
|
||||
_qualityCheckedById = grn.qualityCheckedBy;
|
||||
_receivedById = _normalizeUserId(grn.receivedBy);
|
||||
_qualityCheckedById = _normalizeUserId(grn.qualityCheckedBy);
|
||||
_remarksController.text = grn.remarks ?? '';
|
||||
});
|
||||
}
|
||||
@ -112,11 +115,18 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
|
||||
int? _parseId(String value) => int.tryParse(value.trim());
|
||||
|
||||
int? _normalizeUserId(int? id) => id != null && id > 0 ? id : null;
|
||||
|
||||
int? _dropdownValue(int? selected, Iterable<int> validIds) {
|
||||
if (selected == null) return null;
|
||||
return validIds.contains(selected) ? selected : null;
|
||||
}
|
||||
|
||||
void _putOptionalUserId(Map<String, dynamic> payload, String key, int? id) {
|
||||
final normalized = _normalizeUserId(id);
|
||||
if (normalized != null) payload[key] = normalized;
|
||||
}
|
||||
|
||||
List<AppDropdownOption<int>> _intOptions(List<FilterOptionModel> options) {
|
||||
return options
|
||||
.map((e) {
|
||||
@ -130,7 +140,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
|
||||
Map<String, dynamic> _buildCreatePayload() {
|
||||
final poId = int.tryParse(_selectedPoId ?? '');
|
||||
return {
|
||||
final payload = <String, dynamic>{
|
||||
'grn_date': DateFormatter.toApiDate(_grnDate ?? DateTime.now()),
|
||||
'po_id': poId,
|
||||
'warehouse_id': _warehouseId,
|
||||
@ -143,17 +153,19 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
double.tryParse(_vendorInvoiceAmountController.text.trim()),
|
||||
if (_vehicleNoController.text.trim().isNotEmpty)
|
||||
'vehicle_no': _vehicleNoController.text.trim(),
|
||||
if (_lrNoController.text.trim().isNotEmpty) 'lr_no': _lrNoController.text.trim(),
|
||||
if (_lrNoController.text.trim().isNotEmpty)
|
||||
'lr_no': _lrNoController.text.trim(),
|
||||
if (_lrDate != null) 'lr_date': DateFormatter.toApiDate(_lrDate!),
|
||||
if (_receivedById != null) 'received_by': _receivedById,
|
||||
if (_qualityCheckedById != null) 'quality_checked_by': _qualityCheckedById,
|
||||
'remarks': _remarksController.text.trim(),
|
||||
'items': _lines.map((line) => line.toPayload()).toList(),
|
||||
};
|
||||
_putOptionalUserId(payload, 'received_by', _receivedById);
|
||||
_putOptionalUserId(payload, 'quality_checked_by', _qualityCheckedById);
|
||||
payload['remarks'] = _remarksController.text.trim();
|
||||
payload['items'] = _lines.map((line) => line.toPayload()).toList();
|
||||
return payload;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _buildUpdatePayload() {
|
||||
return {
|
||||
final payload = <String, dynamic>{
|
||||
if (_vendorInvoiceNoController.text.trim().isNotEmpty)
|
||||
'vendor_invoice_no': _vendorInvoiceNoController.text.trim(),
|
||||
if (_vendorInvoiceDate != null)
|
||||
@ -163,12 +175,14 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
double.tryParse(_vendorInvoiceAmountController.text.trim()),
|
||||
if (_vehicleNoController.text.trim().isNotEmpty)
|
||||
'vehicle_no': _vehicleNoController.text.trim(),
|
||||
if (_lrNoController.text.trim().isNotEmpty) 'lr_no': _lrNoController.text.trim(),
|
||||
if (_lrNoController.text.trim().isNotEmpty)
|
||||
'lr_no': _lrNoController.text.trim(),
|
||||
if (_lrDate != null) 'lr_date': DateFormatter.toApiDate(_lrDate!),
|
||||
if (_receivedById != null) 'received_by': _receivedById,
|
||||
if (_qualityCheckedById != null) 'quality_checked_by': _qualityCheckedById,
|
||||
'remarks': _remarksController.text.trim(),
|
||||
};
|
||||
_putOptionalUserId(payload, 'received_by', _receivedById);
|
||||
_putOptionalUserId(payload, 'quality_checked_by', _qualityCheckedById);
|
||||
payload['remarks'] = _remarksController.text.trim();
|
||||
return payload;
|
||||
}
|
||||
|
||||
String? _lineItemsError() {
|
||||
@ -180,6 +194,10 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
if (line.acceptedQty < 0) {
|
||||
return 'Accepted quantity must be zero or more for line ${line.lineNo}';
|
||||
}
|
||||
if (line.rejectedQty > 0 &&
|
||||
line.rejectionReasonController.text.trim().isEmpty) {
|
||||
return 'Rejection reason is required for line ${line.lineNo}';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@ -206,7 +224,8 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
}
|
||||
final lineError = _lineItemsError();
|
||||
if (lineError != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(lineError)));
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(lineError)));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@ -241,7 +260,8 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
if (!mounted) return;
|
||||
final message =
|
||||
e is Failure ? validationErrorMessage(e) : e.toString();
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(message)));
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSubmitting = false);
|
||||
}
|
||||
@ -260,6 +280,17 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
if (picked != null) onPicked(picked);
|
||||
}
|
||||
|
||||
void _goBack(GrnModel? existing) {
|
||||
if (_isSubmitting) return;
|
||||
if (widget.isEditing && existing != null) {
|
||||
context.go('${RouteConstants.grn}/${existing.id}');
|
||||
} else if (widget.isEditing) {
|
||||
context.go('${RouteConstants.grn}/${widget.grnId}');
|
||||
} else {
|
||||
context.go(RouteConstants.grn);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final lookupsAsync = ref.watch(grnLookupsProvider);
|
||||
@ -268,33 +299,33 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
: const AsyncData<GrnModel?>(null);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.go(
|
||||
widget.isEditing
|
||||
? '${RouteConstants.grn}/${widget.grnId}'
|
||||
: RouteConstants.grn,
|
||||
),
|
||||
),
|
||||
title: Text(widget.isEditing ? 'Edit GRN' : 'Create GRN'),
|
||||
),
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
body: lookupsAsync.when(
|
||||
loading: () => const AppLoadingView(message: 'Loading form...'),
|
||||
skipLoadingOnReload: true,
|
||||
loading: () => lookupsAsync.hasValue
|
||||
? _buildFormBody(
|
||||
lookups: lookupsAsync.value!,
|
||||
existing: existingAsync.valueOrNull,
|
||||
)
|
||||
: const AppLoadingView(message: 'Loading form...'),
|
||||
error: (e, _) => ErrorView.fromFailure(
|
||||
e is Failure ? e : Failure.unknown(message: e.toString()),
|
||||
onRetry: () => ref.invalidate(grnLookupsProvider),
|
||||
),
|
||||
data: (lookups) => existingAsync.when(
|
||||
loading: () => const AppLoadingView(message: 'Loading GRN...'),
|
||||
skipLoadingOnReload: true,
|
||||
loading: () => existingAsync.hasValue
|
||||
? _buildFormBody(
|
||||
lookups: lookups,
|
||||
existing: existingAsync.valueOrNull,
|
||||
)
|
||||
: const AppLoadingView(message: 'Loading GRN...'),
|
||||
error: (e, _) => ErrorView.fromFailure(
|
||||
e is Failure ? e : Failure.unknown(message: e.toString()),
|
||||
onRetry: () => ref.invalidate(grnFormProvider(widget.grnId)),
|
||||
),
|
||||
data: (existing) => _buildFormBody(lookups: lookups, existing: existing),
|
||||
data: (existing) =>
|
||||
_buildFormBody(lookups: lookups, existing: existing),
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -323,6 +354,8 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
|
||||
final warehouseIds =
|
||||
lookups.warehouses.map((e) => _parseId(e.id)).whereType<int>();
|
||||
final userIds =
|
||||
lookups.users.map((e) => _parseId(e.id)).whereType<int>();
|
||||
final poOptions = lookups.receivablePurchaseOrders
|
||||
.map(
|
||||
(po) => AppDropdownOption(
|
||||
@ -331,220 +364,401 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(24),
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1200),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (!widget.isEditing)
|
||||
const PageHeader(
|
||||
title: 'Create Goods Received Note',
|
||||
subtitle: 'Receive items against an approved purchase order',
|
||||
)
|
||||
else if (existing?.grnNumber != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Text(
|
||||
existing!.grnNumber!,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ResponsiveFormGrid(
|
||||
children: [
|
||||
_DateField(
|
||||
label: 'GRN Date *',
|
||||
value: _grnDate,
|
||||
enabled: !widget.isEditing,
|
||||
onTap: widget.isEditing
|
||||
? null
|
||||
: () => _pickDate(
|
||||
current: _grnDate,
|
||||
onPicked: (d) => setState(() => _grnDate = d),
|
||||
),
|
||||
),
|
||||
if (!widget.isEditing)
|
||||
AppSearchableDropdown<String>(
|
||||
label: 'Purchase Order *',
|
||||
value: _selectedPoId,
|
||||
searchHint: 'Search PO...',
|
||||
isDense: true,
|
||||
options: poOptions,
|
||||
onChanged: (v) async {
|
||||
setState(() => _selectedPoId = v);
|
||||
if (v == null) {
|
||||
for (final line in _lines) {
|
||||
line.dispose();
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildHeader(existing),
|
||||
const SizedBox(height: 16),
|
||||
_SectionCard(
|
||||
title: 'RECEIPT DETAILS',
|
||||
child: Column(
|
||||
children: [
|
||||
FormRowFour(
|
||||
children: [
|
||||
_DateField(
|
||||
label: 'GRN date *',
|
||||
value: _grnDate,
|
||||
enabled: !widget.isEditing,
|
||||
onTap: widget.isEditing
|
||||
? null
|
||||
: () => _pickDate(
|
||||
current: _grnDate,
|
||||
onPicked: (d) =>
|
||||
setState(() => _grnDate = d),
|
||||
),
|
||||
),
|
||||
if (!widget.isEditing)
|
||||
AppSearchableDropdown<String>(
|
||||
label: 'Purchase order *',
|
||||
value: _selectedPoId,
|
||||
hint: 'Select PO',
|
||||
searchHint: 'Search PO...',
|
||||
options: poOptions,
|
||||
onChanged: (v) async {
|
||||
setState(() => _selectedPoId = v);
|
||||
if (v == null) {
|
||||
for (final line in _lines) {
|
||||
line.dispose();
|
||||
}
|
||||
setState(() => _lines.clear());
|
||||
return;
|
||||
}
|
||||
setState(() => _lines.clear());
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final po = await ref.read(
|
||||
grnPurchaseOrderProvider(v).future,
|
||||
);
|
||||
if (mounted && po != null) _loadLinesFromPo(po);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString())),
|
||||
);
|
||||
}
|
||||
},
|
||||
validator: (v) =>
|
||||
v == null ? 'Purchase order is required' : null,
|
||||
)
|
||||
else
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: InputDecorator(
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Purchase Order',
|
||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||
isDense: true,
|
||||
),
|
||||
child: Text(existing?.poNumber ?? '—'),
|
||||
try {
|
||||
final po = await ref.read(
|
||||
grnPurchaseOrderProvider(v).future,
|
||||
);
|
||||
if (mounted && po != null) {
|
||||
_loadLinesFromPo(po);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString())),
|
||||
);
|
||||
}
|
||||
},
|
||||
validator: (v) => v == null
|
||||
? 'Purchase order is required'
|
||||
: null,
|
||||
)
|
||||
else
|
||||
_ReadOnlyField(
|
||||
label: 'Purchase order',
|
||||
value: existing?.poNumber ?? '—',
|
||||
),
|
||||
AppSearchableDropdown<int>(
|
||||
label: 'Warehouse *',
|
||||
value: _dropdownValue(_warehouseId, warehouseIds),
|
||||
hint: 'Select warehouse',
|
||||
searchHint: 'Search warehouse...',
|
||||
options: _intOptions(lookups.warehouses),
|
||||
onChanged: widget.isEditing
|
||||
? (_) {}
|
||||
: (v) => setState(() => _warehouseId = v),
|
||||
validator: widget.isEditing
|
||||
? null
|
||||
: (v) =>
|
||||
v == null ? 'Warehouse is required' : null,
|
||||
enabled: !widget.isEditing,
|
||||
),
|
||||
AppSearchableDropdown<int>(
|
||||
label: 'Warehouse *',
|
||||
value: _dropdownValue(_warehouseId, warehouseIds),
|
||||
searchHint: 'Search warehouse...',
|
||||
isDense: true,
|
||||
options: _intOptions(lookups.warehouses),
|
||||
onChanged: widget.isEditing
|
||||
? (_) {}
|
||||
: (v) => setState(() => _warehouseId = v),
|
||||
validator: widget.isEditing
|
||||
? null
|
||||
: (v) => v == null ? 'Warehouse is required' : null,
|
||||
enabled: !widget.isEditing,
|
||||
),
|
||||
AppTextField(
|
||||
label: 'Vendor Invoice No',
|
||||
controller: _vendorInvoiceNoController,
|
||||
isDense: true,
|
||||
),
|
||||
_DateField(
|
||||
label: 'Vendor Invoice Date',
|
||||
value: _vendorInvoiceDate,
|
||||
onTap: () => _pickDate(
|
||||
current: _vendorInvoiceDate,
|
||||
onPicked: (d) =>
|
||||
setState(() => _vendorInvoiceDate = d),
|
||||
AppTextField(
|
||||
label: 'Vendor invoice no',
|
||||
controller: _vendorInvoiceNoController,
|
||||
),
|
||||
),
|
||||
AppTextField(
|
||||
label: 'Vendor Invoice Amount',
|
||||
controller: _vendorInvoiceAmountController,
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
isDense: true,
|
||||
),
|
||||
AppTextField(
|
||||
label: 'Vehicle No',
|
||||
controller: _vehicleNoController,
|
||||
isDense: true,
|
||||
),
|
||||
AppTextField(
|
||||
label: 'LR No',
|
||||
controller: _lrNoController,
|
||||
isDense: true,
|
||||
),
|
||||
_DateField(
|
||||
label: 'LR Date',
|
||||
value: _lrDate,
|
||||
onTap: () => _pickDate(
|
||||
current: _lrDate,
|
||||
onPicked: (d) => setState(() => _lrDate = d),
|
||||
),
|
||||
),
|
||||
AppSearchableDropdown<int>(
|
||||
label: 'Received By',
|
||||
value: _dropdownValue(
|
||||
_receivedById,
|
||||
lookups.users.map((e) => _parseId(e.id)).whereType<int>(),
|
||||
),
|
||||
searchHint: 'Search user...',
|
||||
isDense: true,
|
||||
options: _intOptions(lookups.users),
|
||||
onChanged: (v) => setState(() => _receivedById = v),
|
||||
),
|
||||
AppSearchableDropdown<int>(
|
||||
label: 'Quality Checked By',
|
||||
value: _dropdownValue(
|
||||
_qualityCheckedById,
|
||||
lookups.users.map((e) => _parseId(e.id)).whereType<int>(),
|
||||
),
|
||||
searchHint: 'Search user...',
|
||||
isDense: true,
|
||||
options: _intOptions(lookups.users),
|
||||
onChanged: (v) =>
|
||||
setState(() => _qualityCheckedById = v),
|
||||
),
|
||||
AppTextField(
|
||||
label: 'Remarks',
|
||||
controller: _remarksController,
|
||||
maxLines: 3,
|
||||
isDense: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (!widget.isEditing) ...[
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Line Items',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
GrnLineItemsEditor(
|
||||
items: _lines,
|
||||
onChanged: () => setState(() {}),
|
||||
),
|
||||
] else ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Line items cannot be changed after posting.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
FormRowFour(
|
||||
children: [
|
||||
_DateField(
|
||||
label: 'Vendor invoice date',
|
||||
value: _vendorInvoiceDate,
|
||||
onTap: () => _pickDate(
|
||||
current: _vendorInvoiceDate,
|
||||
onPicked: (d) =>
|
||||
setState(() => _vendorInvoiceDate = d),
|
||||
),
|
||||
),
|
||||
AppTextField(
|
||||
label: 'Vendor invoice amount',
|
||||
controller: _vendorInvoiceAmountController,
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
),
|
||||
AppTextField(
|
||||
label: 'Vehicle no',
|
||||
controller: _vehicleNoController,
|
||||
),
|
||||
AppTextField(
|
||||
label: 'LR no',
|
||||
controller: _lrNoController,
|
||||
),
|
||||
],
|
||||
),
|
||||
FormRowFour(
|
||||
children: [
|
||||
_DateField(
|
||||
label: 'LR date',
|
||||
value: _lrDate,
|
||||
onTap: () => _pickDate(
|
||||
current: _lrDate,
|
||||
onPicked: (d) => setState(() => _lrDate = d),
|
||||
),
|
||||
),
|
||||
AppSearchableDropdown<int>(
|
||||
label: 'Received by',
|
||||
value: _dropdownValue(
|
||||
_normalizeUserId(_receivedById),
|
||||
userIds,
|
||||
),
|
||||
hint: 'Select user',
|
||||
searchHint: 'Search user...',
|
||||
options: _intOptions(lookups.users),
|
||||
onChanged: (v) =>
|
||||
setState(() => _receivedById = v),
|
||||
),
|
||||
AppSearchableDropdown<int>(
|
||||
label: 'Quality checked by',
|
||||
value: _dropdownValue(
|
||||
_normalizeUserId(_qualityCheckedById),
|
||||
userIds,
|
||||
),
|
||||
hint: 'Select user',
|
||||
searchHint: 'Search user...',
|
||||
options: _intOptions(lookups.users),
|
||||
onChanged: (v) =>
|
||||
setState(() => _qualityCheckedById = v),
|
||||
),
|
||||
const SizedBox.shrink(),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (!widget.isEditing)
|
||||
GrnLineItemsEditor(
|
||||
items: _lines,
|
||||
onChanged: () => setState(() {}),
|
||||
)
|
||||
else ...[
|
||||
_SectionCard(
|
||||
title: 'LINE ITEMS · ${existing?.items.length ?? 0}',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: AppButton(
|
||||
label: widget.isEditing ? 'Save Changes' : 'Create GRN',
|
||||
onPressed: _isSubmitting ? null : _submit,
|
||||
isLoading: _isSubmitting,
|
||||
Text(
|
||||
'Line items cannot be changed after posting.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (existing?.items.isNotEmpty == true) ...[
|
||||
const SizedBox(height: 12),
|
||||
GrnItemsTable(items: existing!.items),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
_SectionCard(
|
||||
title: 'ADDITIONAL DETAILS',
|
||||
child: AppTextField(
|
||||
controller: _remarksController,
|
||||
label: 'Remarks',
|
||||
hint: 'Any additional notes for this receipt.',
|
||||
maxLines: 4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Fields marked * are required',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
widget.isEditing
|
||||
? '${existing?.items.length ?? 0} line item${(existing?.items.length ?? 0) == 1 ? '' : 's'} · editing header only'
|
||||
: '${_lines.length} line item${_lines.length == 1 ? '' : 's'} · draft not yet saved',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(GrnModel? existing) {
|
||||
final theme = Theme.of(context);
|
||||
final title = widget.isEditing
|
||||
? 'Edit ${existing?.grnNumber ?? 'GRN'}'
|
||||
: 'Create goods received note';
|
||||
final subtitle = widget.isEditing
|
||||
? null
|
||||
: 'Select an approved purchase order, enter receipt details, then confirm quantities.';
|
||||
|
||||
final actions = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: _isSubmitting ? null : () => _goBack(existing),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
AppButton(
|
||||
label: widget.isEditing ? 'Update GRN' : 'Save GRN',
|
||||
icon: Icons.check,
|
||||
expand: false,
|
||||
isLoading: _isSubmitting,
|
||||
onPressed: _isSubmitting ? null : _submit,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final titleBlock = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
Text(title, style: theme.textTheme.headlineSmall),
|
||||
if (widget.isEditing && existing != null)
|
||||
GrnStatusChip(status: existing.status, compact: true),
|
||||
],
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final stack = constraints.maxWidth < 720;
|
||||
if (stack) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Back',
|
||||
onPressed:
|
||||
_isSubmitting ? null : () => _goBack(existing),
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(child: titleBlock),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Align(alignment: Alignment.centerRight, child: actions),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Back',
|
||||
onPressed: _isSubmitting ? null : () => _goBack(existing),
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(child: titleBlock),
|
||||
const SizedBox(width: 12),
|
||||
actions,
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionCard extends StatelessWidget {
|
||||
const _SectionCard({
|
||||
required this.title,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final cardColor = isDark
|
||||
? theme.colorScheme.surfaceContainerHighest
|
||||
: theme.colorScheme.surface;
|
||||
final borderColor = theme.colorScheme.outline.withValues(
|
||||
alpha: isDark ? 0.35 : 0.2,
|
||||
);
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: cardColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: borderColor),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.8,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReadOnlyField extends StatelessWidget {
|
||||
const _ReadOnlyField({
|
||||
required this.label,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||
),
|
||||
child: Text(value),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DateField extends StatelessWidget {
|
||||
@ -571,16 +785,15 @@ class _DateField extends StatelessWidget {
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||
isDense: true,
|
||||
suffixIcon: const Icon(Icons.calendar_today_outlined, size: 18),
|
||||
enabled: enabled,
|
||||
),
|
||||
child: Text(
|
||||
value != null ? DateFormatter.displayDate(value) : 'Select date',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: value != null
|
||||
? null
|
||||
: Theme.of(context).hintColor,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: value == null
|
||||
? Theme.of(context).colorScheme.onSurfaceVariant
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
371
lib/modules/grn/presentation/widgets/grn_attachments_card.dart
Normal file
371
lib/modules/grn/presentation/widgets/grn_attachments_card.dart
Normal file
@ -0,0 +1,371 @@
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/errors/failure.dart';
|
||||
import '../../../../core/network/api_handler.dart';
|
||||
import '../../../../core/utils/formatters.dart';
|
||||
import '../../../../shared/models/grn_model.dart';
|
||||
import '../../../../shared/utils/file_download_helper.dart';
|
||||
import '../../../../shared/widgets/app_confirmation_dialog.dart';
|
||||
import '../providers/grn_provider.dart';
|
||||
|
||||
const _allowedExtensions = ['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(GrnAttachmentModel attachment) {
|
||||
if (attachment.isPdf) return Icons.picture_as_pdf_outlined;
|
||||
if (attachment.isImage) return Icons.image_outlined;
|
||||
return Icons.insert_drive_file_outlined;
|
||||
}
|
||||
|
||||
/// Attachments section for GRN detail — list / upload / download / delete.
|
||||
class GrnAttachmentsCard extends ConsumerStatefulWidget {
|
||||
const GrnAttachmentsCard({
|
||||
super.key,
|
||||
required this.grn,
|
||||
required this.canUpload,
|
||||
required this.canDelete,
|
||||
});
|
||||
|
||||
final GrnModel grn;
|
||||
final bool canUpload;
|
||||
final bool canDelete;
|
||||
|
||||
@override
|
||||
ConsumerState<GrnAttachmentsCard> createState() => _GrnAttachmentsCardState();
|
||||
}
|
||||
|
||||
class _GrnAttachmentsCardState extends ConsumerState<GrnAttachmentsCard> {
|
||||
bool _isUploading = false;
|
||||
String? _busyAttachmentId;
|
||||
|
||||
bool get _canManage =>
|
||||
widget.grn.canManageAttachments && (widget.canUpload || widget.canDelete);
|
||||
|
||||
Future<void> _upload() async {
|
||||
if (!widget.canUpload || !widget.grn.canManageAttachments) return;
|
||||
|
||||
final result = await FilePicker.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: _allowedExtensions,
|
||||
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 (!_allowedExtensions.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 ref.read(grnDetailProvider(widget.grn.id).notifier).uploadAttachment(
|
||||
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<void> _download(GrnAttachmentModel attachment) async {
|
||||
setState(() => _busyAttachmentId = attachment.id);
|
||||
try {
|
||||
final bytes = await ref
|
||||
.read(grnDetailProvider(widget.grn.id).notifier)
|
||||
.downloadAttachment(attachment.id);
|
||||
if (bytes.isEmpty) throw Exception('Empty file response');
|
||||
await downloadFile(
|
||||
bytes: bytes,
|
||||
fileName: attachment.fileName ?? 'grn-attachment-${attachment.id}',
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString())),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _busyAttachmentId = null);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _delete(GrnAttachmentModel attachment) async {
|
||||
if (!widget.canDelete || !widget.grn.canManageAttachments) 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 ref
|
||||
.read(grnDetailProvider(widget.grn.id).notifier)
|
||||
.deleteAttachment(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.grn.attachments;
|
||||
final showUpload =
|
||||
widget.canUpload && widget.grn.canManageAttachments;
|
||||
|
||||
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
|
||||
? 'PDF, JPEG, PNG, or WebP · upload/delete only while GRN is Posted'
|
||||
: 'Supporting documents for this GRN',
|
||||
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
|
||||
? 'No attachments yet. Upload a vendor invoice, LR copy, or receipt photo.'
|
||||
: 'No attachments',
|
||||
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 && widget.grn.canManageAttachments,
|
||||
onDownload: () => _download(attachment),
|
||||
onDelete: () => _delete(attachment),
|
||||
),
|
||||
);
|
||||
}),
|
||||
if (!_canManage &&
|
||||
!widget.grn.canManageAttachments &&
|
||||
attachments.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'This GRN is cancelled — attachments are view/download only.',
|
||||
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 GrnAttachmentModel 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 = <String>[
|
||||
_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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,20 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/utils/formatters.dart';
|
||||
import '../../../../shared/models/asset_model.dart';
|
||||
import '../../../../shared/models/grn_model.dart';
|
||||
import '../../../../shared/models/purchase_order_model.dart';
|
||||
import '../../../../shared/models/user_management_models.dart';
|
||||
import '../../../../shared/widgets/app_data_table.dart';
|
||||
import '../../../../shared/widgets/app_dropdown.dart';
|
||||
import '../../../../shared/widgets/app_searchable_dropdown.dart';
|
||||
import '../../../../shared/widgets/app_side_panel.dart';
|
||||
import '../../../../shared/widgets/app_text_field.dart';
|
||||
import '../../../assets/presentation/providers/asset_categories_provider.dart';
|
||||
import '../providers/grn_lookups_provider.dart';
|
||||
|
||||
String _formatQty(double value) {
|
||||
if (value % 1 == 0) return value.toInt().toString();
|
||||
@ -38,8 +29,6 @@ class GrnLineItemDraft {
|
||||
TextEditingController? remarksController,
|
||||
this.mfgDate,
|
||||
this.expiryDate,
|
||||
this.assetCategoryId,
|
||||
this.assetSubcategoryId,
|
||||
}) : acceptedQtyController = acceptedQtyController ??
|
||||
TextEditingController(
|
||||
text: remainingQty > 0 ? _formatQty(remainingQty) : '',
|
||||
@ -68,11 +57,11 @@ class GrnLineItemDraft {
|
||||
final TextEditingController remarksController;
|
||||
DateTime? mfgDate;
|
||||
DateTime? expiryDate;
|
||||
int? assetCategoryId;
|
||||
int? assetSubcategoryId;
|
||||
|
||||
double get acceptedQty => double.tryParse(acceptedQtyController.text.trim()) ?? 0;
|
||||
double get rejectedQty => double.tryParse(rejectedQtyController.text.trim()) ?? 0;
|
||||
double get acceptedQty =>
|
||||
double.tryParse(acceptedQtyController.text.trim()) ?? 0;
|
||||
double get rejectedQty =>
|
||||
double.tryParse(rejectedQtyController.text.trim()) ?? 0;
|
||||
double get currentQty => acceptedQty + rejectedQty;
|
||||
|
||||
void dispose() {
|
||||
@ -92,8 +81,8 @@ class GrnLineItemDraft {
|
||||
'line_no': lineNo,
|
||||
'current_qty': currentQty,
|
||||
'accepted_qty': acceptedQty,
|
||||
if (rejectedQty > 0) 'rejected_qty': rejectedQty,
|
||||
if (rejectionReasonController.text.trim().isNotEmpty)
|
||||
'rejected_qty': rejectedQty,
|
||||
if (rejectedQty > 0 && rejectionReasonController.text.trim().isNotEmpty)
|
||||
'rejection_reason': rejectionReasonController.text.trim(),
|
||||
if (rateController.text.trim().isNotEmpty)
|
||||
'rate': double.tryParse(rateController.text.trim()),
|
||||
@ -103,8 +92,6 @@ class GrnLineItemDraft {
|
||||
if (expiryDate != null) 'expiry_date': DateFormatter.toApiDate(expiryDate!),
|
||||
if (storageLocationController.text.trim().isNotEmpty)
|
||||
'storage_location': storageLocationController.text.trim(),
|
||||
if (assetCategoryId != null) 'asset_category_id': assetCategoryId,
|
||||
if (assetSubcategoryId != null) 'asset_subcategory_id': assetSubcategoryId,
|
||||
if (remarksController.text.trim().isNotEmpty)
|
||||
'remarks': remarksController.text.trim(),
|
||||
};
|
||||
@ -145,44 +132,78 @@ class GrnLineItemsEditor extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (items.isEmpty) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightSurface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppColors.textSecondary.withValues(alpha: 0.2)),
|
||||
),
|
||||
child: Text(
|
||||
readOnly
|
||||
? 'No line items.'
|
||||
: 'Select a purchase order to load receivable line items.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final cardColor = isDark
|
||||
? theme.colorScheme.surfaceContainerHighest
|
||||
: theme.colorScheme.surface;
|
||||
final borderColor = theme.colorScheme.outline.withValues(
|
||||
alpha: isDark ? 0.35 : 0.2,
|
||||
);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
for (var i = 0; i < items.length; i++)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: i == items.length - 1 ? 0 : 12),
|
||||
child: _GrnLineItemCard(
|
||||
item: items[i],
|
||||
readOnly: readOnly,
|
||||
onChanged: onChanged,
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: cardColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: borderColor),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'LINE ITEMS',
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.8,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
if (items.isEmpty)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: borderColor),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
readOnly
|
||||
? 'No line items.'
|
||||
: 'Select a purchase order to load receivable line items.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
)
|
||||
else
|
||||
...items.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final item = entry.value;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: index == items.length - 1 ? 0 : 12,
|
||||
),
|
||||
child: _GrnLineItemCard(
|
||||
key: ObjectKey(item),
|
||||
item: item,
|
||||
readOnly: readOnly,
|
||||
onChanged: onChanged,
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GrnLineItemCard extends ConsumerWidget {
|
||||
class _GrnLineItemCard extends StatefulWidget {
|
||||
const _GrnLineItemCard({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.onChanged,
|
||||
required this.readOnly,
|
||||
@ -192,41 +213,35 @@ class _GrnLineItemCard extends ConsumerWidget {
|
||||
final VoidCallback onChanged;
|
||||
final bool readOnly;
|
||||
|
||||
@override
|
||||
State<_GrnLineItemCard> createState() => _GrnLineItemCardState();
|
||||
}
|
||||
|
||||
class _GrnLineItemCardState extends State<_GrnLineItemCard> {
|
||||
static final _qtyFormatters = [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,4}')),
|
||||
];
|
||||
|
||||
int? _dropdownValue(int? selected, Iterable<int> validIds) {
|
||||
if (selected == null) return null;
|
||||
return validIds.contains(selected) ? selected : null;
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.item.acceptedQtyController.addListener(_onFieldChanged);
|
||||
widget.item.rejectedQtyController.addListener(_onFieldChanged);
|
||||
}
|
||||
|
||||
List<AppDropdownOption<int>> _intOptions(List<FilterOptionModel> options) {
|
||||
return options
|
||||
.map((e) {
|
||||
final id = int.tryParse(e.id);
|
||||
if (id == null) return null;
|
||||
return AppDropdownOption(value: id, label: e.name);
|
||||
})
|
||||
.whereType<AppDropdownOption<int>>()
|
||||
.toList();
|
||||
@override
|
||||
void dispose() {
|
||||
widget.item.acceptedQtyController.removeListener(_onFieldChanged);
|
||||
widget.item.rejectedQtyController.removeListener(_onFieldChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<AppDropdownOption<int>> _categoryOptions(
|
||||
List<AssetCategoryModel> categories,
|
||||
) {
|
||||
return categories
|
||||
.map((c) {
|
||||
final id = int.tryParse(c.id);
|
||||
if (id == null) return null;
|
||||
return AppDropdownOption(value: id, label: c.name);
|
||||
})
|
||||
.whereType<AppDropdownOption<int>>()
|
||||
.toList();
|
||||
void _onFieldChanged() {
|
||||
widget.onChanged();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _pickDate(
|
||||
BuildContext context, {
|
||||
Future<void> _pickDate({
|
||||
required DateTime? current,
|
||||
required ValueChanged<DateTime?> onPicked,
|
||||
}) async {
|
||||
@ -240,201 +255,279 @@ class _GrnLineItemCard extends ConsumerWidget {
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final categoryId = item.assetCategoryId;
|
||||
final categoriesAsync = ref.watch(assetCategoriesProvider);
|
||||
final categoryOptions = categoriesAsync.maybeWhen(
|
||||
data: _categoryOptions,
|
||||
orElse: () => const <AppDropdownOption<int>>[],
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final item = widget.item;
|
||||
final lineKey = 'grn-line-${item.lineNo}';
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final borderColor = theme.colorScheme.outline.withValues(
|
||||
alpha: isDark ? 0.35 : 0.18,
|
||||
);
|
||||
final categoryIds = categoryOptions.map((e) => e.value);
|
||||
final subcategoriesAsync = ref.watch(grnAssetSubcategoriesProvider(categoryId));
|
||||
final subcategoryOptions = subcategoriesAsync.maybeWhen(
|
||||
data: _intOptions,
|
||||
orElse: () => const <AppDropdownOption<int>>[],
|
||||
final currentBg = theme.colorScheme.primary.withValues(
|
||||
alpha: isDark ? 0.18 : 0.08,
|
||||
);
|
||||
final subcategoryIds = subcategoryOptions.map((e) => e.value);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightSurface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppColors.textSecondary.withValues(alpha: 0.2)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Line ${item.lineNo}',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.itemName,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Ordered: ${_formatQty(item.orderedQty)} · '
|
||||
'Already received: ${_formatQty(item.receivedQty)} · '
|
||||
'Remaining: ${_formatQty(item.remainingQty)}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
if (!readOnly) ...[
|
||||
ResponsiveFormGrid(
|
||||
children: _buildLineItemFields(
|
||||
context: context,
|
||||
categoryOptions: categoryOptions,
|
||||
categoryIds: categoryIds,
|
||||
subcategoryOptions: subcategoryOptions,
|
||||
subcategoryIds: subcategoryIds,
|
||||
),
|
||||
if (widget.readOnly) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: borderColor),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_LineItemTitle(
|
||||
lineNo: item.lineNo,
|
||||
itemName: item.itemName,
|
||||
orderedQty: item.orderedQty,
|
||||
receivedQty: item.receivedQty,
|
||||
remainingQty: item.remainingQty,
|
||||
),
|
||||
] else ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Accepted: ${_formatQty(item.acceptedQty)} · '
|
||||
'Rejected: ${_formatQty(item.rejectedQty)} · '
|
||||
'Current: ${_formatQty(item.currentQty)}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: borderColor),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_LineItemTitle(
|
||||
lineNo: item.lineNo,
|
||||
itemName: item.itemName,
|
||||
orderedQty: item.orderedQty,
|
||||
receivedQty: item.receivedQty,
|
||||
remainingQty: item.remainingQty,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FormRow(
|
||||
columnCount: 12,
|
||||
spans: const [1, 1, 1, 2, 3, 4],
|
||||
spacing: 8,
|
||||
stackBelowWidth: 1100,
|
||||
children: [
|
||||
AppTextField(
|
||||
key: ValueKey('$lineKey-accepted'),
|
||||
controller: item.acceptedQtyController,
|
||||
label: 'Accepted *',
|
||||
hint: '0',
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: _qtyFormatters,
|
||||
isDense: true,
|
||||
),
|
||||
AppTextField(
|
||||
key: ValueKey('$lineKey-rejected'),
|
||||
controller: item.rejectedQtyController,
|
||||
label: 'Rejected',
|
||||
hint: '0',
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: _qtyFormatters,
|
||||
isDense: true,
|
||||
),
|
||||
_GrnLineReadOnlyField(
|
||||
key: ValueKey('$lineKey-current'),
|
||||
label: 'Current',
|
||||
value: _formatQty(item.currentQty),
|
||||
backgroundColor: currentBg,
|
||||
),
|
||||
AppTextField(
|
||||
key: ValueKey('$lineKey-rate'),
|
||||
controller: item.rateController,
|
||||
label: 'Rate',
|
||||
hint: '0.00',
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: _qtyFormatters,
|
||||
isDense: true,
|
||||
onChanged: (_) => widget.onChanged(),
|
||||
),
|
||||
AppTextField(
|
||||
key: ValueKey('$lineKey-batch'),
|
||||
controller: item.batchNoController,
|
||||
label: 'Batch No',
|
||||
isDense: true,
|
||||
onChanged: (_) => widget.onChanged(),
|
||||
),
|
||||
AppTextField(
|
||||
key: ValueKey('$lineKey-storage'),
|
||||
controller: item.storageLocationController,
|
||||
label: 'Storage',
|
||||
isDense: true,
|
||||
onChanged: (_) => widget.onChanged(),
|
||||
),
|
||||
],
|
||||
),
|
||||
Theme(
|
||||
data: theme.copyWith(dividerColor: Colors.transparent),
|
||||
child: ExpansionTile(
|
||||
tilePadding: EdgeInsets.zero,
|
||||
childrenPadding: EdgeInsets.zero,
|
||||
title: Text(
|
||||
'More details',
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
FormRow(
|
||||
columnCount: 12,
|
||||
spans: const [3, 3, 3, 3],
|
||||
spacing: 8,
|
||||
stackBelowWidth: 1100,
|
||||
children: [
|
||||
_GrnLineDateField(
|
||||
key: ValueKey('$lineKey-mfg'),
|
||||
label: 'Mfg Date',
|
||||
value: item.mfgDate,
|
||||
onTap: () => _pickDate(
|
||||
current: item.mfgDate,
|
||||
onPicked: (date) {
|
||||
item.mfgDate = date;
|
||||
widget.onChanged();
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
),
|
||||
_GrnLineDateField(
|
||||
key: ValueKey('$lineKey-expiry'),
|
||||
label: 'Expiry Date',
|
||||
value: item.expiryDate,
|
||||
onTap: () => _pickDate(
|
||||
current: item.expiryDate,
|
||||
onPicked: (date) {
|
||||
item.expiryDate = date;
|
||||
widget.onChanged();
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
),
|
||||
AppTextField(
|
||||
key: ValueKey('$lineKey-rejection'),
|
||||
controller: item.rejectionReasonController,
|
||||
label: 'Rejection Reason',
|
||||
isDense: true,
|
||||
onChanged: (_) => widget.onChanged(),
|
||||
),
|
||||
AppTextField(
|
||||
key: ValueKey('$lineKey-remarks'),
|
||||
controller: item.remarksController,
|
||||
label: 'Remarks',
|
||||
isDense: true,
|
||||
onChanged: (_) => widget.onChanged(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<Widget> _buildLineItemFields({
|
||||
required BuildContext context,
|
||||
required List<AppDropdownOption<int>> categoryOptions,
|
||||
required Iterable<int> categoryIds,
|
||||
required List<AppDropdownOption<int>> subcategoryOptions,
|
||||
required Iterable<int> subcategoryIds,
|
||||
}) {
|
||||
final hasCategory = item.assetCategoryId != null;
|
||||
return [
|
||||
AppTextField(
|
||||
label: 'Accepted Qty *',
|
||||
controller: item.acceptedQtyController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: _qtyFormatters,
|
||||
isDense: true,
|
||||
onChanged: (_) => onChanged(),
|
||||
),
|
||||
AppTextField(
|
||||
label: 'Rejected Qty',
|
||||
controller: item.rejectedQtyController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: _qtyFormatters,
|
||||
isDense: true,
|
||||
onChanged: (_) => onChanged(),
|
||||
),
|
||||
AppTextField(
|
||||
label: 'Rate',
|
||||
controller: item.rateController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: _qtyFormatters,
|
||||
isDense: true,
|
||||
onChanged: (_) => onChanged(),
|
||||
),
|
||||
AppTextField(
|
||||
label: 'Batch No',
|
||||
controller: item.batchNoController,
|
||||
isDense: true,
|
||||
onChanged: (_) => onChanged(),
|
||||
),
|
||||
_GrnLineDateField(
|
||||
label: 'Mfg Date',
|
||||
value: item.mfgDate,
|
||||
onTap: () => _pickDate(
|
||||
context,
|
||||
current: item.mfgDate,
|
||||
onPicked: (date) {
|
||||
item.mfgDate = date;
|
||||
onChanged();
|
||||
},
|
||||
/// Title for a line item — name + ordered/received/remaining, not a form field.
|
||||
class _LineItemTitle extends StatelessWidget {
|
||||
const _LineItemTitle({
|
||||
required this.lineNo,
|
||||
required this.itemName,
|
||||
required this.orderedQty,
|
||||
required this.receivedQty,
|
||||
required this.remainingQty,
|
||||
});
|
||||
|
||||
final int lineNo;
|
||||
final String itemName;
|
||||
final double orderedQty;
|
||||
final double receivedQty;
|
||||
final double remainingQty;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Line $lineNo · $itemName',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Ordered ${_formatQty(orderedQty)} · '
|
||||
'Received ${_formatQty(receivedQty)} · '
|
||||
'Remaining ${_formatQty(remainingQty)}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GrnLineReadOnlyField extends StatelessWidget {
|
||||
const _GrnLineReadOnlyField({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.backgroundColor,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final Color? backgroundColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||
isDense: true,
|
||||
filled: backgroundColor != null,
|
||||
fillColor: backgroundColor,
|
||||
enabled: false,
|
||||
),
|
||||
child: Text(
|
||||
value,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
_GrnLineDateField(
|
||||
label: 'Expiry Date',
|
||||
value: item.expiryDate,
|
||||
onTap: () => _pickDate(
|
||||
context,
|
||||
current: item.expiryDate,
|
||||
onPicked: (date) {
|
||||
item.expiryDate = date;
|
||||
onChanged();
|
||||
},
|
||||
),
|
||||
),
|
||||
AppTextField(
|
||||
label: 'Storage Location',
|
||||
controller: item.storageLocationController,
|
||||
isDense: true,
|
||||
onChanged: (_) => onChanged(),
|
||||
),
|
||||
AppTextField(
|
||||
label: 'Rejection Reason',
|
||||
controller: item.rejectionReasonController,
|
||||
isDense: true,
|
||||
onChanged: (_) => onChanged(),
|
||||
),
|
||||
AppSearchableDropdown<int>(
|
||||
key: ValueKey('line_${item.lineNo}_asset_category'),
|
||||
label: 'Asset Category',
|
||||
value: _dropdownValue(item.assetCategoryId, categoryIds),
|
||||
searchHint: 'Search asset category...',
|
||||
isDense: true,
|
||||
enabled: categoryOptions.isNotEmpty,
|
||||
options: categoryOptions,
|
||||
onChanged: (v) {
|
||||
item.assetCategoryId = v;
|
||||
item.assetSubcategoryId = null;
|
||||
onChanged();
|
||||
},
|
||||
),
|
||||
AppSearchableDropdown<int>(
|
||||
key: ValueKey('line_${item.lineNo}_asset_subcategory'),
|
||||
label: 'Asset Subcategory',
|
||||
value: _dropdownValue(item.assetSubcategoryId, subcategoryIds),
|
||||
searchHint: 'Search subcategory...',
|
||||
isDense: true,
|
||||
enabled: hasCategory && subcategoryOptions.isNotEmpty,
|
||||
hint: !hasCategory
|
||||
? 'Select category first'
|
||||
: subcategoryOptions.isEmpty
|
||||
? 'No subcategories found'
|
||||
: null,
|
||||
options: subcategoryOptions,
|
||||
onChanged: (v) {
|
||||
item.assetSubcategoryId = v;
|
||||
onChanged();
|
||||
},
|
||||
),
|
||||
AppTextField(
|
||||
label: 'Remarks',
|
||||
controller: item.remarksController,
|
||||
isDense: true,
|
||||
maxLines: 2,
|
||||
onChanged: (_) => onChanged(),
|
||||
),
|
||||
];
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GrnLineDateField extends StatelessWidget {
|
||||
const _GrnLineDateField({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.onTap,
|
||||
@ -460,8 +553,10 @@ class _GrnLineDateField extends StatelessWidget {
|
||||
),
|
||||
child: Text(
|
||||
value != null ? DateFormatter.displayDate(value) : 'Select date',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: value != null ? null : Theme.of(context).hintColor,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: value != null
|
||||
? null
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -470,6 +565,7 @@ class _GrnLineDateField extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only single-row line items table for GRN detail / edit view.
|
||||
class GrnItemsTable extends StatelessWidget {
|
||||
const GrnItemsTable({super.key, required this.items});
|
||||
|
||||
@ -477,70 +573,211 @@ class GrnItemsTable extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppDataTable<GrnItemModel>(
|
||||
wrapInCard: false,
|
||||
shrinkWrap: true,
|
||||
emptyMessage: 'No line items',
|
||||
columns: [
|
||||
AppDataColumn(
|
||||
label: '#',
|
||||
flex: 1,
|
||||
cellBuilder: (_, item) => Text('${item.lineNo ?? '—'}'),
|
||||
final theme = Theme.of(context);
|
||||
final borderColor = theme.colorScheme.outline.withValues(alpha: 0.15);
|
||||
|
||||
if (items.isEmpty) {
|
||||
return Text(
|
||||
'No line items',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Item',
|
||||
flex: 3,
|
||||
cellBuilder: (_, item) => Text(item.itemName ?? item.itemCode ?? '—'),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Accepted',
|
||||
flex: 1,
|
||||
cellBuilder: (_, item) => Text(_formatQty(item.acceptedQty ?? 0)),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Rejected',
|
||||
flex: 1,
|
||||
cellBuilder: (_, item) => Text(_formatQty(item.rejectedQty ?? 0)),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Rate',
|
||||
flex: 1,
|
||||
cellBuilder: (_, item) => Text(
|
||||
item.rate != null ? _formatQty(item.rate!) : '—',
|
||||
);
|
||||
}
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final tableWidth =
|
||||
constraints.maxWidth < 1100 ? 1100.0 : constraints.maxWidth;
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: SizedBox(
|
||||
width: tableWidth,
|
||||
child: Column(
|
||||
children: [
|
||||
_GrnItemsHeader(borderColor: borderColor),
|
||||
...items.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final item = entry.value;
|
||||
return _GrnItemRow(
|
||||
item: item,
|
||||
showDivider: index < items.length - 1,
|
||||
borderColor: borderColor,
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Batch',
|
||||
flex: 1,
|
||||
cellBuilder: (_, item) => Text(item.batchNo ?? '—'),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Mfg Date',
|
||||
flex: 1,
|
||||
cellBuilder: (_, item) => Text(DateFormatter.displayDate(item.mfgDate)),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Expiry',
|
||||
flex: 1,
|
||||
cellBuilder: (_, item) => Text(DateFormatter.displayDate(item.expiryDate)),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Storage',
|
||||
flex: 1,
|
||||
cellBuilder: (_, item) => Text(item.storageLocation ?? '—'),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Rejection Reason',
|
||||
flex: 2,
|
||||
cellBuilder: (_, item) => Text(item.rejectionReason ?? '—'),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Remarks',
|
||||
flex: 2,
|
||||
cellBuilder: (_, item) => Text(item.remarks ?? '—'),
|
||||
),
|
||||
],
|
||||
rows: items,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GrnItemsHeader extends StatelessWidget {
|
||||
const _GrnItemsHeader({required this.borderColor});
|
||||
|
||||
final Color borderColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final style = theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.5,
|
||||
);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: borderColor)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: 40, child: Text('#', style: style)),
|
||||
Expanded(flex: 3, child: Text('ITEM', style: style)),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: Text('ACCEPTED', style: style, textAlign: TextAlign.right),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: Text('REJECTED', style: style, textAlign: TextAlign.right),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: Text('CURRENT', style: style, textAlign: TextAlign.right),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: Text('RATE', style: style, textAlign: TextAlign.right),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(width: 90, child: Text('BATCH', style: style)),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(width: 90, child: Text('MFG', style: style)),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(width: 90, child: Text('EXPIRY', style: style)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(flex: 2, child: Text('STORAGE', style: style)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GrnItemRow extends StatelessWidget {
|
||||
const _GrnItemRow({
|
||||
required this.item,
|
||||
required this.showDivider,
|
||||
required this.borderColor,
|
||||
});
|
||||
|
||||
final GrnItemModel item;
|
||||
final bool showDivider;
|
||||
final Color borderColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final body = theme.textTheme.bodyMedium;
|
||||
final strong = body?.copyWith(fontWeight: FontWeight.w600);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: showDivider
|
||||
? BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: borderColor)),
|
||||
)
|
||||
: null,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 40,
|
||||
child: Text('${item.lineNo ?? '—'}', style: body),
|
||||
),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Text(
|
||||
item.itemName ?? item.itemCode ?? '—',
|
||||
style: strong,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: Text(
|
||||
_formatQty(item.acceptedQty ?? 0),
|
||||
style: body,
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: Text(
|
||||
_formatQty(item.rejectedQty ?? 0),
|
||||
style: body,
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: Text(
|
||||
_formatQty(item.currentQty ?? 0),
|
||||
style: strong,
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: Text(
|
||||
item.rate != null ? _formatQty(item.rate!) : '—',
|
||||
style: body,
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 90,
|
||||
child: Text(
|
||||
item.batchNo ?? '—',
|
||||
style: body,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 90,
|
||||
child: Text(DateFormatter.displayDate(item.mfgDate), style: body),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 90,
|
||||
child: Text(DateFormatter.displayDate(item.expiryDate), style: body),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Text(
|
||||
item.storageLocation ?? '—',
|
||||
style: body,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -84,7 +84,7 @@ class MasterCrudRemoteDataSource {
|
||||
final result = await list(
|
||||
definition,
|
||||
page: page,
|
||||
limit: AppConstants.maxPageSize,
|
||||
limit: AppConstants.defaultPageSize,
|
||||
);
|
||||
allItems.addAll(
|
||||
result.items.where((item) => item['is_active'] != false),
|
||||
|
||||
@ -11,9 +11,12 @@ class MasterFieldDef {
|
||||
this.type = MasterFieldType.text,
|
||||
this.required = false,
|
||||
this.showInList = false,
|
||||
this.showInForm = true,
|
||||
this.optionsMasterKey,
|
||||
this.staticOptions,
|
||||
this.multiline = false,
|
||||
this.filterByFieldKey,
|
||||
this.filterByOptionKey,
|
||||
});
|
||||
|
||||
final String key;
|
||||
@ -21,11 +24,17 @@ class MasterFieldDef {
|
||||
final MasterFieldType type;
|
||||
final bool required;
|
||||
final bool showInList;
|
||||
/// When false, field is list/display-only and excluded from create/update payloads.
|
||||
final bool showInForm;
|
||||
/// Master key used to populate dropdown options (e.g. `plants` for plant_id).
|
||||
final String? optionsMasterKey;
|
||||
/// Fixed dropdown choices (e.g. brand type) — no API lookup.
|
||||
final List<String>? staticOptions;
|
||||
final bool multiline;
|
||||
/// Form field whose value filters this dropdown (e.g. `item_category_id`).
|
||||
final String? filterByFieldKey;
|
||||
/// Option-row key matched against [filterByFieldKey] (defaults to same key).
|
||||
final String? filterByOptionKey;
|
||||
}
|
||||
|
||||
class MasterDefinition {
|
||||
@ -54,7 +63,8 @@ class MasterDefinition {
|
||||
List<MasterFieldDef> get listFields =>
|
||||
fields.where((field) => field.showInList).toList();
|
||||
|
||||
List<MasterFieldDef> get formFields => fields;
|
||||
List<MasterFieldDef> get formFields =>
|
||||
fields.where((field) => field.showInForm).toList();
|
||||
|
||||
String listRoute(String base) => '$base/$routeKey';
|
||||
String addRoute(String base) => '$base/$routeKey/add';
|
||||
@ -109,6 +119,19 @@ const masterDefinitions = <MasterDefinition>[
|
||||
fields: [
|
||||
MasterFieldDef(key: 'code', label: 'Code', required: true, showInList: true),
|
||||
MasterFieldDef(key: 'name', label: 'Name', required: true, showInList: true),
|
||||
MasterFieldDef(key: 'code_prefix', label: 'Code Prefix', showInList: true),
|
||||
MasterFieldDef(
|
||||
key: 'default_useful_life_years',
|
||||
label: 'Useful Life (Years)',
|
||||
type: MasterFieldType.number,
|
||||
),
|
||||
MasterFieldDef(
|
||||
key: 'default_depreciation_method',
|
||||
label: 'Depreciation Method',
|
||||
type: MasterFieldType.dropdown,
|
||||
showInList: true,
|
||||
optionsMasterKey: 'asset_depreciation_methods',
|
||||
),
|
||||
_activeField,
|
||||
],
|
||||
),
|
||||
@ -145,7 +168,13 @@ const masterDefinitions = <MasterDefinition>[
|
||||
module: 'items',
|
||||
icon: Icons.inventory_outlined,
|
||||
fields: [
|
||||
MasterFieldDef(key: 'item_code', label: 'Item Code', required: true, showInList: true),
|
||||
// Auto-generated by backend — list only; never sent on create/update.
|
||||
MasterFieldDef(
|
||||
key: 'item_code',
|
||||
label: 'Item Code',
|
||||
showInList: true,
|
||||
showInForm: false,
|
||||
),
|
||||
MasterFieldDef(key: 'item_name', label: 'Item Name', required: true, showInList: true),
|
||||
MasterFieldDef(
|
||||
key: 'item_category_id',
|
||||
@ -159,6 +188,7 @@ const masterDefinitions = <MasterDefinition>[
|
||||
label: 'Sub Category',
|
||||
type: MasterFieldType.dropdown,
|
||||
optionsMasterKey: 'item_subcategories',
|
||||
filterByFieldKey: 'item_category_id',
|
||||
),
|
||||
MasterFieldDef(
|
||||
key: 'uom_id',
|
||||
@ -167,6 +197,13 @@ const masterDefinitions = <MasterDefinition>[
|
||||
required: true,
|
||||
optionsMasterKey: 'uom',
|
||||
),
|
||||
MasterFieldDef(
|
||||
key: 'hsn_code_id',
|
||||
label: 'HSN Code',
|
||||
type: MasterFieldType.dropdown,
|
||||
showInList: true,
|
||||
optionsMasterKey: 'hsn_codes',
|
||||
),
|
||||
MasterFieldDef(
|
||||
key: 'gst_rate_id',
|
||||
label: 'GST Rate',
|
||||
@ -202,6 +239,27 @@ const masterDefinitions = <MasterDefinition>[
|
||||
_activeField,
|
||||
],
|
||||
),
|
||||
MasterDefinition(
|
||||
id: 'hsn_codes',
|
||||
title: 'HSN Codes',
|
||||
subtitle: 'HSN/SAC tax classification codes',
|
||||
category: 'Finance & Terms',
|
||||
routeKey: 'hsn-codes',
|
||||
apiPath: '/masters/hsn-codes',
|
||||
module: 'hsn_codes',
|
||||
icon: Icons.qr_code_outlined,
|
||||
fields: [
|
||||
MasterFieldDef(key: 'code', label: 'HSN/SAC Code', required: true, showInList: true),
|
||||
MasterFieldDef(
|
||||
key: 'description',
|
||||
label: 'Description',
|
||||
required: true,
|
||||
showInList: true,
|
||||
multiline: true,
|
||||
),
|
||||
_activeField,
|
||||
],
|
||||
),
|
||||
MasterDefinition(
|
||||
id: 'brands',
|
||||
title: 'Brands',
|
||||
@ -330,59 +388,6 @@ const masterDefinitions = <MasterDefinition>[
|
||||
_activeField,
|
||||
],
|
||||
),
|
||||
MasterDefinition(
|
||||
id: 'asset_categories',
|
||||
title: 'Asset Categories',
|
||||
subtitle: 'Fixed asset classification',
|
||||
category: 'Assets',
|
||||
routeKey: 'asset-categories',
|
||||
apiPath: '/masters/asset-categories',
|
||||
module: 'asset_categories',
|
||||
icon: Icons.precision_manufacturing_outlined,
|
||||
fields: [
|
||||
MasterFieldDef(key: 'code', label: 'Code', required: true, showInList: true),
|
||||
MasterFieldDef(key: 'name', label: 'Name', required: true, showInList: true),
|
||||
MasterFieldDef(key: 'code_prefix', label: 'Code Prefix', required: true, showInList: true),
|
||||
MasterFieldDef(
|
||||
key: 'default_useful_life_years',
|
||||
label: 'Useful Life (Years)',
|
||||
type: MasterFieldType.number,
|
||||
required: true,
|
||||
),
|
||||
MasterFieldDef(
|
||||
key: 'default_depreciation_method',
|
||||
label: 'Depreciation Method',
|
||||
type: MasterFieldType.dropdown,
|
||||
required: true,
|
||||
showInList: true,
|
||||
optionsMasterKey: 'asset_depreciation_methods',
|
||||
),
|
||||
_activeField,
|
||||
],
|
||||
),
|
||||
MasterDefinition(
|
||||
id: 'asset_subcategories',
|
||||
title: 'Asset Subcategories',
|
||||
subtitle: 'Sub-classification under asset categories',
|
||||
category: 'Assets',
|
||||
routeKey: 'asset-subcategories',
|
||||
apiPath: '/masters/asset-subcategories',
|
||||
module: 'asset_subcategories',
|
||||
icon: Icons.category_outlined,
|
||||
fields: [
|
||||
MasterFieldDef(
|
||||
key: 'asset_category_id',
|
||||
label: 'Asset Category',
|
||||
type: MasterFieldType.dropdown,
|
||||
required: true,
|
||||
showInList: true,
|
||||
optionsMasterKey: 'asset_categories',
|
||||
),
|
||||
MasterFieldDef(key: 'code', label: 'Code', required: true, showInList: true),
|
||||
MasterFieldDef(key: 'name', label: 'Name', required: true, showInList: true),
|
||||
_activeField,
|
||||
],
|
||||
),
|
||||
MasterDefinition(
|
||||
id: 'delivery_terms',
|
||||
title: 'Delivery Terms',
|
||||
@ -455,16 +460,35 @@ String masterRecordLabel(Map<String, dynamic> row) {
|
||||
? ratePct.toDouble()
|
||||
: double.tryParse(ratePct.toString());
|
||||
if (rate != null) {
|
||||
return rate % 1 == 0 ? '${rate.toInt()}%' : '$rate%';
|
||||
final rateLabel = rate % 1 == 0 ? '${rate.toInt()}%' : '$rate%';
|
||||
final desc = row['description'];
|
||||
if (desc != null && desc.toString().trim().isNotEmpty) {
|
||||
return '$rateLabel — ${desc.toString().trim()}';
|
||||
}
|
||||
return rateLabel;
|
||||
}
|
||||
}
|
||||
|
||||
for (final key in ['name', 'item_name', 'code', 'item_code', 'description']) {
|
||||
for (final key in ['name', 'item_name', 'item_code']) {
|
||||
final value = row[key];
|
||||
if (value != null && value.toString().trim().isNotEmpty) {
|
||||
return value.toString();
|
||||
}
|
||||
}
|
||||
|
||||
final code = row['code'];
|
||||
if (code != null && code.toString().trim().isNotEmpty) {
|
||||
final desc = row['description'];
|
||||
if (desc != null && desc.toString().trim().isNotEmpty) {
|
||||
return '${code.toString().trim()} — ${desc.toString().trim()}';
|
||||
}
|
||||
return code.toString().trim();
|
||||
}
|
||||
|
||||
final description = row['description'];
|
||||
if (description != null && description.toString().trim().isNotEmpty) {
|
||||
return description.toString().trim();
|
||||
}
|
||||
return row['id']?.toString() ?? 'Record';
|
||||
}
|
||||
|
||||
@ -480,8 +504,8 @@ String masterCellValue(Map<String, dynamic> row, MasterFieldDef field) {
|
||||
final label = row['${field.key}_label'];
|
||||
if (label != null && label.toString().isNotEmpty) return label.toString();
|
||||
|
||||
// Prefer explicit "<base>_name" or nested "<base>.name" from API payloads
|
||||
// (e.g. asset_category_id -> asset_category_name / asset_category.name)
|
||||
// Prefer explicit "<base>_name" or nested "<base>.name" / flat code from API
|
||||
// (e.g. asset_category_id -> asset_category_name; hsn_code_id -> hsn_code)
|
||||
if (field.key.endsWith('_id')) {
|
||||
final baseKey = field.key.substring(0, field.key.length - 3);
|
||||
final explicitName = row['${baseKey}_name'];
|
||||
@ -490,10 +514,16 @@ String masterCellValue(Map<String, dynamic> row, MasterFieldDef field) {
|
||||
}
|
||||
final nested = row[baseKey];
|
||||
if (nested is Map) {
|
||||
final nestedName = nested['name'];
|
||||
if (nestedName != null && nestedName.toString().trim().isNotEmpty) {
|
||||
return nestedName.toString().trim();
|
||||
for (final nestedKey in ['code', 'name', 'description']) {
|
||||
final nestedValue = nested[nestedKey];
|
||||
if (nestedValue != null &&
|
||||
nestedValue.toString().trim().isNotEmpty) {
|
||||
return nestedValue.toString().trim();
|
||||
}
|
||||
}
|
||||
} else if (nested != null && nested.toString().trim().isNotEmpty) {
|
||||
// Flat denormalized value (e.g. items.hsn_code string)
|
||||
return nested.toString().trim();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -259,7 +259,7 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
|
||||
final result = await ref.read(masterRepositoryProvider).list(
|
||||
_definition,
|
||||
page: page,
|
||||
limit: AppConstants.maxPageSize,
|
||||
limit: AppConstants.defaultPageSize,
|
||||
);
|
||||
if (result.failure != null) break;
|
||||
|
||||
@ -310,6 +310,27 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
|
||||
if (current == null) return;
|
||||
final values = Map<String, dynamic>.from(current.values);
|
||||
values[key] = value;
|
||||
|
||||
// Clear dependent dropdowns when their parent filter value changes
|
||||
// (e.g. item_category_id → item_subcategory_id).
|
||||
for (final field in _definition.formFields) {
|
||||
if (field.filterByFieldKey != key) continue;
|
||||
final dependentValue = values[field.key];
|
||||
if (dependentValue == null || dependentValue == '') continue;
|
||||
|
||||
final optionKey = field.filterByOptionKey ?? field.filterByFieldKey!;
|
||||
final options =
|
||||
current.dropdownOptions[field.optionsMasterKey] ?? const [];
|
||||
final stillValid = options.any(
|
||||
(item) =>
|
||||
item['id']?.toString() == dependentValue.toString() &&
|
||||
item[optionKey]?.toString() == value?.toString(),
|
||||
);
|
||||
if (!stillValid) {
|
||||
values[field.key] = null;
|
||||
}
|
||||
}
|
||||
|
||||
state = AsyncData(current.copyWith(values: values));
|
||||
}
|
||||
|
||||
|
||||
@ -386,7 +386,7 @@ class _MasterListTable extends StatelessWidget {
|
||||
|
||||
int _columnFlex(MasterFieldDef field) {
|
||||
return switch (field.key) {
|
||||
'code' || 'item_code' || 'series_code' => 1,
|
||||
'code' || 'item_code' || 'series_code' || 'hsn_code_id' => 1,
|
||||
'name' || 'item_name' || 'description' || 'term_name' => 3,
|
||||
_ => 2,
|
||||
};
|
||||
|
||||
@ -99,8 +99,20 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
||||
if (field.staticOptions != null) {
|
||||
dropdownOptions = stringDropdownOptions(field.staticOptions!);
|
||||
} else {
|
||||
final options = formState.dropdownOptions[field.optionsMasterKey] ??
|
||||
var options = formState.dropdownOptions[field.optionsMasterKey] ??
|
||||
const <Map<String, dynamic>>[];
|
||||
final filterField = field.filterByFieldKey;
|
||||
if (filterField != null) {
|
||||
final parentId = formState.values[filterField]?.toString();
|
||||
final optionKey = field.filterByOptionKey ?? filterField;
|
||||
if (parentId == null || parentId.isEmpty) {
|
||||
options = const [];
|
||||
} else {
|
||||
options = options
|
||||
.where((item) => item[optionKey]?.toString() == parentId)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
dropdownOptions = <AppDropdownOption<String>>[];
|
||||
for (final item in options) {
|
||||
final id = item['id']?.toString();
|
||||
@ -111,15 +123,37 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
||||
}
|
||||
}
|
||||
|
||||
final filterField = field.filterByFieldKey;
|
||||
final parentSelected = filterField == null ||
|
||||
(formState.values[filterField] != null &&
|
||||
formState.values[filterField].toString().isNotEmpty);
|
||||
final enabled = field.staticOptions != null
|
||||
? true
|
||||
: parentSelected && dropdownOptions.isNotEmpty;
|
||||
String parentLabel = 'parent';
|
||||
if (filterField != null) {
|
||||
for (final f in _definition.formFields) {
|
||||
if (f.key == filterField) {
|
||||
parentLabel = f.label.toLowerCase();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return AppSearchableDropdown<String>(
|
||||
key: ValueKey(
|
||||
'${field.key}-${filterField == null ? '' : formState.values[filterField]}',
|
||||
),
|
||||
label: _fieldLabel(field),
|
||||
value: value?.toString(),
|
||||
options: dropdownOptions,
|
||||
hint: dropdownOptions.isEmpty
|
||||
? 'No options available'
|
||||
: 'Select ${field.label.toLowerCase()}',
|
||||
hint: !parentSelected
|
||||
? 'Select $parentLabel first'
|
||||
: dropdownOptions.isEmpty
|
||||
? 'No options available'
|
||||
: 'Select ${field.label.toLowerCase()}',
|
||||
searchHint: 'Search ${field.label.toLowerCase()}...',
|
||||
enabled: dropdownOptions.isNotEmpty,
|
||||
enabled: enabled,
|
||||
onChanged: (selected) => notifier.updateValue(field.key, selected),
|
||||
validator: field.required
|
||||
? (v) => v == null ? '${field.label} is required' : null
|
||||
@ -139,15 +173,29 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
||||
);
|
||||
|
||||
case MasterFieldType.text:
|
||||
final formatters = Validators.inputFormattersForFieldKey(field.key);
|
||||
final isHsnCodeField =
|
||||
widget.masterId == 'hsn_codes' && field.key == 'code';
|
||||
final formatters = isHsnCodeField
|
||||
? Validators.hsnCodeInput
|
||||
: Validators.inputFormattersForFieldKey(field.key);
|
||||
return TextFormField(
|
||||
key: ValueKey(field.key),
|
||||
initialValue: value?.toString(),
|
||||
maxLines: field.multiline ? 3 : 1,
|
||||
keyboardType: _keyboardTypeForFieldKey(field.key),
|
||||
keyboardType: isHsnCodeField
|
||||
? TextInputType.number
|
||||
: _keyboardTypeForFieldKey(field.key),
|
||||
inputFormatters: formatters.isEmpty ? null : formatters,
|
||||
decoration: InputDecoration(labelText: _fieldLabel(field)),
|
||||
validator: (v) {
|
||||
if (isHsnCodeField) {
|
||||
return Validators.uniqueHsnCode(
|
||||
v,
|
||||
existingRecords: formState.existingRecords,
|
||||
currentRecordId: widget.recordId,
|
||||
fieldName: field.label,
|
||||
);
|
||||
}
|
||||
if (Validators.isMasterNameFieldKey(field.key)) {
|
||||
return Validators.uniqueMasterName(
|
||||
v,
|
||||
|
||||
@ -44,62 +44,96 @@ class MasterRemoteDataSource {
|
||||
Future<List<FilterOptionModel>> listGstRates() =>
|
||||
_listOptions(ApiEndpoints.gstRates);
|
||||
|
||||
Future<List<FilterOptionModel>> listAssetCategories() =>
|
||||
_listOptions(ApiEndpoints.assetCategories);
|
||||
Future<List<FilterOptionModel>> listHsnCodes() =>
|
||||
_listOptions(ApiEndpoints.hsnCodes);
|
||||
|
||||
Future<List<FilterOptionModel>> listAssetSubcategories({int? assetCategoryId}) async {
|
||||
final response = await dio.get(
|
||||
ApiEndpoints.assetSubcategories,
|
||||
queryParameters: {
|
||||
'limit': AppConstants.maxPageSize,
|
||||
'is_active': true,
|
||||
if (assetCategoryId != null) 'asset_category_id': assetCategoryId,
|
||||
},
|
||||
/// Active items with default HSN / UOM / GST for PO line autofill.
|
||||
Future<
|
||||
({
|
||||
List<FilterOptionModel> options,
|
||||
Map<String, int?> hsnByItemId,
|
||||
Map<String, int?> uomByItemId,
|
||||
Map<String, int?> gstRateByItemId,
|
||||
})> listItemsWithHsn() async {
|
||||
final rows = await _listAllMaps(
|
||||
ApiEndpoints.items,
|
||||
queryParameters: {'is_active': true},
|
||||
);
|
||||
final categoryFilter = assetCategoryId?.toString();
|
||||
return _parseOptions(
|
||||
response.data,
|
||||
extraFilter: categoryFilter == null
|
||||
? null
|
||||
: (item) => item['asset_category_id']?.toString() == categoryFilter,
|
||||
);
|
||||
}
|
||||
final hsnByItemId = <String, int?>{};
|
||||
final uomByItemId = <String, int?>{};
|
||||
final gstRateByItemId = <String, int?>{};
|
||||
final options = <FilterOptionModel>[];
|
||||
|
||||
Future<List<FilterOptionModel>> _listOptions(String endpoint) async {
|
||||
final response = await dio.get(
|
||||
endpoint,
|
||||
queryParameters: {
|
||||
'limit': AppConstants.maxPageSize,
|
||||
'is_active': true,
|
||||
},
|
||||
);
|
||||
return _parseOptions(response.data);
|
||||
}
|
||||
for (final item in rows) {
|
||||
if (item['is_active'] == false) continue;
|
||||
final id = item['id']?.toString() ?? '';
|
||||
if (id.isEmpty) continue;
|
||||
|
||||
/// Parses masters list payloads. Uses `is List` (not `List<dynamic>`) so
|
||||
/// Flutter web JSON arrays are not dropped as empty.
|
||||
List<FilterOptionModel> _parseOptions(
|
||||
dynamic body, {
|
||||
bool Function(Map<String, dynamic> item)? extraFilter,
|
||||
}) {
|
||||
if (body is! Map) return const [];
|
||||
final raw = body['data'];
|
||||
hsnByItemId[id] = _asInt(item['hsn_code_id']);
|
||||
uomByItemId[id] = _asInt(item['uom_id']);
|
||||
gstRateByItemId[id] = _asInt(item['gst_rate_id']);
|
||||
|
||||
final List list;
|
||||
if (raw is List) {
|
||||
list = raw;
|
||||
} else if (raw is Map) {
|
||||
final items = raw['items'];
|
||||
list = items is List ? items : const [];
|
||||
} else {
|
||||
list = const [];
|
||||
final name = _optionLabel(item);
|
||||
if (name.isEmpty) continue;
|
||||
options.add(FilterOptionModel(id: id, name: name));
|
||||
}
|
||||
|
||||
return list
|
||||
.whereType<Map>()
|
||||
.map((item) => Map<String, dynamic>.from(item))
|
||||
return (
|
||||
options: options,
|
||||
hsnByItemId: hsnByItemId,
|
||||
uomByItemId: uomByItemId,
|
||||
gstRateByItemId: gstRateByItemId,
|
||||
);
|
||||
}
|
||||
|
||||
/// GST rate options with numeric `rate_pct` for tax calculations.
|
||||
Future<({List<FilterOptionModel> options, Map<String, double> pctById})>
|
||||
listGstRatesWithPct() async {
|
||||
final rows = await _listAllMaps(
|
||||
ApiEndpoints.gstRates,
|
||||
queryParameters: {'is_active': true},
|
||||
);
|
||||
final options = <FilterOptionModel>[];
|
||||
final pctById = <String, double>{};
|
||||
|
||||
for (final item in rows) {
|
||||
if (item['is_active'] == false) continue;
|
||||
final id = item['id']?.toString() ?? '';
|
||||
if (id.isEmpty) continue;
|
||||
|
||||
final pctRaw = item['rate_pct'];
|
||||
final pct = pctRaw is num
|
||||
? pctRaw.toDouble()
|
||||
: double.tryParse(pctRaw?.toString() ?? '');
|
||||
if (pct != null) pctById[id] = pct;
|
||||
|
||||
final name = _optionLabel(item);
|
||||
if (name.isEmpty) continue;
|
||||
options.add(FilterOptionModel(id: id, name: name));
|
||||
}
|
||||
|
||||
return (options: options, pctById: pctById);
|
||||
}
|
||||
|
||||
Future<List<FilterOptionModel>> listItemCategories() =>
|
||||
_listOptions(ApiEndpoints.itemCategories);
|
||||
|
||||
Future<List<FilterOptionModel>> listItemSubcategories({int? itemCategoryId}) async {
|
||||
final rows = await _listAllMaps(
|
||||
ApiEndpoints.itemSubcategories,
|
||||
queryParameters: {
|
||||
'is_active': true,
|
||||
if (itemCategoryId != null) 'item_category_id': itemCategoryId,
|
||||
},
|
||||
);
|
||||
final categoryFilter = itemCategoryId?.toString();
|
||||
return rows
|
||||
.where((item) => item['is_active'] != false)
|
||||
.where((item) => extraFilter == null || extraFilter(item))
|
||||
.where(
|
||||
(item) =>
|
||||
categoryFilter == null ||
|
||||
item['item_category_id']?.toString() == categoryFilter,
|
||||
)
|
||||
.map(
|
||||
(item) => FilterOptionModel(
|
||||
id: item['id']?.toString() ?? '',
|
||||
@ -110,6 +144,101 @@ class MasterRemoteDataSource {
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<List<FilterOptionModel>> _listOptions(String endpoint) async {
|
||||
final rows = await _listAllMaps(
|
||||
endpoint,
|
||||
queryParameters: {'is_active': true},
|
||||
);
|
||||
return rows
|
||||
.where((item) => item['is_active'] != false)
|
||||
.map(
|
||||
(item) => FilterOptionModel(
|
||||
id: item['id']?.toString() ?? '',
|
||||
name: _optionLabel(item),
|
||||
),
|
||||
)
|
||||
.where((item) => item.id.isNotEmpty && item.name.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Loads every page of a masters list (API default page size = 20).
|
||||
Future<List<Map<String, dynamic>>> _listAllMaps(
|
||||
String endpoint, {
|
||||
Map<String, dynamic>? queryParameters,
|
||||
}) async {
|
||||
final all = <Map<String, dynamic>>[];
|
||||
var page = 1;
|
||||
var totalPages = 1;
|
||||
final pageSize = AppConstants.defaultPageSize;
|
||||
|
||||
while (page <= totalPages) {
|
||||
final response = await dio.get(
|
||||
endpoint,
|
||||
queryParameters: {
|
||||
'page': page,
|
||||
'limit': pageSize,
|
||||
...?queryParameters,
|
||||
},
|
||||
);
|
||||
final parsed = _parsePage(response.data, fallbackLimit: pageSize);
|
||||
all.addAll(parsed.items);
|
||||
totalPages = parsed.totalPages;
|
||||
if (parsed.items.isEmpty) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
return all;
|
||||
}
|
||||
|
||||
({List<Map<String, dynamic>> items, int totalPages}) _parsePage(
|
||||
dynamic body, {
|
||||
required int fallbackLimit,
|
||||
}) {
|
||||
if (body is! Map) {
|
||||
return (items: const <Map<String, dynamic>>[], totalPages: 1);
|
||||
}
|
||||
|
||||
final raw = body['data'];
|
||||
final meta = body['meta'] is Map
|
||||
? Map<String, dynamic>.from(body['meta'] as Map)
|
||||
: <String, dynamic>{};
|
||||
|
||||
List list;
|
||||
Map<String, dynamic> pageMeta = meta;
|
||||
|
||||
if (raw is List) {
|
||||
list = raw;
|
||||
} else if (raw is Map) {
|
||||
final map = Map<String, dynamic>.from(raw);
|
||||
final items = map['items'];
|
||||
list = items is List ? items : const [];
|
||||
pageMeta = {...meta, ...map};
|
||||
} else {
|
||||
list = const [];
|
||||
}
|
||||
|
||||
final items = list
|
||||
.whereType<Map>()
|
||||
.map((item) => Map<String, dynamic>.from(item))
|
||||
.toList();
|
||||
|
||||
final total = _asInt(pageMeta['total']) ?? items.length;
|
||||
final limit = _asInt(pageMeta['limit']) ?? fallbackLimit;
|
||||
final explicitTotalPages = _asInt(pageMeta['totalPages']) ??
|
||||
_asInt(pageMeta['total_pages']);
|
||||
final totalPages = explicitTotalPages ??
|
||||
(limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1);
|
||||
|
||||
return (items: items, totalPages: totalPages < 1 ? 1 : totalPages);
|
||||
}
|
||||
|
||||
int? _asInt(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
if (value is String) return int.tryParse(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
String _optionLabel(Map<String, dynamic> item) {
|
||||
final ratePct = item['rate_pct'];
|
||||
if (ratePct != null) {
|
||||
@ -132,14 +261,26 @@ class MasterRemoteDataSource {
|
||||
'item_name',
|
||||
'term_name',
|
||||
'vendor_name',
|
||||
'code',
|
||||
'description',
|
||||
]) {
|
||||
final value = item[key];
|
||||
if (value is String && value.trim().isNotEmpty) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
final code = item['code'];
|
||||
if (code is String && code.trim().isNotEmpty) {
|
||||
final desc = item['description'];
|
||||
if (desc is String && desc.trim().isNotEmpty) {
|
||||
return '${code.trim()} — ${desc.trim()}';
|
||||
}
|
||||
return code.trim();
|
||||
}
|
||||
|
||||
final description = item['description'];
|
||||
if (description is String && description.trim().isNotEmpty) {
|
||||
return description.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,9 +21,16 @@ class PurchaseOrderRemoteDataSource {
|
||||
|
||||
Future<PurchaseOrderModel> getPurchaseOrderById(String id) async {
|
||||
final response = await dio.get(ApiEndpoints.purchaseOrderById(id));
|
||||
return PurchaseOrderModel.fromJson(
|
||||
response.data['data'] as Map<String, dynamic>,
|
||||
);
|
||||
final raw = response.data['data'];
|
||||
if (raw is! Map) {
|
||||
throw StateError('Invalid purchase order detail response');
|
||||
}
|
||||
final map = Map<String, dynamic>.from(raw);
|
||||
// Support alternate line-item key from API payloads.
|
||||
if (map['items'] == null && map['line_items'] is List) {
|
||||
map['items'] = map['line_items'];
|
||||
}
|
||||
return PurchaseOrderModel.fromJson(map);
|
||||
}
|
||||
|
||||
Future<PurchaseOrderModel> createPurchaseOrder(Map<String, dynamic> data) async {
|
||||
|
||||
@ -16,8 +16,13 @@ class PurchaseOrderLookups {
|
||||
this.paymentTerms = const [],
|
||||
this.deliveryTerms = const [],
|
||||
this.items = const [],
|
||||
this.itemHsnById = const {},
|
||||
this.itemUomById = const {},
|
||||
this.itemGstRateById = const {},
|
||||
this.uom = const [],
|
||||
this.gstRates = const [],
|
||||
this.gstRatePctById = const {},
|
||||
this.hsnCodes = const [],
|
||||
});
|
||||
|
||||
final List<FilterOptionModel> vendors;
|
||||
@ -27,8 +32,17 @@ class PurchaseOrderLookups {
|
||||
final List<FilterOptionModel> paymentTerms;
|
||||
final List<FilterOptionModel> deliveryTerms;
|
||||
final List<FilterOptionModel> items;
|
||||
/// Item id → default `hsn_code_id` from item master.
|
||||
final Map<String, int?> itemHsnById;
|
||||
/// Item id → default `uom_id` from item master.
|
||||
final Map<String, int?> itemUomById;
|
||||
/// Item id → default `gst_rate_id` from item master.
|
||||
final Map<String, int?> itemGstRateById;
|
||||
final List<FilterOptionModel> uom;
|
||||
final List<FilterOptionModel> gstRates;
|
||||
/// GST rate id → `rate_pct` for tax calculations.
|
||||
final Map<String, double> gstRatePctById;
|
||||
final List<FilterOptionModel> hsnCodes;
|
||||
}
|
||||
|
||||
final purchaseOrderLookupsProvider =
|
||||
@ -38,15 +52,17 @@ final purchaseOrderLookupsProvider =
|
||||
|
||||
final vendors = await _safeOptions(() => _fetchActiveVendors(vendorRepo));
|
||||
|
||||
final itemsWithDefaults = await _safeItemsWithDefaults(master.listItemsWithHsn);
|
||||
final gstWithPct = await _safeGstRatesWithPct(master.listGstRatesWithPct);
|
||||
|
||||
final results = await Future.wait([
|
||||
_safeOptions(master.listPlants),
|
||||
_safeOptions(master.listWarehouses),
|
||||
_safeOptions(master.listBrands),
|
||||
_safeOptions(master.listPaymentTerms),
|
||||
_safeOptions(master.listDeliveryTerms),
|
||||
_safeOptions(master.listItems),
|
||||
_safeOptions(master.listUom),
|
||||
_safeOptions(master.listGstRates),
|
||||
_safeOptions(master.listHsnCodes),
|
||||
]);
|
||||
|
||||
return PurchaseOrderLookups(
|
||||
@ -56,9 +72,14 @@ final purchaseOrderLookupsProvider =
|
||||
brands: results[2],
|
||||
paymentTerms: results[3],
|
||||
deliveryTerms: results[4],
|
||||
items: results[5],
|
||||
uom: results[6],
|
||||
gstRates: results[7],
|
||||
items: itemsWithDefaults.options,
|
||||
itemHsnById: itemsWithDefaults.hsnByItemId,
|
||||
itemUomById: itemsWithDefaults.uomByItemId,
|
||||
itemGstRateById: itemsWithDefaults.gstRateByItemId,
|
||||
uom: results[5],
|
||||
gstRates: gstWithPct.options,
|
||||
gstRatePctById: gstWithPct.pctById,
|
||||
hsnCodes: results[6],
|
||||
);
|
||||
});
|
||||
|
||||
@ -72,6 +93,48 @@ Future<List<FilterOptionModel>> _safeOptions(
|
||||
}
|
||||
}
|
||||
|
||||
Future<
|
||||
({
|
||||
List<FilterOptionModel> options,
|
||||
Map<String, int?> hsnByItemId,
|
||||
Map<String, int?> uomByItemId,
|
||||
Map<String, int?> gstRateByItemId,
|
||||
})> _safeItemsWithDefaults(
|
||||
Future<
|
||||
({
|
||||
List<FilterOptionModel> options,
|
||||
Map<String, int?> hsnByItemId,
|
||||
Map<String, int?> uomByItemId,
|
||||
Map<String, int?> gstRateByItemId,
|
||||
})>
|
||||
Function()
|
||||
load,
|
||||
) async {
|
||||
try {
|
||||
return await load();
|
||||
} catch (_) {
|
||||
return (
|
||||
options: <FilterOptionModel>[],
|
||||
hsnByItemId: <String, int?>{},
|
||||
uomByItemId: <String, int?>{},
|
||||
gstRateByItemId: <String, int?>{},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<({List<FilterOptionModel> options, Map<String, double> pctById})>
|
||||
_safeGstRatesWithPct(
|
||||
Future<({List<FilterOptionModel> options, Map<String, double> pctById})>
|
||||
Function()
|
||||
load,
|
||||
) async {
|
||||
try {
|
||||
return await load();
|
||||
} catch (_) {
|
||||
return (options: <FilterOptionModel>[], pctById: <String, double>{});
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<FilterOptionModel>> _fetchActiveVendors(
|
||||
VendorRepository vendorRepo,
|
||||
) async {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../shared/models/purchase_order_model.dart';
|
||||
import '../../../grn/presentation/providers/grn_lookups_provider.dart';
|
||||
import '../../data/repositories/purchase_order_repository_impl.dart';
|
||||
|
||||
class PurchaseOrdersListState {
|
||||
@ -131,6 +132,7 @@ class PurchaseOrdersListNotifier
|
||||
}
|
||||
return false;
|
||||
}
|
||||
ref.invalidate(grnLookupsProvider);
|
||||
await refresh();
|
||||
final current = state.valueOrNull;
|
||||
if (current != null) {
|
||||
@ -166,6 +168,7 @@ class PurchaseOrderDetailNotifier
|
||||
if (result.failure != null) throw result.failure!;
|
||||
state = AsyncData(result.data!);
|
||||
ref.invalidate(purchaseOrdersListProvider);
|
||||
ref.invalidate(grnLookupsProvider);
|
||||
return result.data!;
|
||||
}
|
||||
|
||||
@ -175,6 +178,7 @@ class PurchaseOrderDetailNotifier
|
||||
if (result.failure != null) throw result.failure!;
|
||||
state = AsyncData(result.data!);
|
||||
ref.invalidate(purchaseOrdersListProvider);
|
||||
ref.invalidate(grnLookupsProvider);
|
||||
return result.data!;
|
||||
}
|
||||
|
||||
@ -184,6 +188,7 @@ class PurchaseOrderDetailNotifier
|
||||
if (result.failure != null) throw result.failure!;
|
||||
state = AsyncData(result.data!);
|
||||
ref.invalidate(purchaseOrdersListProvider);
|
||||
ref.invalidate(grnLookupsProvider);
|
||||
return result.data!;
|
||||
}
|
||||
|
||||
@ -192,6 +197,7 @@ class PurchaseOrderDetailNotifier
|
||||
final result = await repository.amendPurchaseOrder(arg);
|
||||
if (result.failure != null) throw result.failure!;
|
||||
ref.invalidate(purchaseOrdersListProvider);
|
||||
ref.invalidate(grnLookupsProvider);
|
||||
return result.data!;
|
||||
}
|
||||
|
||||
@ -201,6 +207,7 @@ class PurchaseOrderDetailNotifier
|
||||
if (result.failure != null) throw result.failure!;
|
||||
state = AsyncData(result.data!);
|
||||
ref.invalidate(purchaseOrdersListProvider);
|
||||
ref.invalidate(grnLookupsProvider);
|
||||
return result.data!;
|
||||
}
|
||||
|
||||
@ -232,6 +239,7 @@ class PurchaseOrderFormNotifier extends FamilyAsyncNotifier<PurchaseOrderModel?,
|
||||
final result = await repository.createPurchaseOrder(data);
|
||||
if (result.failure != null) throw result.failure!;
|
||||
ref.invalidate(purchaseOrdersListProvider);
|
||||
ref.invalidate(grnLookupsProvider);
|
||||
return result.data!;
|
||||
}
|
||||
|
||||
@ -241,6 +249,7 @@ class PurchaseOrderFormNotifier extends FamilyAsyncNotifier<PurchaseOrderModel?,
|
||||
if (result.failure != null) throw result.failure!;
|
||||
ref.invalidate(purchaseOrdersListProvider);
|
||||
ref.invalidate(purchaseOrderDetailProvider(id));
|
||||
ref.invalidate(grnLookupsProvider);
|
||||
return result.data!;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/constants/route_constants.dart';
|
||||
import '../../../../core/errors/failure.dart';
|
||||
import '../../../../core/network/api_handler.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/utils/formatters.dart';
|
||||
import '../../../../shared/models/purchase_order_model.dart';
|
||||
import '../../../../shared/models/user_management_models.dart';
|
||||
@ -16,10 +17,11 @@ import '../../../../shared/widgets/app_searchable_dropdown.dart';
|
||||
import '../../../../shared/widgets/app_text_field.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
import '../../../../shared/widgets/app_side_panel.dart';
|
||||
import '../../../../shared/widgets/page_header.dart';
|
||||
import '../../data/repositories/purchase_order_repository_impl.dart';
|
||||
import '../../../grn/presentation/providers/grn_lookups_provider.dart';
|
||||
import '../providers/purchase_order_lookups_provider.dart';
|
||||
import '../providers/purchase_orders_provider.dart';
|
||||
import '../widgets/po_status_chip.dart';
|
||||
import '../widgets/purchase_order_line_items_editor.dart';
|
||||
|
||||
class PurchaseOrderFormScreen extends ConsumerStatefulWidget {
|
||||
@ -37,9 +39,9 @@ class PurchaseOrderFormScreen extends ConsumerStatefulWidget {
|
||||
class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _scrollController = ScrollController();
|
||||
final _discountController = TextEditingController();
|
||||
final _freightController = TextEditingController();
|
||||
final _otherChargesController = TextEditingController();
|
||||
final _discountController = TextEditingController(text: '0.00');
|
||||
final _freightController = TextEditingController(text: '0.00');
|
||||
final _otherChargesController = TextEditingController(text: '0.00');
|
||||
final _termsController = TextEditingController();
|
||||
final _remarksController = TextEditingController();
|
||||
|
||||
@ -63,10 +65,16 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
_poDate = DateTime.now();
|
||||
_lines.add(PoLineItemDraft(lineNo: 1));
|
||||
}
|
||||
_discountController.addListener(_onChargesChanged);
|
||||
_freightController.addListener(_onChargesChanged);
|
||||
_otherChargesController.addListener(_onChargesChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_discountController.removeListener(_onChargesChanged);
|
||||
_freightController.removeListener(_onChargesChanged);
|
||||
_otherChargesController.removeListener(_onChargesChanged);
|
||||
_scrollController.dispose();
|
||||
_discountController.dispose();
|
||||
_freightController.dispose();
|
||||
@ -79,6 +87,8 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onChargesChanged() => setState(() {});
|
||||
|
||||
String _orderSignature(PurchaseOrderModel order) =>
|
||||
'${order.id}:${order.updatedAt?.toIso8601String()}:${order.items.length}';
|
||||
|
||||
@ -93,9 +103,12 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
_brandId = order.brandId;
|
||||
_paymentTermId = order.paymentTermId;
|
||||
_deliveryTermId = order.deliveryTermId;
|
||||
_discountController.text = order.discountAmount?.toString() ?? '';
|
||||
_freightController.text = order.freightCharges?.toString() ?? '';
|
||||
_otherChargesController.text = order.otherCharges?.toString() ?? '';
|
||||
_discountController.text =
|
||||
(order.discountAmount ?? 0).toStringAsFixed(2);
|
||||
_freightController.text =
|
||||
(order.freightCharges ?? 0).toStringAsFixed(2);
|
||||
_otherChargesController.text =
|
||||
(order.otherCharges ?? 0).toStringAsFixed(2);
|
||||
_termsController.text = order.termsAndConditions ?? '';
|
||||
_remarksController.text = order.remarks ?? '';
|
||||
for (final line in _lines) {
|
||||
@ -145,9 +158,11 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
.toList();
|
||||
}
|
||||
|
||||
List<AppDropdownOption<int?>> _nullableIntOptions(List<FilterOptionModel> options) {
|
||||
List<AppDropdownOption<int?>> _nullableIntOptions(
|
||||
List<FilterOptionModel> options,
|
||||
) {
|
||||
return [
|
||||
const AppDropdownOption<int?>(value: null, label: 'None'),
|
||||
const AppDropdownOption<int?>(value: null, label: '—'),
|
||||
...options.map(
|
||||
(e) {
|
||||
final id = _parseId(e.id);
|
||||
@ -158,6 +173,17 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
].whereType<AppDropdownOption<int?>>().toList();
|
||||
}
|
||||
|
||||
PoOrderTotals _computeTotals(Map<String, double> gstRatePctById) {
|
||||
final lineCalcs =
|
||||
_lines.map((line) => line.calculate(gstRatePctById)).toList();
|
||||
return PoOrderTotals.compute(
|
||||
lines: lineCalcs,
|
||||
freight: double.tryParse(_freightController.text.trim()) ?? 0,
|
||||
otherCharges: double.tryParse(_otherChargesController.text.trim()) ?? 0,
|
||||
discountAmount: double.tryParse(_discountController.text.trim()) ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _buildPayload() {
|
||||
return {
|
||||
'po_date': DateFormatter.toApiDate(_poDate ?? DateTime.now()),
|
||||
@ -171,12 +197,12 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
if (_expectedDeliveryDate != null)
|
||||
'expected_delivery_date':
|
||||
DateFormatter.toApiDate(_expectedDeliveryDate!),
|
||||
if (_discountController.text.trim().isNotEmpty)
|
||||
'discount_amount': double.tryParse(_discountController.text.trim()),
|
||||
if (_freightController.text.trim().isNotEmpty)
|
||||
'freight_charges': double.tryParse(_freightController.text.trim()),
|
||||
if (_otherChargesController.text.trim().isNotEmpty)
|
||||
'other_charges': double.tryParse(_otherChargesController.text.trim()),
|
||||
'discount_amount':
|
||||
double.tryParse(_discountController.text.trim()) ?? 0,
|
||||
'freight_charges':
|
||||
double.tryParse(_freightController.text.trim()) ?? 0,
|
||||
'other_charges':
|
||||
double.tryParse(_otherChargesController.text.trim()) ?? 0,
|
||||
if (_termsController.text.trim().isNotEmpty)
|
||||
'terms_and_conditions': _termsController.text.trim(),
|
||||
if (_remarksController.text.trim().isNotEmpty)
|
||||
@ -251,6 +277,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
if (result.failure != null) throw result.failure!;
|
||||
saved = result.data!;
|
||||
ref.invalidate(purchaseOrdersListProvider);
|
||||
ref.invalidate(grnLookupsProvider);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
@ -294,6 +321,12 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
if (picked != null) onPicked(picked);
|
||||
}
|
||||
|
||||
bool _showReapprovalWarning(PurchaseOrderModel? existing) {
|
||||
if (existing == null) return false;
|
||||
final s = existing.status.toUpperCase();
|
||||
return s == 'SUBMITTED' || s == 'PENDING_APPROVAL' || s == 'PENDING';
|
||||
}
|
||||
|
||||
Widget _buildFormBody({
|
||||
required PurchaseOrderLookups lookups,
|
||||
PurchaseOrderModel? existing,
|
||||
@ -323,214 +356,345 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
lookups.vendors.map((e) => _parseId(e.id)).whereType<int>();
|
||||
final plantIds =
|
||||
lookups.plants.map((e) => _parseId(e.id)).whereType<int>();
|
||||
final totals = _computeTotals(lookups.gstRatePctById);
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(24),
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1200),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (!widget.isEditing)
|
||||
PageHeader(
|
||||
title: 'Create Purchase Order',
|
||||
subtitle: 'Fill header details and add line items',
|
||||
)
|
||||
else if (existing?.poNo != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Text(
|
||||
existing!.poNo!,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildHeader(existing),
|
||||
if (_showReapprovalWarning(existing)) ...[
|
||||
const SizedBox(height: 8),
|
||||
_ReapprovalBanner(),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
_SectionCard(
|
||||
title: 'ORDER DETAILS',
|
||||
child: Column(
|
||||
children: [
|
||||
FormRowFour(
|
||||
children: [
|
||||
_DateField(
|
||||
label: 'PO date *',
|
||||
value: _poDate,
|
||||
onTap: () => _pickDate(
|
||||
current: _poDate,
|
||||
onPicked: (d) => setState(() => _poDate = d),
|
||||
),
|
||||
),
|
||||
AppSearchableDropdown<String>(
|
||||
label: 'PO type *',
|
||||
value: _poType,
|
||||
hint: 'Select PO type',
|
||||
searchHint: 'Search type...',
|
||||
options: poTypeOptions
|
||||
.map(
|
||||
(e) => AppDropdownOption(
|
||||
value: e.$1,
|
||||
label: e.$2,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (v) => setState(() => _poType = v),
|
||||
validator: (v) =>
|
||||
v == null ? 'PO type is required' : null,
|
||||
),
|
||||
AppSearchableDropdown<int>(
|
||||
label: 'Vendor *',
|
||||
value: _dropdownValue(_vendorId, vendorIds),
|
||||
hint: 'Select vendor',
|
||||
searchHint: 'Search vendor...',
|
||||
options: _intOptions(lookups.vendors),
|
||||
onChanged: (v) => setState(() => _vendorId = v),
|
||||
validator: (v) =>
|
||||
v == null ? 'Vendor is required' : null,
|
||||
),
|
||||
AppSearchableDropdown<int>(
|
||||
label: 'Plant *',
|
||||
value: _dropdownValue(_plantId, plantIds),
|
||||
hint: 'Select plant',
|
||||
searchHint: 'Search plant...',
|
||||
options: _intOptions(lookups.plants),
|
||||
onChanged: (v) => setState(() => _plantId = v),
|
||||
validator: (v) =>
|
||||
v == null ? 'Plant is required' : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
FormRowFour(
|
||||
children: [
|
||||
AppSearchableDropdown<int?>(
|
||||
label: 'Warehouse',
|
||||
value: _warehouseId,
|
||||
hint: 'Select warehouse',
|
||||
searchHint: 'Search warehouse...',
|
||||
options: _nullableIntOptions(lookups.warehouses),
|
||||
onChanged: (v) =>
|
||||
setState(() => _warehouseId = v),
|
||||
),
|
||||
AppSearchableDropdown<int?>(
|
||||
label: 'Brand',
|
||||
value: _brandId,
|
||||
hint: 'Select brand',
|
||||
searchHint: 'Search brand...',
|
||||
options: _nullableIntOptions(lookups.brands),
|
||||
onChanged: (v) => setState(() => _brandId = v),
|
||||
),
|
||||
AppSearchableDropdown<int?>(
|
||||
label: 'Payment term',
|
||||
value: _paymentTermId,
|
||||
hint: 'Select payment term',
|
||||
searchHint: 'Search payment term...',
|
||||
options:
|
||||
_nullableIntOptions(lookups.paymentTerms),
|
||||
onChanged: (v) =>
|
||||
setState(() => _paymentTermId = v),
|
||||
),
|
||||
AppSearchableDropdown<int?>(
|
||||
label: 'Delivery term',
|
||||
value: _deliveryTermId,
|
||||
hint: 'Select delivery term',
|
||||
searchHint: 'Search delivery term...',
|
||||
options:
|
||||
_nullableIntOptions(lookups.deliveryTerms),
|
||||
onChanged: (v) =>
|
||||
setState(() => _deliveryTermId = v),
|
||||
),
|
||||
],
|
||||
),
|
||||
FormRow(
|
||||
columnCount: 4,
|
||||
children: [
|
||||
_DateField(
|
||||
label: 'Expected delivery',
|
||||
value: _expectedDeliveryDate,
|
||||
onTap: () => _pickDate(
|
||||
current: _expectedDeliveryDate,
|
||||
onPicked: (d) => setState(
|
||||
() => _expectedDeliveryDate = d,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
const SizedBox(height: 16),
|
||||
PurchaseOrderLineItemsEditor(
|
||||
lines: _lines,
|
||||
items: lookups.items,
|
||||
itemHsnById: lookups.itemHsnById,
|
||||
itemUomById: lookups.itemUomById,
|
||||
itemGstRateById: lookups.itemGstRateById,
|
||||
uom: lookups.uom,
|
||||
gstRates: lookups.gstRates,
|
||||
gstRatePctById: lookups.gstRatePctById,
|
||||
onAddLine: _addLine,
|
||||
onRemoveLine: _removeLine,
|
||||
onChanged: () => setState(() {}),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final stack = constraints.maxWidth < 900;
|
||||
final additional = _SectionCard(
|
||||
title: 'ADDITIONAL DETAILS',
|
||||
child: Column(
|
||||
children: [
|
||||
AppTextField(
|
||||
controller: _termsController,
|
||||
label: 'Terms & conditions',
|
||||
hint: 'Payment terms, inspection conditions, etc.',
|
||||
maxLines: 5,
|
||||
),
|
||||
AppTextField(
|
||||
controller: _remarksController,
|
||||
label: 'Remarks',
|
||||
hint: 'Any additional notes for this order.',
|
||||
maxLines: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
final summary = _AmountSummaryCard(
|
||||
totals: totals,
|
||||
freightController: _freightController,
|
||||
otherChargesController: _otherChargesController,
|
||||
discountController: _discountController,
|
||||
isEditing: widget.isEditing,
|
||||
);
|
||||
|
||||
if (stack) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
additional,
|
||||
const SizedBox(height: 16),
|
||||
summary,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(flex: 3, child: additional),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(flex: 2, child: summary),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
FormRowFour(
|
||||
children: [
|
||||
_DateField(
|
||||
label: 'PO Date *',
|
||||
value: _poDate,
|
||||
onTap: () => _pickDate(
|
||||
current: _poDate,
|
||||
onPicked: (d) => setState(() => _poDate = d),
|
||||
),
|
||||
),
|
||||
AppSearchableDropdown<String>(
|
||||
label: 'PO Type *',
|
||||
value: _poType,
|
||||
searchHint: 'Search type...',
|
||||
options: poTypeOptions
|
||||
.map(
|
||||
(e) =>
|
||||
AppDropdownOption(value: e.$1, label: e.$2),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (v) => setState(() => _poType = v),
|
||||
validator: (v) =>
|
||||
v == null ? 'PO type is required' : null,
|
||||
),
|
||||
AppSearchableDropdown<int>(
|
||||
label: 'Vendor *',
|
||||
value: _dropdownValue(_vendorId, vendorIds),
|
||||
searchHint: 'Search vendor...',
|
||||
options: _intOptions(lookups.vendors),
|
||||
onChanged: (v) => setState(() => _vendorId = v),
|
||||
validator: (v) =>
|
||||
v == null ? 'Vendor is required' : null,
|
||||
),
|
||||
AppSearchableDropdown<int>(
|
||||
label: 'Plant *',
|
||||
value: _dropdownValue(_plantId, plantIds),
|
||||
searchHint: 'Search plant...',
|
||||
options: _intOptions(lookups.plants),
|
||||
onChanged: (v) => setState(() => _plantId = v),
|
||||
validator: (v) =>
|
||||
v == null ? 'Plant is required' : null,
|
||||
),
|
||||
],
|
||||
Text(
|
||||
'Fields marked * are required',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
FormRowFour(
|
||||
children: [
|
||||
AppSearchableDropdown<int?>(
|
||||
label: 'Warehouse',
|
||||
value: _warehouseId,
|
||||
searchHint: 'Search warehouse...',
|
||||
options: _nullableIntOptions(lookups.warehouses),
|
||||
onChanged: (v) => setState(() => _warehouseId = v),
|
||||
),
|
||||
AppSearchableDropdown<int?>(
|
||||
label: 'Brand',
|
||||
value: _brandId,
|
||||
searchHint: 'Search brand...',
|
||||
options: _nullableIntOptions(lookups.brands),
|
||||
onChanged: (v) => setState(() => _brandId = v),
|
||||
),
|
||||
AppSearchableDropdown<int?>(
|
||||
label: 'Payment Term',
|
||||
value: _paymentTermId,
|
||||
searchHint: 'Search payment term...',
|
||||
options: _nullableIntOptions(lookups.paymentTerms),
|
||||
onChanged: (v) => setState(() => _paymentTermId = v),
|
||||
),
|
||||
AppSearchableDropdown<int?>(
|
||||
label: 'Delivery Term',
|
||||
value: _deliveryTermId,
|
||||
searchHint: 'Search delivery term...',
|
||||
options: _nullableIntOptions(lookups.deliveryTerms),
|
||||
onChanged: (v) => setState(() => _deliveryTermId = v),
|
||||
),
|
||||
],
|
||||
),
|
||||
FormRowFour(
|
||||
children: [
|
||||
_DateField(
|
||||
label: 'Expected Delivery',
|
||||
value: _expectedDeliveryDate,
|
||||
onTap: () => _pickDate(
|
||||
current: _expectedDeliveryDate,
|
||||
onPicked: (d) =>
|
||||
setState(() => _expectedDeliveryDate = d),
|
||||
),
|
||||
),
|
||||
AppTextField(
|
||||
controller: _discountController,
|
||||
label: 'Discount Amount',
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
),
|
||||
AppTextField(
|
||||
controller: _freightController,
|
||||
label: 'Freight Charges',
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
),
|
||||
AppTextField(
|
||||
controller: _otherChargesController,
|
||||
label: 'Other Charges',
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
FormRowFour(
|
||||
spans: const [2, 2],
|
||||
children: [
|
||||
AppTextField(
|
||||
controller: _termsController,
|
||||
label: 'Terms & Conditions',
|
||||
maxLines: 3,
|
||||
),
|
||||
AppTextField(
|
||||
controller: _remarksController,
|
||||
label: 'Remarks',
|
||||
maxLines: 3,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
PurchaseOrderLineItemsEditor(
|
||||
lines: _lines,
|
||||
items: lookups.items,
|
||||
uom: lookups.uom,
|
||||
gstRates: lookups.gstRates,
|
||||
onAddLine: _addLine,
|
||||
onRemoveLine: _removeLine,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: _isSubmitting
|
||||
? null
|
||||
: () => context.pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: AppButton(
|
||||
label: widget.isEditing ? 'Update PO' : 'Create PO',
|
||||
expand: false,
|
||||
isLoading: _isSubmitting,
|
||||
onPressed: _isSubmitting ? null : _submit,
|
||||
),
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
Text(
|
||||
widget.isEditing
|
||||
? '${_lines.length} line item${_lines.length == 1 ? '' : 's'} · editing existing draft'
|
||||
: '${_lines.length} line item${_lines.length == 1 ? '' : 's'} · draft not yet saved',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(PurchaseOrderModel? existing) {
|
||||
final theme = Theme.of(context);
|
||||
final title = widget.isEditing
|
||||
? 'Edit ${existing?.poNo ?? 'purchase order'}'
|
||||
: 'Create purchase order';
|
||||
final subtitle = widget.isEditing
|
||||
? null
|
||||
: 'Fill in order details, add line items, then review the totals before saving.';
|
||||
|
||||
final actions = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: _isSubmitting ? null : () => context.pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
AppButton(
|
||||
label: widget.isEditing
|
||||
? 'Update purchase order'
|
||||
: 'Save purchase order',
|
||||
icon: Icons.check,
|
||||
expand: false,
|
||||
isLoading: _isSubmitting,
|
||||
onPressed: _isSubmitting ? null : _submit,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final titleBlock = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
Text(title, style: theme.textTheme.headlineSmall),
|
||||
if (widget.isEditing && existing != null) ...[
|
||||
PoStatusChip(status: existing.status, compact: true),
|
||||
if (existing.revisionNo != null && existing.revisionNo! > 0)
|
||||
PoRevisionChip(
|
||||
revisionNo: existing.revisionNo!,
|
||||
compact: true,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final stack = constraints.maxWidth < 720;
|
||||
if (stack) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Back',
|
||||
onPressed: _isSubmitting ? null : () => context.pop(),
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(child: titleBlock),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Align(alignment: Alignment.centerRight, child: actions),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Back',
|
||||
onPressed: _isSubmitting ? null : () => context.pop(),
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(child: titleBlock),
|
||||
const SizedBox(width: 12),
|
||||
actions,
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final lookupsAsync = ref.watch(purchaseOrderLookupsProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
title: Text(
|
||||
widget.isEditing ? 'Edit Purchase Order' : 'Create Purchase Order',
|
||||
),
|
||||
),
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
body: lookupsAsync.when(
|
||||
loading: () => const AppLoadingView(message: 'Loading form options...'),
|
||||
error: (e, _) => ErrorView.fromFailure(
|
||||
@ -562,6 +726,264 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionCard extends StatelessWidget {
|
||||
const _SectionCard({
|
||||
required this.title,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final cardColor = isDark
|
||||
? theme.colorScheme.surfaceContainerHighest
|
||||
: theme.colorScheme.surface;
|
||||
final borderColor = theme.colorScheme.outline.withValues(
|
||||
alpha: isDark ? 0.35 : 0.2,
|
||||
);
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: cardColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: borderColor),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.8,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AmountSummaryCard extends StatelessWidget {
|
||||
const _AmountSummaryCard({
|
||||
required this.totals,
|
||||
required this.freightController,
|
||||
required this.otherChargesController,
|
||||
required this.discountController,
|
||||
required this.isEditing,
|
||||
});
|
||||
|
||||
final PoOrderTotals totals;
|
||||
final TextEditingController freightController;
|
||||
final TextEditingController otherChargesController;
|
||||
final TextEditingController discountController;
|
||||
final bool isEditing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final discountValue = double.tryParse(discountController.text.trim()) ?? 0;
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final primaryTint = theme.colorScheme.primary.withValues(
|
||||
alpha: isDark ? 0.22 : 0.1,
|
||||
);
|
||||
|
||||
return _SectionCard(
|
||||
title: 'AMOUNT SUMMARY',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_SummaryReadOnlyRow(
|
||||
label: 'Taxable amount',
|
||||
value: CurrencyFormatter.format(totals.taxableAmount),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_SummaryReadOnlyRow(
|
||||
label: 'Tax (GST)',
|
||||
value: CurrencyFormatter.format(totals.taxAmount),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_SummaryInputRow(
|
||||
label: 'Freight charges',
|
||||
controller: freightController,
|
||||
),
|
||||
_SummaryInputRow(
|
||||
label: 'Other charges',
|
||||
controller: otherChargesController,
|
||||
),
|
||||
_SummaryInputRow(
|
||||
label: 'Discount amount',
|
||||
controller: discountController,
|
||||
valueColor: discountValue > 0 ? AppColors.error : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: primaryTint,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'Grand total',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
CurrencyFormatter.format(totals.grandTotal),
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
isEditing
|
||||
? 'Editing charges or discount recalculates the grand total immediately — matches what will print on the PDF.'
|
||||
: 'Taxable amount and tax are calculated automatically from line items. Grand total updates as you edit freight, other charges or discount.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SummaryReadOnlyRow extends StatelessWidget {
|
||||
const _SummaryReadOnlyRow({
|
||||
required this.label,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SummaryInputRow extends StatelessWidget {
|
||||
const _SummaryInputRow({
|
||||
required this.label,
|
||||
required this.controller,
|
||||
this.valueColor,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final TextEditingController controller;
|
||||
final Color? valueColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 120,
|
||||
child: TextFormField(
|
||||
controller: controller,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
textAlign: TextAlign.right,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: valueColor,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 10,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReapprovalBanner extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF4E5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFFFFCC80)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, color: Colors.orange.shade800),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'This order is already Pending approval. Saving changes will reset it to Draft and require re-approval.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.orange.shade900,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DateField extends StatelessWidget {
|
||||
const _DateField({
|
||||
required this.label,
|
||||
@ -584,10 +1006,15 @@ class _DateField extends StatelessWidget {
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||
suffixIcon: const Icon(Icons.calendar_today_outlined),
|
||||
suffixIcon: const Icon(Icons.calendar_today_outlined, size: 18),
|
||||
),
|
||||
child: Text(
|
||||
value != null ? DateFormatter.displayDate(value) : 'Select date',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: value == null
|
||||
? Theme.of(context).colorScheme.onSurfaceVariant
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -169,6 +169,8 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
|
||||
}
|
||||
|
||||
void _viewOrder(PurchaseOrderModel order) {
|
||||
// Always hit GET /purchase-orders/{id} for the detail screen.
|
||||
ref.invalidate(purchaseOrderDetailProvider(order.id));
|
||||
context.push('${RouteConstants.purchaseOrders}/${order.id}');
|
||||
}
|
||||
|
||||
|
||||
@ -15,29 +15,29 @@ class PoStatusChip extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (color, label) = _resolveStatus(status);
|
||||
return _PoChip(label: label, color: color, compact: compact);
|
||||
return _PoBadge(label: label, color: color, compact: compact);
|
||||
}
|
||||
|
||||
(Color, String) _resolveStatus(String raw) {
|
||||
switch (raw.toUpperCase()) {
|
||||
case 'DRAFT':
|
||||
return (Colors.blueGrey.shade700, poStatusLabel(raw));
|
||||
return (const Color(0xFF546E7A), poStatusLabel(raw));
|
||||
case 'SUBMITTED':
|
||||
case 'PENDING_APPROVAL':
|
||||
case 'PENDING':
|
||||
return (Colors.orange.shade800, poStatusLabel(raw));
|
||||
return (const Color(0xFFE65100), poStatusLabel(raw));
|
||||
case 'APPROVED':
|
||||
return (Colors.green.shade700, poStatusLabel(raw));
|
||||
return (const Color(0xFF2E7D32), poStatusLabel(raw));
|
||||
case 'REJECTED':
|
||||
return (Colors.red.shade700, poStatusLabel(raw));
|
||||
return (const Color(0xFFC62828), poStatusLabel(raw));
|
||||
case 'CANCELLED':
|
||||
return (Colors.grey.shade700, poStatusLabel(raw));
|
||||
return (const Color(0xFF616161), poStatusLabel(raw));
|
||||
case 'PARTIALLY_RECEIVED':
|
||||
return (Colors.teal.shade700, poStatusLabel(raw));
|
||||
return (const Color(0xFF00695C), poStatusLabel(raw));
|
||||
case 'FULLY_RECEIVED':
|
||||
return (Colors.indigo.shade700, poStatusLabel(raw));
|
||||
return (const Color(0xFF283593), poStatusLabel(raw));
|
||||
default:
|
||||
return (Colors.blueGrey, poStatusLabel(raw));
|
||||
return (const Color(0xFF546E7A), poStatusLabel(raw));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -54,17 +54,16 @@ class PoRevisionChip extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = Theme.of(context).colorScheme.primary;
|
||||
return _PoChip(
|
||||
return _PoBadge(
|
||||
label: 'Revision $revisionNo',
|
||||
color: color,
|
||||
color: const Color(0xFF607D8B),
|
||||
compact: compact,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PoChip extends StatelessWidget {
|
||||
const _PoChip({
|
||||
class _PoBadge extends StatelessWidget {
|
||||
const _PoBadge({
|
||||
required this.label,
|
||||
required this.color,
|
||||
this.compact = false,
|
||||
@ -76,26 +75,29 @@ class _PoChip extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Chip(
|
||||
label: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: compact ? 11 : 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.2,
|
||||
// Avoid Container.alignment — it expands to max width inside Wrap/Row
|
||||
// and turns the chip into a full-width bar under the PO number.
|
||||
return Container(
|
||||
height: compact ? 22 : 26,
|
||||
padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 10),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: color.withValues(alpha: 0.28)),
|
||||
),
|
||||
child: Center(
|
||||
widthFactor: 1,
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: compact ? 11 : 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1,
|
||||
letterSpacing: 0.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
backgroundColor: color.withValues(alpha: 0.12),
|
||||
side: BorderSide(color: color.withValues(alpha: 0.3)),
|
||||
visualDensity: compact ? VisualDensity.compact : VisualDensity.standard,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
padding: compact
|
||||
? EdgeInsets.zero
|
||||
: const EdgeInsets.symmetric(horizontal: 4),
|
||||
labelPadding: compact
|
||||
? EdgeInsets.zero
|
||||
: const EdgeInsets.symmetric(horizontal: 4),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/utils/formatters.dart';
|
||||
import '../../../../shared/models/purchase_order_model.dart';
|
||||
import '../../../../shared/models/user_management_models.dart';
|
||||
import '../../../../shared/widgets/app_dropdown.dart';
|
||||
@ -7,6 +8,92 @@ import '../../../../shared/widgets/app_searchable_dropdown.dart';
|
||||
import '../../../../shared/widgets/app_side_panel.dart';
|
||||
import '../../../../shared/widgets/app_text_field.dart';
|
||||
|
||||
/// Per-line amount breakdown for PO calculations.
|
||||
class PoLineCalculation {
|
||||
const PoLineCalculation({
|
||||
required this.baseAmount,
|
||||
required this.discountAmount,
|
||||
required this.lineAmount,
|
||||
required this.gstAmount,
|
||||
});
|
||||
|
||||
final double baseAmount;
|
||||
final double discountAmount;
|
||||
final double lineAmount;
|
||||
final double gstAmount;
|
||||
|
||||
static const zero = PoLineCalculation(
|
||||
baseAmount: 0,
|
||||
discountAmount: 0,
|
||||
lineAmount: 0,
|
||||
gstAmount: 0,
|
||||
);
|
||||
|
||||
/// 3.1 Base = Qty × Rate
|
||||
/// 3.2 Discount = Base × Disc% ÷ 100
|
||||
/// 3.3 Line Amount = Base − Discount
|
||||
/// 3.4 GST Amount = Line Amount × GST% ÷ 100
|
||||
factory PoLineCalculation.compute({
|
||||
required double qty,
|
||||
required double rate,
|
||||
required double discPct,
|
||||
required double gstPct,
|
||||
}) {
|
||||
final baseAmount = qty * rate;
|
||||
final discountAmount = baseAmount * discPct / 100;
|
||||
final lineAmount = baseAmount - discountAmount;
|
||||
final gstAmount = lineAmount * gstPct / 100;
|
||||
return PoLineCalculation(
|
||||
baseAmount: baseAmount,
|
||||
discountAmount: discountAmount,
|
||||
lineAmount: lineAmount,
|
||||
gstAmount: gstAmount,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Order-level totals derived from line calculations + header charges.
|
||||
class PoOrderTotals {
|
||||
const PoOrderTotals({
|
||||
required this.taxableAmount,
|
||||
required this.taxAmount,
|
||||
required this.grandTotal,
|
||||
});
|
||||
|
||||
final double taxableAmount;
|
||||
final double taxAmount;
|
||||
final double grandTotal;
|
||||
|
||||
static const zero = PoOrderTotals(
|
||||
taxableAmount: 0,
|
||||
taxAmount: 0,
|
||||
grandTotal: 0,
|
||||
);
|
||||
|
||||
/// 3.5 Taxable = sum of Line Amounts
|
||||
/// 3.6 Tax = sum of GST Amounts
|
||||
/// 3.7 Grand Total = Taxable + Tax + Freight + Other − Discount
|
||||
factory PoOrderTotals.compute({
|
||||
required Iterable<PoLineCalculation> lines,
|
||||
required double freight,
|
||||
required double otherCharges,
|
||||
required double discountAmount,
|
||||
}) {
|
||||
var taxable = 0.0;
|
||||
var tax = 0.0;
|
||||
for (final line in lines) {
|
||||
taxable += line.lineAmount;
|
||||
tax += line.gstAmount;
|
||||
}
|
||||
final grandTotal = taxable + tax + freight + otherCharges - discountAmount;
|
||||
return PoOrderTotals(
|
||||
taxableAmount: taxable,
|
||||
taxAmount: tax,
|
||||
grandTotal: grandTotal,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PoLineItemDraft {
|
||||
PoLineItemDraft({
|
||||
this.itemId,
|
||||
@ -16,11 +103,11 @@ class PoLineItemDraft {
|
||||
TextEditingController? rateController,
|
||||
TextEditingController? discountController,
|
||||
this.gstRateId,
|
||||
TextEditingController? remarksController,
|
||||
this.hsnCodeId,
|
||||
}) : qtyController = qtyController ?? TextEditingController(),
|
||||
rateController = rateController ?? TextEditingController(),
|
||||
discountController = discountController ?? TextEditingController(text: '0'),
|
||||
remarksController = remarksController ?? TextEditingController();
|
||||
discountController =
|
||||
discountController ?? TextEditingController(text: '0');
|
||||
|
||||
int? itemId;
|
||||
int lineNo;
|
||||
@ -29,19 +116,20 @@ class PoLineItemDraft {
|
||||
final TextEditingController rateController;
|
||||
final TextEditingController discountController;
|
||||
int? gstRateId;
|
||||
final TextEditingController remarksController;
|
||||
int? hsnCodeId;
|
||||
|
||||
factory PoLineItemDraft.fromModel(PurchaseOrderItemModel item) {
|
||||
return PoLineItemDraft(
|
||||
itemId: item.itemId,
|
||||
lineNo: item.lineNo ?? 1,
|
||||
qtyController: TextEditingController(text: item.orderedQty?.toString() ?? ''),
|
||||
qtyController:
|
||||
TextEditingController(text: item.orderedQty?.toString() ?? ''),
|
||||
uomId: item.uomId,
|
||||
rateController: TextEditingController(text: item.rate?.toString() ?? ''),
|
||||
discountController:
|
||||
TextEditingController(text: item.discountPct?.toString() ?? '0'),
|
||||
gstRateId: item.gstRateId,
|
||||
remarksController: TextEditingController(text: item.remarks ?? ''),
|
||||
hsnCodeId: item.hsnCodeId,
|
||||
);
|
||||
}
|
||||
|
||||
@ -49,7 +137,21 @@ class PoLineItemDraft {
|
||||
qtyController.dispose();
|
||||
rateController.dispose();
|
||||
discountController.dispose();
|
||||
remarksController.dispose();
|
||||
}
|
||||
|
||||
PoLineCalculation calculate(Map<String, double> gstRatePctById) {
|
||||
final qty = double.tryParse(qtyController.text.trim()) ?? 0;
|
||||
final rate = double.tryParse(rateController.text.trim()) ?? 0;
|
||||
final discPct = double.tryParse(discountController.text.trim()) ?? 0;
|
||||
final gstPct = gstRateId == null
|
||||
? 0.0
|
||||
: (gstRatePctById[gstRateId.toString()] ?? 0.0);
|
||||
return PoLineCalculation.compute(
|
||||
qty: qty,
|
||||
rate: rate,
|
||||
discPct: discPct,
|
||||
gstPct: gstPct,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toPayload() {
|
||||
@ -66,8 +168,7 @@ class PoLineItemDraft {
|
||||
'rate': rate,
|
||||
'discount_pct': double.tryParse(discountController.text.trim()) ?? 0,
|
||||
if (gstRateId != null) 'gst_rate_id': gstRateId,
|
||||
if (remarksController.text.trim().isNotEmpty)
|
||||
'remarks': remarksController.text.trim(),
|
||||
if (hsnCodeId != null) 'hsn_code_id': hsnCodeId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -77,79 +178,134 @@ class PurchaseOrderLineItemsEditor extends StatefulWidget {
|
||||
super.key,
|
||||
required this.lines,
|
||||
required this.items,
|
||||
required this.itemHsnById,
|
||||
required this.itemUomById,
|
||||
required this.itemGstRateById,
|
||||
required this.uom,
|
||||
required this.gstRates,
|
||||
required this.gstRatePctById,
|
||||
required this.onAddLine,
|
||||
required this.onRemoveLine,
|
||||
this.onChanged,
|
||||
});
|
||||
|
||||
final List<PoLineItemDraft> lines;
|
||||
final List<FilterOptionModel> items;
|
||||
final Map<String, int?> itemHsnById;
|
||||
final Map<String, int?> itemUomById;
|
||||
final Map<String, int?> itemGstRateById;
|
||||
final List<FilterOptionModel> uom;
|
||||
final List<FilterOptionModel> gstRates;
|
||||
final Map<String, double> gstRatePctById;
|
||||
final VoidCallback onAddLine;
|
||||
final ValueChanged<int> onRemoveLine;
|
||||
final VoidCallback? onChanged;
|
||||
|
||||
@override
|
||||
State<PurchaseOrderLineItemsEditor> createState() =>
|
||||
_PurchaseOrderLineItemsEditorState();
|
||||
}
|
||||
|
||||
class _PurchaseOrderLineItemsEditorState extends State<PurchaseOrderLineItemsEditor> {
|
||||
class _PurchaseOrderLineItemsEditorState
|
||||
extends State<PurchaseOrderLineItemsEditor> {
|
||||
void _notifyChanged() {
|
||||
widget.onChanged?.call();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final cardColor = isDark
|
||||
? theme.colorScheme.surfaceContainerHighest
|
||||
: theme.colorScheme.surface;
|
||||
final borderColor = theme.colorScheme.outline.withValues(
|
||||
alpha: isDark ? 0.35 : 0.2,
|
||||
);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text('Line Items', style: theme.textTheme.titleMedium),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
onPressed: widget.onAddLine,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add line'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (widget.lines.isEmpty)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: theme.dividerColor),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'Add at least one line item',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: cardColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: borderColor),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'LINE ITEMS',
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.8,
|
||||
),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
)
|
||||
else
|
||||
...widget.lines.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final line = entry.value;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _LineItemCard(
|
||||
key: ObjectKey(line),
|
||||
line: line,
|
||||
items: widget.items,
|
||||
uom: widget.uom,
|
||||
gstRates: widget.gstRates,
|
||||
onRemove: widget.lines.length > 1
|
||||
? () => widget.onRemoveLine(index)
|
||||
: null,
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
widget.onAddLine();
|
||||
widget.onChanged?.call();
|
||||
},
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: const Text('Add line'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (widget.lines.isEmpty)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: borderColor),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'Add at least one line item',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
)
|
||||
else
|
||||
...widget.lines.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final line = entry.value;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: index == widget.lines.length - 1 ? 0 : 12,
|
||||
),
|
||||
child: _LineItemCard(
|
||||
key: ObjectKey(line),
|
||||
line: line,
|
||||
items: widget.items,
|
||||
itemHsnById: widget.itemHsnById,
|
||||
itemUomById: widget.itemUomById,
|
||||
itemGstRateById: widget.itemGstRateById,
|
||||
uom: widget.uom,
|
||||
gstRates: widget.gstRates,
|
||||
gstRatePctById: widget.gstRatePctById,
|
||||
onChanged: _notifyChanged,
|
||||
onRemove: widget.lines.length > 1
|
||||
? () {
|
||||
widget.onRemoveLine(index);
|
||||
widget.onChanged?.call();
|
||||
}
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -159,15 +315,25 @@ class _LineItemCard extends StatefulWidget {
|
||||
super.key,
|
||||
required this.line,
|
||||
required this.items,
|
||||
required this.itemHsnById,
|
||||
required this.itemUomById,
|
||||
required this.itemGstRateById,
|
||||
required this.uom,
|
||||
required this.gstRates,
|
||||
required this.gstRatePctById,
|
||||
required this.onChanged,
|
||||
this.onRemove,
|
||||
});
|
||||
|
||||
final PoLineItemDraft line;
|
||||
final List<FilterOptionModel> items;
|
||||
final Map<String, int?> itemHsnById;
|
||||
final Map<String, int?> itemUomById;
|
||||
final Map<String, int?> itemGstRateById;
|
||||
final List<FilterOptionModel> uom;
|
||||
final List<FilterOptionModel> gstRates;
|
||||
final Map<String, double> gstRatePctById;
|
||||
final VoidCallback onChanged;
|
||||
final VoidCallback? onRemove;
|
||||
|
||||
@override
|
||||
@ -175,18 +341,69 @@ class _LineItemCard extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _LineItemCardState extends State<_LineItemCard> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.line.qtyController.addListener(_onFieldChanged);
|
||||
widget.line.rateController.addListener(_onFieldChanged);
|
||||
widget.line.discountController.addListener(_onFieldChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.line.qtyController.removeListener(_onFieldChanged);
|
||||
widget.line.rateController.removeListener(_onFieldChanged);
|
||||
widget.line.discountController.removeListener(_onFieldChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onFieldChanged() {
|
||||
widget.onChanged();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
int? _parseId(String value) => int.tryParse(value.trim());
|
||||
|
||||
void _updateLine(void Function() mutate) {
|
||||
mutate();
|
||||
widget.onChanged();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _onItemChanged(int? itemId) {
|
||||
_updateLine(() {
|
||||
widget.line.itemId = itemId;
|
||||
if (itemId == null) return;
|
||||
final key = itemId.toString();
|
||||
final defaultUom = widget.itemUomById[key];
|
||||
if (defaultUom != null) {
|
||||
widget.line.uomId = defaultUom;
|
||||
}
|
||||
final defaultGst = widget.itemGstRateById[key];
|
||||
if (defaultGst != null) {
|
||||
widget.line.gstRateId = defaultGst;
|
||||
}
|
||||
final defaultHsn = widget.itemHsnById[key];
|
||||
if (defaultHsn != null) {
|
||||
widget.line.hsnCodeId = defaultHsn;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final line = widget.line;
|
||||
final lineKey = 'po-line-${line.lineNo}';
|
||||
final calc = line.calculate(widget.gstRatePctById);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final borderColor = theme.colorScheme.outline.withValues(
|
||||
alpha: isDark ? 0.35 : 0.18,
|
||||
);
|
||||
final amountBg = theme.colorScheme.primary.withValues(
|
||||
alpha: isDark ? 0.18 : 0.08,
|
||||
);
|
||||
|
||||
final itemOptions = widget.items
|
||||
.map((e) {
|
||||
final id = _parseId(e.id);
|
||||
@ -204,7 +421,7 @@ class _LineItemCardState extends State<_LineItemCard> {
|
||||
.whereType<AppDropdownOption<int>>()
|
||||
.toList();
|
||||
final gstOptions = [
|
||||
const AppDropdownOption<int?>(value: null, label: 'No GST'),
|
||||
const AppDropdownOption<int?>(value: null, label: 'Select GST rate'),
|
||||
...widget.gstRates.map((e) {
|
||||
final id = _parseId(e.id);
|
||||
if (id == null) return null;
|
||||
@ -213,116 +430,179 @@ class _LineItemCardState extends State<_LineItemCard> {
|
||||
].whereType<AppDropdownOption<int?>>().toList();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: theme.dividerColor),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: borderColor),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
child: FormRow(
|
||||
columnCount: 12,
|
||||
spans: const [3, 1, 2, 1, 1, 2, 2],
|
||||
spacing: 8,
|
||||
stackBelowWidth: 1100,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Text('Line ${line.lineNo}', style: theme.textTheme.titleSmall),
|
||||
const Spacer(),
|
||||
if (widget.onRemove != null)
|
||||
IconButton(
|
||||
tooltip: 'Remove line',
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: widget.onRemove,
|
||||
),
|
||||
],
|
||||
),
|
||||
AppSearchableDropdown<int>(
|
||||
key: ValueKey('$lineKey-item'),
|
||||
label: 'Item *',
|
||||
value: line.itemId,
|
||||
hint: 'Select item',
|
||||
searchHint: 'Search item...',
|
||||
options: itemOptions,
|
||||
onChanged: _onItemChanged,
|
||||
validator: (v) => v == null ? 'Item is required' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FormRow(
|
||||
columnCount: 6,
|
||||
horizontalPadding: 16,
|
||||
spacing: 8,
|
||||
stackBelowWidth: 992,
|
||||
children: [
|
||||
AppSearchableDropdown<int>(
|
||||
key: ValueKey('$lineKey-item'),
|
||||
label: 'Item *',
|
||||
value: line.itemId,
|
||||
searchHint: 'Search item...',
|
||||
options: itemOptions,
|
||||
onChanged: (v) => _updateLine(() => line.itemId = v),
|
||||
validator: (v) => v == null ? 'Item is required' : null,
|
||||
),
|
||||
AppTextField(
|
||||
key: ValueKey('$lineKey-qty'),
|
||||
controller: line.qtyController,
|
||||
label: 'Quantity *',
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().isEmpty) {
|
||||
return 'Quantity is required';
|
||||
}
|
||||
final qty = double.tryParse(v);
|
||||
if (qty == null || qty <= 0) return 'Enter a valid quantity';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
AppSearchableDropdown<int>(
|
||||
key: ValueKey('$lineKey-uom'),
|
||||
label: 'UOM *',
|
||||
value: line.uomId,
|
||||
searchHint: 'Search UOM...',
|
||||
options: uomOptions,
|
||||
onChanged: (v) => _updateLine(() => line.uomId = v),
|
||||
validator: (v) => v == null ? 'UOM is required' : null,
|
||||
),
|
||||
AppTextField(
|
||||
key: ValueKey('$lineKey-rate'),
|
||||
controller: line.rateController,
|
||||
label: 'Rate *',
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().isEmpty) return 'Rate is required';
|
||||
final rate = double.tryParse(v);
|
||||
if (rate == null || rate < 0) return 'Enter a valid rate';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
AppTextField(
|
||||
key: ValueKey('$lineKey-discount'),
|
||||
controller: line.discountController,
|
||||
label: 'Discount %',
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
),
|
||||
AppSearchableDropdown<int?>(
|
||||
key: ValueKey('$lineKey-gst'),
|
||||
label: 'GST Rate',
|
||||
value: line.gstRateId,
|
||||
searchHint: 'Search GST rate...',
|
||||
options: gstOptions,
|
||||
onChanged: (v) => _updateLine(() => line.gstRateId = v),
|
||||
),
|
||||
],
|
||||
AppTextField(
|
||||
key: ValueKey('$lineKey-qty'),
|
||||
controller: line.qtyController,
|
||||
label: 'Qty *',
|
||||
hint: '0',
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().isEmpty) {
|
||||
return 'Required';
|
||||
}
|
||||
final qty = double.tryParse(v);
|
||||
if (qty == null || qty <= 0) return 'Invalid';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
FormRow(
|
||||
columnCount: 6,
|
||||
spans: const [6],
|
||||
horizontalPadding: 16,
|
||||
spacing: 8,
|
||||
stackBelowWidth: 992,
|
||||
children: [
|
||||
AppTextField(
|
||||
key: ValueKey('$lineKey-remarks'),
|
||||
controller: line.remarksController,
|
||||
label: 'Remarks',
|
||||
maxLines: 2,
|
||||
),
|
||||
],
|
||||
AppSearchableDropdown<int>(
|
||||
key: ValueKey('$lineKey-uom'),
|
||||
label: 'UOM *',
|
||||
value: line.uomId,
|
||||
hint: 'Select UOM',
|
||||
searchHint: 'Search UOM...',
|
||||
options: uomOptions,
|
||||
onChanged: (v) => _updateLine(() => line.uomId = v),
|
||||
validator: (v) => v == null ? 'Required' : null,
|
||||
),
|
||||
AppTextField(
|
||||
key: ValueKey('$lineKey-rate'),
|
||||
controller: line.rateController,
|
||||
label: 'Rate *',
|
||||
hint: '0.00',
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().isEmpty) return 'Required';
|
||||
final rate = double.tryParse(v);
|
||||
if (rate == null || rate < 0) return 'Invalid';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
AppTextField(
|
||||
key: ValueKey('$lineKey-discount'),
|
||||
controller: line.discountController,
|
||||
label: 'Disc %',
|
||||
hint: '0',
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
),
|
||||
AppSearchableDropdown<int?>(
|
||||
key: ValueKey('$lineKey-gst'),
|
||||
label: 'GST rate',
|
||||
value: line.gstRateId,
|
||||
hint: 'Select GST rate',
|
||||
searchHint: 'Search GST rate...',
|
||||
options: gstOptions,
|
||||
onChanged: (v) => _updateLine(() => line.gstRateId = v),
|
||||
),
|
||||
_AmountWithRemove(
|
||||
amount: CurrencyFormatter.format(calc.lineAmount),
|
||||
backgroundColor: amountBg,
|
||||
onRemove: widget.onRemove,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AmountWithRemove extends StatelessWidget {
|
||||
const _AmountWithRemove({
|
||||
required this.amount,
|
||||
required this.backgroundColor,
|
||||
this.onRemove,
|
||||
});
|
||||
|
||||
final String amount;
|
||||
final Color backgroundColor;
|
||||
final VoidCallback? onRemove;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _AmountDisplay(
|
||||
label: 'Amount',
|
||||
value: amount,
|
||||
backgroundColor: backgroundColor,
|
||||
),
|
||||
),
|
||||
if (onRemove != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 20),
|
||||
child: IconButton(
|
||||
tooltip: 'Remove line',
|
||||
onPressed: onRemove,
|
||||
icon: const Icon(Icons.delete_outline, size: 20),
|
||||
color: theme.colorScheme.error,
|
||||
visualDensity: VisualDensity.compact,
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AmountDisplay extends StatelessWidget {
|
||||
const _AmountDisplay({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.backgroundColor,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final Color backgroundColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||
filled: true,
|
||||
fillColor: backgroundColor,
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
value,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../shared/widgets/app_data_table.dart';
|
||||
|
||||
import '../../../../shared/models/user_management_models.dart';
|
||||
import '../../../../shared/widgets/app_card.dart';
|
||||
|
||||
@ -221,12 +223,13 @@ class EmployeeCodeBadge extends StatelessWidget {
|
||||
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.7),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
child: AppTableCell.text(
|
||||
code,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
showTooltip: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -246,24 +249,24 @@ class UserTableUserCell extends StatelessWidget {
|
||||
UserAvatarChip(
|
||||
name: user.fullName,
|
||||
initials: user.initialsDisplay,
|
||||
radius: 16,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
AppTableCell.text(
|
||||
user.fullName,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
AppTableCell.text(
|
||||
user.email,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -274,10 +277,16 @@ class UserTableUserCell extends StatelessWidget {
|
||||
}
|
||||
|
||||
class UserAvatarChip extends StatelessWidget {
|
||||
const UserAvatarChip({super.key, required this.name, this.initials});
|
||||
const UserAvatarChip({
|
||||
super.key,
|
||||
required this.name,
|
||||
this.initials,
|
||||
this.radius = 20,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String? initials;
|
||||
final double radius;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -293,14 +302,14 @@ class UserAvatarChip extends StatelessWidget {
|
||||
(name.isNotEmpty ? name.trim()[0].toUpperCase() : 'U');
|
||||
|
||||
return CircleAvatar(
|
||||
radius: 20,
|
||||
radius: radius,
|
||||
backgroundColor: color.withValues(alpha: 0.12),
|
||||
child: Text(
|
||||
display.length > 2 ? display.substring(0, 2) : display,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 12,
|
||||
fontSize: radius * 0.6,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import '../../../../shared/widgets/app_card.dart';
|
||||
import '../../../../shared/widgets/app_data_table.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
@ -98,7 +99,7 @@ class _MatrixGrid extends ConsumerWidget {
|
||||
.map(
|
||||
(row) => DataRow(
|
||||
cells: [
|
||||
DataCell(Text(row.name)),
|
||||
DataCell(AppTableCell.text(row.name)),
|
||||
...matrix.actionColumns.map(
|
||||
(action) => DataCell(
|
||||
_actionToggle(
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../../core/constants/api_endpoints.dart';
|
||||
import '../../../../core/utils/media_url.dart';
|
||||
import '../../domain/entities/app_settings.dart';
|
||||
|
||||
class SettingsRemoteDataSource {
|
||||
@ -8,36 +9,54 @@ class SettingsRemoteDataSource {
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
Map<String, dynamic>? _asDataMap(dynamic responseData) {
|
||||
if (responseData is! Map) return null;
|
||||
final root = Map<String, dynamic>.from(responseData);
|
||||
final data = root['data'];
|
||||
if (data is Map<String, dynamic>) return data;
|
||||
if (data is Map) return Map<String, dynamic>.from(data);
|
||||
// Some responses return the entity at the root alongside success/message.
|
||||
if (root.containsKey('org_name') ||
|
||||
root.containsKey('smtp_host') ||
|
||||
root.containsKey('logo_url') ||
|
||||
root.containsKey('logo')) {
|
||||
return root;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<AppSettings?> fetch() async {
|
||||
final response = await _dio.get(ApiEndpoints.settings);
|
||||
final data = response.data['data'];
|
||||
if (data is! Map<String, dynamic>) return null;
|
||||
final data = _asDataMap(response.data);
|
||||
if (data == null) return null;
|
||||
return AppSettings.fromJson(data);
|
||||
}
|
||||
|
||||
Future<AppSettings> save(AppSettings settings) async {
|
||||
final response = await _dio.put(ApiEndpoints.settings, data: settings.toJson());
|
||||
final data = response.data['data'];
|
||||
if (data is Map<String, dynamic>) {
|
||||
final data = _asDataMap(response.data);
|
||||
if (data != null) {
|
||||
return AppSettings.fromJson(data);
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
/// GET `/settings/company`
|
||||
Future<CompanyProfileSettings?> fetchCompany() async {
|
||||
final response = await _dio.get(ApiEndpoints.settingsCompany);
|
||||
final data = response.data['data'];
|
||||
if (data is! Map<String, dynamic>) return null;
|
||||
final data = _asDataMap(response.data);
|
||||
if (data == null) return null;
|
||||
return CompanyProfileSettings.fromApiJson(data);
|
||||
}
|
||||
|
||||
/// PUT `/settings/company`
|
||||
Future<CompanyProfileSettings> saveCompany(CompanyProfileSettings profile) async {
|
||||
final response = await _dio.put(
|
||||
ApiEndpoints.settingsCompany,
|
||||
data: profile.toApiJson(),
|
||||
);
|
||||
final data = response.data['data'];
|
||||
if (data is Map<String, dynamic>) {
|
||||
final data = _asDataMap(response.data);
|
||||
if (data != null) {
|
||||
return CompanyProfileSettings.fromApiJson(data).copyWith(
|
||||
companyCode: profile.companyCode,
|
||||
registrationNumber: profile.registrationNumber,
|
||||
@ -48,6 +67,7 @@ class SettingsRemoteDataSource {
|
||||
return profile;
|
||||
}
|
||||
|
||||
/// POST `/settings/company/logo` (multipart field `logo`)
|
||||
Future<String?> uploadCompanyLogo(List<int> bytes, String filename) async {
|
||||
final formData = FormData.fromMap({
|
||||
'logo': MultipartFile.fromBytes(bytes, filename: filename),
|
||||
@ -56,20 +76,26 @@ class SettingsRemoteDataSource {
|
||||
ApiEndpoints.settingsCompanyLogo,
|
||||
data: formData,
|
||||
);
|
||||
final data = response.data['data'];
|
||||
if (data is Map<String, dynamic>) {
|
||||
return data['logo_url'] as String? ?? data['logo'] as String?;
|
||||
final data = _asDataMap(response.data);
|
||||
if (data != null) {
|
||||
return resolveMediaUrl(
|
||||
data['logo_url'] as String? ??
|
||||
data['logoUrl'] as String? ??
|
||||
data['logo'] as String?,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// GET `/settings/email`
|
||||
Future<EmailConfigurationSettings?> fetchEmail() async {
|
||||
final response = await _dio.get(ApiEndpoints.settingsEmail);
|
||||
final data = response.data['data'];
|
||||
if (data is! Map<String, dynamic>) return null;
|
||||
final data = _asDataMap(response.data);
|
||||
if (data == null) return null;
|
||||
return EmailConfigurationSettings.fromApiJson(data);
|
||||
}
|
||||
|
||||
/// PUT `/settings/email`
|
||||
Future<EmailConfigurationSettings> saveEmail(
|
||||
EmailConfigurationSettings email,
|
||||
) async {
|
||||
@ -77,8 +103,8 @@ class SettingsRemoteDataSource {
|
||||
ApiEndpoints.settingsEmail,
|
||||
data: email.toApiJson(),
|
||||
);
|
||||
final data = response.data['data'];
|
||||
if (data is Map<String, dynamic>) {
|
||||
final data = _asDataMap(response.data);
|
||||
if (data != null) {
|
||||
return EmailConfigurationSettings.fromApiJson(data).copyWith(
|
||||
allocationTemplate: email.allocationTemplate,
|
||||
returnTemplate: email.returnTemplate,
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/utils/media_url.dart';
|
||||
|
||||
class GeneralSettings {
|
||||
const GeneralSettings({
|
||||
this.defaultBranch = '',
|
||||
@ -145,18 +147,17 @@ class CompanyProfileSettings {
|
||||
'faviconUrl': faviconUrl,
|
||||
};
|
||||
|
||||
Map<String, dynamic> toApiJson() {
|
||||
final payload = <String, dynamic>{};
|
||||
if (companyName.isNotEmpty) payload['org_name'] = companyName;
|
||||
if (phone.isNotEmpty) payload['mobile'] = phone;
|
||||
if (email.isNotEmpty) payload['email'] = email;
|
||||
if (website.isNotEmpty) payload['website'] = website;
|
||||
if (address.isNotEmpty) payload['address'] = address;
|
||||
if (city.isNotEmpty) payload['city'] = city;
|
||||
if (state.isNotEmpty) payload['state'] = state;
|
||||
if (pincode.isNotEmpty) payload['pincode'] = pincode;
|
||||
return payload;
|
||||
}
|
||||
/// Payload for `PUT /settings/company` ([CompanySettingsBody]).
|
||||
Map<String, dynamic> toApiJson() => {
|
||||
'org_name': companyName,
|
||||
'mobile': phone,
|
||||
'email': email,
|
||||
'website': website,
|
||||
'address': address,
|
||||
'city': city,
|
||||
'state': state,
|
||||
'pincode': pincode,
|
||||
};
|
||||
|
||||
factory CompanyProfileSettings.fromJson(Map<String, dynamic> json) =>
|
||||
CompanyProfileSettings.fromApiJson(json);
|
||||
@ -176,11 +177,13 @@ class CompanyProfileSettings {
|
||||
email: json['email'] as String? ?? '',
|
||||
phone: json['mobile'] as String? ?? json['phone'] as String? ?? '',
|
||||
website: json['website'] as String? ?? '',
|
||||
logoUrl: json['logo_url'] as String? ??
|
||||
json['logoUrl'] as String? ??
|
||||
json['logo'] as String? ??
|
||||
logoUrl: resolveMediaUrl(
|
||||
json['logo_url'] as String? ??
|
||||
json['logoUrl'] as String? ??
|
||||
json['logo'] as String?,
|
||||
) ??
|
||||
'',
|
||||
faviconUrl: json['faviconUrl'] as String? ?? '',
|
||||
faviconUrl: resolveMediaUrl(json['faviconUrl'] as String?) ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
@ -450,14 +453,19 @@ class EmailConfigurationSettings {
|
||||
'warrantyTemplate': warrantyTemplate,
|
||||
};
|
||||
|
||||
/// Payload for `PUT /settings/email` ([EmailSettingsBody]).
|
||||
/// Omits blank password so an existing SMTP password is not cleared.
|
||||
Map<String, dynamic> toApiJson() {
|
||||
final payload = <String, dynamic>{};
|
||||
if (smtpHost.isNotEmpty) payload['smtp_host'] = smtpHost;
|
||||
if (smtpPort > 0) payload['smtp_port'] = smtpPort;
|
||||
if (smtpUsername.isNotEmpty) payload['smtp_username'] = smtpUsername;
|
||||
if (smtpPassword.isNotEmpty) payload['smtp_password'] = smtpPassword;
|
||||
if (senderEmail.isNotEmpty) payload['sender_email'] = senderEmail;
|
||||
if (senderName.isNotEmpty) payload['sender_name'] = senderName;
|
||||
final payload = <String, dynamic>{
|
||||
'smtp_host': smtpHost,
|
||||
'smtp_port': smtpPort,
|
||||
'smtp_username': smtpUsername,
|
||||
'sender_email': senderEmail,
|
||||
'sender_name': senderName,
|
||||
};
|
||||
if (smtpPassword.isNotEmpty) {
|
||||
payload['smtp_password'] = smtpPassword;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
@ -659,6 +667,7 @@ class SettingsSection {
|
||||
required this.icon,
|
||||
required this.route,
|
||||
this.phase = 1,
|
||||
this.hidden = false,
|
||||
});
|
||||
|
||||
final String id;
|
||||
@ -667,6 +676,8 @@ class SettingsSection {
|
||||
final IconData icon;
|
||||
final String route;
|
||||
final int phase;
|
||||
/// When true, the card stays defined but is not shown on the Settings hub.
|
||||
final bool hidden;
|
||||
}
|
||||
|
||||
const phase1SettingsSections = [
|
||||
@ -696,7 +707,7 @@ const phase1SettingsSections = [
|
||||
title: 'Roles & Permissions',
|
||||
subtitle: 'Manage roles, permissions, and menu access',
|
||||
icon: Icons.security_outlined,
|
||||
route: '/settings/roles',
|
||||
route: '/users-roles?tab=permissions',
|
||||
),
|
||||
SettingsSection(
|
||||
id: 'asset',
|
||||
@ -704,6 +715,7 @@ const phase1SettingsSections = [
|
||||
subtitle: 'Asset codes, statuses, warranty, and QR',
|
||||
icon: Icons.inventory_2_outlined,
|
||||
route: '/settings/asset',
|
||||
hidden: true,
|
||||
),
|
||||
SettingsSection(
|
||||
id: 'notifications',
|
||||
@ -711,11 +723,12 @@ const phase1SettingsSections = [
|
||||
subtitle: 'Email, SMS, push, and in-app alerts',
|
||||
icon: Icons.notifications_outlined,
|
||||
route: '/settings/notifications',
|
||||
hidden: true,
|
||||
),
|
||||
SettingsSection(
|
||||
id: 'email',
|
||||
title: 'Email Configuration',
|
||||
subtitle: 'SMTP server and email templates',
|
||||
subtitle: 'SMTP server settings',
|
||||
icon: Icons.email_outlined,
|
||||
route: '/settings/email',
|
||||
),
|
||||
@ -725,9 +738,11 @@ const phase1SettingsSections = [
|
||||
subtitle: 'Authentication, session, and audit policies',
|
||||
icon: Icons.lock_outline,
|
||||
route: '/settings/security',
|
||||
hidden: true,
|
||||
),
|
||||
];
|
||||
|
||||
/// Phase 2 cards are kept for later; currently hidden on the Settings hub.
|
||||
const phase2SettingsSections = [
|
||||
SettingsSection(
|
||||
id: 'workflow',
|
||||
@ -736,6 +751,7 @@ const phase2SettingsSections = [
|
||||
icon: Icons.account_tree_outlined,
|
||||
route: '/settings/workflow',
|
||||
phase: 2,
|
||||
hidden: true,
|
||||
),
|
||||
SettingsSection(
|
||||
id: 'dashboard',
|
||||
@ -744,6 +760,7 @@ const phase2SettingsSections = [
|
||||
icon: Icons.dashboard_outlined,
|
||||
route: '/settings/dashboard',
|
||||
phase: 2,
|
||||
hidden: true,
|
||||
),
|
||||
SettingsSection(
|
||||
id: 'reports',
|
||||
@ -752,6 +769,7 @@ const phase2SettingsSections = [
|
||||
icon: Icons.assessment_outlined,
|
||||
route: '/settings/reports',
|
||||
phase: 2,
|
||||
hidden: true,
|
||||
),
|
||||
SettingsSection(
|
||||
id: 'storage',
|
||||
@ -760,6 +778,7 @@ const phase2SettingsSections = [
|
||||
icon: Icons.cloud_upload_outlined,
|
||||
route: '/settings/storage',
|
||||
phase: 2,
|
||||
hidden: true,
|
||||
),
|
||||
SettingsSection(
|
||||
id: 'audit',
|
||||
@ -768,6 +787,7 @@ const phase2SettingsSections = [
|
||||
icon: Icons.history_outlined,
|
||||
route: '/settings/audit',
|
||||
phase: 2,
|
||||
hidden: true,
|
||||
),
|
||||
SettingsSection(
|
||||
id: 'mobile',
|
||||
@ -776,6 +796,7 @@ const phase2SettingsSections = [
|
||||
icon: Icons.phone_android_outlined,
|
||||
route: '/settings/mobile',
|
||||
phase: 2,
|
||||
hidden: true,
|
||||
),
|
||||
SettingsSection(
|
||||
id: 'integrations',
|
||||
@ -784,5 +805,14 @@ const phase2SettingsSections = [
|
||||
icon: Icons.extension_outlined,
|
||||
route: '/settings/integrations',
|
||||
phase: 2,
|
||||
hidden: true,
|
||||
),
|
||||
];
|
||||
|
||||
/// Visible Phase 1 cards for the Settings hub.
|
||||
List<SettingsSection> get visiblePhase1SettingsSections =>
|
||||
phase1SettingsSections.where((s) => !s.hidden).toList();
|
||||
|
||||
/// Visible Phase 2 cards for the Settings hub (empty while all are hidden).
|
||||
List<SettingsSection> get visiblePhase2SettingsSections =>
|
||||
phase2SettingsSections.where((s) => !s.hidden).toList();
|
||||
|
||||
@ -1,8 +1,12 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/errors/failure.dart';
|
||||
import '../../../../core/network/api_handler.dart';
|
||||
import '../../../../core/network/dio_client.dart';
|
||||
import '../../../../core/theme/branding_config.dart';
|
||||
import '../../../../core/theme/theme_provider.dart';
|
||||
import '../../../../core/utils/favicon_store.dart';
|
||||
import '../../../../core/utils/media_url.dart';
|
||||
import '../../data/datasources/settings_local_data_source.dart';
|
||||
import '../../data/datasources/settings_remote_data_source.dart';
|
||||
import '../../data/repositories/settings_repository_impl.dart';
|
||||
@ -42,6 +46,17 @@ final appSettingsProvider =
|
||||
saveSettings: ref.watch(saveSettingsUseCaseProvider),
|
||||
repository: ref.watch(settingsRepositoryProvider),
|
||||
faviconStore: FaviconStore(ref.watch(sharedPreferencesProvider)),
|
||||
syncAppLogo: (logoUrl, companyName) async {
|
||||
final branding = ref.read(brandingProvider);
|
||||
await ref.read(brandingProvider.notifier).updateBranding(
|
||||
BrandingConfig(
|
||||
logoUrl: logoUrl,
|
||||
primaryColorValue: branding.primaryColorValue,
|
||||
secondaryColorValue: branding.secondaryColorValue,
|
||||
companyName: companyName ?? branding.companyName,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@ -51,10 +66,13 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
|
||||
required SaveSettingsUseCase saveSettings,
|
||||
required SettingsRepository repository,
|
||||
required FaviconStore faviconStore,
|
||||
required Future<void> Function(String? logoUrl, String? companyName)
|
||||
syncAppLogo,
|
||||
}) : _getSettings = getSettings,
|
||||
_saveSettings = saveSettings,
|
||||
_repository = repository,
|
||||
_faviconStore = faviconStore,
|
||||
_syncAppLogo = syncAppLogo,
|
||||
super(const AppSettings()) {
|
||||
_load();
|
||||
}
|
||||
@ -63,25 +81,38 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
|
||||
final SaveSettingsUseCase _saveSettings;
|
||||
final SettingsRepository _repository;
|
||||
final FaviconStore _faviconStore;
|
||||
final Future<void> Function(String? logoUrl, String? companyName) _syncAppLogo;
|
||||
|
||||
Future<void> _syncMainLogo(CompanyProfileSettings profile) async {
|
||||
final logo = resolveMediaUrl(profile.logoUrl);
|
||||
await _syncAppLogo(
|
||||
(logo == null || logo.isEmpty) ? null : logo,
|
||||
profile.companyName.isEmpty ? null : profile.companyName,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final result = await _getSettings();
|
||||
state = result.data ?? const AppSettings();
|
||||
_faviconStore.apply();
|
||||
await _syncMainLogo(state.companyProfile);
|
||||
}
|
||||
|
||||
Future<void> refreshCompanyProfile() async {
|
||||
Future<Failure?> refreshCompanyProfile() async {
|
||||
final result = await _repository.fetchCompanyProfile();
|
||||
if (result.failure == null && result.data != null) {
|
||||
state = state.copyWith(companyProfile: result.data!);
|
||||
await _syncMainLogo(result.data!);
|
||||
}
|
||||
return result.failure;
|
||||
}
|
||||
|
||||
Future<void> refreshEmailSettings() async {
|
||||
Future<Failure?> refreshEmailSettings() async {
|
||||
final result = await _repository.fetchEmailSettings();
|
||||
if (result.failure == null && result.data != null) {
|
||||
state = state.copyWith(email: result.data!);
|
||||
}
|
||||
return result.failure;
|
||||
}
|
||||
|
||||
Future<void> _persist(AppSettings settings) async {
|
||||
@ -94,29 +125,44 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
|
||||
await _persist(state.copyWith(general: general));
|
||||
}
|
||||
|
||||
Future<void> updateCompanyProfile(CompanyProfileSettings profile) async {
|
||||
/// PUT `/settings/company` — returns failure when the API call fails.
|
||||
Future<Failure?> updateCompanyProfile(CompanyProfileSettings profile) async {
|
||||
final result = await _repository.saveCompanyProfile(profile);
|
||||
if (result.failure == null && result.data != null) {
|
||||
if (result.failure != null) return result.failure;
|
||||
if (result.data != null) {
|
||||
state = state.copyWith(companyProfile: result.data!);
|
||||
return;
|
||||
}
|
||||
await _persist(state.copyWith(companyProfile: profile));
|
||||
}
|
||||
|
||||
Future<String?> uploadCompanyLogo(List<int> bytes, String filename) async {
|
||||
final result = await _repository.uploadCompanyLogo(bytes, filename);
|
||||
if (result.failure == null && result.data != null) {
|
||||
final logoUrl = result.data!;
|
||||
await _persist(
|
||||
state.copyWith(
|
||||
companyProfile: state.companyProfile.copyWith(logoUrl: logoUrl),
|
||||
),
|
||||
);
|
||||
return logoUrl;
|
||||
await _saveSettings(state);
|
||||
await _syncMainLogo(result.data!);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// POST `/settings/company/logo` — also updates the app main logo.
|
||||
Future<Result<String?>> uploadCompanyLogo(
|
||||
List<int> bytes,
|
||||
String filename,
|
||||
) async {
|
||||
final result = await _repository.uploadCompanyLogo(bytes, filename);
|
||||
if (result.failure == null && result.data != null && result.data!.isNotEmpty) {
|
||||
final logoUrl = resolveMediaUrl(result.data!) ?? result.data!;
|
||||
final profile = state.companyProfile.copyWith(logoUrl: logoUrl);
|
||||
state = state.copyWith(companyProfile: profile);
|
||||
await _saveSettings(state);
|
||||
await _syncMainLogo(profile);
|
||||
return (data: logoUrl, failure: null);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Applies a local/preview logo to company profile + main app branding.
|
||||
Future<void> applyLocalCompanyLogo(String logoUrl) async {
|
||||
final resolved = resolveMediaUrl(logoUrl) ?? logoUrl;
|
||||
final profile = state.companyProfile.copyWith(logoUrl: resolved);
|
||||
state = state.copyWith(companyProfile: profile);
|
||||
await _saveSettings(state);
|
||||
await _syncMainLogo(profile);
|
||||
}
|
||||
|
||||
Future<void> updateUiPreferences(UiPreferencesSettings prefs) async {
|
||||
await _persist(state.copyWith(uiPreferences: prefs));
|
||||
}
|
||||
@ -129,14 +175,15 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
|
||||
await _persist(state.copyWith(notifications: notifications));
|
||||
}
|
||||
|
||||
Future<void> updateEmail(EmailConfigurationSettings email) async {
|
||||
/// PUT `/settings/email` — returns failure when the API call fails.
|
||||
Future<Failure?> updateEmail(EmailConfigurationSettings email) async {
|
||||
final result = await _repository.saveEmailSettings(email);
|
||||
if (result.failure == null && result.data != null) {
|
||||
if (result.failure != null) return result.failure;
|
||||
if (result.data != null) {
|
||||
state = state.copyWith(email: result.data!);
|
||||
await _saveSettings(state);
|
||||
return;
|
||||
}
|
||||
await _persist(state.copyWith(email: email));
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> updateSecurity(SecuritySettingsConfig security) async {
|
||||
@ -145,5 +192,6 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
|
||||
|
||||
Future<void> resetToDefaults() async {
|
||||
await _persist(const AppSettings());
|
||||
await _syncAppLogo(null, null);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/network/api_handler.dart';
|
||||
import '../../../../core/theme/theme_provider.dart';
|
||||
import '../../../../core/utils/favicon_store.dart';
|
||||
import '../../../../core/utils/favicon_updater.dart';
|
||||
@ -41,15 +42,16 @@ class _CompanyProfileSettingsScreenState
|
||||
late final TextEditingController _logoUrlController;
|
||||
late final TextEditingController _faviconUrlController;
|
||||
|
||||
bool _loading = true;
|
||||
bool _saving = false;
|
||||
bool _uploadingLogo = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final profile = ref.read(appSettingsProvider).companyProfile;
|
||||
final faviconFromPrefs =
|
||||
FaviconStore(ref.read(sharedPreferencesProvider)).read();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(appSettingsProvider.notifier).refreshCompanyProfile();
|
||||
});
|
||||
_nameController = TextEditingController(text: profile.companyName);
|
||||
_codeController = TextEditingController(text: profile.companyCode);
|
||||
_registrationController =
|
||||
@ -68,6 +70,43 @@ class _CompanyProfileSettingsScreenState
|
||||
? profile.faviconUrl
|
||||
: faviconFromPrefs,
|
||||
);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _loadFromApi());
|
||||
}
|
||||
|
||||
Future<void> _loadFromApi() async {
|
||||
setState(() => _loading = true);
|
||||
final failure =
|
||||
await ref.read(appSettingsProvider.notifier).refreshCompanyProfile();
|
||||
if (!mounted) return;
|
||||
_applyProfile(ref.read(appSettingsProvider).companyProfile);
|
||||
setState(() => _loading = false);
|
||||
if (failure != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(failure.message),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _applyProfile(CompanyProfileSettings profile) {
|
||||
_nameController.text = profile.companyName;
|
||||
_codeController.text = profile.companyCode;
|
||||
_registrationController.text = profile.registrationNumber;
|
||||
_gstController.text = profile.gstNumber;
|
||||
_addressController.text = profile.address;
|
||||
_cityController.text = profile.city;
|
||||
_stateController.text = profile.state;
|
||||
_pincodeController.text = profile.pincode;
|
||||
_emailController.text = profile.email;
|
||||
_phoneController.text = profile.phone;
|
||||
_websiteController.text = profile.website;
|
||||
_logoUrlController.text = profile.logoUrl;
|
||||
if (profile.faviconUrl.isNotEmpty) {
|
||||
_faviconUrlController.text = profile.faviconUrl;
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
@ -95,23 +134,37 @@ class _CompanyProfileSettingsScreenState
|
||||
final faviconUrl = _faviconUrlController.text.trim();
|
||||
final companyName = _nameController.text.trim();
|
||||
|
||||
await ref.read(appSettingsProvider.notifier).updateCompanyProfile(
|
||||
CompanyProfileSettings(
|
||||
companyName: companyName,
|
||||
companyCode: _codeController.text.trim(),
|
||||
registrationNumber: _registrationController.text.trim(),
|
||||
gstNumber: _gstController.text.trim(),
|
||||
address: _addressController.text.trim(),
|
||||
city: _cityController.text.trim(),
|
||||
state: _stateController.text.trim(),
|
||||
pincode: _pincodeController.text.trim(),
|
||||
email: _emailController.text.trim(),
|
||||
phone: _phoneController.text.trim(),
|
||||
website: _websiteController.text.trim(),
|
||||
logoUrl: logoUrl,
|
||||
faviconUrl: faviconUrl,
|
||||
),
|
||||
);
|
||||
setState(() => _saving = true);
|
||||
final failure =
|
||||
await ref.read(appSettingsProvider.notifier).updateCompanyProfile(
|
||||
CompanyProfileSettings(
|
||||
companyName: companyName,
|
||||
companyCode: _codeController.text.trim(),
|
||||
registrationNumber: _registrationController.text.trim(),
|
||||
gstNumber: _gstController.text.trim(),
|
||||
address: _addressController.text.trim(),
|
||||
city: _cityController.text.trim(),
|
||||
state: _stateController.text.trim(),
|
||||
pincode: _pincodeController.text.trim(),
|
||||
email: _emailController.text.trim(),
|
||||
phone: _phoneController.text.trim(),
|
||||
website: _websiteController.text.trim(),
|
||||
logoUrl: logoUrl,
|
||||
faviconUrl: faviconUrl,
|
||||
),
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _saving = false);
|
||||
|
||||
if (failure != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(validationErrorMessage(failure)),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await ref.read(brandingProvider.notifier).updateBranding(
|
||||
ref.read(brandingProvider).copyWith(
|
||||
@ -165,14 +218,19 @@ class _CompanyProfileSettingsScreenState
|
||||
final bytes = file.bytes;
|
||||
if (bytes == null) return;
|
||||
|
||||
final uploadedUrl = await ref.read(appSettingsProvider.notifier).uploadCompanyLogo(
|
||||
bytes,
|
||||
file.name,
|
||||
);
|
||||
|
||||
setState(() => _uploadingLogo = true);
|
||||
final uploadResult =
|
||||
await ref.read(appSettingsProvider.notifier).uploadCompanyLogo(
|
||||
bytes,
|
||||
file.name,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _uploadingLogo = false);
|
||||
|
||||
if (uploadedUrl != null && uploadedUrl.isNotEmpty) {
|
||||
final uploadedUrl = uploadResult.data;
|
||||
if (uploadResult.failure == null &&
|
||||
uploadedUrl != null &&
|
||||
uploadedUrl.isNotEmpty) {
|
||||
setState(() => _logoUrlController.text = uploadedUrl);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Logo uploaded')),
|
||||
@ -180,9 +238,21 @@ class _CompanyProfileSettingsScreenState
|
||||
return;
|
||||
}
|
||||
|
||||
if (uploadResult.failure != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(uploadResult.failure!.message),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Local preview fallback when upload is unavailable — still apply as main logo.
|
||||
final ext = (file.extension ?? 'png').toLowerCase();
|
||||
final mime = ext == 'jpg' ? 'jpeg' : ext;
|
||||
final dataUri = 'data:image/$mime;base64,${base64Encode(bytes)}';
|
||||
await ref.read(appSettingsProvider.notifier).applyLocalCompanyLogo(dataUri);
|
||||
if (!mounted) return;
|
||||
setState(() => _logoUrlController.text = dataUri);
|
||||
}
|
||||
|
||||
@ -195,147 +265,165 @@ class _CompanyProfileSettingsScreenState
|
||||
return SettingsPageLayout(
|
||||
title: 'Company Profile',
|
||||
subtitle: 'Company information and branding assets',
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
children: [
|
||||
SettingsFormCard(
|
||||
title: 'Company Information',
|
||||
children: [
|
||||
AppTextField(
|
||||
controller: _nameController,
|
||||
label: 'Company Name',
|
||||
validator: (v) => Validators.required(v, fieldName: 'Company name'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _codeController,
|
||||
label: 'Company Code',
|
||||
validator: (v) => Validators.required(v, fieldName: 'Company code'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _registrationController,
|
||||
label: 'Registration Number',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _gstController,
|
||||
label: 'GST/VAT Number',
|
||||
validator: Validators.optionalGstin,
|
||||
inputFormatters: Validators.gstinInput,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _addressController,
|
||||
label: 'Address',
|
||||
maxLines: 2,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SidePanelFormRow(
|
||||
left: AppTextField(
|
||||
controller: _cityController,
|
||||
label: 'City',
|
||||
child: _loading
|
||||
? const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 48),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
children: [
|
||||
SettingsFormCard(
|
||||
title: 'Company Information',
|
||||
children: [
|
||||
AppTextField(
|
||||
controller: _nameController,
|
||||
label: 'Company Name',
|
||||
validator: (v) =>
|
||||
Validators.required(v, fieldName: 'Company name'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _codeController,
|
||||
label: 'Company Code',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _registrationController,
|
||||
label: 'Registration Number',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _gstController,
|
||||
label: 'GST/VAT Number',
|
||||
validator: Validators.optionalGstin,
|
||||
inputFormatters: Validators.gstinInput,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _addressController,
|
||||
label: 'Address',
|
||||
maxLines: 2,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SidePanelFormRow(
|
||||
left: AppTextField(
|
||||
controller: _cityController,
|
||||
label: 'City',
|
||||
),
|
||||
right: AppTextField(
|
||||
controller: _stateController,
|
||||
label: 'State',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _pincodeController,
|
||||
label: 'Pincode',
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _emailController,
|
||||
label: 'Email',
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: Validators.optionalEmail,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _phoneController,
|
||||
label: 'Phone',
|
||||
keyboardType: TextInputType.phone,
|
||||
validator: Validators.optionalMobile,
|
||||
inputFormatters: Validators.mobileInput,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _websiteController,
|
||||
label: 'Website',
|
||||
keyboardType: TextInputType.url,
|
||||
),
|
||||
],
|
||||
),
|
||||
right: AppTextField(
|
||||
controller: _stateController,
|
||||
label: 'State',
|
||||
const SizedBox(height: 16),
|
||||
SettingsFormCard(
|
||||
title: 'Logo Upload',
|
||||
subtitle: 'Upload an image or provide a logo URL',
|
||||
children: [
|
||||
Center(
|
||||
child: SidebarLogo(
|
||||
logoUrl: _logoUrlController.text.trim().isEmpty
|
||||
? null
|
||||
: _logoUrlController.text.trim(),
|
||||
width: 240,
|
||||
height: 80,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _logoUrlController,
|
||||
label: 'Logo URL',
|
||||
hint: 'https://example.com/logo.png',
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _uploadingLogo ? null : _pickLogo,
|
||||
icon: _uploadingLogo
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.upload_file),
|
||||
label: Text(
|
||||
_uploadingLogo ? 'Uploading…' : 'Upload Logo',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _pincodeController,
|
||||
label: 'Pincode',
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _emailController,
|
||||
label: 'Email',
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: Validators.email,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _phoneController,
|
||||
label: 'Phone',
|
||||
keyboardType: TextInputType.phone,
|
||||
validator: Validators.optionalMobile,
|
||||
inputFormatters: Validators.mobileInput,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _websiteController,
|
||||
label: 'Website',
|
||||
keyboardType: TextInputType.url,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
SettingsFormCard(
|
||||
title: 'Favicon Upload',
|
||||
subtitle:
|
||||
'Upload an image or provide a favicon URL for the browser tab',
|
||||
children: [
|
||||
Center(
|
||||
child: SidebarLogo(
|
||||
logoUrl: _faviconUrlController.text.trim().isEmpty
|
||||
? null
|
||||
: _faviconUrlController.text.trim(),
|
||||
width: 64,
|
||||
height: 64,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _faviconUrlController,
|
||||
label: 'Favicon URL',
|
||||
hint: 'https://example.com/favicon.ico',
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _pickFavicon,
|
||||
icon: const Icon(Icons.upload_file),
|
||||
label: const Text('Upload Favicon'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
AppButton(
|
||||
label: 'Save Changes',
|
||||
onPressed: _save,
|
||||
isLoading: _saving,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SettingsFormCard(
|
||||
title: 'Logo Upload',
|
||||
subtitle: 'Upload an image or provide a logo URL',
|
||||
children: [
|
||||
Center(
|
||||
child: SidebarLogo(
|
||||
logoUrl: _logoUrlController.text.trim().isEmpty
|
||||
? null
|
||||
: _logoUrlController.text.trim(),
|
||||
width: 240,
|
||||
height: 80,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _logoUrlController,
|
||||
label: 'Logo URL',
|
||||
hint: 'https://example.com/logo.png',
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _pickLogo,
|
||||
icon: const Icon(Icons.upload_file),
|
||||
label: const Text('Upload Logo'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SettingsFormCard(
|
||||
title: 'Favicon Upload',
|
||||
subtitle: 'Upload an image or provide a favicon URL for the browser tab',
|
||||
children: [
|
||||
Center(
|
||||
child: SidebarLogo(
|
||||
logoUrl: _faviconUrlController.text.trim().isEmpty
|
||||
? null
|
||||
: _faviconUrlController.text.trim(),
|
||||
width: 64,
|
||||
height: 64,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _faviconUrlController,
|
||||
label: 'Favicon URL',
|
||||
hint: 'https://example.com/favicon.ico',
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _pickFavicon,
|
||||
icon: const Icon(Icons.upload_file),
|
||||
label: const Text('Upload Favicon'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
AppButton(label: 'Save Changes', onPressed: _save),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/network/api_handler.dart';
|
||||
import '../../../../core/utils/validators.dart';
|
||||
import '../../../../shared/widgets/app_button.dart';
|
||||
import '../../../../shared/widgets/app_text_field.dart';
|
||||
@ -25,31 +26,49 @@ class _EmailConfigurationScreenState
|
||||
late final TextEditingController _passwordController;
|
||||
late final TextEditingController _senderEmailController;
|
||||
late final TextEditingController _senderNameController;
|
||||
late final TextEditingController _allocationTemplateController;
|
||||
late final TextEditingController _returnTemplateController;
|
||||
late final TextEditingController _maintenanceTemplateController;
|
||||
late final TextEditingController _warrantyTemplateController;
|
||||
|
||||
bool _loading = true;
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final email = ref.read(appSettingsProvider).email;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(appSettingsProvider.notifier).refreshEmailSettings();
|
||||
});
|
||||
_hostController = TextEditingController(text: email.smtpHost);
|
||||
_portController = TextEditingController(text: '${email.smtpPort}');
|
||||
_usernameController = TextEditingController(text: email.smtpUsername);
|
||||
_passwordController = TextEditingController(text: email.smtpPassword);
|
||||
_senderEmailController = TextEditingController(text: email.senderEmail);
|
||||
_senderNameController = TextEditingController(text: email.senderName);
|
||||
_allocationTemplateController =
|
||||
TextEditingController(text: email.allocationTemplate);
|
||||
_returnTemplateController = TextEditingController(text: email.returnTemplate);
|
||||
_maintenanceTemplateController =
|
||||
TextEditingController(text: email.maintenanceTemplate);
|
||||
_warrantyTemplateController =
|
||||
TextEditingController(text: email.warrantyTemplate);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _loadFromApi());
|
||||
}
|
||||
|
||||
Future<void> _loadFromApi() async {
|
||||
setState(() => _loading = true);
|
||||
final failure =
|
||||
await ref.read(appSettingsProvider.notifier).refreshEmailSettings();
|
||||
if (!mounted) return;
|
||||
_applyEmail(ref.read(appSettingsProvider).email);
|
||||
setState(() => _loading = false);
|
||||
if (failure != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(failure.message),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _applyEmail(EmailConfigurationSettings email) {
|
||||
_hostController.text = email.smtpHost;
|
||||
_portController.text = '${email.smtpPort}';
|
||||
_usernameController.text = email.smtpUsername;
|
||||
if (email.smtpPassword.isNotEmpty) {
|
||||
_passwordController.text = email.smtpPassword;
|
||||
}
|
||||
_senderEmailController.text = email.senderEmail;
|
||||
_senderNameController.text = email.senderName;
|
||||
}
|
||||
|
||||
@override
|
||||
@ -60,121 +79,117 @@ class _EmailConfigurationScreenState
|
||||
_passwordController.dispose();
|
||||
_senderEmailController.dispose();
|
||||
_senderNameController.dispose();
|
||||
_allocationTemplateController.dispose();
|
||||
_returnTemplateController.dispose();
|
||||
_maintenanceTemplateController.dispose();
|
||||
_warrantyTemplateController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
await ref.read(appSettingsProvider.notifier).updateEmail(
|
||||
EmailConfigurationSettings(
|
||||
final port = int.tryParse(_portController.text.trim());
|
||||
if (port == null || port <= 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Enter a valid SMTP port')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final current = ref.read(appSettingsProvider).email;
|
||||
setState(() => _saving = true);
|
||||
final failure = await ref.read(appSettingsProvider.notifier).updateEmail(
|
||||
current.copyWith(
|
||||
smtpHost: _hostController.text.trim(),
|
||||
smtpPort: int.parse(_portController.text.trim()),
|
||||
smtpPort: port,
|
||||
smtpUsername: _usernameController.text.trim(),
|
||||
smtpPassword: _passwordController.text.trim(),
|
||||
senderEmail: _senderEmailController.text.trim(),
|
||||
senderName: _senderNameController.text.trim(),
|
||||
allocationTemplate: _allocationTemplateController.text.trim(),
|
||||
returnTemplate: _returnTemplateController.text.trim(),
|
||||
maintenanceTemplate: _maintenanceTemplateController.text.trim(),
|
||||
warrantyTemplate: _warrantyTemplateController.text.trim(),
|
||||
),
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _saving = false);
|
||||
|
||||
if (mounted) {
|
||||
if (failure != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Email configuration saved')),
|
||||
SnackBar(
|
||||
content: Text(validationErrorMessage(failure)),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Email configuration saved')),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SettingsPageLayout(
|
||||
title: 'Email Configuration',
|
||||
subtitle: 'SMTP server settings and email templates',
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
children: [
|
||||
SettingsFormCard(
|
||||
title: 'SMTP Settings',
|
||||
children: [
|
||||
AppTextField(
|
||||
controller: _hostController,
|
||||
label: 'SMTP Host',
|
||||
hint: 'smtp.gmail.com',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _portController,
|
||||
label: 'SMTP Port',
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _usernameController,
|
||||
label: 'Username',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _passwordController,
|
||||
label: 'Password',
|
||||
obscureText: true,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _senderEmailController,
|
||||
label: 'Sender Email',
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: Validators.email,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _senderNameController,
|
||||
label: 'Sender Name',
|
||||
),
|
||||
],
|
||||
subtitle: 'SMTP server settings',
|
||||
child: _loading
|
||||
? const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 48),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
children: [
|
||||
SettingsFormCard(
|
||||
title: 'SMTP Settings',
|
||||
children: [
|
||||
AppTextField(
|
||||
controller: _hostController,
|
||||
label: 'SMTP Host',
|
||||
hint: 'smtp.gmail.com',
|
||||
validator: (v) =>
|
||||
Validators.required(v, fieldName: 'SMTP host'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _portController,
|
||||
label: 'SMTP Port',
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (v) =>
|
||||
Validators.required(v, fieldName: 'SMTP port'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _usernameController,
|
||||
label: 'Username',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _passwordController,
|
||||
label: 'Password',
|
||||
obscureText: true,
|
||||
hint: 'Leave blank to keep existing password',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _senderEmailController,
|
||||
label: 'Sender Email',
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: Validators.email,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _senderNameController,
|
||||
label: 'Sender Name',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
AppButton(
|
||||
label: 'Save Changes',
|
||||
onPressed: _save,
|
||||
isLoading: _saving,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SettingsFormCard(
|
||||
title: 'Email Templates',
|
||||
subtitle: 'Use {{asset_name}} and {{date}} as placeholders',
|
||||
children: [
|
||||
AppTextField(
|
||||
controller: _allocationTemplateController,
|
||||
label: 'Asset Allocation Email',
|
||||
maxLines: 2,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _returnTemplateController,
|
||||
label: 'Asset Return Email',
|
||||
maxLines: 2,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _maintenanceTemplateController,
|
||||
label: 'Maintenance Email',
|
||||
maxLines: 2,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _warrantyTemplateController,
|
||||
label: 'Warranty Expiry Email',
|
||||
maxLines: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
AppButton(label: 'Save Changes', onPressed: _save),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,6 +11,9 @@ class SettingsScreen extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final visiblePhase1 = visiblePhase1SettingsSections;
|
||||
final visiblePhase2 = visiblePhase2SettingsSections;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
@ -21,35 +24,31 @@ class SettingsScreen extends StatelessWidget {
|
||||
children: [
|
||||
const PageHeader(
|
||||
title: 'Settings',
|
||||
subtitle: 'Configure company, security, assets, and system behavior',
|
||||
),
|
||||
Text(
|
||||
'Phase 1 — Asset Management MVP',
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SettingsSectionGrid(
|
||||
sections: phase1SettingsSections,
|
||||
onSectionTap: (section) => context.go(section.route),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Coming in Phase 2',
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SettingsSectionGrid(
|
||||
sections: phase2SettingsSections,
|
||||
enabled: false,
|
||||
badge: 'Phase 2',
|
||||
onSectionTap: (_) {},
|
||||
subtitle:
|
||||
'Configure company, security, assets, and system behavior',
|
||||
),
|
||||
if (visiblePhase1.isNotEmpty)
|
||||
SettingsSectionGrid(
|
||||
sections: visiblePhase1,
|
||||
onSectionTap: (section) => context.go(section.route),
|
||||
),
|
||||
if (visiblePhase2.isNotEmpty) ...[
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Coming in Phase 2',
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SettingsSectionGrid(
|
||||
sections: visiblePhase2,
|
||||
enabled: false,
|
||||
badge: 'Phase 2',
|
||||
onSectionTap: (_) {},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@ -36,10 +36,13 @@ Object? _readNestedName(Map<dynamic, dynamic> json, String nestedKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Object? _readAssetCategoryName(Map<dynamic, dynamic> json, String key) {
|
||||
final flat = json['asset_category_name'];
|
||||
if (flat is String && flat.isNotEmpty) return flat;
|
||||
return _readNestedName(json, 'asset_category');
|
||||
Object? _readItemCategoryName(Map<dynamic, dynamic> json, String key) {
|
||||
for (final flatKey in ['item_category_name', 'asset_category_name']) {
|
||||
final flat = json[flatKey];
|
||||
if (flat is String && flat.isNotEmpty) return flat;
|
||||
}
|
||||
return _readNestedName(json, 'item_category') ??
|
||||
_readNestedName(json, 'asset_category');
|
||||
}
|
||||
|
||||
Object? _readPlantName(Map<dynamic, dynamic> json, String key) {
|
||||
@ -48,10 +51,12 @@ Object? _readPlantName(Map<dynamic, dynamic> json, String key) {
|
||||
return _readNestedName(json, 'plant');
|
||||
}
|
||||
|
||||
Object? _readAssetCategoryId(Map<dynamic, dynamic> json, String key) {
|
||||
final flat = json['asset_category_id'];
|
||||
if (flat != null) return flat;
|
||||
final nested = json['asset_category'];
|
||||
Object? _readItemCategoryId(Map<dynamic, dynamic> json, String key) {
|
||||
for (final flatKey in ['item_category_id', 'asset_category_id']) {
|
||||
final flat = json[flatKey];
|
||||
if (flat != null) return flat;
|
||||
}
|
||||
final nested = json['item_category'] ?? json['asset_category'];
|
||||
if (nested is Map) return nested['id'];
|
||||
return null;
|
||||
}
|
||||
@ -64,16 +69,21 @@ Object? _readPlantId(Map<dynamic, dynamic> json, String key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Object? _readAssetSubcategoryName(Map<dynamic, dynamic> json, String key) {
|
||||
final flat = json['asset_subcategory_name'];
|
||||
if (flat is String && flat.isNotEmpty) return flat;
|
||||
return _readNestedName(json, 'asset_subcategory');
|
||||
Object? _readItemSubcategoryName(Map<dynamic, dynamic> json, String key) {
|
||||
for (final flatKey in ['item_subcategory_name', 'asset_subcategory_name']) {
|
||||
final flat = json[flatKey];
|
||||
if (flat is String && flat.isNotEmpty) return flat;
|
||||
}
|
||||
return _readNestedName(json, 'item_subcategory') ??
|
||||
_readNestedName(json, 'asset_subcategory');
|
||||
}
|
||||
|
||||
Object? _readAssetSubcategoryId(Map<dynamic, dynamic> json, String key) {
|
||||
final flat = json['asset_subcategory_id'];
|
||||
if (flat != null) return flat;
|
||||
final nested = json['asset_subcategory'];
|
||||
Object? _readItemSubcategoryId(Map<dynamic, dynamic> json, String key) {
|
||||
for (final flatKey in ['item_subcategory_id', 'asset_subcategory_id']) {
|
||||
final flat = json[flatKey];
|
||||
if (flat != null) return flat;
|
||||
}
|
||||
final nested = json['item_subcategory'] ?? json['asset_subcategory'];
|
||||
if (nested is Map) return nested['id'];
|
||||
return null;
|
||||
}
|
||||
@ -127,20 +137,20 @@ class AssetModel with _$AssetModel {
|
||||
@JsonKey(name: 'asset_name') required String assetName,
|
||||
@JsonKey(name: 'asset_code') String? assetCode,
|
||||
@JsonKey(
|
||||
name: 'asset_category_id',
|
||||
readValue: _readAssetCategoryId,
|
||||
name: 'item_category_id',
|
||||
readValue: _readItemCategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
int? assetCategoryId,
|
||||
@JsonKey(name: 'asset_category_name', readValue: _readAssetCategoryName)
|
||||
@JsonKey(name: 'item_category_name', readValue: _readItemCategoryName)
|
||||
String? assetCategoryName,
|
||||
@JsonKey(
|
||||
name: 'asset_subcategory_id',
|
||||
readValue: _readAssetSubcategoryId,
|
||||
name: 'item_subcategory_id',
|
||||
readValue: _readItemSubcategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
int? assetSubcategoryId,
|
||||
@JsonKey(name: 'asset_subcategory_name', readValue: _readAssetSubcategoryName)
|
||||
@JsonKey(name: 'item_subcategory_name', readValue: _readItemSubcategoryName)
|
||||
String? assetSubcategoryName,
|
||||
@JsonKey(name: 'plant_id', readValue: _readPlantId, fromJson: _intFromJsonNullable)
|
||||
int? plantId,
|
||||
|
||||
@ -397,20 +397,20 @@ mixin _$AssetModel {
|
||||
@JsonKey(name: 'asset_code')
|
||||
String? get assetCode => throw _privateConstructorUsedError;
|
||||
@JsonKey(
|
||||
name: 'asset_category_id',
|
||||
readValue: _readAssetCategoryId,
|
||||
name: 'item_category_id',
|
||||
readValue: _readItemCategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
int? get assetCategoryId => throw _privateConstructorUsedError;
|
||||
@JsonKey(name: 'asset_category_name', readValue: _readAssetCategoryName)
|
||||
@JsonKey(name: 'item_category_name', readValue: _readItemCategoryName)
|
||||
String? get assetCategoryName => throw _privateConstructorUsedError;
|
||||
@JsonKey(
|
||||
name: 'asset_subcategory_id',
|
||||
readValue: _readAssetSubcategoryId,
|
||||
name: 'item_subcategory_id',
|
||||
readValue: _readItemSubcategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
int? get assetSubcategoryId => throw _privateConstructorUsedError;
|
||||
@JsonKey(name: 'asset_subcategory_name', readValue: _readAssetSubcategoryName)
|
||||
@JsonKey(name: 'item_subcategory_name', readValue: _readItemSubcategoryName)
|
||||
String? get assetSubcategoryName => throw _privateConstructorUsedError;
|
||||
@JsonKey(
|
||||
name: 'plant_id',
|
||||
@ -503,23 +503,20 @@ abstract class $AssetModelCopyWith<$Res> {
|
||||
@JsonKey(name: 'asset_name') String assetName,
|
||||
@JsonKey(name: 'asset_code') String? assetCode,
|
||||
@JsonKey(
|
||||
name: 'asset_category_id',
|
||||
readValue: _readAssetCategoryId,
|
||||
name: 'item_category_id',
|
||||
readValue: _readItemCategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
int? assetCategoryId,
|
||||
@JsonKey(name: 'asset_category_name', readValue: _readAssetCategoryName)
|
||||
@JsonKey(name: 'item_category_name', readValue: _readItemCategoryName)
|
||||
String? assetCategoryName,
|
||||
@JsonKey(
|
||||
name: 'asset_subcategory_id',
|
||||
readValue: _readAssetSubcategoryId,
|
||||
name: 'item_subcategory_id',
|
||||
readValue: _readItemSubcategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
int? assetSubcategoryId,
|
||||
@JsonKey(
|
||||
name: 'asset_subcategory_name',
|
||||
readValue: _readAssetSubcategoryName,
|
||||
)
|
||||
@JsonKey(name: 'item_subcategory_name', readValue: _readItemSubcategoryName)
|
||||
String? assetSubcategoryName,
|
||||
@JsonKey(
|
||||
name: 'plant_id',
|
||||
@ -823,23 +820,20 @@ abstract class _$$AssetModelImplCopyWith<$Res>
|
||||
@JsonKey(name: 'asset_name') String assetName,
|
||||
@JsonKey(name: 'asset_code') String? assetCode,
|
||||
@JsonKey(
|
||||
name: 'asset_category_id',
|
||||
readValue: _readAssetCategoryId,
|
||||
name: 'item_category_id',
|
||||
readValue: _readItemCategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
int? assetCategoryId,
|
||||
@JsonKey(name: 'asset_category_name', readValue: _readAssetCategoryName)
|
||||
@JsonKey(name: 'item_category_name', readValue: _readItemCategoryName)
|
||||
String? assetCategoryName,
|
||||
@JsonKey(
|
||||
name: 'asset_subcategory_id',
|
||||
readValue: _readAssetSubcategoryId,
|
||||
name: 'item_subcategory_id',
|
||||
readValue: _readItemSubcategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
int? assetSubcategoryId,
|
||||
@JsonKey(
|
||||
name: 'asset_subcategory_name',
|
||||
readValue: _readAssetSubcategoryName,
|
||||
)
|
||||
@JsonKey(name: 'item_subcategory_name', readValue: _readItemSubcategoryName)
|
||||
String? assetSubcategoryName,
|
||||
@JsonKey(
|
||||
name: 'plant_id',
|
||||
@ -1135,23 +1129,20 @@ class _$AssetModelImpl implements _AssetModel {
|
||||
@JsonKey(name: 'asset_name') required this.assetName,
|
||||
@JsonKey(name: 'asset_code') this.assetCode,
|
||||
@JsonKey(
|
||||
name: 'asset_category_id',
|
||||
readValue: _readAssetCategoryId,
|
||||
name: 'item_category_id',
|
||||
readValue: _readItemCategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
this.assetCategoryId,
|
||||
@JsonKey(name: 'asset_category_name', readValue: _readAssetCategoryName)
|
||||
@JsonKey(name: 'item_category_name', readValue: _readItemCategoryName)
|
||||
this.assetCategoryName,
|
||||
@JsonKey(
|
||||
name: 'asset_subcategory_id',
|
||||
readValue: _readAssetSubcategoryId,
|
||||
name: 'item_subcategory_id',
|
||||
readValue: _readItemSubcategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
this.assetSubcategoryId,
|
||||
@JsonKey(
|
||||
name: 'asset_subcategory_name',
|
||||
readValue: _readAssetSubcategoryName,
|
||||
)
|
||||
@JsonKey(name: 'item_subcategory_name', readValue: _readItemSubcategoryName)
|
||||
this.assetSubcategoryName,
|
||||
@JsonKey(
|
||||
name: 'plant_id',
|
||||
@ -1225,23 +1216,23 @@ class _$AssetModelImpl implements _AssetModel {
|
||||
final String? assetCode;
|
||||
@override
|
||||
@JsonKey(
|
||||
name: 'asset_category_id',
|
||||
readValue: _readAssetCategoryId,
|
||||
name: 'item_category_id',
|
||||
readValue: _readItemCategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
final int? assetCategoryId;
|
||||
@override
|
||||
@JsonKey(name: 'asset_category_name', readValue: _readAssetCategoryName)
|
||||
@JsonKey(name: 'item_category_name', readValue: _readItemCategoryName)
|
||||
final String? assetCategoryName;
|
||||
@override
|
||||
@JsonKey(
|
||||
name: 'asset_subcategory_id',
|
||||
readValue: _readAssetSubcategoryId,
|
||||
name: 'item_subcategory_id',
|
||||
readValue: _readItemSubcategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
final int? assetSubcategoryId;
|
||||
@override
|
||||
@JsonKey(name: 'asset_subcategory_name', readValue: _readAssetSubcategoryName)
|
||||
@JsonKey(name: 'item_subcategory_name', readValue: _readItemSubcategoryName)
|
||||
final String? assetSubcategoryName;
|
||||
@override
|
||||
@JsonKey(
|
||||
@ -1501,23 +1492,20 @@ abstract class _AssetModel implements AssetModel {
|
||||
@JsonKey(name: 'asset_name') required final String assetName,
|
||||
@JsonKey(name: 'asset_code') final String? assetCode,
|
||||
@JsonKey(
|
||||
name: 'asset_category_id',
|
||||
readValue: _readAssetCategoryId,
|
||||
name: 'item_category_id',
|
||||
readValue: _readItemCategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
final int? assetCategoryId,
|
||||
@JsonKey(name: 'asset_category_name', readValue: _readAssetCategoryName)
|
||||
@JsonKey(name: 'item_category_name', readValue: _readItemCategoryName)
|
||||
final String? assetCategoryName,
|
||||
@JsonKey(
|
||||
name: 'asset_subcategory_id',
|
||||
readValue: _readAssetSubcategoryId,
|
||||
name: 'item_subcategory_id',
|
||||
readValue: _readItemSubcategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
final int? assetSubcategoryId,
|
||||
@JsonKey(
|
||||
name: 'asset_subcategory_name',
|
||||
readValue: _readAssetSubcategoryName,
|
||||
)
|
||||
@JsonKey(name: 'item_subcategory_name', readValue: _readItemSubcategoryName)
|
||||
final String? assetSubcategoryName,
|
||||
@JsonKey(
|
||||
name: 'plant_id',
|
||||
@ -1593,23 +1581,23 @@ abstract class _AssetModel implements AssetModel {
|
||||
String? get assetCode;
|
||||
@override
|
||||
@JsonKey(
|
||||
name: 'asset_category_id',
|
||||
readValue: _readAssetCategoryId,
|
||||
name: 'item_category_id',
|
||||
readValue: _readItemCategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
int? get assetCategoryId;
|
||||
@override
|
||||
@JsonKey(name: 'asset_category_name', readValue: _readAssetCategoryName)
|
||||
@JsonKey(name: 'item_category_name', readValue: _readItemCategoryName)
|
||||
String? get assetCategoryName;
|
||||
@override
|
||||
@JsonKey(
|
||||
name: 'asset_subcategory_id',
|
||||
readValue: _readAssetSubcategoryId,
|
||||
name: 'item_subcategory_id',
|
||||
readValue: _readItemSubcategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
int? get assetSubcategoryId;
|
||||
@override
|
||||
@JsonKey(name: 'asset_subcategory_name', readValue: _readAssetSubcategoryName)
|
||||
@JsonKey(name: 'item_subcategory_name', readValue: _readItemSubcategoryName)
|
||||
String? get assetSubcategoryName;
|
||||
@override
|
||||
@JsonKey(
|
||||
|
||||
@ -46,15 +46,15 @@ _$AssetModelImpl _$$AssetModelImplFromJson(Map<String, dynamic> json) =>
|
||||
assetName: json['asset_name'] as String,
|
||||
assetCode: json['asset_code'] as String?,
|
||||
assetCategoryId: _intFromJsonNullable(
|
||||
_readAssetCategoryId(json, 'asset_category_id'),
|
||||
_readItemCategoryId(json, 'item_category_id'),
|
||||
),
|
||||
assetCategoryName:
|
||||
_readAssetCategoryName(json, 'asset_category_name') as String?,
|
||||
_readItemCategoryName(json, 'item_category_name') as String?,
|
||||
assetSubcategoryId: _intFromJsonNullable(
|
||||
_readAssetSubcategoryId(json, 'asset_subcategory_id'),
|
||||
_readItemSubcategoryId(json, 'item_subcategory_id'),
|
||||
),
|
||||
assetSubcategoryName:
|
||||
_readAssetSubcategoryName(json, 'asset_subcategory_name') as String?,
|
||||
_readItemSubcategoryName(json, 'item_subcategory_name') as String?,
|
||||
plantId: _intFromJsonNullable(_readPlantId(json, 'plant_id')),
|
||||
plantName: _readPlantName(json, 'plant_name') as String?,
|
||||
brandModel: json['brand_model'] as String?,
|
||||
@ -96,10 +96,10 @@ Map<String, dynamic> _$$AssetModelImplToJson(_$AssetModelImpl instance) =>
|
||||
'id': instance.id,
|
||||
'asset_name': instance.assetName,
|
||||
'asset_code': instance.assetCode,
|
||||
'asset_category_id': instance.assetCategoryId,
|
||||
'asset_category_name': instance.assetCategoryName,
|
||||
'asset_subcategory_id': instance.assetSubcategoryId,
|
||||
'asset_subcategory_name': instance.assetSubcategoryName,
|
||||
'item_category_id': instance.assetCategoryId,
|
||||
'item_category_name': instance.assetCategoryName,
|
||||
'item_subcategory_id': instance.assetSubcategoryId,
|
||||
'item_subcategory_name': instance.assetSubcategoryName,
|
||||
'plant_id': instance.plantId,
|
||||
'plant_name': instance.plantName,
|
||||
'brand_model': instance.brandModel,
|
||||
|
||||
193
lib/shared/models/audit_log_model.dart
Normal file
193
lib/shared/models/audit_log_model.dart
Normal file
@ -0,0 +1,193 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
// ignore_for_file: invalid_annotation_target
|
||||
|
||||
part 'audit_log_model.freezed.dart';
|
||||
part 'audit_log_model.g.dart';
|
||||
|
||||
String _idFromJson(Object? value) => value?.toString() ?? '';
|
||||
|
||||
String? _idFromJsonNullable(Object? value) {
|
||||
if (value == null) return null;
|
||||
final text = value.toString().trim();
|
||||
return text.isEmpty ? null : text;
|
||||
}
|
||||
|
||||
DateTime? _dateFromJsonNullable(Object? value) {
|
||||
if (value == null) return null;
|
||||
if (value is DateTime) return value;
|
||||
return DateTime.tryParse(value.toString());
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _mapFromJsonNullable(Object? value) {
|
||||
if (value == null) return null;
|
||||
if (value is Map<String, dynamic>) return value;
|
||||
if (value is Map) {
|
||||
return value.map((key, val) => MapEntry(key.toString(), val));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class AuditLogUserModel with _$AuditLogUserModel {
|
||||
const factory AuditLogUserModel({
|
||||
@JsonKey(fromJson: _idFromJson) required String id,
|
||||
@JsonKey(name: 'full_name') String? fullName,
|
||||
@JsonKey(name: 'employee_code') String? employeeCode,
|
||||
String? email,
|
||||
}) = _AuditLogUserModel;
|
||||
|
||||
factory AuditLogUserModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$AuditLogUserModelFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class AuditLogPerformerOption with _$AuditLogPerformerOption {
|
||||
const factory AuditLogPerformerOption({
|
||||
@JsonKey(fromJson: _idFromJson) required String id,
|
||||
@JsonKey(name: 'full_name') String? fullName,
|
||||
@JsonKey(name: 'employee_code') String? employeeCode,
|
||||
}) = _AuditLogPerformerOption;
|
||||
|
||||
factory AuditLogPerformerOption.fromJson(Map<String, dynamic> json) =>
|
||||
_$AuditLogPerformerOptionFromJson(json);
|
||||
|
||||
const AuditLogPerformerOption._();
|
||||
|
||||
String get label {
|
||||
final name = fullName?.trim();
|
||||
final code = employeeCode?.trim();
|
||||
if (name != null && name.isNotEmpty && code != null && code.isNotEmpty) {
|
||||
return '$name ($code)';
|
||||
}
|
||||
if (name != null && name.isNotEmpty) return name;
|
||||
if (code != null && code.isNotEmpty) return code;
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class AuditLogFilterOptions with _$AuditLogFilterOptions {
|
||||
const factory AuditLogFilterOptions({
|
||||
@JsonKey(name: 'table_names') @Default([]) List<String> tableNames,
|
||||
@Default([]) List<String> actions,
|
||||
@Default([]) List<AuditLogPerformerOption> performers,
|
||||
}) = _AuditLogFilterOptions;
|
||||
|
||||
factory AuditLogFilterOptions.fromJson(Map<String, dynamic> json) =>
|
||||
_$AuditLogFilterOptionsFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class AuditLogEntryModel with _$AuditLogEntryModel {
|
||||
const factory AuditLogEntryModel({
|
||||
@JsonKey(fromJson: _idFromJson) required String id,
|
||||
@JsonKey(name: 'table_name') required String tableName,
|
||||
@JsonKey(name: 'record_id', fromJson: _idFromJsonNullable) String? recordId,
|
||||
required String action,
|
||||
@JsonKey(name: 'performed_at', fromJson: _dateFromJsonNullable)
|
||||
DateTime? performedAt,
|
||||
@JsonKey(name: 'performed_by', fromJson: _idFromJsonNullable)
|
||||
String? performedBy,
|
||||
@JsonKey(name: 'request_id') String? requestId,
|
||||
@JsonKey(name: 'performed_by_user') AuditLogUserModel? performedByUser,
|
||||
@JsonKey(name: 'has_old_value') @Default(false) bool hasOldValue,
|
||||
@JsonKey(name: 'has_new_value') @Default(false) bool hasNewValue,
|
||||
}) = _AuditLogEntryModel;
|
||||
|
||||
factory AuditLogEntryModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$AuditLogEntryModelFromJson(json);
|
||||
|
||||
const AuditLogEntryModel._();
|
||||
|
||||
String get performerLabel {
|
||||
final user = performedByUser;
|
||||
if (user == null) return performedBy ?? '—';
|
||||
final name = user.fullName?.trim();
|
||||
final code = user.employeeCode?.trim();
|
||||
if (name != null && name.isNotEmpty && code != null && code.isNotEmpty) {
|
||||
return '$name ($code)';
|
||||
}
|
||||
if (name != null && name.isNotEmpty) return name;
|
||||
return performedBy ?? '—';
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class AuditLogDetailModel with _$AuditLogDetailModel {
|
||||
const factory AuditLogDetailModel({
|
||||
@JsonKey(fromJson: _idFromJson) required String id,
|
||||
@JsonKey(name: 'table_name') required String tableName,
|
||||
@JsonKey(name: 'record_id', fromJson: _idFromJsonNullable) String? recordId,
|
||||
required String action,
|
||||
@JsonKey(name: 'performed_at', fromJson: _dateFromJsonNullable)
|
||||
DateTime? performedAt,
|
||||
@JsonKey(name: 'performed_by', fromJson: _idFromJsonNullable)
|
||||
String? performedBy,
|
||||
@JsonKey(name: 'request_id') String? requestId,
|
||||
@JsonKey(name: 'performed_by_user') AuditLogUserModel? performedByUser,
|
||||
@JsonKey(name: 'has_old_value') @Default(false) bool hasOldValue,
|
||||
@JsonKey(name: 'has_new_value') @Default(false) bool hasNewValue,
|
||||
@JsonKey(name: 'old_value', fromJson: _mapFromJsonNullable)
|
||||
Map<String, dynamic>? oldValue,
|
||||
@JsonKey(name: 'new_value', fromJson: _mapFromJsonNullable)
|
||||
Map<String, dynamic>? newValue,
|
||||
}) = _AuditLogDetailModel;
|
||||
|
||||
factory AuditLogDetailModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$AuditLogDetailModelFromJson(json);
|
||||
|
||||
const AuditLogDetailModel._();
|
||||
|
||||
String get performerLabel {
|
||||
final user = performedByUser;
|
||||
if (user == null) return performedBy ?? '—';
|
||||
final name = user.fullName?.trim();
|
||||
final code = user.employeeCode?.trim();
|
||||
if (name != null && name.isNotEmpty && code != null && code.isNotEmpty) {
|
||||
return '$name ($code)';
|
||||
}
|
||||
if (name != null && name.isNotEmpty) return name;
|
||||
return performedBy ?? '—';
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class AuditLogListQuery with _$AuditLogListQuery {
|
||||
const factory AuditLogListQuery({
|
||||
@Default(1) int page,
|
||||
@Default(20) int limit,
|
||||
@JsonKey(name: 'table_name') String? tableName,
|
||||
@JsonKey(name: 'record_id') int? recordId,
|
||||
String? action,
|
||||
@JsonKey(name: 'performed_by') int? performedBy,
|
||||
@JsonKey(name: 'request_id') String? requestId,
|
||||
@JsonKey(name: 'date_from') DateTime? dateFrom,
|
||||
@JsonKey(name: 'date_to') DateTime? dateTo,
|
||||
String? search,
|
||||
}) = _AuditLogListQuery;
|
||||
|
||||
const AuditLogListQuery._();
|
||||
|
||||
bool get hasActiveFilter =>
|
||||
(tableName?.isNotEmpty ?? false) ||
|
||||
recordId != null ||
|
||||
(action?.isNotEmpty ?? false) ||
|
||||
performedBy != null ||
|
||||
(requestId?.isNotEmpty ?? false) ||
|
||||
dateFrom != null ||
|
||||
dateTo != null ||
|
||||
(search?.isNotEmpty ?? false);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class AuditLogListResult with _$AuditLogListResult {
|
||||
const factory AuditLogListResult({
|
||||
@Default([]) List<AuditLogEntryModel> items,
|
||||
@Default(1) int page,
|
||||
@Default(20) int limit,
|
||||
@Default(0) int total,
|
||||
@Default(1) int totalPages,
|
||||
@Default(false) bool filtersRequired,
|
||||
}) = _AuditLogListResult;
|
||||
}
|
||||
2283
lib/shared/models/audit_log_model.freezed.dart
Normal file
2283
lib/shared/models/audit_log_model.freezed.dart
Normal file
File diff suppressed because it is too large
Load Diff
141
lib/shared/models/audit_log_model.g.dart
Normal file
141
lib/shared/models/audit_log_model.g.dart
Normal file
@ -0,0 +1,141 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'audit_log_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_$AuditLogUserModelImpl _$$AuditLogUserModelImplFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _$AuditLogUserModelImpl(
|
||||
id: _idFromJson(json['id']),
|
||||
fullName: json['full_name'] as String?,
|
||||
employeeCode: json['employee_code'] as String?,
|
||||
email: json['email'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$AuditLogUserModelImplToJson(
|
||||
_$AuditLogUserModelImpl instance,
|
||||
) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'full_name': instance.fullName,
|
||||
'employee_code': instance.employeeCode,
|
||||
'email': instance.email,
|
||||
};
|
||||
|
||||
_$AuditLogPerformerOptionImpl _$$AuditLogPerformerOptionImplFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _$AuditLogPerformerOptionImpl(
|
||||
id: _idFromJson(json['id']),
|
||||
fullName: json['full_name'] as String?,
|
||||
employeeCode: json['employee_code'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$AuditLogPerformerOptionImplToJson(
|
||||
_$AuditLogPerformerOptionImpl instance,
|
||||
) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'full_name': instance.fullName,
|
||||
'employee_code': instance.employeeCode,
|
||||
};
|
||||
|
||||
_$AuditLogFilterOptionsImpl _$$AuditLogFilterOptionsImplFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _$AuditLogFilterOptionsImpl(
|
||||
tableNames:
|
||||
(json['table_names'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList() ??
|
||||
const [],
|
||||
actions:
|
||||
(json['actions'] as List<dynamic>?)?.map((e) => e as String).toList() ??
|
||||
const [],
|
||||
performers:
|
||||
(json['performers'] as List<dynamic>?)
|
||||
?.map(
|
||||
(e) => AuditLogPerformerOption.fromJson(e as Map<String, dynamic>),
|
||||
)
|
||||
.toList() ??
|
||||
const [],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$AuditLogFilterOptionsImplToJson(
|
||||
_$AuditLogFilterOptionsImpl instance,
|
||||
) => <String, dynamic>{
|
||||
'table_names': instance.tableNames,
|
||||
'actions': instance.actions,
|
||||
'performers': instance.performers,
|
||||
};
|
||||
|
||||
_$AuditLogEntryModelImpl _$$AuditLogEntryModelImplFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _$AuditLogEntryModelImpl(
|
||||
id: _idFromJson(json['id']),
|
||||
tableName: json['table_name'] as String,
|
||||
recordId: _idFromJsonNullable(json['record_id']),
|
||||
action: json['action'] as String,
|
||||
performedAt: _dateFromJsonNullable(json['performed_at']),
|
||||
performedBy: _idFromJsonNullable(json['performed_by']),
|
||||
requestId: json['request_id'] as String?,
|
||||
performedByUser: json['performed_by_user'] == null
|
||||
? null
|
||||
: AuditLogUserModel.fromJson(
|
||||
json['performed_by_user'] as Map<String, dynamic>,
|
||||
),
|
||||
hasOldValue: json['has_old_value'] as bool? ?? false,
|
||||
hasNewValue: json['has_new_value'] as bool? ?? false,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$AuditLogEntryModelImplToJson(
|
||||
_$AuditLogEntryModelImpl instance,
|
||||
) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'table_name': instance.tableName,
|
||||
'record_id': instance.recordId,
|
||||
'action': instance.action,
|
||||
'performed_at': instance.performedAt?.toIso8601String(),
|
||||
'performed_by': instance.performedBy,
|
||||
'request_id': instance.requestId,
|
||||
'performed_by_user': instance.performedByUser,
|
||||
'has_old_value': instance.hasOldValue,
|
||||
'has_new_value': instance.hasNewValue,
|
||||
};
|
||||
|
||||
_$AuditLogDetailModelImpl _$$AuditLogDetailModelImplFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _$AuditLogDetailModelImpl(
|
||||
id: _idFromJson(json['id']),
|
||||
tableName: json['table_name'] as String,
|
||||
recordId: _idFromJsonNullable(json['record_id']),
|
||||
action: json['action'] as String,
|
||||
performedAt: _dateFromJsonNullable(json['performed_at']),
|
||||
performedBy: _idFromJsonNullable(json['performed_by']),
|
||||
requestId: json['request_id'] as String?,
|
||||
performedByUser: json['performed_by_user'] == null
|
||||
? null
|
||||
: AuditLogUserModel.fromJson(
|
||||
json['performed_by_user'] as Map<String, dynamic>,
|
||||
),
|
||||
hasOldValue: json['has_old_value'] as bool? ?? false,
|
||||
hasNewValue: json['has_new_value'] as bool? ?? false,
|
||||
oldValue: _mapFromJsonNullable(json['old_value']),
|
||||
newValue: _mapFromJsonNullable(json['new_value']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$AuditLogDetailModelImplToJson(
|
||||
_$AuditLogDetailModelImpl instance,
|
||||
) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'table_name': instance.tableName,
|
||||
'record_id': instance.recordId,
|
||||
'action': instance.action,
|
||||
'performed_at': instance.performedAt?.toIso8601String(),
|
||||
'performed_by': instance.performedBy,
|
||||
'request_id': instance.requestId,
|
||||
'performed_by_user': instance.performedByUser,
|
||||
'has_old_value': instance.hasOldValue,
|
||||
'has_new_value': instance.hasNewValue,
|
||||
'old_value': instance.oldValue,
|
||||
'new_value': instance.newValue,
|
||||
};
|
||||
@ -87,6 +87,36 @@ Object? _readUomName(Map<dynamic, dynamic> json, String key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Object? _readGrnItemCategoryId(Map<dynamic, dynamic> json, String key) {
|
||||
for (final flatKey in ['item_category_id', 'asset_category_id']) {
|
||||
final flat = json[flatKey];
|
||||
if (flat != null) return flat;
|
||||
}
|
||||
final nested = json['item_category'] ?? json['asset_category'];
|
||||
if (nested is Map) return nested['id'];
|
||||
return null;
|
||||
}
|
||||
|
||||
Object? _readGrnItemSubcategoryId(Map<dynamic, dynamic> json, String key) {
|
||||
for (final flatKey in ['item_subcategory_id', 'asset_subcategory_id']) {
|
||||
final flat = json[flatKey];
|
||||
if (flat != null) return flat;
|
||||
}
|
||||
final nested = json['item_subcategory'] ?? json['asset_subcategory'];
|
||||
if (nested is Map) return nested['id'];
|
||||
return null;
|
||||
}
|
||||
|
||||
Object? _readUploadedByName(Map<dynamic, dynamic> json, String key) {
|
||||
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) {
|
||||
return nested['full_name'] ?? nested['name'];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class GrnModel with _$GrnModel {
|
||||
const GrnModel._();
|
||||
@ -118,6 +148,7 @@ class GrnModel with _$GrnModel {
|
||||
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable) DateTime? createdAt,
|
||||
@JsonKey(name: 'updated_at', fromJson: _dateFromJsonNullable) DateTime? updatedAt,
|
||||
@Default([]) List<GrnItemModel> items,
|
||||
@Default([]) List<GrnAttachmentModel> attachments,
|
||||
}) = _GrnModel;
|
||||
|
||||
factory GrnModel.fromJson(Map<String, dynamic> json) => _$GrnModelFromJson(json);
|
||||
@ -125,6 +156,42 @@ class GrnModel with _$GrnModel {
|
||||
bool get canEdit => status.toUpperCase() == 'POSTED';
|
||||
|
||||
bool get canCancel => status.toUpperCase() == 'POSTED';
|
||||
|
||||
/// Upload/delete attachments only while GRN is POSTED.
|
||||
bool get canManageAttachments => status.toUpperCase() == 'POSTED';
|
||||
}
|
||||
|
||||
@freezed
|
||||
class GrnAttachmentModel with _$GrnAttachmentModel {
|
||||
const GrnAttachmentModel._();
|
||||
|
||||
const factory GrnAttachmentModel({
|
||||
@JsonKey(fromJson: _idFromJson) required String id,
|
||||
@JsonKey(name: 'grn_id', fromJson: _idFromJson) String? grnId,
|
||||
@JsonKey(name: 'file_name') String? fileName,
|
||||
@JsonKey(name: 'file_type') String? fileType,
|
||||
@JsonKey(name: 'file_size', fromJson: _intFromJsonNullable) int? fileSize,
|
||||
@JsonKey(name: 'uploaded_by_name', readValue: _readUploadedByName)
|
||||
String? uploadedByName,
|
||||
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable) DateTime? createdAt,
|
||||
}) = _GrnAttachmentModel;
|
||||
|
||||
factory GrnAttachmentModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$GrnAttachmentModelFromJson(json);
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
@ -149,10 +216,18 @@ class GrnItemModel with _$GrnItemModel {
|
||||
@JsonKey(name: 'mfg_date', fromJson: _dateFromJsonNullable) DateTime? mfgDate,
|
||||
@JsonKey(name: 'expiry_date', fromJson: _dateFromJsonNullable) DateTime? expiryDate,
|
||||
@JsonKey(name: 'storage_location') String? storageLocation,
|
||||
@JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable)
|
||||
int? assetCategoryId,
|
||||
@JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable)
|
||||
int? assetSubcategoryId,
|
||||
@JsonKey(
|
||||
name: 'item_category_id',
|
||||
readValue: _readGrnItemCategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
int? itemCategoryId,
|
||||
@JsonKey(
|
||||
name: 'item_subcategory_id',
|
||||
readValue: _readGrnItemSubcategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
int? itemSubcategoryId,
|
||||
@JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable) int? uomId,
|
||||
@JsonKey(name: 'uom_name', readValue: _readUomName) String? uomName,
|
||||
String? remarks,
|
||||
|
||||
@ -64,6 +64,8 @@ mixin _$GrnModel {
|
||||
@JsonKey(name: 'updated_at', fromJson: _dateFromJsonNullable)
|
||||
DateTime? get updatedAt => throw _privateConstructorUsedError;
|
||||
List<GrnItemModel> get items => throw _privateConstructorUsedError;
|
||||
List<GrnAttachmentModel> get attachments =>
|
||||
throw _privateConstructorUsedError;
|
||||
|
||||
/// Serializes this GrnModel to a JSON map.
|
||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||
@ -114,6 +116,7 @@ abstract class $GrnModelCopyWith<$Res> {
|
||||
@JsonKey(name: 'updated_at', fromJson: _dateFromJsonNullable)
|
||||
DateTime? updatedAt,
|
||||
List<GrnItemModel> items,
|
||||
List<GrnAttachmentModel> attachments,
|
||||
});
|
||||
}
|
||||
|
||||
@ -155,6 +158,7 @@ class _$GrnModelCopyWithImpl<$Res, $Val extends GrnModel>
|
||||
Object? createdAt = freezed,
|
||||
Object? updatedAt = freezed,
|
||||
Object? items = null,
|
||||
Object? attachments = null,
|
||||
}) {
|
||||
return _then(
|
||||
_value.copyWith(
|
||||
@ -250,6 +254,10 @@ class _$GrnModelCopyWithImpl<$Res, $Val extends GrnModel>
|
||||
? _value.items
|
||||
: items // ignore: cast_nullable_to_non_nullable
|
||||
as List<GrnItemModel>,
|
||||
attachments: null == attachments
|
||||
? _value.attachments
|
||||
: attachments // ignore: cast_nullable_to_non_nullable
|
||||
as List<GrnAttachmentModel>,
|
||||
)
|
||||
as $Val,
|
||||
);
|
||||
@ -299,6 +307,7 @@ abstract class _$$GrnModelImplCopyWith<$Res>
|
||||
@JsonKey(name: 'updated_at', fromJson: _dateFromJsonNullable)
|
||||
DateTime? updatedAt,
|
||||
List<GrnItemModel> items,
|
||||
List<GrnAttachmentModel> attachments,
|
||||
});
|
||||
}
|
||||
|
||||
@ -339,6 +348,7 @@ class __$$GrnModelImplCopyWithImpl<$Res>
|
||||
Object? createdAt = freezed,
|
||||
Object? updatedAt = freezed,
|
||||
Object? items = null,
|
||||
Object? attachments = null,
|
||||
}) {
|
||||
return _then(
|
||||
_$GrnModelImpl(
|
||||
@ -434,6 +444,10 @@ class __$$GrnModelImplCopyWithImpl<$Res>
|
||||
? _value._items
|
||||
: items // ignore: cast_nullable_to_non_nullable
|
||||
as List<GrnItemModel>,
|
||||
attachments: null == attachments
|
||||
? _value._attachments
|
||||
: attachments // ignore: cast_nullable_to_non_nullable
|
||||
as List<GrnAttachmentModel>,
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -474,7 +488,9 @@ class _$GrnModelImpl extends _GrnModel {
|
||||
@JsonKey(name: 'updated_at', fromJson: _dateFromJsonNullable)
|
||||
this.updatedAt,
|
||||
final List<GrnItemModel> items = const [],
|
||||
final List<GrnAttachmentModel> attachments = const [],
|
||||
}) : _items = items,
|
||||
_attachments = attachments,
|
||||
super._();
|
||||
|
||||
factory _$GrnModelImpl.fromJson(Map<String, dynamic> json) =>
|
||||
@ -554,9 +570,18 @@ class _$GrnModelImpl extends _GrnModel {
|
||||
return EqualUnmodifiableListView(_items);
|
||||
}
|
||||
|
||||
final List<GrnAttachmentModel> _attachments;
|
||||
@override
|
||||
@JsonKey()
|
||||
List<GrnAttachmentModel> get attachments {
|
||||
if (_attachments is EqualUnmodifiableListView) return _attachments;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_attachments);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'GrnModel(id: $id, grnNumber: $grnNumber, grnDate: $grnDate, status: $status, poId: $poId, poNumber: $poNumber, vendorId: $vendorId, vendorName: $vendorName, warehouseId: $warehouseId, warehouseName: $warehouseName, vendorInvoiceNo: $vendorInvoiceNo, vendorInvoiceDate: $vendorInvoiceDate, vendorInvoiceAmount: $vendorInvoiceAmount, vehicleNo: $vehicleNo, lrNo: $lrNo, lrDate: $lrDate, receivedBy: $receivedBy, qualityCheckedBy: $qualityCheckedBy, remarks: $remarks, cancellationReason: $cancellationReason, createdAt: $createdAt, updatedAt: $updatedAt, items: $items)';
|
||||
return 'GrnModel(id: $id, grnNumber: $grnNumber, grnDate: $grnDate, status: $status, poId: $poId, poNumber: $poNumber, vendorId: $vendorId, vendorName: $vendorName, warehouseId: $warehouseId, warehouseName: $warehouseName, vendorInvoiceNo: $vendorInvoiceNo, vendorInvoiceDate: $vendorInvoiceDate, vendorInvoiceAmount: $vendorInvoiceAmount, vehicleNo: $vehicleNo, lrNo: $lrNo, lrDate: $lrDate, receivedBy: $receivedBy, qualityCheckedBy: $qualityCheckedBy, remarks: $remarks, cancellationReason: $cancellationReason, createdAt: $createdAt, updatedAt: $updatedAt, items: $items, attachments: $attachments)';
|
||||
}
|
||||
|
||||
@override
|
||||
@ -601,7 +626,11 @@ class _$GrnModelImpl extends _GrnModel {
|
||||
other.createdAt == createdAt) &&
|
||||
(identical(other.updatedAt, updatedAt) ||
|
||||
other.updatedAt == updatedAt) &&
|
||||
const DeepCollectionEquality().equals(other._items, _items));
|
||||
const DeepCollectionEquality().equals(other._items, _items) &&
|
||||
const DeepCollectionEquality().equals(
|
||||
other._attachments,
|
||||
_attachments,
|
||||
));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@ -631,6 +660,7 @@ class _$GrnModelImpl extends _GrnModel {
|
||||
createdAt,
|
||||
updatedAt,
|
||||
const DeepCollectionEquality().hash(_items),
|
||||
const DeepCollectionEquality().hash(_attachments),
|
||||
]);
|
||||
|
||||
/// Create a copy of GrnModel
|
||||
@ -686,6 +716,7 @@ abstract class _GrnModel extends GrnModel {
|
||||
@JsonKey(name: 'updated_at', fromJson: _dateFromJsonNullable)
|
||||
final DateTime? updatedAt,
|
||||
final List<GrnItemModel> items,
|
||||
final List<GrnAttachmentModel> attachments,
|
||||
}) = _$GrnModelImpl;
|
||||
const _GrnModel._() : super._();
|
||||
|
||||
@ -758,6 +789,8 @@ abstract class _GrnModel extends GrnModel {
|
||||
DateTime? get updatedAt;
|
||||
@override
|
||||
List<GrnItemModel> get items;
|
||||
@override
|
||||
List<GrnAttachmentModel> get attachments;
|
||||
|
||||
/// Create a copy of GrnModel
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@ -767,6 +800,337 @@ abstract class _GrnModel extends GrnModel {
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
GrnAttachmentModel _$GrnAttachmentModelFromJson(Map<String, dynamic> json) {
|
||||
return _GrnAttachmentModel.fromJson(json);
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
mixin _$GrnAttachmentModel {
|
||||
@JsonKey(fromJson: _idFromJson)
|
||||
String get id => throw _privateConstructorUsedError;
|
||||
@JsonKey(name: 'grn_id', fromJson: _idFromJson)
|
||||
String? get grnId => throw _privateConstructorUsedError;
|
||||
@JsonKey(name: 'file_name')
|
||||
String? get fileName => throw _privateConstructorUsedError;
|
||||
@JsonKey(name: 'file_type')
|
||||
String? get fileType => throw _privateConstructorUsedError;
|
||||
@JsonKey(name: 'file_size', fromJson: _intFromJsonNullable)
|
||||
int? get fileSize => throw _privateConstructorUsedError;
|
||||
@JsonKey(name: 'uploaded_by_name', readValue: _readUploadedByName)
|
||||
String? get uploadedByName => throw _privateConstructorUsedError;
|
||||
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable)
|
||||
DateTime? get createdAt => throw _privateConstructorUsedError;
|
||||
|
||||
/// Serializes this GrnAttachmentModel to a JSON map.
|
||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||
|
||||
/// Create a copy of GrnAttachmentModel
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
$GrnAttachmentModelCopyWith<GrnAttachmentModel> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $GrnAttachmentModelCopyWith<$Res> {
|
||||
factory $GrnAttachmentModelCopyWith(
|
||||
GrnAttachmentModel value,
|
||||
$Res Function(GrnAttachmentModel) then,
|
||||
) = _$GrnAttachmentModelCopyWithImpl<$Res, GrnAttachmentModel>;
|
||||
@useResult
|
||||
$Res call({
|
||||
@JsonKey(fromJson: _idFromJson) String id,
|
||||
@JsonKey(name: 'grn_id', fromJson: _idFromJson) String? grnId,
|
||||
@JsonKey(name: 'file_name') String? fileName,
|
||||
@JsonKey(name: 'file_type') String? fileType,
|
||||
@JsonKey(name: 'file_size', fromJson: _intFromJsonNullable) int? fileSize,
|
||||
@JsonKey(name: 'uploaded_by_name', readValue: _readUploadedByName)
|
||||
String? uploadedByName,
|
||||
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable)
|
||||
DateTime? createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$GrnAttachmentModelCopyWithImpl<$Res, $Val extends GrnAttachmentModel>
|
||||
implements $GrnAttachmentModelCopyWith<$Res> {
|
||||
_$GrnAttachmentModelCopyWithImpl(this._value, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
/// Create a copy of GrnAttachmentModel
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? grnId = freezed,
|
||||
Object? fileName = freezed,
|
||||
Object? fileType = freezed,
|
||||
Object? fileSize = freezed,
|
||||
Object? uploadedByName = freezed,
|
||||
Object? createdAt = freezed,
|
||||
}) {
|
||||
return _then(
|
||||
_value.copyWith(
|
||||
id: null == id
|
||||
? _value.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
grnId: freezed == grnId
|
||||
? _value.grnId
|
||||
: grnId // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
fileName: freezed == fileName
|
||||
? _value.fileName
|
||||
: fileName // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
fileType: freezed == fileType
|
||||
? _value.fileType
|
||||
: fileType // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
fileSize: freezed == fileSize
|
||||
? _value.fileSize
|
||||
: fileSize // ignore: cast_nullable_to_non_nullable
|
||||
as int?,
|
||||
uploadedByName: freezed == uploadedByName
|
||||
? _value.uploadedByName
|
||||
: uploadedByName // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
createdAt: freezed == createdAt
|
||||
? _value.createdAt
|
||||
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,
|
||||
)
|
||||
as $Val,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$GrnAttachmentModelImplCopyWith<$Res>
|
||||
implements $GrnAttachmentModelCopyWith<$Res> {
|
||||
factory _$$GrnAttachmentModelImplCopyWith(
|
||||
_$GrnAttachmentModelImpl value,
|
||||
$Res Function(_$GrnAttachmentModelImpl) then,
|
||||
) = __$$GrnAttachmentModelImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call({
|
||||
@JsonKey(fromJson: _idFromJson) String id,
|
||||
@JsonKey(name: 'grn_id', fromJson: _idFromJson) String? grnId,
|
||||
@JsonKey(name: 'file_name') String? fileName,
|
||||
@JsonKey(name: 'file_type') String? fileType,
|
||||
@JsonKey(name: 'file_size', fromJson: _intFromJsonNullable) int? fileSize,
|
||||
@JsonKey(name: 'uploaded_by_name', readValue: _readUploadedByName)
|
||||
String? uploadedByName,
|
||||
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable)
|
||||
DateTime? createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$GrnAttachmentModelImplCopyWithImpl<$Res>
|
||||
extends _$GrnAttachmentModelCopyWithImpl<$Res, _$GrnAttachmentModelImpl>
|
||||
implements _$$GrnAttachmentModelImplCopyWith<$Res> {
|
||||
__$$GrnAttachmentModelImplCopyWithImpl(
|
||||
_$GrnAttachmentModelImpl _value,
|
||||
$Res Function(_$GrnAttachmentModelImpl) _then,
|
||||
) : super(_value, _then);
|
||||
|
||||
/// Create a copy of GrnAttachmentModel
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? grnId = freezed,
|
||||
Object? fileName = freezed,
|
||||
Object? fileType = freezed,
|
||||
Object? fileSize = freezed,
|
||||
Object? uploadedByName = freezed,
|
||||
Object? createdAt = freezed,
|
||||
}) {
|
||||
return _then(
|
||||
_$GrnAttachmentModelImpl(
|
||||
id: null == id
|
||||
? _value.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
grnId: freezed == grnId
|
||||
? _value.grnId
|
||||
: grnId // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
fileName: freezed == fileName
|
||||
? _value.fileName
|
||||
: fileName // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
fileType: freezed == fileType
|
||||
? _value.fileType
|
||||
: fileType // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
fileSize: freezed == fileSize
|
||||
? _value.fileSize
|
||||
: fileSize // ignore: cast_nullable_to_non_nullable
|
||||
as int?,
|
||||
uploadedByName: freezed == uploadedByName
|
||||
? _value.uploadedByName
|
||||
: uploadedByName // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
createdAt: freezed == createdAt
|
||||
? _value.createdAt
|
||||
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
class _$GrnAttachmentModelImpl extends _GrnAttachmentModel {
|
||||
const _$GrnAttachmentModelImpl({
|
||||
@JsonKey(fromJson: _idFromJson) required this.id,
|
||||
@JsonKey(name: 'grn_id', fromJson: _idFromJson) this.grnId,
|
||||
@JsonKey(name: 'file_name') this.fileName,
|
||||
@JsonKey(name: 'file_type') this.fileType,
|
||||
@JsonKey(name: 'file_size', fromJson: _intFromJsonNullable) this.fileSize,
|
||||
@JsonKey(name: 'uploaded_by_name', readValue: _readUploadedByName)
|
||||
this.uploadedByName,
|
||||
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable)
|
||||
this.createdAt,
|
||||
}) : super._();
|
||||
|
||||
factory _$GrnAttachmentModelImpl.fromJson(Map<String, dynamic> json) =>
|
||||
_$$GrnAttachmentModelImplFromJson(json);
|
||||
|
||||
@override
|
||||
@JsonKey(fromJson: _idFromJson)
|
||||
final String id;
|
||||
@override
|
||||
@JsonKey(name: 'grn_id', fromJson: _idFromJson)
|
||||
final String? grnId;
|
||||
@override
|
||||
@JsonKey(name: 'file_name')
|
||||
final String? fileName;
|
||||
@override
|
||||
@JsonKey(name: 'file_type')
|
||||
final String? fileType;
|
||||
@override
|
||||
@JsonKey(name: 'file_size', fromJson: _intFromJsonNullable)
|
||||
final int? fileSize;
|
||||
@override
|
||||
@JsonKey(name: 'uploaded_by_name', readValue: _readUploadedByName)
|
||||
final String? uploadedByName;
|
||||
@override
|
||||
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable)
|
||||
final DateTime? createdAt;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'GrnAttachmentModel(id: $id, grnId: $grnId, fileName: $fileName, fileType: $fileType, fileSize: $fileSize, uploadedByName: $uploadedByName, createdAt: $createdAt)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$GrnAttachmentModelImpl &&
|
||||
(identical(other.id, id) || other.id == id) &&
|
||||
(identical(other.grnId, grnId) || other.grnId == grnId) &&
|
||||
(identical(other.fileName, fileName) ||
|
||||
other.fileName == fileName) &&
|
||||
(identical(other.fileType, fileType) ||
|
||||
other.fileType == fileType) &&
|
||||
(identical(other.fileSize, fileSize) ||
|
||||
other.fileSize == fileSize) &&
|
||||
(identical(other.uploadedByName, uploadedByName) ||
|
||||
other.uploadedByName == uploadedByName) &&
|
||||
(identical(other.createdAt, createdAt) ||
|
||||
other.createdAt == createdAt));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
runtimeType,
|
||||
id,
|
||||
grnId,
|
||||
fileName,
|
||||
fileType,
|
||||
fileSize,
|
||||
uploadedByName,
|
||||
createdAt,
|
||||
);
|
||||
|
||||
/// Create a copy of GrnAttachmentModel
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$GrnAttachmentModelImplCopyWith<_$GrnAttachmentModelImpl> get copyWith =>
|
||||
__$$GrnAttachmentModelImplCopyWithImpl<_$GrnAttachmentModelImpl>(
|
||||
this,
|
||||
_$identity,
|
||||
);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$$GrnAttachmentModelImplToJson(this);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _GrnAttachmentModel extends GrnAttachmentModel {
|
||||
const factory _GrnAttachmentModel({
|
||||
@JsonKey(fromJson: _idFromJson) required final String id,
|
||||
@JsonKey(name: 'grn_id', fromJson: _idFromJson) final String? grnId,
|
||||
@JsonKey(name: 'file_name') final String? fileName,
|
||||
@JsonKey(name: 'file_type') final String? fileType,
|
||||
@JsonKey(name: 'file_size', fromJson: _intFromJsonNullable)
|
||||
final int? fileSize,
|
||||
@JsonKey(name: 'uploaded_by_name', readValue: _readUploadedByName)
|
||||
final String? uploadedByName,
|
||||
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable)
|
||||
final DateTime? createdAt,
|
||||
}) = _$GrnAttachmentModelImpl;
|
||||
const _GrnAttachmentModel._() : super._();
|
||||
|
||||
factory _GrnAttachmentModel.fromJson(Map<String, dynamic> json) =
|
||||
_$GrnAttachmentModelImpl.fromJson;
|
||||
|
||||
@override
|
||||
@JsonKey(fromJson: _idFromJson)
|
||||
String get id;
|
||||
@override
|
||||
@JsonKey(name: 'grn_id', fromJson: _idFromJson)
|
||||
String? get grnId;
|
||||
@override
|
||||
@JsonKey(name: 'file_name')
|
||||
String? get fileName;
|
||||
@override
|
||||
@JsonKey(name: 'file_type')
|
||||
String? get fileType;
|
||||
@override
|
||||
@JsonKey(name: 'file_size', fromJson: _intFromJsonNullable)
|
||||
int? get fileSize;
|
||||
@override
|
||||
@JsonKey(name: 'uploaded_by_name', readValue: _readUploadedByName)
|
||||
String? get uploadedByName;
|
||||
@override
|
||||
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable)
|
||||
DateTime? get createdAt;
|
||||
|
||||
/// Create a copy of GrnAttachmentModel
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
_$$GrnAttachmentModelImplCopyWith<_$GrnAttachmentModelImpl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
GrnItemModel _$GrnItemModelFromJson(Map<String, dynamic> json) {
|
||||
return _GrnItemModel.fromJson(json);
|
||||
}
|
||||
@ -811,10 +1175,18 @@ mixin _$GrnItemModel {
|
||||
DateTime? get expiryDate => throw _privateConstructorUsedError;
|
||||
@JsonKey(name: 'storage_location')
|
||||
String? get storageLocation => throw _privateConstructorUsedError;
|
||||
@JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable)
|
||||
int? get assetCategoryId => throw _privateConstructorUsedError;
|
||||
@JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable)
|
||||
int? get assetSubcategoryId => throw _privateConstructorUsedError;
|
||||
@JsonKey(
|
||||
name: 'item_category_id',
|
||||
readValue: _readGrnItemCategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
int? get itemCategoryId => throw _privateConstructorUsedError;
|
||||
@JsonKey(
|
||||
name: 'item_subcategory_id',
|
||||
readValue: _readGrnItemSubcategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
int? get itemSubcategoryId => throw _privateConstructorUsedError;
|
||||
@JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable)
|
||||
int? get uomId => throw _privateConstructorUsedError;
|
||||
@JsonKey(name: 'uom_name', readValue: _readUomName)
|
||||
@ -866,10 +1238,18 @@ abstract class $GrnItemModelCopyWith<$Res> {
|
||||
@JsonKey(name: 'expiry_date', fromJson: _dateFromJsonNullable)
|
||||
DateTime? expiryDate,
|
||||
@JsonKey(name: 'storage_location') String? storageLocation,
|
||||
@JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable)
|
||||
int? assetCategoryId,
|
||||
@JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable)
|
||||
int? assetSubcategoryId,
|
||||
@JsonKey(
|
||||
name: 'item_category_id',
|
||||
readValue: _readGrnItemCategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
int? itemCategoryId,
|
||||
@JsonKey(
|
||||
name: 'item_subcategory_id',
|
||||
readValue: _readGrnItemSubcategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
int? itemSubcategoryId,
|
||||
@JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable) int? uomId,
|
||||
@JsonKey(name: 'uom_name', readValue: _readUomName) String? uomName,
|
||||
String? remarks,
|
||||
@ -910,8 +1290,8 @@ class _$GrnItemModelCopyWithImpl<$Res, $Val extends GrnItemModel>
|
||||
Object? mfgDate = freezed,
|
||||
Object? expiryDate = freezed,
|
||||
Object? storageLocation = freezed,
|
||||
Object? assetCategoryId = freezed,
|
||||
Object? assetSubcategoryId = freezed,
|
||||
Object? itemCategoryId = freezed,
|
||||
Object? itemSubcategoryId = freezed,
|
||||
Object? uomId = freezed,
|
||||
Object? uomName = freezed,
|
||||
Object? remarks = freezed,
|
||||
@ -994,13 +1374,13 @@ class _$GrnItemModelCopyWithImpl<$Res, $Val extends GrnItemModel>
|
||||
? _value.storageLocation
|
||||
: storageLocation // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
assetCategoryId: freezed == assetCategoryId
|
||||
? _value.assetCategoryId
|
||||
: assetCategoryId // ignore: cast_nullable_to_non_nullable
|
||||
itemCategoryId: freezed == itemCategoryId
|
||||
? _value.itemCategoryId
|
||||
: itemCategoryId // ignore: cast_nullable_to_non_nullable
|
||||
as int?,
|
||||
assetSubcategoryId: freezed == assetSubcategoryId
|
||||
? _value.assetSubcategoryId
|
||||
: assetSubcategoryId // ignore: cast_nullable_to_non_nullable
|
||||
itemSubcategoryId: freezed == itemSubcategoryId
|
||||
? _value.itemSubcategoryId
|
||||
: itemSubcategoryId // ignore: cast_nullable_to_non_nullable
|
||||
as int?,
|
||||
uomId: freezed == uomId
|
||||
? _value.uomId
|
||||
@ -1057,10 +1437,18 @@ abstract class _$$GrnItemModelImplCopyWith<$Res>
|
||||
@JsonKey(name: 'expiry_date', fromJson: _dateFromJsonNullable)
|
||||
DateTime? expiryDate,
|
||||
@JsonKey(name: 'storage_location') String? storageLocation,
|
||||
@JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable)
|
||||
int? assetCategoryId,
|
||||
@JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable)
|
||||
int? assetSubcategoryId,
|
||||
@JsonKey(
|
||||
name: 'item_category_id',
|
||||
readValue: _readGrnItemCategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
int? itemCategoryId,
|
||||
@JsonKey(
|
||||
name: 'item_subcategory_id',
|
||||
readValue: _readGrnItemSubcategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
int? itemSubcategoryId,
|
||||
@JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable) int? uomId,
|
||||
@JsonKey(name: 'uom_name', readValue: _readUomName) String? uomName,
|
||||
String? remarks,
|
||||
@ -1100,8 +1488,8 @@ class __$$GrnItemModelImplCopyWithImpl<$Res>
|
||||
Object? mfgDate = freezed,
|
||||
Object? expiryDate = freezed,
|
||||
Object? storageLocation = freezed,
|
||||
Object? assetCategoryId = freezed,
|
||||
Object? assetSubcategoryId = freezed,
|
||||
Object? itemCategoryId = freezed,
|
||||
Object? itemSubcategoryId = freezed,
|
||||
Object? uomId = freezed,
|
||||
Object? uomName = freezed,
|
||||
Object? remarks = freezed,
|
||||
@ -1184,13 +1572,13 @@ class __$$GrnItemModelImplCopyWithImpl<$Res>
|
||||
? _value.storageLocation
|
||||
: storageLocation // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
assetCategoryId: freezed == assetCategoryId
|
||||
? _value.assetCategoryId
|
||||
: assetCategoryId // ignore: cast_nullable_to_non_nullable
|
||||
itemCategoryId: freezed == itemCategoryId
|
||||
? _value.itemCategoryId
|
||||
: itemCategoryId // ignore: cast_nullable_to_non_nullable
|
||||
as int?,
|
||||
assetSubcategoryId: freezed == assetSubcategoryId
|
||||
? _value.assetSubcategoryId
|
||||
: assetSubcategoryId // ignore: cast_nullable_to_non_nullable
|
||||
itemSubcategoryId: freezed == itemSubcategoryId
|
||||
? _value.itemSubcategoryId
|
||||
: itemSubcategoryId // ignore: cast_nullable_to_non_nullable
|
||||
as int?,
|
||||
uomId: freezed == uomId
|
||||
? _value.uomId
|
||||
@ -1239,10 +1627,18 @@ class _$GrnItemModelImpl implements _GrnItemModel {
|
||||
@JsonKey(name: 'expiry_date', fromJson: _dateFromJsonNullable)
|
||||
this.expiryDate,
|
||||
@JsonKey(name: 'storage_location') this.storageLocation,
|
||||
@JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable)
|
||||
this.assetCategoryId,
|
||||
@JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable)
|
||||
this.assetSubcategoryId,
|
||||
@JsonKey(
|
||||
name: 'item_category_id',
|
||||
readValue: _readGrnItemCategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
this.itemCategoryId,
|
||||
@JsonKey(
|
||||
name: 'item_subcategory_id',
|
||||
readValue: _readGrnItemSubcategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
this.itemSubcategoryId,
|
||||
@JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable) this.uomId,
|
||||
@JsonKey(name: 'uom_name', readValue: _readUomName) this.uomName,
|
||||
this.remarks,
|
||||
@ -1309,11 +1705,19 @@ class _$GrnItemModelImpl implements _GrnItemModel {
|
||||
@JsonKey(name: 'storage_location')
|
||||
final String? storageLocation;
|
||||
@override
|
||||
@JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable)
|
||||
final int? assetCategoryId;
|
||||
@JsonKey(
|
||||
name: 'item_category_id',
|
||||
readValue: _readGrnItemCategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
final int? itemCategoryId;
|
||||
@override
|
||||
@JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable)
|
||||
final int? assetSubcategoryId;
|
||||
@JsonKey(
|
||||
name: 'item_subcategory_id',
|
||||
readValue: _readGrnItemSubcategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
final int? itemSubcategoryId;
|
||||
@override
|
||||
@JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable)
|
||||
final int? uomId;
|
||||
@ -1325,7 +1729,7 @@ class _$GrnItemModelImpl implements _GrnItemModel {
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'GrnItemModel(id: $id, grnId: $grnId, poItemId: $poItemId, itemId: $itemId, itemCode: $itemCode, itemName: $itemName, lineNo: $lineNo, currentQty: $currentQty, acceptedQty: $acceptedQty, damagedQty: $damagedQty, shortQty: $shortQty, excessQty: $excessQty, rejectedQty: $rejectedQty, rejectionReason: $rejectionReason, rate: $rate, batchNo: $batchNo, mfgDate: $mfgDate, expiryDate: $expiryDate, storageLocation: $storageLocation, assetCategoryId: $assetCategoryId, assetSubcategoryId: $assetSubcategoryId, uomId: $uomId, uomName: $uomName, remarks: $remarks)';
|
||||
return 'GrnItemModel(id: $id, grnId: $grnId, poItemId: $poItemId, itemId: $itemId, itemCode: $itemCode, itemName: $itemName, lineNo: $lineNo, currentQty: $currentQty, acceptedQty: $acceptedQty, damagedQty: $damagedQty, shortQty: $shortQty, excessQty: $excessQty, rejectedQty: $rejectedQty, rejectionReason: $rejectionReason, rate: $rate, batchNo: $batchNo, mfgDate: $mfgDate, expiryDate: $expiryDate, storageLocation: $storageLocation, itemCategoryId: $itemCategoryId, itemSubcategoryId: $itemSubcategoryId, uomId: $uomId, uomName: $uomName, remarks: $remarks)';
|
||||
}
|
||||
|
||||
@override
|
||||
@ -1364,10 +1768,10 @@ class _$GrnItemModelImpl implements _GrnItemModel {
|
||||
other.expiryDate == expiryDate) &&
|
||||
(identical(other.storageLocation, storageLocation) ||
|
||||
other.storageLocation == storageLocation) &&
|
||||
(identical(other.assetCategoryId, assetCategoryId) ||
|
||||
other.assetCategoryId == assetCategoryId) &&
|
||||
(identical(other.assetSubcategoryId, assetSubcategoryId) ||
|
||||
other.assetSubcategoryId == assetSubcategoryId) &&
|
||||
(identical(other.itemCategoryId, itemCategoryId) ||
|
||||
other.itemCategoryId == itemCategoryId) &&
|
||||
(identical(other.itemSubcategoryId, itemSubcategoryId) ||
|
||||
other.itemSubcategoryId == itemSubcategoryId) &&
|
||||
(identical(other.uomId, uomId) || other.uomId == uomId) &&
|
||||
(identical(other.uomName, uomName) || other.uomName == uomName) &&
|
||||
(identical(other.remarks, remarks) || other.remarks == remarks));
|
||||
@ -1396,8 +1800,8 @@ class _$GrnItemModelImpl implements _GrnItemModel {
|
||||
mfgDate,
|
||||
expiryDate,
|
||||
storageLocation,
|
||||
assetCategoryId,
|
||||
assetSubcategoryId,
|
||||
itemCategoryId,
|
||||
itemSubcategoryId,
|
||||
uomId,
|
||||
uomName,
|
||||
remarks,
|
||||
@ -1449,10 +1853,18 @@ abstract class _GrnItemModel implements GrnItemModel {
|
||||
@JsonKey(name: 'expiry_date', fromJson: _dateFromJsonNullable)
|
||||
final DateTime? expiryDate,
|
||||
@JsonKey(name: 'storage_location') final String? storageLocation,
|
||||
@JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable)
|
||||
final int? assetCategoryId,
|
||||
@JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable)
|
||||
final int? assetSubcategoryId,
|
||||
@JsonKey(
|
||||
name: 'item_category_id',
|
||||
readValue: _readGrnItemCategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
final int? itemCategoryId,
|
||||
@JsonKey(
|
||||
name: 'item_subcategory_id',
|
||||
readValue: _readGrnItemSubcategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
final int? itemSubcategoryId,
|
||||
@JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable) final int? uomId,
|
||||
@JsonKey(name: 'uom_name', readValue: _readUomName) final String? uomName,
|
||||
final String? remarks,
|
||||
@ -1519,11 +1931,19 @@ abstract class _GrnItemModel implements GrnItemModel {
|
||||
@JsonKey(name: 'storage_location')
|
||||
String? get storageLocation;
|
||||
@override
|
||||
@JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable)
|
||||
int? get assetCategoryId;
|
||||
@JsonKey(
|
||||
name: 'item_category_id',
|
||||
readValue: _readGrnItemCategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
int? get itemCategoryId;
|
||||
@override
|
||||
@JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable)
|
||||
int? get assetSubcategoryId;
|
||||
@JsonKey(
|
||||
name: 'item_subcategory_id',
|
||||
readValue: _readGrnItemSubcategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
int? get itemSubcategoryId;
|
||||
@override
|
||||
@JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable)
|
||||
int? get uomId;
|
||||
|
||||
@ -6,38 +6,42 @@ part of 'grn_model.dart';
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_$GrnModelImpl _$$GrnModelImplFromJson(Map<String, dynamic> json) =>
|
||||
_$GrnModelImpl(
|
||||
id: _idFromJson(json['id']),
|
||||
grnNumber: _readPoNumber(json, 'grn_number') as String?,
|
||||
grnDate: _dateFromJsonNullable(json['grn_date']),
|
||||
status: json['status'] as String? ?? 'POSTED',
|
||||
poId: _intFromJsonNullable(json['po_id']),
|
||||
poNumber: _readPoRefNumber(json, 'po_number') as String?,
|
||||
vendorId: _intFromJsonNullable(json['vendor_id']),
|
||||
vendorName: _readVendorName(json, 'vendor_name') as String?,
|
||||
warehouseId: _intFromJsonNullable(json['warehouse_id']),
|
||||
warehouseName: _readWarehouseName(json, 'warehouse_name') as String?,
|
||||
vendorInvoiceNo: json['vendor_invoice_no'] as String?,
|
||||
vendorInvoiceDate: _dateFromJsonNullable(json['vendor_invoice_date']),
|
||||
vendorInvoiceAmount: _doubleFromJsonNullable(
|
||||
json['vendor_invoice_amount'],
|
||||
),
|
||||
vehicleNo: json['vehicle_no'] as String?,
|
||||
lrNo: json['lr_no'] as String?,
|
||||
lrDate: _dateFromJsonNullable(json['lr_date']),
|
||||
receivedBy: _intFromJsonNullable(json['received_by']),
|
||||
qualityCheckedBy: _intFromJsonNullable(json['quality_checked_by']),
|
||||
remarks: json['remarks'] as String?,
|
||||
cancellationReason: json['cancellation_reason'] as String?,
|
||||
createdAt: _dateFromJsonNullable(json['created_at']),
|
||||
updatedAt: _dateFromJsonNullable(json['updated_at']),
|
||||
items:
|
||||
(json['items'] as List<dynamic>?)
|
||||
?.map((e) => GrnItemModel.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const [],
|
||||
);
|
||||
_$GrnModelImpl _$$GrnModelImplFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _$GrnModelImpl(
|
||||
id: _idFromJson(json['id']),
|
||||
grnNumber: _readPoNumber(json, 'grn_number') as String?,
|
||||
grnDate: _dateFromJsonNullable(json['grn_date']),
|
||||
status: json['status'] as String? ?? 'POSTED',
|
||||
poId: _intFromJsonNullable(json['po_id']),
|
||||
poNumber: _readPoRefNumber(json, 'po_number') as String?,
|
||||
vendorId: _intFromJsonNullable(json['vendor_id']),
|
||||
vendorName: _readVendorName(json, 'vendor_name') as String?,
|
||||
warehouseId: _intFromJsonNullable(json['warehouse_id']),
|
||||
warehouseName: _readWarehouseName(json, 'warehouse_name') as String?,
|
||||
vendorInvoiceNo: json['vendor_invoice_no'] as String?,
|
||||
vendorInvoiceDate: _dateFromJsonNullable(json['vendor_invoice_date']),
|
||||
vendorInvoiceAmount: _doubleFromJsonNullable(json['vendor_invoice_amount']),
|
||||
vehicleNo: json['vehicle_no'] as String?,
|
||||
lrNo: json['lr_no'] as String?,
|
||||
lrDate: _dateFromJsonNullable(json['lr_date']),
|
||||
receivedBy: _intFromJsonNullable(json['received_by']),
|
||||
qualityCheckedBy: _intFromJsonNullable(json['quality_checked_by']),
|
||||
remarks: json['remarks'] as String?,
|
||||
cancellationReason: json['cancellation_reason'] as String?,
|
||||
createdAt: _dateFromJsonNullable(json['created_at']),
|
||||
updatedAt: _dateFromJsonNullable(json['updated_at']),
|
||||
items:
|
||||
(json['items'] as List<dynamic>?)
|
||||
?.map((e) => GrnItemModel.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const [],
|
||||
attachments:
|
||||
(json['attachments'] as List<dynamic>?)
|
||||
?.map((e) => GrnAttachmentModel.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const [],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$GrnModelImplToJson(_$GrnModelImpl instance) =>
|
||||
<String, dynamic>{
|
||||
@ -64,8 +68,33 @@ Map<String, dynamic> _$$GrnModelImplToJson(_$GrnModelImpl instance) =>
|
||||
'created_at': instance.createdAt?.toIso8601String(),
|
||||
'updated_at': instance.updatedAt?.toIso8601String(),
|
||||
'items': instance.items,
|
||||
'attachments': instance.attachments,
|
||||
};
|
||||
|
||||
_$GrnAttachmentModelImpl _$$GrnAttachmentModelImplFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _$GrnAttachmentModelImpl(
|
||||
id: _idFromJson(json['id']),
|
||||
grnId: _idFromJson(json['grn_id']),
|
||||
fileName: json['file_name'] as String?,
|
||||
fileType: json['file_type'] as String?,
|
||||
fileSize: _intFromJsonNullable(json['file_size']),
|
||||
uploadedByName: _readUploadedByName(json, 'uploaded_by_name') as String?,
|
||||
createdAt: _dateFromJsonNullable(json['created_at']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$GrnAttachmentModelImplToJson(
|
||||
_$GrnAttachmentModelImpl instance,
|
||||
) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'grn_id': instance.grnId,
|
||||
'file_name': instance.fileName,
|
||||
'file_type': instance.fileType,
|
||||
'file_size': instance.fileSize,
|
||||
'uploaded_by_name': instance.uploadedByName,
|
||||
'created_at': instance.createdAt?.toIso8601String(),
|
||||
};
|
||||
|
||||
_$GrnItemModelImpl _$$GrnItemModelImplFromJson(Map<String, dynamic> json) =>
|
||||
_$GrnItemModelImpl(
|
||||
id: _idFromJson(json['id']),
|
||||
@ -87,8 +116,12 @@ _$GrnItemModelImpl _$$GrnItemModelImplFromJson(Map<String, dynamic> json) =>
|
||||
mfgDate: _dateFromJsonNullable(json['mfg_date']),
|
||||
expiryDate: _dateFromJsonNullable(json['expiry_date']),
|
||||
storageLocation: json['storage_location'] as String?,
|
||||
assetCategoryId: _intFromJsonNullable(json['asset_category_id']),
|
||||
assetSubcategoryId: _intFromJsonNullable(json['asset_subcategory_id']),
|
||||
itemCategoryId: _intFromJsonNullable(
|
||||
_readGrnItemCategoryId(json, 'item_category_id'),
|
||||
),
|
||||
itemSubcategoryId: _intFromJsonNullable(
|
||||
_readGrnItemSubcategoryId(json, 'item_subcategory_id'),
|
||||
),
|
||||
uomId: _intFromJsonNullable(json['uom_id']),
|
||||
uomName: _readUomName(json, 'uom_name') as String?,
|
||||
remarks: json['remarks'] as String?,
|
||||
@ -115,8 +148,8 @@ Map<String, dynamic> _$$GrnItemModelImplToJson(_$GrnItemModelImpl instance) =>
|
||||
'mfg_date': instance.mfgDate?.toIso8601String(),
|
||||
'expiry_date': instance.expiryDate?.toIso8601String(),
|
||||
'storage_location': instance.storageLocation,
|
||||
'asset_category_id': instance.assetCategoryId,
|
||||
'asset_subcategory_id': instance.assetSubcategoryId,
|
||||
'item_category_id': instance.itemCategoryId,
|
||||
'item_subcategory_id': instance.itemSubcategoryId,
|
||||
'uom_id': instance.uomId,
|
||||
'uom_name': instance.uomName,
|
||||
'remarks': instance.remarks,
|
||||
|
||||
@ -48,7 +48,10 @@ Object? _readItemName(Map<dynamic, dynamic> json, String key) {
|
||||
final flat = json['item_name'];
|
||||
if (flat is String && flat.isNotEmpty) return flat;
|
||||
final nested = json['item'];
|
||||
if (nested is Map) return nested['name'];
|
||||
if (nested is Map) {
|
||||
final name = nested['item_name'] ?? nested['name'];
|
||||
if (name is String && name.isNotEmpty) return name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -56,7 +59,10 @@ Object? _readItemCode(Map<dynamic, dynamic> json, String key) {
|
||||
final flat = json['item_code'];
|
||||
if (flat is String && flat.isNotEmpty) return flat;
|
||||
final nested = json['item'];
|
||||
if (nested is Map) return nested['code'];
|
||||
if (nested is Map) {
|
||||
final code = nested['item_code'] ?? nested['code'];
|
||||
if (code is String && code.isNotEmpty) return code;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -64,10 +70,46 @@ Object? _readUomName(Map<dynamic, dynamic> json, String key) {
|
||||
final flat = json['uom_name'];
|
||||
if (flat is String && flat.isNotEmpty) return flat;
|
||||
final nested = json['uom'];
|
||||
if (nested is Map) return nested['name'];
|
||||
if (nested is Map) {
|
||||
final name = nested['name'] ?? nested['uom_name'] ?? nested['code'];
|
||||
if (name is String && name.isNotEmpty) return name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Object? _readHsnCodeId(Map<dynamic, dynamic> json, String key) {
|
||||
final flat = json['hsn_code_id'];
|
||||
if (flat != null) return flat;
|
||||
final nested = json['hsn_code'] ?? json['hsn'];
|
||||
if (nested is Map) return nested['id'] ?? nested['hsn_code_id'];
|
||||
return null;
|
||||
}
|
||||
|
||||
Object? _readHsnCodeName(Map<dynamic, dynamic> json, String key) {
|
||||
final flat = json['hsn_code_name'] ?? json['hsn_code'];
|
||||
if (flat is String && flat.isNotEmpty) return flat;
|
||||
final nested = json['hsn_code'] ?? json['hsn'];
|
||||
if (nested is Map) {
|
||||
final code = nested['code'] ?? nested['hsn_code'] ?? nested['name'];
|
||||
if (code is String && code.isNotEmpty) return code;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Object? _readPoItemNestedInt(Map<dynamic, dynamic> json, String field) {
|
||||
final flat = json[field];
|
||||
if (flat != null) return flat;
|
||||
final nested = json['item'];
|
||||
if (nested is Map) return nested[field];
|
||||
return null;
|
||||
}
|
||||
|
||||
Object? _readPoItemCategoryId(Map<dynamic, dynamic> json, String key) =>
|
||||
_readPoItemNestedInt(json, 'item_category_id');
|
||||
|
||||
Object? _readPoItemSubcategoryId(Map<dynamic, dynamic> json, String key) =>
|
||||
_readPoItemNestedInt(json, 'item_subcategory_id');
|
||||
|
||||
Object? _readPoNumber(Map<dynamic, dynamic> json, String key) {
|
||||
final poNumber = json['po_number'];
|
||||
if (poNumber != null && poNumber.toString().trim().isNotEmpty) {
|
||||
@ -176,9 +218,28 @@ class PurchaseOrderItemModel with _$PurchaseOrderItemModel {
|
||||
@JsonKey(name: 'discount_amount', fromJson: _doubleFromJsonNullable)
|
||||
double? discountAmount,
|
||||
@JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable) int? gstRateId,
|
||||
@JsonKey(name: 'hsn_code_id', fromJson: _intFromJsonNullable) int? hsnCodeId,
|
||||
@JsonKey(
|
||||
name: 'hsn_code_id',
|
||||
readValue: _readHsnCodeId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
int? hsnCodeId,
|
||||
@JsonKey(name: 'hsn_code_name', readValue: _readHsnCodeName)
|
||||
String? hsnCodeName,
|
||||
@JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable)
|
||||
double? lineAmount,
|
||||
@JsonKey(
|
||||
name: 'item_category_id',
|
||||
readValue: _readPoItemCategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
int? itemCategoryId,
|
||||
@JsonKey(
|
||||
name: 'item_subcategory_id',
|
||||
readValue: _readPoItemSubcategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
int? itemSubcategoryId,
|
||||
String? remarks,
|
||||
}) = _PurchaseOrderItemModel;
|
||||
|
||||
|
||||
@ -922,10 +922,28 @@ mixin _$PurchaseOrderItemModel {
|
||||
double? get discountAmount => throw _privateConstructorUsedError;
|
||||
@JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable)
|
||||
int? get gstRateId => throw _privateConstructorUsedError;
|
||||
@JsonKey(name: 'hsn_code_id', fromJson: _intFromJsonNullable)
|
||||
@JsonKey(
|
||||
name: 'hsn_code_id',
|
||||
readValue: _readHsnCodeId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
int? get hsnCodeId => throw _privateConstructorUsedError;
|
||||
@JsonKey(name: 'hsn_code_name', readValue: _readHsnCodeName)
|
||||
String? get hsnCodeName => throw _privateConstructorUsedError;
|
||||
@JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable)
|
||||
double? get lineAmount => throw _privateConstructorUsedError;
|
||||
@JsonKey(
|
||||
name: 'item_category_id',
|
||||
readValue: _readPoItemCategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
int? get itemCategoryId => throw _privateConstructorUsedError;
|
||||
@JsonKey(
|
||||
name: 'item_subcategory_id',
|
||||
readValue: _readPoItemSubcategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
int? get itemSubcategoryId => throw _privateConstructorUsedError;
|
||||
String? get remarks => throw _privateConstructorUsedError;
|
||||
|
||||
/// Serializes this PurchaseOrderItemModel to a JSON map.
|
||||
@ -965,10 +983,28 @@ abstract class $PurchaseOrderItemModelCopyWith<$Res> {
|
||||
double? discountAmount,
|
||||
@JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable)
|
||||
int? gstRateId,
|
||||
@JsonKey(name: 'hsn_code_id', fromJson: _intFromJsonNullable)
|
||||
@JsonKey(
|
||||
name: 'hsn_code_id',
|
||||
readValue: _readHsnCodeId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
int? hsnCodeId,
|
||||
@JsonKey(name: 'hsn_code_name', readValue: _readHsnCodeName)
|
||||
String? hsnCodeName,
|
||||
@JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable)
|
||||
double? lineAmount,
|
||||
@JsonKey(
|
||||
name: 'item_category_id',
|
||||
readValue: _readPoItemCategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
int? itemCategoryId,
|
||||
@JsonKey(
|
||||
name: 'item_subcategory_id',
|
||||
readValue: _readPoItemSubcategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
int? itemSubcategoryId,
|
||||
String? remarks,
|
||||
});
|
||||
}
|
||||
@ -1006,7 +1042,10 @@ class _$PurchaseOrderItemModelCopyWithImpl<
|
||||
Object? discountAmount = freezed,
|
||||
Object? gstRateId = freezed,
|
||||
Object? hsnCodeId = freezed,
|
||||
Object? hsnCodeName = freezed,
|
||||
Object? lineAmount = freezed,
|
||||
Object? itemCategoryId = freezed,
|
||||
Object? itemSubcategoryId = freezed,
|
||||
Object? remarks = freezed,
|
||||
}) {
|
||||
return _then(
|
||||
@ -1071,10 +1110,22 @@ class _$PurchaseOrderItemModelCopyWithImpl<
|
||||
? _value.hsnCodeId
|
||||
: hsnCodeId // ignore: cast_nullable_to_non_nullable
|
||||
as int?,
|
||||
hsnCodeName: freezed == hsnCodeName
|
||||
? _value.hsnCodeName
|
||||
: hsnCodeName // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
lineAmount: freezed == lineAmount
|
||||
? _value.lineAmount
|
||||
: lineAmount // ignore: cast_nullable_to_non_nullable
|
||||
as double?,
|
||||
itemCategoryId: freezed == itemCategoryId
|
||||
? _value.itemCategoryId
|
||||
: itemCategoryId // ignore: cast_nullable_to_non_nullable
|
||||
as int?,
|
||||
itemSubcategoryId: freezed == itemSubcategoryId
|
||||
? _value.itemSubcategoryId
|
||||
: itemSubcategoryId // ignore: cast_nullable_to_non_nullable
|
||||
as int?,
|
||||
remarks: freezed == remarks
|
||||
? _value.remarks
|
||||
: remarks // ignore: cast_nullable_to_non_nullable
|
||||
@ -1114,10 +1165,28 @@ abstract class _$$PurchaseOrderItemModelImplCopyWith<$Res>
|
||||
double? discountAmount,
|
||||
@JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable)
|
||||
int? gstRateId,
|
||||
@JsonKey(name: 'hsn_code_id', fromJson: _intFromJsonNullable)
|
||||
@JsonKey(
|
||||
name: 'hsn_code_id',
|
||||
readValue: _readHsnCodeId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
int? hsnCodeId,
|
||||
@JsonKey(name: 'hsn_code_name', readValue: _readHsnCodeName)
|
||||
String? hsnCodeName,
|
||||
@JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable)
|
||||
double? lineAmount,
|
||||
@JsonKey(
|
||||
name: 'item_category_id',
|
||||
readValue: _readPoItemCategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
int? itemCategoryId,
|
||||
@JsonKey(
|
||||
name: 'item_subcategory_id',
|
||||
readValue: _readPoItemSubcategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
int? itemSubcategoryId,
|
||||
String? remarks,
|
||||
});
|
||||
}
|
||||
@ -1152,7 +1221,10 @@ class __$$PurchaseOrderItemModelImplCopyWithImpl<$Res>
|
||||
Object? discountAmount = freezed,
|
||||
Object? gstRateId = freezed,
|
||||
Object? hsnCodeId = freezed,
|
||||
Object? hsnCodeName = freezed,
|
||||
Object? lineAmount = freezed,
|
||||
Object? itemCategoryId = freezed,
|
||||
Object? itemSubcategoryId = freezed,
|
||||
Object? remarks = freezed,
|
||||
}) {
|
||||
return _then(
|
||||
@ -1217,10 +1289,22 @@ class __$$PurchaseOrderItemModelImplCopyWithImpl<$Res>
|
||||
? _value.hsnCodeId
|
||||
: hsnCodeId // ignore: cast_nullable_to_non_nullable
|
||||
as int?,
|
||||
hsnCodeName: freezed == hsnCodeName
|
||||
? _value.hsnCodeName
|
||||
: hsnCodeName // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
lineAmount: freezed == lineAmount
|
||||
? _value.lineAmount
|
||||
: lineAmount // ignore: cast_nullable_to_non_nullable
|
||||
as double?,
|
||||
itemCategoryId: freezed == itemCategoryId
|
||||
? _value.itemCategoryId
|
||||
: itemCategoryId // ignore: cast_nullable_to_non_nullable
|
||||
as int?,
|
||||
itemSubcategoryId: freezed == itemSubcategoryId
|
||||
? _value.itemSubcategoryId
|
||||
: itemSubcategoryId // ignore: cast_nullable_to_non_nullable
|
||||
as int?,
|
||||
remarks: freezed == remarks
|
||||
? _value.remarks
|
||||
: remarks // ignore: cast_nullable_to_non_nullable
|
||||
@ -1253,10 +1337,28 @@ class _$PurchaseOrderItemModelImpl implements _PurchaseOrderItemModel {
|
||||
this.discountAmount,
|
||||
@JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable)
|
||||
this.gstRateId,
|
||||
@JsonKey(name: 'hsn_code_id', fromJson: _intFromJsonNullable)
|
||||
@JsonKey(
|
||||
name: 'hsn_code_id',
|
||||
readValue: _readHsnCodeId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
this.hsnCodeId,
|
||||
@JsonKey(name: 'hsn_code_name', readValue: _readHsnCodeName)
|
||||
this.hsnCodeName,
|
||||
@JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable)
|
||||
this.lineAmount,
|
||||
@JsonKey(
|
||||
name: 'item_category_id',
|
||||
readValue: _readPoItemCategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
this.itemCategoryId,
|
||||
@JsonKey(
|
||||
name: 'item_subcategory_id',
|
||||
readValue: _readPoItemSubcategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
this.itemSubcategoryId,
|
||||
this.remarks,
|
||||
});
|
||||
|
||||
@ -1306,17 +1408,38 @@ class _$PurchaseOrderItemModelImpl implements _PurchaseOrderItemModel {
|
||||
@JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable)
|
||||
final int? gstRateId;
|
||||
@override
|
||||
@JsonKey(name: 'hsn_code_id', fromJson: _intFromJsonNullable)
|
||||
@JsonKey(
|
||||
name: 'hsn_code_id',
|
||||
readValue: _readHsnCodeId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
final int? hsnCodeId;
|
||||
@override
|
||||
@JsonKey(name: 'hsn_code_name', readValue: _readHsnCodeName)
|
||||
final String? hsnCodeName;
|
||||
@override
|
||||
@JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable)
|
||||
final double? lineAmount;
|
||||
@override
|
||||
@JsonKey(
|
||||
name: 'item_category_id',
|
||||
readValue: _readPoItemCategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
final int? itemCategoryId;
|
||||
@override
|
||||
@JsonKey(
|
||||
name: 'item_subcategory_id',
|
||||
readValue: _readPoItemSubcategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
final int? itemSubcategoryId;
|
||||
@override
|
||||
final String? remarks;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PurchaseOrderItemModel(id: $id, poId: $poId, itemId: $itemId, itemCode: $itemCode, itemName: $itemName, lineNo: $lineNo, orderedQty: $orderedQty, receivedQty: $receivedQty, uomId: $uomId, uomName: $uomName, rate: $rate, discountPct: $discountPct, discountAmount: $discountAmount, gstRateId: $gstRateId, hsnCodeId: $hsnCodeId, lineAmount: $lineAmount, remarks: $remarks)';
|
||||
return 'PurchaseOrderItemModel(id: $id, poId: $poId, itemId: $itemId, itemCode: $itemCode, itemName: $itemName, lineNo: $lineNo, orderedQty: $orderedQty, receivedQty: $receivedQty, uomId: $uomId, uomName: $uomName, rate: $rate, discountPct: $discountPct, discountAmount: $discountAmount, gstRateId: $gstRateId, hsnCodeId: $hsnCodeId, hsnCodeName: $hsnCodeName, lineAmount: $lineAmount, itemCategoryId: $itemCategoryId, itemSubcategoryId: $itemSubcategoryId, remarks: $remarks)';
|
||||
}
|
||||
|
||||
@override
|
||||
@ -1347,14 +1470,20 @@ class _$PurchaseOrderItemModelImpl implements _PurchaseOrderItemModel {
|
||||
other.gstRateId == gstRateId) &&
|
||||
(identical(other.hsnCodeId, hsnCodeId) ||
|
||||
other.hsnCodeId == hsnCodeId) &&
|
||||
(identical(other.hsnCodeName, hsnCodeName) ||
|
||||
other.hsnCodeName == hsnCodeName) &&
|
||||
(identical(other.lineAmount, lineAmount) ||
|
||||
other.lineAmount == lineAmount) &&
|
||||
(identical(other.itemCategoryId, itemCategoryId) ||
|
||||
other.itemCategoryId == itemCategoryId) &&
|
||||
(identical(other.itemSubcategoryId, itemSubcategoryId) ||
|
||||
other.itemSubcategoryId == itemSubcategoryId) &&
|
||||
(identical(other.remarks, remarks) || other.remarks == remarks));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
int get hashCode => Object.hashAll([
|
||||
runtimeType,
|
||||
id,
|
||||
poId,
|
||||
@ -1371,9 +1500,12 @@ class _$PurchaseOrderItemModelImpl implements _PurchaseOrderItemModel {
|
||||
discountAmount,
|
||||
gstRateId,
|
||||
hsnCodeId,
|
||||
hsnCodeName,
|
||||
lineAmount,
|
||||
itemCategoryId,
|
||||
itemSubcategoryId,
|
||||
remarks,
|
||||
);
|
||||
]);
|
||||
|
||||
/// Create a copy of PurchaseOrderItemModel
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@ -1416,10 +1548,28 @@ abstract class _PurchaseOrderItemModel implements PurchaseOrderItemModel {
|
||||
final double? discountAmount,
|
||||
@JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable)
|
||||
final int? gstRateId,
|
||||
@JsonKey(name: 'hsn_code_id', fromJson: _intFromJsonNullable)
|
||||
@JsonKey(
|
||||
name: 'hsn_code_id',
|
||||
readValue: _readHsnCodeId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
final int? hsnCodeId,
|
||||
@JsonKey(name: 'hsn_code_name', readValue: _readHsnCodeName)
|
||||
final String? hsnCodeName,
|
||||
@JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable)
|
||||
final double? lineAmount,
|
||||
@JsonKey(
|
||||
name: 'item_category_id',
|
||||
readValue: _readPoItemCategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
final int? itemCategoryId,
|
||||
@JsonKey(
|
||||
name: 'item_subcategory_id',
|
||||
readValue: _readPoItemSubcategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
final int? itemSubcategoryId,
|
||||
final String? remarks,
|
||||
}) = _$PurchaseOrderItemModelImpl;
|
||||
|
||||
@ -1469,12 +1619,33 @@ abstract class _PurchaseOrderItemModel implements PurchaseOrderItemModel {
|
||||
@JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable)
|
||||
int? get gstRateId;
|
||||
@override
|
||||
@JsonKey(name: 'hsn_code_id', fromJson: _intFromJsonNullable)
|
||||
@JsonKey(
|
||||
name: 'hsn_code_id',
|
||||
readValue: _readHsnCodeId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
int? get hsnCodeId;
|
||||
@override
|
||||
@JsonKey(name: 'hsn_code_name', readValue: _readHsnCodeName)
|
||||
String? get hsnCodeName;
|
||||
@override
|
||||
@JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable)
|
||||
double? get lineAmount;
|
||||
@override
|
||||
@JsonKey(
|
||||
name: 'item_category_id',
|
||||
readValue: _readPoItemCategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
int? get itemCategoryId;
|
||||
@override
|
||||
@JsonKey(
|
||||
name: 'item_subcategory_id',
|
||||
readValue: _readPoItemSubcategoryId,
|
||||
fromJson: _intFromJsonNullable,
|
||||
)
|
||||
int? get itemSubcategoryId;
|
||||
@override
|
||||
String? get remarks;
|
||||
|
||||
/// Create a copy of PurchaseOrderItemModel
|
||||
|
||||
@ -93,8 +93,15 @@ _$PurchaseOrderItemModelImpl _$$PurchaseOrderItemModelImplFromJson(
|
||||
discountPct: _doubleFromJsonNullable(json['discount_pct']),
|
||||
discountAmount: _doubleFromJsonNullable(json['discount_amount']),
|
||||
gstRateId: _intFromJsonNullable(json['gst_rate_id']),
|
||||
hsnCodeId: _intFromJsonNullable(json['hsn_code_id']),
|
||||
hsnCodeId: _intFromJsonNullable(_readHsnCodeId(json, 'hsn_code_id')),
|
||||
hsnCodeName: _readHsnCodeName(json, 'hsn_code_name') as String?,
|
||||
lineAmount: _doubleFromJsonNullable(json['line_amount']),
|
||||
itemCategoryId: _intFromJsonNullable(
|
||||
_readPoItemCategoryId(json, 'item_category_id'),
|
||||
),
|
||||
itemSubcategoryId: _intFromJsonNullable(
|
||||
_readPoItemSubcategoryId(json, 'item_subcategory_id'),
|
||||
),
|
||||
remarks: json['remarks'] as String?,
|
||||
);
|
||||
|
||||
@ -116,6 +123,9 @@ Map<String, dynamic> _$$PurchaseOrderItemModelImplToJson(
|
||||
'discount_amount': instance.discountAmount,
|
||||
'gst_rate_id': instance.gstRateId,
|
||||
'hsn_code_id': instance.hsnCodeId,
|
||||
'hsn_code_name': instance.hsnCodeName,
|
||||
'line_amount': instance.lineAmount,
|
||||
'item_category_id': instance.itemCategoryId,
|
||||
'item_subcategory_id': instance.itemSubcategoryId,
|
||||
'remarks': instance.remarks,
|
||||
};
|
||||
|
||||
@ -6,7 +6,6 @@ import '../../core/config/dev_config.dart';
|
||||
import '../../core/constants/route_constants.dart';
|
||||
import '../../modules/dashboard/presentation/screens/dashboard_screen.dart';
|
||||
import '../../modules/assets/presentation/screens/asset_alerts_screen.dart';
|
||||
import '../../modules/assets/presentation/screens/asset_categories_screen.dart';
|
||||
import '../../modules/assets/presentation/screens/asset_detail_screen.dart';
|
||||
import '../../modules/assets/presentation/screens/asset_list_screen.dart';
|
||||
import '../../modules/auth/presentation/screens/change_password_screen.dart';
|
||||
@ -215,18 +214,27 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'add',
|
||||
builder: (context, state) => const PurchaseOrderFormScreen(),
|
||||
pageBuilder: (context, state) => shellPage(
|
||||
state,
|
||||
const PurchaseOrderFormScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: ':id/edit',
|
||||
builder: (context, state) => PurchaseOrderFormScreen(
|
||||
purchaseOrderId: state.pathParameters['id']!,
|
||||
pageBuilder: (context, state) => shellPage(
|
||||
state,
|
||||
PurchaseOrderFormScreen(
|
||||
purchaseOrderId: state.pathParameters['id']!,
|
||||
),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: ':id',
|
||||
builder: (context, state) => PurchaseOrderDetailScreen(
|
||||
purchaseOrderId: state.pathParameters['id']!,
|
||||
pageBuilder: (context, state) => shellPage(
|
||||
state,
|
||||
PurchaseOrderDetailScreen(
|
||||
purchaseOrderId: state.pathParameters['id']!,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -272,10 +280,6 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
pageBuilder: (context, state) =>
|
||||
shellPage(state, const AssetListScreen()),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'categories',
|
||||
builder: (context, state) => const AssetCategoriesScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'alerts',
|
||||
builder: (context, state) => const AssetAlertsScreen(),
|
||||
|
||||
@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'app_card.dart';
|
||||
|
||||
/// Fixed height for every row in [AppDataTable] and themed [DataTable] widgets.
|
||||
const double kAppTableRowHeight = 52;
|
||||
|
||||
class AppDataColumn<T> {
|
||||
const AppDataColumn({
|
||||
required this.label,
|
||||
@ -18,6 +21,32 @@ class AppDataColumn<T> {
|
||||
final Alignment alignment;
|
||||
}
|
||||
|
||||
/// Helpers for table cell content — single-line text with ellipsis and tooltip.
|
||||
class AppTableCell {
|
||||
AppTableCell._();
|
||||
|
||||
/// Renders [value] on one line; shows the full text in a tooltip when truncated.
|
||||
static Widget text(
|
||||
String? value, {
|
||||
TextStyle? style,
|
||||
String placeholder = '—',
|
||||
TextAlign? textAlign,
|
||||
bool showTooltip = true,
|
||||
}) {
|
||||
final display =
|
||||
(value == null || value.trim().isEmpty) ? placeholder : value.trim();
|
||||
return _EllipsisTooltipText(
|
||||
text: display,
|
||||
style: style,
|
||||
textAlign: textAlign,
|
||||
showTooltip: showTooltip && display != placeholder,
|
||||
);
|
||||
}
|
||||
|
||||
/// Wraps non-text cell widgets (chips, actions) inside the row height budget.
|
||||
static Widget child(Widget widget) => widget;
|
||||
}
|
||||
|
||||
class AppDataTable<T> extends StatelessWidget {
|
||||
const AppDataTable({
|
||||
super.key,
|
||||
@ -38,6 +67,7 @@ class AppDataTable<T> extends StatelessWidget {
|
||||
final void Function(String column, bool ascending)? onSort;
|
||||
final String emptyMessage;
|
||||
final bool wrapInCard;
|
||||
|
||||
/// Set true when the table is placed inside another scrollable.
|
||||
final bool shrinkWrap;
|
||||
|
||||
@ -64,9 +94,7 @@ class AppDataTable<T> extends StatelessWidget {
|
||||
return ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
shrinkWrap: shrinkWrap,
|
||||
physics: shrinkWrap
|
||||
? const NeverScrollableScrollPhysics()
|
||||
: null,
|
||||
physics: shrinkWrap ? const NeverScrollableScrollPhysics() : null,
|
||||
children: [
|
||||
_TableHeaderRow<T>(
|
||||
columns: columns,
|
||||
@ -111,56 +139,63 @@ class _TableHeaderRow<T> extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
return SizedBox(
|
||||
height: kAppTableRowHeight,
|
||||
width: double.infinity,
|
||||
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||
child: Row(
|
||||
children: columns.map((col) {
|
||||
final isSorted = col.sortKey != null && col.sortKey == sortColumn;
|
||||
final label = Text(
|
||||
col.label.toUpperCase(),
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.6,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
child: ColoredBox(
|
||||
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: columns.map((col) {
|
||||
final isSorted = col.sortKey != null && col.sortKey == sortColumn;
|
||||
final label = Text(
|
||||
col.label.toUpperCase(),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.6,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
|
||||
Widget header = label;
|
||||
if (col.sortKey != null && onSort != null) {
|
||||
header = InkWell(
|
||||
onTap: () => onSort!(
|
||||
col.sortKey!,
|
||||
isSorted ? !sortAscending : true,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
label,
|
||||
if (isSorted) ...[
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
sortAscending
|
||||
? Icons.arrow_upward
|
||||
: Icons.arrow_downward,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
Widget header = label;
|
||||
if (col.sortKey != null && onSort != null) {
|
||||
header = InkWell(
|
||||
onTap: () => onSort!(
|
||||
col.sortKey!,
|
||||
isSorted ? !sortAscending : true,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(child: label),
|
||||
if (isSorted) ...[
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
sortAscending
|
||||
? Icons.arrow_upward
|
||||
: Icons.arrow_downward,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Expanded(
|
||||
flex: col.flex,
|
||||
child: Align(
|
||||
alignment: col.alignment,
|
||||
child: header,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
return Expanded(
|
||||
flex: col.flex,
|
||||
child: Align(
|
||||
alignment: col.alignment,
|
||||
child: header,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -179,28 +214,125 @@ class _TableDataRow<T> extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
return SizedBox(
|
||||
height: kAppTableRowHeight,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: theme.colorScheme.outline.withValues(alpha: 0.08),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: theme.colorScheme.outline.withValues(alpha: 0.08),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: columns.map((col) {
|
||||
return Expanded(
|
||||
flex: col.flex,
|
||||
child: Align(
|
||||
alignment: col.alignment,
|
||||
child: _TableCellSlot(
|
||||
alignment: col.alignment,
|
||||
child: col.cellBuilder(context, row),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: columns.map((col) {
|
||||
return Expanded(
|
||||
flex: col.flex,
|
||||
child: Align(
|
||||
alignment: col.alignment,
|
||||
child: col.cellBuilder(context, row),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TableCellSlot extends StatelessWidget {
|
||||
const _TableCellSlot({
|
||||
required this.child,
|
||||
required this.alignment,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final Alignment alignment;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SizedBox(
|
||||
width: constraints.maxWidth,
|
||||
child: Align(
|
||||
alignment: alignment,
|
||||
widthFactor: 1,
|
||||
child: _coerceTableCell(child, context),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _coerceTableCell(Widget widget, BuildContext context) {
|
||||
if (widget is Text) {
|
||||
final text = widget.data ?? widget.textSpan?.toPlainText() ?? '';
|
||||
if (text.isEmpty) return widget;
|
||||
return AppTableCell.text(
|
||||
text,
|
||||
style: widget.style ?? DefaultTextStyle.of(context).style,
|
||||
textAlign: widget.textAlign,
|
||||
);
|
||||
}
|
||||
return widget;
|
||||
}
|
||||
}
|
||||
|
||||
class _EllipsisTooltipText extends StatelessWidget {
|
||||
const _EllipsisTooltipText({
|
||||
required this.text,
|
||||
this.style,
|
||||
this.textAlign,
|
||||
this.showTooltip = true,
|
||||
});
|
||||
|
||||
final String text;
|
||||
final TextStyle? style;
|
||||
final TextAlign? textAlign;
|
||||
final bool showTooltip;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final effectiveStyle = style ?? DefaultTextStyle.of(context).style;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxWidth = constraints.maxWidth;
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(text: text, style: effectiveStyle),
|
||||
maxLines: 1,
|
||||
textDirection: Directionality.of(context),
|
||||
textAlign: textAlign ?? TextAlign.start,
|
||||
)..layout(maxWidth: maxWidth.isFinite ? maxWidth : double.infinity);
|
||||
|
||||
final overflows = maxWidth.isFinite &&
|
||||
(painter.didExceedMaxLines || painter.width > maxWidth);
|
||||
|
||||
final textWidget = Text(
|
||||
text,
|
||||
style: effectiveStyle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: textAlign,
|
||||
);
|
||||
|
||||
if (!showTooltip || !overflows) return textWidget;
|
||||
|
||||
return Tooltip(
|
||||
message: text,
|
||||
waitDuration: const Duration(milliseconds: 400),
|
||||
child: textWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,10 +35,27 @@ class AppSearchableDropdown<T> extends StatefulWidget {
|
||||
class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
|
||||
final _layerLink = LayerLink();
|
||||
final _fieldKey = GlobalKey();
|
||||
final _formFieldKey = UniqueKey();
|
||||
final _formFieldStateKey = GlobalKey<FormFieldState<T>>();
|
||||
OverlayEntry? _overlayEntry;
|
||||
bool _ignoreOutsideTap = false;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant AppSearchableDropdown<T> oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.value == oldWidget.value) return;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final fieldState = _formFieldStateKey.currentState;
|
||||
if (fieldState == null || fieldState.value == widget.value) return;
|
||||
|
||||
fieldState.didChange(widget.value);
|
||||
if (fieldState.hasError) {
|
||||
fieldState.validate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_removeOverlay();
|
||||
@ -167,7 +184,7 @@ class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return FormField<T>(
|
||||
key: widget.key ?? _formFieldKey,
|
||||
key: _formFieldStateKey,
|
||||
initialValue: widget.value,
|
||||
validator: widget.validator,
|
||||
builder: (field) {
|
||||
@ -342,3 +359,216 @@ class _SearchableDropdownPanelState<T>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Searchable lookup anchored to the field — same overlay UI as
|
||||
/// [AppSearchableDropdown] but without a [FormField] wrapper.
|
||||
class AppSearchableLookupField<T> extends StatefulWidget {
|
||||
const AppSearchableLookupField({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.options,
|
||||
required this.onChanged,
|
||||
this.hint,
|
||||
this.searchHint = 'Search...',
|
||||
this.enabled = true,
|
||||
this.isDense = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final T? value;
|
||||
final List<AppDropdownOption<T>> options;
|
||||
final ValueChanged<T?> onChanged;
|
||||
final String? hint;
|
||||
final String searchHint;
|
||||
final bool enabled;
|
||||
final bool isDense;
|
||||
|
||||
@override
|
||||
State<AppSearchableLookupField<T>> createState() =>
|
||||
_AppSearchableLookupFieldState<T>();
|
||||
}
|
||||
|
||||
class _AppSearchableLookupFieldState<T>
|
||||
extends State<AppSearchableLookupField<T>> {
|
||||
final _layerLink = LayerLink();
|
||||
final _fieldKey = GlobalKey();
|
||||
OverlayEntry? _overlayEntry;
|
||||
bool _ignoreOutsideTap = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_removeOverlay();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String? _labelForValue(T? selected) {
|
||||
if (selected == null) return null;
|
||||
for (final option in widget.options) {
|
||||
if (option.value == selected) return option.label;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _removeOverlay() {
|
||||
if (_overlayEntry == null) return;
|
||||
_overlayEntry!.remove();
|
||||
_overlayEntry = null;
|
||||
_ignoreOutsideTap = false;
|
||||
if (mounted) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _openPicker() {
|
||||
if (!widget.enabled || widget.options.isEmpty) return;
|
||||
if (_overlayEntry != null) {
|
||||
_removeOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || _overlayEntry != null) return;
|
||||
_showOverlay();
|
||||
});
|
||||
}
|
||||
|
||||
void _showOverlay() {
|
||||
final renderBox =
|
||||
_fieldKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (renderBox == null || !renderBox.hasSize) return;
|
||||
|
||||
final fieldSize = renderBox.size;
|
||||
final fieldTopLeft = renderBox.localToGlobal(Offset.zero);
|
||||
final screenSize = MediaQuery.sizeOf(context);
|
||||
final viewInsets = MediaQuery.viewInsetsOf(context);
|
||||
|
||||
final spaceBelow = screenSize.height -
|
||||
viewInsets.bottom -
|
||||
fieldTopLeft.dy -
|
||||
fieldSize.height;
|
||||
final spaceAbove = fieldTopLeft.dy - viewInsets.top;
|
||||
final showAbove = spaceBelow < 180 && spaceAbove > spaceBelow;
|
||||
|
||||
final availableSpace = (showAbove ? spaceAbove : spaceBelow) - 8;
|
||||
final maxPanelHeight =
|
||||
availableSpace.clamp(120.0, screenSize.height * 0.45);
|
||||
|
||||
_ignoreOutsideTap = true;
|
||||
_overlayEntry = OverlayEntry(
|
||||
builder: (overlayContext) {
|
||||
final theme = Theme.of(overlayContext);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: _removeOverlay,
|
||||
behavior: HitTestBehavior.translucent,
|
||||
),
|
||||
),
|
||||
CompositedTransformFollower(
|
||||
link: _layerLink,
|
||||
showWhenUnlinked: false,
|
||||
targetAnchor:
|
||||
showAbove ? Alignment.topLeft : Alignment.bottomLeft,
|
||||
followerAnchor:
|
||||
showAbove ? Alignment.bottomLeft : Alignment.topLeft,
|
||||
offset: Offset(0, showAbove ? -4 : 4),
|
||||
child: TapRegion(
|
||||
onTapOutside: (_) {
|
||||
if (_ignoreOutsideTap) return;
|
||||
_removeOverlay();
|
||||
},
|
||||
child: Material(
|
||||
elevation: 8,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
color: theme.colorScheme.surface,
|
||||
shadowColor: Colors.black45,
|
||||
child: SizedBox(
|
||||
width: fieldSize.width,
|
||||
child: _SearchableDropdownPanel<T>(
|
||||
maxHeight: maxPanelHeight,
|
||||
options: widget.options,
|
||||
selected: widget.value,
|
||||
searchHint: widget.searchHint,
|
||||
onSelected: (value) {
|
||||
_removeOverlay();
|
||||
widget.onChanged(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
final overlay = Overlay.maybeOf(context, rootOverlay: true) ??
|
||||
Overlay.of(context);
|
||||
overlay.insert(_overlayEntry!);
|
||||
setState(() {});
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_ignoreOutsideTap = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final effectiveHint =
|
||||
widget.hint ?? 'Select ${widget.label.toLowerCase()}';
|
||||
final canOpen = widget.enabled && widget.options.isNotEmpty;
|
||||
final colors = theme.colorScheme;
|
||||
final displayLabel = _labelForValue(widget.value);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: CompositedTransformTarget(
|
||||
link: _layerLink,
|
||||
child: KeyedSubtree(
|
||||
key: _fieldKey,
|
||||
child: InkWell(
|
||||
onTap: canOpen ? _openPicker : null,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InputDecorator(
|
||||
isFocused: _overlayEntry != null,
|
||||
isEmpty: displayLabel == null,
|
||||
decoration: InputDecoration(
|
||||
labelText: widget.label,
|
||||
hintText: displayLabel == null ? effectiveHint : null,
|
||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||
isDense: widget.isDense,
|
||||
suffixIcon: Icon(
|
||||
_overlayEntry != null
|
||||
? Icons.arrow_drop_up
|
||||
: Icons.arrow_drop_down,
|
||||
color: canOpen
|
||||
? colors.onSurfaceVariant
|
||||
: theme.disabledColor,
|
||||
),
|
||||
enabled: canOpen,
|
||||
),
|
||||
child: Text(
|
||||
displayLabel ?? '\u00A0',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: displayLabel == null
|
||||
? Colors.transparent
|
||||
: colors.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,6 +23,10 @@ const _sidebarItemPadding = 12.0;
|
||||
const _sidebarIconSize = 20.0;
|
||||
const _sidebarChildIndent = 28.0;
|
||||
|
||||
/// Set to `true` to show the Light/Dark toggle in the sidebar again.
|
||||
/// Kept hidden for now — do not delete `_buildThemeToggle`.
|
||||
const showSidebarThemeToggle = false;
|
||||
|
||||
class AppSidebar extends ConsumerStatefulWidget {
|
||||
const AppSidebar({
|
||||
super.key,
|
||||
@ -149,7 +153,10 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildHeader(context, isNarrow: isNarrow),
|
||||
if (!isNarrow) ...[
|
||||
// NOTE: Light/Dark theme toggle is temporarily hidden from the
|
||||
// sidebar. Do not remove `_buildThemeToggle` — restore by
|
||||
// setting [showSidebarThemeToggle] to true.
|
||||
if (!isNarrow && showSidebarThemeToggle) ...[
|
||||
const SizedBox(height: 16),
|
||||
_buildThemeToggle(context, isLightActive),
|
||||
],
|
||||
@ -221,14 +228,15 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
|
||||
}),
|
||||
const SizedBox(height: 16),
|
||||
if (!isNarrow) const _SectionLabel(label: 'SUPPORT'),
|
||||
_SidebarNavItem(
|
||||
icon: Icons.notifications_outlined,
|
||||
label: 'Notifications',
|
||||
selected: false,
|
||||
collapsed: isNarrow,
|
||||
badge: isNarrow ? null : '3',
|
||||
onTap: () {},
|
||||
),
|
||||
if (AppConstants.showNotificationsMenu)
|
||||
_SidebarNavItem(
|
||||
icon: Icons.notifications_outlined,
|
||||
label: 'Notifications',
|
||||
selected: false,
|
||||
collapsed: isNarrow,
|
||||
badge: isNarrow ? null : '3',
|
||||
onTap: () {},
|
||||
),
|
||||
if (_hasSettings)
|
||||
_SidebarNavItem(
|
||||
icon: Icons.settings_outlined,
|
||||
|
||||
@ -105,10 +105,11 @@ class AppTopNav extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.notifications_outlined, size: 22),
|
||||
onPressed: () {},
|
||||
),
|
||||
if (AppConstants.showNotificationsMenu)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.notifications_outlined, size: 22),
|
||||
onPressed: () {},
|
||||
),
|
||||
if (DevConfig.screenPreviewEnabled)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.apps_outlined, size: 22),
|
||||
|
||||
@ -3,6 +3,8 @@ import 'dart:convert';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/utils/media_url.dart';
|
||||
|
||||
/// Displays company logo in the sidebar from URL, data URI, or fallback icon.
|
||||
class SidebarLogo extends StatelessWidget {
|
||||
const SidebarLogo({
|
||||
@ -53,7 +55,7 @@ class SidebarLogo extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget _buildLogoContent(Widget fallback) {
|
||||
final url = logoUrl?.trim();
|
||||
final url = resolveMediaUrl(logoUrl);
|
||||
if (url == null || url.isEmpty) {
|
||||
return Center(child: fallback);
|
||||
}
|
||||
@ -99,9 +101,9 @@ String? resolveSidebarLogoUrl({
|
||||
required String companyProfileLogo,
|
||||
required String? brandingLogo,
|
||||
}) {
|
||||
if (companyProfileLogo.isNotEmpty) return companyProfileLogo;
|
||||
if (brandingLogo != null && brandingLogo.isNotEmpty) return brandingLogo;
|
||||
return null;
|
||||
final fromProfile = resolveMediaUrl(companyProfileLogo);
|
||||
if (fromProfile != null && fromProfile.isNotEmpty) return fromProfile;
|
||||
return resolveMediaUrl(brandingLogo);
|
||||
}
|
||||
|
||||
/// Resolves sidebar title from company name or app tagline.
|
||||
|
||||
@ -182,4 +182,29 @@ void main() {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('hsnCode', () {
|
||||
test('accepts 4–8 digit codes', () {
|
||||
expect(Validators.hsnCode('3402'), isNull);
|
||||
expect(Validators.hsnCode('34029099'), isNull);
|
||||
});
|
||||
|
||||
test('rejects non-digit or wrong length codes', () {
|
||||
expect(Validators.hsnCode('340'), isNotNull);
|
||||
expect(Validators.hsnCode('340290991'), isNotNull);
|
||||
expect(Validators.hsnCode('HSN3402'), isNotNull);
|
||||
});
|
||||
|
||||
test('rejects duplicate HSN codes', () {
|
||||
expect(
|
||||
Validators.uniqueHsnCode(
|
||||
'34029099',
|
||||
existingRecords: const [
|
||||
{'id': '1', 'code': '34029099'},
|
||||
],
|
||||
),
|
||||
isNotNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user