Bug And Changes screen
This commit is contained in:
parent
83c73e31ae
commit
b740edf0e8
@ -37,12 +37,6 @@ class ApiEndpoints {
|
|||||||
static String rolePermissionMatrix(String roleId) =>
|
static String rolePermissionMatrix(String roleId) =>
|
||||||
'/roles/$roleId/permission-matrix';
|
'/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
|
// Masters
|
||||||
static const String departments = '/masters/departments';
|
static const String departments = '/masters/departments';
|
||||||
static String departmentById(String id) => '/masters/departments/$id';
|
static String departmentById(String id) => '/masters/departments/$id';
|
||||||
@ -69,6 +63,8 @@ class ApiEndpoints {
|
|||||||
static String paymentTermById(String id) => '/masters/payment-terms/$id';
|
static String paymentTermById(String id) => '/masters/payment-terms/$id';
|
||||||
static const String gstRates = '/masters/gst-rates';
|
static const String gstRates = '/masters/gst-rates';
|
||||||
static String gstRateById(String id) => '/masters/gst-rates/$id';
|
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 const String warehouses = '/masters/warehouses';
|
||||||
static String warehouseById(String id) => '/masters/warehouses/$id';
|
static String warehouseById(String id) => '/masters/warehouses/$id';
|
||||||
|
|
||||||
@ -107,6 +103,11 @@ class ApiEndpoints {
|
|||||||
static String grnById(String id) => '/grn/$id';
|
static String grnById(String id) => '/grn/$id';
|
||||||
static String grnCancel(String id) => '/grn/$id/cancel';
|
static String grnCancel(String id) => '/grn/$id/cancel';
|
||||||
static String grnPdf(String id) => '/grn/$id/pdf';
|
static String grnPdf(String id) => '/grn/$id/pdf';
|
||||||
|
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
|
// Assets
|
||||||
static const String assets = '/assets';
|
static const String assets = '/assets';
|
||||||
@ -165,6 +166,9 @@ class ApiEndpoints {
|
|||||||
|
|
||||||
// Audit
|
// Audit
|
||||||
static const String auditLogs = '/audit-logs';
|
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
|
// Notifications
|
||||||
static const String notifications = '/notifications';
|
static const String notifications = '/notifications';
|
||||||
|
|||||||
@ -10,4 +10,7 @@ class AppConstants {
|
|||||||
|
|
||||||
static const Duration animationDuration = Duration(milliseconds: 300);
|
static const Duration animationDuration = Duration(milliseconds: 300);
|
||||||
static const Duration snackBarDuration = Duration(seconds: 3);
|
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 assetAdd = '/assets/add';
|
||||||
static const String assetEdit = '/assets/:id/edit';
|
static const String assetEdit = '/assets/:id/edit';
|
||||||
static const String assetDetail = '/assets/:id';
|
static const String assetDetail = '/assets/:id';
|
||||||
static const String assetCategories = '/assets/categories';
|
|
||||||
static const String assetAlerts = '/assets/alerts';
|
static const String assetAlerts = '/assets/alerts';
|
||||||
|
|
||||||
// Master Data
|
// Master Data
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../shared/widgets/app_data_table.dart';
|
||||||
import 'app_colors.dart';
|
import 'app_colors.dart';
|
||||||
import 'app_typography.dart';
|
import 'app_typography.dart';
|
||||||
import 'branding_config.dart';
|
import 'branding_config.dart';
|
||||||
@ -202,6 +203,9 @@ class AppTheme {
|
|||||||
headingRowColor: WidgetStateProperty.all(
|
headingRowColor: WidgetStateProperty.all(
|
||||||
colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||||
),
|
),
|
||||||
|
headingRowHeight: kAppTableRowHeight,
|
||||||
|
dataRowMinHeight: kAppTableRowHeight,
|
||||||
|
dataRowMaxHeight: kAppTableRowHeight,
|
||||||
headingTextStyle: textTheme.labelLarge,
|
headingTextStyle: textTheme.labelLarge,
|
||||||
dataTextStyle: textTheme.bodyMedium,
|
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';
|
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 - _ /
|
/// Required master code — allowed: A-Z a-z 0-9 - _ /
|
||||||
static String? masterCode(String? value, {String fieldName = 'Code'}) {
|
static String? masterCode(String? value, {String fieldName = 'Code'}) {
|
||||||
final requiredError = required(value, fieldName: fieldName);
|
final requiredError = required(value, fieldName: fieldName);
|
||||||
|
|||||||
@ -51,7 +51,7 @@ class AssetRemoteDataSource {
|
|||||||
|
|
||||||
Future<List<AssetCategoryModel>> getCategories() async {
|
Future<List<AssetCategoryModel>> getCategories() async {
|
||||||
final response = await dio.get(
|
final response = await dio.get(
|
||||||
ApiEndpoints.assetCategories,
|
ApiEndpoints.itemCategories,
|
||||||
queryParameters: const {'limit': 100, 'is_active': true},
|
queryParameters: const {'limit': 100, 'is_active': true},
|
||||||
);
|
);
|
||||||
return _parseList(response.data, AssetCategoryModel.fromJson);
|
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 '../../data/repositories/asset_repository_impl.dart';
|
||||||
import '../../../../shared/models/asset_model.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 repository = ref.watch(assetRepositoryProvider);
|
||||||
final result = await repository.getCategories();
|
final result = await repository.getCategories();
|
||||||
if (result.failure != null) throw result.failure!;
|
if (result.failure != null) throw result.failure!;
|
||||||
return result.data ?? [];
|
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),
|
onRetry: () => ref.invalidate(assetsListProvider),
|
||||||
),
|
),
|
||||||
data: (state) {
|
data: (state) {
|
||||||
final allCategories = ref.watch(assetCategoriesProvider).valueOrNull ?? [];
|
final allCategories = ref.watch(itemCategoriesProvider).valueOrNull ?? [];
|
||||||
final filteredAssets = _filterAssets(state.assets);
|
final filteredAssets = _filterAssets(state.assets);
|
||||||
final categories = _categoryOptions(state.assets, allCategories);
|
final categories = _categoryOptions(state.assets, allCategories);
|
||||||
final plants = _plantOptions(state.assets);
|
final plants = _plantOptions(state.assets);
|
||||||
@ -651,7 +651,7 @@ class _AssetCodeBadge extends StatelessWidget {
|
|||||||
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.7),
|
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.7),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: AppTableCell.text(
|
||||||
code,
|
code,
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
|||||||
@ -306,8 +306,8 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
|
|||||||
Map<String, dynamic> _buildPayload() {
|
Map<String, dynamic> _buildPayload() {
|
||||||
final payload = <String, dynamic>{
|
final payload = <String, dynamic>{
|
||||||
'asset_name': _nameController.text.trim(),
|
'asset_name': _nameController.text.trim(),
|
||||||
'asset_category_id': _categoryId,
|
'item_category_id': _categoryId,
|
||||||
'asset_subcategory_id': _subcategoryId,
|
'item_subcategory_id': _subcategoryId,
|
||||||
'plant_id': _plantId,
|
'plant_id': _plantId,
|
||||||
'is_active': _isActive,
|
'is_active': _isActive,
|
||||||
};
|
};
|
||||||
@ -461,7 +461,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final categoriesAsync = ref.watch(assetCategoriesProvider);
|
final categoriesAsync = ref.watch(itemCategoriesProvider);
|
||||||
final plantsAsync = ref.watch(assetPlantsProvider);
|
final plantsAsync = ref.watch(assetPlantsProvider);
|
||||||
|
|
||||||
if (widget.isEditing) {
|
if (widget.isEditing) {
|
||||||
@ -518,7 +518,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
|
|||||||
AsyncValue<List<AssetCategoryModel>> categoriesAsync,
|
AsyncValue<List<AssetCategoryModel>> categoriesAsync,
|
||||||
AsyncValue<List<FilterOptionModel>> plantsAsync,
|
AsyncValue<List<FilterOptionModel>> plantsAsync,
|
||||||
) {
|
) {
|
||||||
final subcategoriesAsync = ref.watch(assetSubcategoriesProvider(_categoryId));
|
final subcategoriesAsync = ref.watch(itemSubcategoriesProvider(_categoryId));
|
||||||
final lookupsAsync = ref.watch(assetFormLookupsProvider);
|
final lookupsAsync = ref.watch(assetFormLookupsProvider);
|
||||||
final grnItemsAsync = ref.watch(assetGrnItemsProvider(_grnId));
|
final grnItemsAsync = ref.watch(assetGrnItemsProvider(_grnId));
|
||||||
final options = lookupsAsync.valueOrNull?.options ?? const AssetDropdownOptionsModel();
|
final options = lookupsAsync.valueOrNull?.options ?? const AssetDropdownOptionsModel();
|
||||||
@ -1291,9 +1291,9 @@ final assetPlantsProvider = FutureProvider<List<FilterOptionModel>>((ref) async
|
|||||||
return dataSource.listPlants();
|
return dataSource.listPlants();
|
||||||
});
|
});
|
||||||
|
|
||||||
final assetSubcategoriesProvider =
|
final itemSubcategoriesProvider =
|
||||||
FutureProvider.family<List<FilterOptionModel>, int?>((ref, categoryId) async {
|
FutureProvider.family<List<FilterOptionModel>, int?>((ref, categoryId) async {
|
||||||
if (categoryId == null) return [];
|
if (categoryId == null) return [];
|
||||||
final dataSource = ref.watch(masterRemoteDataSourceProvider);
|
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 {
|
import '../../../../core/constants/enums.dart';
|
||||||
const AuditLogsScreen({super.key})
|
import '../../../../core/errors/failure.dart';
|
||||||
: super(title: 'Audit Logs', description: 'System activity and audit trail');
|
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
|
// Assets
|
||||||
_GalleryEntry(title: 'Asset List', route: RouteConstants.assets, group: 'Assets'),
|
_GalleryEntry(title: 'Asset List', route: RouteConstants.assets, group: 'Assets'),
|
||||||
_GalleryEntry(title: 'Asset Detail', route: '/assets/demo-asset', 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'),
|
_GalleryEntry(title: 'Alerts', route: RouteConstants.assetAlerts, group: 'Assets'),
|
||||||
// Master data
|
// Master data
|
||||||
_GalleryEntry(title: 'Master Data Hub', route: RouteConstants.masterData, group: 'Master Data'),
|
_GalleryEntry(title: 'Master Data Hub', route: RouteConstants.masterData, group: 'Master Data'),
|
||||||
|
|||||||
@ -48,6 +48,60 @@ class GrnRemoteDataSource {
|
|||||||
return response.data ?? [];
|
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) {
|
Map<String, dynamic> _queryToMap(GrnListQuery query) {
|
||||||
return {
|
return {
|
||||||
'page': query.page,
|
'page': query.page,
|
||||||
|
|||||||
@ -54,4 +54,41 @@ class GrnRepositoryImpl implements GrnRepository {
|
|||||||
Future<Result<List<int>>> downloadGrnPdf(String id) {
|
Future<Result<List<int>>> downloadGrnPdf(String id) {
|
||||||
return safeApiCall(() => dataSource.downloadGrnPdf(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>> updateGrn(String id, Map<String, dynamic> data);
|
||||||
Future<Result<GrnModel>> cancelGrn(String id, {required String cancellationReason});
|
Future<Result<GrnModel>> cancelGrn(String id, {required String cancellationReason});
|
||||||
Future<Result<List<int>>> downloadGrnPdf(String id);
|
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({
|
const GrnLookups({
|
||||||
this.warehouses = const [],
|
this.warehouses = const [],
|
||||||
this.receivablePurchaseOrders = const [],
|
this.receivablePurchaseOrders = const [],
|
||||||
this.assetCategories = const [],
|
|
||||||
this.users = const [],
|
this.users = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<FilterOptionModel> warehouses;
|
final List<FilterOptionModel> warehouses;
|
||||||
final List<PurchaseOrderModel> receivablePurchaseOrders;
|
final List<PurchaseOrderModel> receivablePurchaseOrders;
|
||||||
final List<FilterOptionModel> assetCategories;
|
|
||||||
final List<FilterOptionModel> users;
|
final List<FilterOptionModel> users;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -26,7 +24,6 @@ final grnLookupsProvider = FutureProvider.autoDispose<GrnLookups>((ref) async {
|
|||||||
final poRepo = ref.watch(purchaseOrderRepositoryProvider);
|
final poRepo = ref.watch(purchaseOrderRepositoryProvider);
|
||||||
|
|
||||||
final warehouses = await _safeOptions(master.listWarehouses);
|
final warehouses = await _safeOptions(master.listWarehouses);
|
||||||
final assetCategories = await _safeOptions(master.listAssetCategories);
|
|
||||||
final users = await _safeUserOptions(ref);
|
final users = await _safeUserOptions(ref);
|
||||||
|
|
||||||
final receivablePos = <PurchaseOrderModel>[];
|
final receivablePos = <PurchaseOrderModel>[];
|
||||||
@ -46,7 +43,6 @@ final grnLookupsProvider = FutureProvider.autoDispose<GrnLookups>((ref) async {
|
|||||||
return GrnLookups(
|
return GrnLookups(
|
||||||
warehouses: warehouses,
|
warehouses: warehouses,
|
||||||
receivablePurchaseOrders: receivablePos,
|
receivablePurchaseOrders: receivablePos,
|
||||||
assetCategories: assetCategories,
|
|
||||||
users: users,
|
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 =
|
final grnPurchaseOrderProvider =
|
||||||
FutureProvider.autoDispose.family<PurchaseOrderModel?, String>((ref, poId) async {
|
FutureProvider.autoDispose.family<PurchaseOrderModel?, String>((ref, poId) async {
|
||||||
final result =
|
final result =
|
||||||
|
|||||||
@ -152,6 +152,55 @@ class GrnDetailNotifier extends FamilyAsyncNotifier<GrnModel, String> {
|
|||||||
if (result.failure != null) throw result.failure!;
|
if (result.failure != null) throw result.failure!;
|
||||||
return result.data ?? [];
|
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 =
|
final grnFormProvider =
|
||||||
|
|||||||
@ -10,13 +10,12 @@ import '../../../../shared/models/grn_model.dart';
|
|||||||
import '../../../../shared/models/user_management_models.dart';
|
import '../../../../shared/models/user_management_models.dart';
|
||||||
import '../../../../shared/providers/permissions_provider.dart';
|
import '../../../../shared/providers/permissions_provider.dart';
|
||||||
import '../../../../shared/utils/file_download_helper.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_text_field.dart';
|
||||||
|
import '../../../../shared/widgets/app_loading_view.dart';
|
||||||
import '../../../../shared/widgets/error_view.dart';
|
import '../../../../shared/widgets/error_view.dart';
|
||||||
import '../../../../shared/widgets/page_header.dart';
|
|
||||||
import '../providers/grn_lookups_provider.dart';
|
import '../providers/grn_lookups_provider.dart';
|
||||||
import '../providers/grn_provider.dart';
|
import '../providers/grn_provider.dart';
|
||||||
|
import '../widgets/grn_attachments_card.dart';
|
||||||
import '../widgets/grn_line_items_editor.dart';
|
import '../widgets/grn_line_items_editor.dart';
|
||||||
import '../widgets/grn_status_chip.dart';
|
import '../widgets/grn_status_chip.dart';
|
||||||
|
|
||||||
@ -35,120 +34,93 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final detailAsync = ref.watch(grnDetailProvider(widget.grnId));
|
final detailAsync = ref.watch(grnDetailProvider(widget.grnId));
|
||||||
|
final lookupsAsync = ref.watch(grnLookupsProvider);
|
||||||
final canEdit = ref.can('grn', PermissionAction.update);
|
final canEdit = ref.can('grn', PermissionAction.update);
|
||||||
|
final canDelete = ref.can('grn', PermissionAction.delete);
|
||||||
final canExport = ref.can('grn', PermissionAction.export);
|
final canExport = ref.can('grn', PermissionAction.export);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
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(
|
body: detailAsync.when(
|
||||||
loading: () => const AppLoadingView(message: 'Loading GRN...'),
|
loading: () => const AppLoadingView(message: 'Loading GRN...'),
|
||||||
error: (e, _) => ErrorView.fromFailure(
|
error: (e, _) => ErrorView.fromFailure(
|
||||||
e is Failure ? e : Failure.unknown(message: e.toString()),
|
e is Failure ? e : Failure.unknown(message: e.toString()),
|
||||||
onRetry: () => ref.invalidate(grnDetailProvider(widget.grnId)),
|
onRetry: () => ref.invalidate(grnDetailProvider(widget.grnId)),
|
||||||
),
|
),
|
||||||
data: (grn) => SingleChildScrollView(
|
data: (grn) {
|
||||||
padding: const EdgeInsets.all(24),
|
final lookups = lookupsAsync.asData?.value;
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 1200),
|
constraints: const BoxConstraints(maxWidth: 1200),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
PageHeader(
|
_DetailHeader(
|
||||||
title: grn.grnNumber ?? 'GRN #${grn.id}',
|
grn: grn,
|
||||||
subtitle:
|
isWorking: _isWorking,
|
||||||
'PO ${grn.poNumber ?? '—'} · ${grn.vendorName ?? '—'}',
|
canEdit: canEdit,
|
||||||
actions: [
|
canExport: canExport,
|
||||||
if (canExport)
|
onBack: () => context.go(RouteConstants.grn),
|
||||||
OutlinedButton.icon(
|
onPdf: () => _downloadPdf(grn),
|
||||||
onPressed: _isWorking ? null : () => _downloadPdf(grn),
|
onEdit: () => context.push(
|
||||||
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',
|
'${RouteConstants.grn}/${grn.id}/edit',
|
||||||
),
|
),
|
||||||
icon: const Icon(Icons.edit_outlined),
|
onCancel: () => _cancel(grn),
|
||||||
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 &&
|
if (grn.cancellationReason != null &&
|
||||||
grn.cancellationReason!.isNotEmpty) ...[
|
grn.cancellationReason!.isNotEmpty) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 12),
|
||||||
Text(
|
_CancellationBanner(reason: grn.cancellationReason!),
|
||||||
'Cancellation reason: ${grn.cancellationReason}',
|
],
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
const SizedBox(height: 16),
|
||||||
color: Theme.of(context).colorScheme.error,
|
_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: 16),
|
const SizedBox(height: 20),
|
||||||
_OverviewCard(grn: grn),
|
_DetailFooter(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),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
);
|
||||||
),
|
},
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _runWorkflow(Future<void> Function() action, String success) async {
|
Future<void> _runWorkflow(
|
||||||
|
Future<void> Function() action,
|
||||||
|
String success,
|
||||||
|
) async {
|
||||||
setState(() => _isWorking = true);
|
setState(() => _isWorking = true);
|
||||||
try {
|
try {
|
||||||
await action();
|
await action();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(success)));
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(SnackBar(content: Text(success)));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(SnackBar(content: Text(e.toString())));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _isWorking = false);
|
if (mounted) setState(() => _isWorking = false);
|
||||||
@ -164,7 +136,9 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
|
|||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
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),
|
const SizedBox(height: 16),
|
||||||
AppTextField(
|
AppTextField(
|
||||||
label: 'Cancellation reason *',
|
label: 'Cancellation reason *',
|
||||||
@ -201,8 +175,9 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
|
|||||||
|
|
||||||
Future<void> _downloadPdf(GrnModel grn) async {
|
Future<void> _downloadPdf(GrnModel grn) async {
|
||||||
await _runWorkflow(() async {
|
await _runWorkflow(() async {
|
||||||
final bytes =
|
final bytes = await ref
|
||||||
await ref.read(grnDetailProvider(widget.grnId).notifier).downloadPdf();
|
.read(grnDetailProvider(widget.grnId).notifier)
|
||||||
|
.downloadPdf();
|
||||||
await downloadFile(
|
await downloadFile(
|
||||||
bytes: bytes,
|
bytes: bytes,
|
||||||
fileName: '${grn.grnNumber ?? 'GRN-${grn.id}'}.pdf',
|
fileName: '${grn.grnNumber ?? 'GRN-${grn.id}'}.pdf',
|
||||||
@ -211,122 +186,381 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _OverviewCard extends ConsumerWidget {
|
String _userLabel(List<FilterOptionModel>? users, int? userId) {
|
||||||
const _OverviewCard({required this.grn});
|
if (userId == null || users == null) return '—';
|
||||||
|
for (final user in users) {
|
||||||
final GrnModel grn;
|
if (int.tryParse(user.id) == userId) return user.name;
|
||||||
|
}
|
||||||
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';
|
return 'User #$userId';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
String _displayOrDash(String? value) {
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
final trimmed = value?.trim();
|
||||||
final theme = Theme.of(context);
|
if (trimmed == null || trimmed.isEmpty) return '—';
|
||||||
final users = ref.watch(grnLookupsProvider).maybeWhen(
|
return trimmed;
|
||||||
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!
|
|
||||||
: '—'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _GrnInfoGrid extends StatelessWidget {
|
class _DetailHeader extends StatelessWidget {
|
||||||
const _GrnInfoGrid({
|
const _DetailHeader({
|
||||||
required this.items,
|
required this.grn,
|
||||||
this.columns = 4,
|
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 GrnModel grn;
|
||||||
final int columns;
|
final bool isWorking;
|
||||||
|
final bool canEdit;
|
||||||
|
final bool canExport;
|
||||||
|
final VoidCallback onBack;
|
||||||
|
final VoidCallback onPdf;
|
||||||
|
final VoidCallback onEdit;
|
||||||
|
final VoidCallback onCancel;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final maxWidth = constraints.maxWidth;
|
final stack = constraints.maxWidth < 800;
|
||||||
final cols = maxWidth < 600
|
if (stack) {
|
||||||
? 1
|
return Column(
|
||||||
: maxWidth < 900
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
? 2
|
children: [
|
||||||
: columns;
|
Row(
|
||||||
const spacing = 16.0;
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
final colWidth = (maxWidth - spacing * (cols - 1)) / cols;
|
children: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Back',
|
||||||
|
onPressed: onBack,
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(child: titleBlock),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
actions,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Wrap(
|
return Row(
|
||||||
spacing: spacing,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
runSpacing: 16,
|
children: [
|
||||||
children: items
|
IconButton(
|
||||||
.map(
|
tooltip: 'Back',
|
||||||
(item) => SizedBox(
|
onPressed: onBack,
|
||||||
width: colWidth,
|
icon: const Icon(Icons.arrow_back),
|
||||||
child: _GrnDetailTile(
|
|
||||||
label: item.label,
|
|
||||||
value: item.value,
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 4),
|
||||||
)
|
Expanded(child: titleBlock),
|
||||||
.toList(),
|
const SizedBox(width: 12),
|
||||||
|
actions,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _GrnDetailTile extends StatelessWidget {
|
class _HeaderActionButton extends StatelessWidget {
|
||||||
const _GrnDetailTile({
|
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.label,
|
||||||
required this.value,
|
required this.value,
|
||||||
});
|
});
|
||||||
@ -337,7 +571,6 @@ class _GrnDetailTile extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@ -349,15 +582,53 @@ class _GrnDetailTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(value, style: theme.textTheme.bodyLarge),
|
Text(
|
||||||
|
value,
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _GrnInfo {
|
class _LineItemsCard extends StatelessWidget {
|
||||||
const _GrnInfo(this.label, this.value);
|
const _LineItemsCard({required this.grn});
|
||||||
|
|
||||||
final String label;
|
final GrnModel grn;
|
||||||
final String value;
|
|
||||||
|
@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_dropdown.dart';
|
||||||
import '../../../../shared/widgets/app_loading_view.dart';
|
import '../../../../shared/widgets/app_loading_view.dart';
|
||||||
import '../../../../shared/widgets/app_searchable_dropdown.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/app_text_field.dart';
|
||||||
import '../../../../shared/widgets/error_view.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_lookups_provider.dart';
|
||||||
import '../providers/grn_provider.dart';
|
import '../providers/grn_provider.dart';
|
||||||
import '../widgets/grn_line_items_editor.dart';
|
import '../widgets/grn_line_items_editor.dart';
|
||||||
|
import '../widgets/grn_status_chip.dart';
|
||||||
|
|
||||||
class GrnFormScreen extends ConsumerStatefulWidget {
|
class GrnFormScreen extends ConsumerStatefulWidget {
|
||||||
const GrnFormScreen({super.key, this.grnId});
|
const GrnFormScreen({super.key, this.grnId});
|
||||||
@ -58,6 +58,9 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
if (!widget.isEditing) {
|
if (!widget.isEditing) {
|
||||||
_grnDate = DateTime.now();
|
_grnDate = DateTime.now();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) ref.invalidate(grnLookupsProvider);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -90,8 +93,8 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
|||||||
_vehicleNoController.text = grn.vehicleNo ?? '';
|
_vehicleNoController.text = grn.vehicleNo ?? '';
|
||||||
_lrNoController.text = grn.lrNo ?? '';
|
_lrNoController.text = grn.lrNo ?? '';
|
||||||
_lrDate = grn.lrDate;
|
_lrDate = grn.lrDate;
|
||||||
_receivedById = grn.receivedBy;
|
_receivedById = _normalizeUserId(grn.receivedBy);
|
||||||
_qualityCheckedById = grn.qualityCheckedBy;
|
_qualityCheckedById = _normalizeUserId(grn.qualityCheckedBy);
|
||||||
_remarksController.text = grn.remarks ?? '';
|
_remarksController.text = grn.remarks ?? '';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -112,11 +115,18 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
|||||||
|
|
||||||
int? _parseId(String value) => int.tryParse(value.trim());
|
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) {
|
int? _dropdownValue(int? selected, Iterable<int> validIds) {
|
||||||
if (selected == null) return null;
|
if (selected == null) return null;
|
||||||
return validIds.contains(selected) ? selected : 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) {
|
List<AppDropdownOption<int>> _intOptions(List<FilterOptionModel> options) {
|
||||||
return options
|
return options
|
||||||
.map((e) {
|
.map((e) {
|
||||||
@ -130,7 +140,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
|||||||
|
|
||||||
Map<String, dynamic> _buildCreatePayload() {
|
Map<String, dynamic> _buildCreatePayload() {
|
||||||
final poId = int.tryParse(_selectedPoId ?? '');
|
final poId = int.tryParse(_selectedPoId ?? '');
|
||||||
return {
|
final payload = <String, dynamic>{
|
||||||
'grn_date': DateFormatter.toApiDate(_grnDate ?? DateTime.now()),
|
'grn_date': DateFormatter.toApiDate(_grnDate ?? DateTime.now()),
|
||||||
'po_id': poId,
|
'po_id': poId,
|
||||||
'warehouse_id': _warehouseId,
|
'warehouse_id': _warehouseId,
|
||||||
@ -143,17 +153,19 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
|||||||
double.tryParse(_vendorInvoiceAmountController.text.trim()),
|
double.tryParse(_vendorInvoiceAmountController.text.trim()),
|
||||||
if (_vehicleNoController.text.trim().isNotEmpty)
|
if (_vehicleNoController.text.trim().isNotEmpty)
|
||||||
'vehicle_no': _vehicleNoController.text.trim(),
|
'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 (_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() {
|
Map<String, dynamic> _buildUpdatePayload() {
|
||||||
return {
|
final payload = <String, dynamic>{
|
||||||
if (_vendorInvoiceNoController.text.trim().isNotEmpty)
|
if (_vendorInvoiceNoController.text.trim().isNotEmpty)
|
||||||
'vendor_invoice_no': _vendorInvoiceNoController.text.trim(),
|
'vendor_invoice_no': _vendorInvoiceNoController.text.trim(),
|
||||||
if (_vendorInvoiceDate != null)
|
if (_vendorInvoiceDate != null)
|
||||||
@ -163,12 +175,14 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
|||||||
double.tryParse(_vendorInvoiceAmountController.text.trim()),
|
double.tryParse(_vendorInvoiceAmountController.text.trim()),
|
||||||
if (_vehicleNoController.text.trim().isNotEmpty)
|
if (_vehicleNoController.text.trim().isNotEmpty)
|
||||||
'vehicle_no': _vehicleNoController.text.trim(),
|
'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 (_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() {
|
String? _lineItemsError() {
|
||||||
@ -180,6 +194,10 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
|||||||
if (line.acceptedQty < 0) {
|
if (line.acceptedQty < 0) {
|
||||||
return 'Accepted quantity must be zero or more for line ${line.lineNo}';
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
@ -206,7 +224,8 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
|||||||
}
|
}
|
||||||
final lineError = _lineItemsError();
|
final lineError = _lineItemsError();
|
||||||
if (lineError != null) {
|
if (lineError != null) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(lineError)));
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(SnackBar(content: Text(lineError)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -241,7 +260,8 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final message =
|
final message =
|
||||||
e is Failure ? validationErrorMessage(e) : e.toString();
|
e is Failure ? validationErrorMessage(e) : e.toString();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(SnackBar(content: Text(message)));
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _isSubmitting = false);
|
if (mounted) setState(() => _isSubmitting = false);
|
||||||
}
|
}
|
||||||
@ -260,6 +280,17 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
|||||||
if (picked != null) onPicked(picked);
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final lookupsAsync = ref.watch(grnLookupsProvider);
|
final lookupsAsync = ref.watch(grnLookupsProvider);
|
||||||
@ -268,33 +299,33 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
|||||||
: const AsyncData<GrnModel?>(null);
|
: const AsyncData<GrnModel?>(null);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||||
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'),
|
|
||||||
),
|
|
||||||
body: lookupsAsync.when(
|
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(
|
error: (e, _) => ErrorView.fromFailure(
|
||||||
e is Failure ? e : Failure.unknown(message: e.toString()),
|
e is Failure ? e : Failure.unknown(message: e.toString()),
|
||||||
onRetry: () => ref.invalidate(grnLookupsProvider),
|
onRetry: () => ref.invalidate(grnLookupsProvider),
|
||||||
),
|
),
|
||||||
data: (lookups) => existingAsync.when(
|
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(
|
error: (e, _) => ErrorView.fromFailure(
|
||||||
e is Failure ? e : Failure.unknown(message: e.toString()),
|
e is Failure ? e : Failure.unknown(message: e.toString()),
|
||||||
onRetry: () => ref.invalidate(grnFormProvider(widget.grnId)),
|
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 =
|
final warehouseIds =
|
||||||
lookups.warehouses.map((e) => _parseId(e.id)).whereType<int>();
|
lookups.warehouses.map((e) => _parseId(e.id)).whereType<int>();
|
||||||
|
final userIds =
|
||||||
|
lookups.users.map((e) => _parseId(e.id)).whereType<int>();
|
||||||
final poOptions = lookups.receivablePurchaseOrders
|
final poOptions = lookups.receivablePurchaseOrders
|
||||||
.map(
|
.map(
|
||||||
(po) => AppDropdownOption(
|
(po) => AppDropdownOption(
|
||||||
@ -331,56 +364,46 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.topCenter,
|
alignment: Alignment.topCenter,
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 1200),
|
constraints: const BoxConstraints(maxWidth: 1200),
|
||||||
child: Column(
|
child: Form(
|
||||||
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,
|
key: _formKey,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
ResponsiveFormGrid(
|
_buildHeader(existing),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_SectionCard(
|
||||||
|
title: 'RECEIPT DETAILS',
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
FormRowFour(
|
||||||
children: [
|
children: [
|
||||||
_DateField(
|
_DateField(
|
||||||
label: 'GRN Date *',
|
label: 'GRN date *',
|
||||||
value: _grnDate,
|
value: _grnDate,
|
||||||
enabled: !widget.isEditing,
|
enabled: !widget.isEditing,
|
||||||
onTap: widget.isEditing
|
onTap: widget.isEditing
|
||||||
? null
|
? null
|
||||||
: () => _pickDate(
|
: () => _pickDate(
|
||||||
current: _grnDate,
|
current: _grnDate,
|
||||||
onPicked: (d) => setState(() => _grnDate = d),
|
onPicked: (d) =>
|
||||||
|
setState(() => _grnDate = d),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!widget.isEditing)
|
if (!widget.isEditing)
|
||||||
AppSearchableDropdown<String>(
|
AppSearchableDropdown<String>(
|
||||||
label: 'Purchase Order *',
|
label: 'Purchase order *',
|
||||||
value: _selectedPoId,
|
value: _selectedPoId,
|
||||||
|
hint: 'Select PO',
|
||||||
searchHint: 'Search PO...',
|
searchHint: 'Search PO...',
|
||||||
isDense: true,
|
|
||||||
options: poOptions,
|
options: poOptions,
|
||||||
onChanged: (v) async {
|
onChanged: (v) async {
|
||||||
setState(() => _selectedPoId = v);
|
setState(() => _selectedPoId = v);
|
||||||
@ -395,7 +418,9 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
|||||||
final po = await ref.read(
|
final po = await ref.read(
|
||||||
grnPurchaseOrderProvider(v).future,
|
grnPurchaseOrderProvider(v).future,
|
||||||
);
|
);
|
||||||
if (mounted && po != null) _loadLinesFromPo(po);
|
if (mounted && po != null) {
|
||||||
|
_loadLinesFromPo(po);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@ -403,42 +428,40 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
validator: (v) =>
|
validator: (v) => v == null
|
||||||
v == null ? 'Purchase order is required' : null,
|
? 'Purchase order is required'
|
||||||
|
: null,
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
Padding(
|
_ReadOnlyField(
|
||||||
padding: const EdgeInsets.only(top: 8),
|
label: 'Purchase order',
|
||||||
child: InputDecorator(
|
value: existing?.poNumber ?? '—',
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Purchase Order',
|
|
||||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
|
||||||
isDense: true,
|
|
||||||
),
|
|
||||||
child: Text(existing?.poNumber ?? '—'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
AppSearchableDropdown<int>(
|
AppSearchableDropdown<int>(
|
||||||
label: 'Warehouse *',
|
label: 'Warehouse *',
|
||||||
value: _dropdownValue(_warehouseId, warehouseIds),
|
value: _dropdownValue(_warehouseId, warehouseIds),
|
||||||
|
hint: 'Select warehouse',
|
||||||
searchHint: 'Search warehouse...',
|
searchHint: 'Search warehouse...',
|
||||||
isDense: true,
|
|
||||||
options: _intOptions(lookups.warehouses),
|
options: _intOptions(lookups.warehouses),
|
||||||
onChanged: widget.isEditing
|
onChanged: widget.isEditing
|
||||||
? (_) {}
|
? (_) {}
|
||||||
: (v) => setState(() => _warehouseId = v),
|
: (v) => setState(() => _warehouseId = v),
|
||||||
validator: widget.isEditing
|
validator: widget.isEditing
|
||||||
? null
|
? null
|
||||||
: (v) => v == null ? 'Warehouse is required' : null,
|
: (v) =>
|
||||||
|
v == null ? 'Warehouse is required' : null,
|
||||||
enabled: !widget.isEditing,
|
enabled: !widget.isEditing,
|
||||||
),
|
),
|
||||||
AppTextField(
|
AppTextField(
|
||||||
label: 'Vendor Invoice No',
|
label: 'Vendor invoice no',
|
||||||
controller: _vendorInvoiceNoController,
|
controller: _vendorInvoiceNoController,
|
||||||
isDense: true,
|
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
FormRowFour(
|
||||||
|
children: [
|
||||||
_DateField(
|
_DateField(
|
||||||
label: 'Vendor Invoice Date',
|
label: 'Vendor invoice date',
|
||||||
value: _vendorInvoiceDate,
|
value: _vendorInvoiceDate,
|
||||||
onTap: () => _pickDate(
|
onTap: () => _pickDate(
|
||||||
current: _vendorInvoiceDate,
|
current: _vendorInvoiceDate,
|
||||||
@ -447,24 +470,26 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
AppTextField(
|
AppTextField(
|
||||||
label: 'Vendor Invoice Amount',
|
label: 'Vendor invoice amount',
|
||||||
controller: _vendorInvoiceAmountController,
|
controller: _vendorInvoiceAmountController,
|
||||||
keyboardType:
|
keyboardType: const TextInputType.numberWithOptions(
|
||||||
const TextInputType.numberWithOptions(decimal: true),
|
decimal: true,
|
||||||
isDense: true,
|
),
|
||||||
),
|
),
|
||||||
AppTextField(
|
AppTextField(
|
||||||
label: 'Vehicle No',
|
label: 'Vehicle no',
|
||||||
controller: _vehicleNoController,
|
controller: _vehicleNoController,
|
||||||
isDense: true,
|
|
||||||
),
|
),
|
||||||
AppTextField(
|
AppTextField(
|
||||||
label: 'LR No',
|
label: 'LR no',
|
||||||
controller: _lrNoController,
|
controller: _lrNoController,
|
||||||
isDense: true,
|
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
FormRowFour(
|
||||||
|
children: [
|
||||||
_DateField(
|
_DateField(
|
||||||
label: 'LR Date',
|
label: 'LR date',
|
||||||
value: _lrDate,
|
value: _lrDate,
|
||||||
onTap: () => _pickDate(
|
onTap: () => _pickDate(
|
||||||
current: _lrDate,
|
current: _lrDate,
|
||||||
@ -472,76 +497,265 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
AppSearchableDropdown<int>(
|
AppSearchableDropdown<int>(
|
||||||
label: 'Received By',
|
label: 'Received by',
|
||||||
value: _dropdownValue(
|
value: _dropdownValue(
|
||||||
_receivedById,
|
_normalizeUserId(_receivedById),
|
||||||
lookups.users.map((e) => _parseId(e.id)).whereType<int>(),
|
userIds,
|
||||||
),
|
),
|
||||||
|
hint: 'Select user',
|
||||||
searchHint: 'Search user...',
|
searchHint: 'Search user...',
|
||||||
isDense: true,
|
|
||||||
options: _intOptions(lookups.users),
|
options: _intOptions(lookups.users),
|
||||||
onChanged: (v) => setState(() => _receivedById = v),
|
onChanged: (v) =>
|
||||||
|
setState(() => _receivedById = v),
|
||||||
),
|
),
|
||||||
AppSearchableDropdown<int>(
|
AppSearchableDropdown<int>(
|
||||||
label: 'Quality Checked By',
|
label: 'Quality checked by',
|
||||||
value: _dropdownValue(
|
value: _dropdownValue(
|
||||||
_qualityCheckedById,
|
_normalizeUserId(_qualityCheckedById),
|
||||||
lookups.users.map((e) => _parseId(e.id)).whereType<int>(),
|
userIds,
|
||||||
),
|
),
|
||||||
|
hint: 'Select user',
|
||||||
searchHint: 'Search user...',
|
searchHint: 'Search user...',
|
||||||
isDense: true,
|
|
||||||
options: _intOptions(lookups.users),
|
options: _intOptions(lookups.users),
|
||||||
onChanged: (v) =>
|
onChanged: (v) =>
|
||||||
setState(() => _qualityCheckedById = v),
|
setState(() => _qualityCheckedById = v),
|
||||||
),
|
),
|
||||||
AppTextField(
|
const SizedBox.shrink(),
|
||||||
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: 16),
|
||||||
const SizedBox(height: 12),
|
if (!widget.isEditing)
|
||||||
GrnLineItemsEditor(
|
GrnLineItemsEditor(
|
||||||
items: _lines,
|
items: _lines,
|
||||||
onChanged: () => setState(() {}),
|
onChanged: () => setState(() {}),
|
||||||
),
|
)
|
||||||
] else ...[
|
else ...[
|
||||||
const SizedBox(height: 16),
|
_SectionCard(
|
||||||
|
title: 'LINE ITEMS · ${existing?.items.length ?? 0}',
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Line items cannot be changed after posting.',
|
'Line items cannot be changed after posting.',
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (existing?.items.isNotEmpty == true) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
GrnItemsTable(items: existing!.items),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 24),
|
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(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Text(
|
||||||
child: AppButton(
|
'Fields marked * are required',
|
||||||
label: widget.isEditing ? 'Save Changes' : 'Create GRN',
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
onPressed: _isSubmitting ? null : _submit,
|
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,
|
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),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -571,16 +785,15 @@ class _DateField extends StatelessWidget {
|
|||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: label,
|
labelText: label,
|
||||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||||
isDense: true,
|
|
||||||
suffixIcon: const Icon(Icons.calendar_today_outlined, size: 18),
|
suffixIcon: const Icon(Icons.calendar_today_outlined, size: 18),
|
||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
value != null ? DateFormatter.displayDate(value) : 'Select date',
|
value != null ? DateFormatter.displayDate(value) : 'Select date',
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: value != null
|
color: value == null
|
||||||
? null
|
? Theme.of(context).colorScheme.onSurfaceVariant
|
||||||
: Theme.of(context).hintColor,
|
: 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/material.dart';
|
||||||
import 'package:flutter/services.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 '../../../../core/utils/formatters.dart';
|
||||||
import '../../../../shared/models/asset_model.dart';
|
|
||||||
import '../../../../shared/models/grn_model.dart';
|
import '../../../../shared/models/grn_model.dart';
|
||||||
import '../../../../shared/models/purchase_order_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_side_panel.dart';
|
||||||
import '../../../../shared/widgets/app_text_field.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) {
|
String _formatQty(double value) {
|
||||||
if (value % 1 == 0) return value.toInt().toString();
|
if (value % 1 == 0) return value.toInt().toString();
|
||||||
@ -38,8 +29,6 @@ class GrnLineItemDraft {
|
|||||||
TextEditingController? remarksController,
|
TextEditingController? remarksController,
|
||||||
this.mfgDate,
|
this.mfgDate,
|
||||||
this.expiryDate,
|
this.expiryDate,
|
||||||
this.assetCategoryId,
|
|
||||||
this.assetSubcategoryId,
|
|
||||||
}) : acceptedQtyController = acceptedQtyController ??
|
}) : acceptedQtyController = acceptedQtyController ??
|
||||||
TextEditingController(
|
TextEditingController(
|
||||||
text: remainingQty > 0 ? _formatQty(remainingQty) : '',
|
text: remainingQty > 0 ? _formatQty(remainingQty) : '',
|
||||||
@ -68,11 +57,11 @@ class GrnLineItemDraft {
|
|||||||
final TextEditingController remarksController;
|
final TextEditingController remarksController;
|
||||||
DateTime? mfgDate;
|
DateTime? mfgDate;
|
||||||
DateTime? expiryDate;
|
DateTime? expiryDate;
|
||||||
int? assetCategoryId;
|
|
||||||
int? assetSubcategoryId;
|
|
||||||
|
|
||||||
double get acceptedQty => double.tryParse(acceptedQtyController.text.trim()) ?? 0;
|
double get acceptedQty =>
|
||||||
double get rejectedQty => double.tryParse(rejectedQtyController.text.trim()) ?? 0;
|
double.tryParse(acceptedQtyController.text.trim()) ?? 0;
|
||||||
|
double get rejectedQty =>
|
||||||
|
double.tryParse(rejectedQtyController.text.trim()) ?? 0;
|
||||||
double get currentQty => acceptedQty + rejectedQty;
|
double get currentQty => acceptedQty + rejectedQty;
|
||||||
|
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@ -92,8 +81,8 @@ class GrnLineItemDraft {
|
|||||||
'line_no': lineNo,
|
'line_no': lineNo,
|
||||||
'current_qty': currentQty,
|
'current_qty': currentQty,
|
||||||
'accepted_qty': acceptedQty,
|
'accepted_qty': acceptedQty,
|
||||||
if (rejectedQty > 0) 'rejected_qty': rejectedQty,
|
'rejected_qty': rejectedQty,
|
||||||
if (rejectionReasonController.text.trim().isNotEmpty)
|
if (rejectedQty > 0 && rejectionReasonController.text.trim().isNotEmpty)
|
||||||
'rejection_reason': rejectionReasonController.text.trim(),
|
'rejection_reason': rejectionReasonController.text.trim(),
|
||||||
if (rateController.text.trim().isNotEmpty)
|
if (rateController.text.trim().isNotEmpty)
|
||||||
'rate': double.tryParse(rateController.text.trim()),
|
'rate': double.tryParse(rateController.text.trim()),
|
||||||
@ -103,8 +92,6 @@ class GrnLineItemDraft {
|
|||||||
if (expiryDate != null) 'expiry_date': DateFormatter.toApiDate(expiryDate!),
|
if (expiryDate != null) 'expiry_date': DateFormatter.toApiDate(expiryDate!),
|
||||||
if (storageLocationController.text.trim().isNotEmpty)
|
if (storageLocationController.text.trim().isNotEmpty)
|
||||||
'storage_location': storageLocationController.text.trim(),
|
'storage_location': storageLocationController.text.trim(),
|
||||||
if (assetCategoryId != null) 'asset_category_id': assetCategoryId,
|
|
||||||
if (assetSubcategoryId != null) 'asset_subcategory_id': assetSubcategoryId,
|
|
||||||
if (remarksController.text.trim().isNotEmpty)
|
if (remarksController.text.trim().isNotEmpty)
|
||||||
'remarks': remarksController.text.trim(),
|
'remarks': remarksController.text.trim(),
|
||||||
};
|
};
|
||||||
@ -145,44 +132,78 @@ class GrnLineItemsEditor extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (items.isEmpty) {
|
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(
|
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,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.lightSurface,
|
border: Border.all(color: borderColor),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(color: AppColors.textSecondary.withValues(alpha: 0.2)),
|
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
readOnly
|
readOnly
|
||||||
? 'No line items.'
|
? 'No line items.'
|
||||||
: 'Select a purchase order to load receivable line items.',
|
: 'Select a purchase order to load receivable line items.',
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
color: AppColors.textSecondary,
|
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,
|
||||||
),
|
),
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
for (var i = 0; i < items.length; i++)
|
|
||||||
Padding(
|
|
||||||
padding: EdgeInsets.only(bottom: i == items.length - 1 ? 0 : 12),
|
|
||||||
child: _GrnLineItemCard(
|
child: _GrnLineItemCard(
|
||||||
item: items[i],
|
key: ObjectKey(item),
|
||||||
|
item: item,
|
||||||
readOnly: readOnly,
|
readOnly: readOnly,
|
||||||
onChanged: onChanged,
|
onChanged: onChanged,
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _GrnLineItemCard extends ConsumerWidget {
|
class _GrnLineItemCard extends StatefulWidget {
|
||||||
const _GrnLineItemCard({
|
const _GrnLineItemCard({
|
||||||
|
super.key,
|
||||||
required this.item,
|
required this.item,
|
||||||
required this.onChanged,
|
required this.onChanged,
|
||||||
required this.readOnly,
|
required this.readOnly,
|
||||||
@ -192,41 +213,35 @@ class _GrnLineItemCard extends ConsumerWidget {
|
|||||||
final VoidCallback onChanged;
|
final VoidCallback onChanged;
|
||||||
final bool readOnly;
|
final bool readOnly;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_GrnLineItemCard> createState() => _GrnLineItemCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GrnLineItemCardState extends State<_GrnLineItemCard> {
|
||||||
static final _qtyFormatters = [
|
static final _qtyFormatters = [
|
||||||
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,4}')),
|
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,4}')),
|
||||||
];
|
];
|
||||||
|
|
||||||
int? _dropdownValue(int? selected, Iterable<int> validIds) {
|
@override
|
||||||
if (selected == null) return null;
|
void initState() {
|
||||||
return validIds.contains(selected) ? selected : null;
|
super.initState();
|
||||||
|
widget.item.acceptedQtyController.addListener(_onFieldChanged);
|
||||||
|
widget.item.rejectedQtyController.addListener(_onFieldChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<AppDropdownOption<int>> _intOptions(List<FilterOptionModel> options) {
|
@override
|
||||||
return options
|
void dispose() {
|
||||||
.map((e) {
|
widget.item.acceptedQtyController.removeListener(_onFieldChanged);
|
||||||
final id = int.tryParse(e.id);
|
widget.item.rejectedQtyController.removeListener(_onFieldChanged);
|
||||||
if (id == null) return null;
|
super.dispose();
|
||||||
return AppDropdownOption(value: id, label: e.name);
|
|
||||||
})
|
|
||||||
.whereType<AppDropdownOption<int>>()
|
|
||||||
.toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
List<AppDropdownOption<int>> _categoryOptions(
|
void _onFieldChanged() {
|
||||||
List<AssetCategoryModel> categories,
|
widget.onChanged();
|
||||||
) {
|
setState(() {});
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickDate(
|
Future<void> _pickDate({
|
||||||
BuildContext context, {
|
|
||||||
required DateTime? current,
|
required DateTime? current,
|
||||||
required ValueChanged<DateTime?> onPicked,
|
required ValueChanged<DateTime?> onPicked,
|
||||||
}) async {
|
}) async {
|
||||||
@ -240,201 +255,279 @@ class _GrnLineItemCard extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context) {
|
||||||
final categoryId = item.assetCategoryId;
|
final theme = Theme.of(context);
|
||||||
final categoriesAsync = ref.watch(assetCategoriesProvider);
|
final item = widget.item;
|
||||||
final categoryOptions = categoriesAsync.maybeWhen(
|
final lineKey = 'grn-line-${item.lineNo}';
|
||||||
data: _categoryOptions,
|
final isDark = theme.brightness == Brightness.dark;
|
||||||
orElse: () => const <AppDropdownOption<int>>[],
|
final borderColor = theme.colorScheme.outline.withValues(
|
||||||
|
alpha: isDark ? 0.35 : 0.18,
|
||||||
);
|
);
|
||||||
final categoryIds = categoryOptions.map((e) => e.value);
|
final currentBg = theme.colorScheme.primary.withValues(
|
||||||
final subcategoriesAsync = ref.watch(grnAssetSubcategoriesProvider(categoryId));
|
alpha: isDark ? 0.18 : 0.08,
|
||||||
final subcategoryOptions = subcategoriesAsync.maybeWhen(
|
|
||||||
data: _intOptions,
|
|
||||||
orElse: () => const <AppDropdownOption<int>>[],
|
|
||||||
);
|
);
|
||||||
final subcategoryIds = subcategoryOptions.map((e) => e.value);
|
|
||||||
|
|
||||||
|
if (widget.readOnly) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.lightSurface,
|
border: Border.all(color: borderColor),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(10),
|
||||||
border: Border.all(color: AppColors.textSecondary.withValues(alpha: 0.2)),
|
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
_LineItemTitle(
|
||||||
children: [
|
lineNo: item.lineNo,
|
||||||
Text(
|
itemName: item.itemName,
|
||||||
'Line ${item.lineNo}',
|
orderedQty: item.orderedQty,
|
||||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
receivedQty: item.receivedQty,
|
||||||
fontWeight: FontWeight.w700,
|
remainingQty: item.remainingQty,
|
||||||
),
|
),
|
||||||
),
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
] else ...[
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Accepted: ${_formatQty(item.acceptedQty)} · '
|
'Accepted: ${_formatQty(item.acceptedQty)} · '
|
||||||
'Rejected: ${_formatQty(item.rejectedQty)} · '
|
'Rejected: ${_formatQty(item.rejectedQty)} · '
|
||||||
'Current: ${_formatQty(item.currentQty)}',
|
'Current: ${_formatQty(item.currentQty)}',
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Widget> _buildLineItemFields({
|
return Container(
|
||||||
required BuildContext context,
|
padding: const EdgeInsets.all(16),
|
||||||
required List<AppDropdownOption<int>> categoryOptions,
|
decoration: BoxDecoration(
|
||||||
required Iterable<int> categoryIds,
|
border: Border.all(color: borderColor),
|
||||||
required List<AppDropdownOption<int>> subcategoryOptions,
|
borderRadius: BorderRadius.circular(10),
|
||||||
required Iterable<int> subcategoryIds,
|
),
|
||||||
}) {
|
child: Column(
|
||||||
final hasCategory = item.assetCategoryId != null;
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
return [
|
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(
|
AppTextField(
|
||||||
label: 'Accepted Qty *',
|
key: ValueKey('$lineKey-accepted'),
|
||||||
controller: item.acceptedQtyController,
|
controller: item.acceptedQtyController,
|
||||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
label: 'Accepted *',
|
||||||
|
hint: '0',
|
||||||
|
keyboardType:
|
||||||
|
const TextInputType.numberWithOptions(decimal: true),
|
||||||
inputFormatters: _qtyFormatters,
|
inputFormatters: _qtyFormatters,
|
||||||
isDense: true,
|
isDense: true,
|
||||||
onChanged: (_) => onChanged(),
|
|
||||||
),
|
),
|
||||||
AppTextField(
|
AppTextField(
|
||||||
label: 'Rejected Qty',
|
key: ValueKey('$lineKey-rejected'),
|
||||||
controller: item.rejectedQtyController,
|
controller: item.rejectedQtyController,
|
||||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
label: 'Rejected',
|
||||||
|
hint: '0',
|
||||||
|
keyboardType:
|
||||||
|
const TextInputType.numberWithOptions(decimal: true),
|
||||||
inputFormatters: _qtyFormatters,
|
inputFormatters: _qtyFormatters,
|
||||||
isDense: true,
|
isDense: true,
|
||||||
onChanged: (_) => onChanged(),
|
),
|
||||||
|
_GrnLineReadOnlyField(
|
||||||
|
key: ValueKey('$lineKey-current'),
|
||||||
|
label: 'Current',
|
||||||
|
value: _formatQty(item.currentQty),
|
||||||
|
backgroundColor: currentBg,
|
||||||
),
|
),
|
||||||
AppTextField(
|
AppTextField(
|
||||||
label: 'Rate',
|
key: ValueKey('$lineKey-rate'),
|
||||||
controller: item.rateController,
|
controller: item.rateController,
|
||||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
label: 'Rate',
|
||||||
|
hint: '0.00',
|
||||||
|
keyboardType:
|
||||||
|
const TextInputType.numberWithOptions(decimal: true),
|
||||||
inputFormatters: _qtyFormatters,
|
inputFormatters: _qtyFormatters,
|
||||||
isDense: true,
|
isDense: true,
|
||||||
onChanged: (_) => onChanged(),
|
onChanged: (_) => widget.onChanged(),
|
||||||
),
|
),
|
||||||
AppTextField(
|
AppTextField(
|
||||||
label: 'Batch No',
|
key: ValueKey('$lineKey-batch'),
|
||||||
controller: item.batchNoController,
|
controller: item.batchNoController,
|
||||||
|
label: 'Batch No',
|
||||||
isDense: true,
|
isDense: true,
|
||||||
onChanged: (_) => onChanged(),
|
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(
|
_GrnLineDateField(
|
||||||
|
key: ValueKey('$lineKey-mfg'),
|
||||||
label: 'Mfg Date',
|
label: 'Mfg Date',
|
||||||
value: item.mfgDate,
|
value: item.mfgDate,
|
||||||
onTap: () => _pickDate(
|
onTap: () => _pickDate(
|
||||||
context,
|
|
||||||
current: item.mfgDate,
|
current: item.mfgDate,
|
||||||
onPicked: (date) {
|
onPicked: (date) {
|
||||||
item.mfgDate = date;
|
item.mfgDate = date;
|
||||||
onChanged();
|
widget.onChanged();
|
||||||
|
setState(() {});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_GrnLineDateField(
|
_GrnLineDateField(
|
||||||
|
key: ValueKey('$lineKey-expiry'),
|
||||||
label: 'Expiry Date',
|
label: 'Expiry Date',
|
||||||
value: item.expiryDate,
|
value: item.expiryDate,
|
||||||
onTap: () => _pickDate(
|
onTap: () => _pickDate(
|
||||||
context,
|
|
||||||
current: item.expiryDate,
|
current: item.expiryDate,
|
||||||
onPicked: (date) {
|
onPicked: (date) {
|
||||||
item.expiryDate = date;
|
item.expiryDate = date;
|
||||||
onChanged();
|
widget.onChanged();
|
||||||
|
setState(() {});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
AppTextField(
|
AppTextField(
|
||||||
label: 'Storage Location',
|
key: ValueKey('$lineKey-rejection'),
|
||||||
controller: item.storageLocationController,
|
|
||||||
isDense: true,
|
|
||||||
onChanged: (_) => onChanged(),
|
|
||||||
),
|
|
||||||
AppTextField(
|
|
||||||
label: 'Rejection Reason',
|
|
||||||
controller: item.rejectionReasonController,
|
controller: item.rejectionReasonController,
|
||||||
|
label: 'Rejection Reason',
|
||||||
isDense: true,
|
isDense: true,
|
||||||
onChanged: (_) => onChanged(),
|
onChanged: (_) => widget.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(
|
AppTextField(
|
||||||
label: 'Remarks',
|
key: ValueKey('$lineKey-remarks'),
|
||||||
controller: item.remarksController,
|
controller: item.remarksController,
|
||||||
|
label: 'Remarks',
|
||||||
isDense: true,
|
isDense: true,
|
||||||
maxLines: 2,
|
onChanged: (_) => widget.onChanged(),
|
||||||
onChanged: (_) => 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _GrnLineDateField extends StatelessWidget {
|
class _GrnLineDateField extends StatelessWidget {
|
||||||
const _GrnLineDateField({
|
const _GrnLineDateField({
|
||||||
|
super.key,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.value,
|
required this.value,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
@ -460,8 +553,10 @@ class _GrnLineDateField extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
value != null ? DateFormatter.displayDate(value) : 'Select date',
|
value != null ? DateFormatter.displayDate(value) : 'Select date',
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: value != null ? null : Theme.of(context).hintColor,
|
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 {
|
class GrnItemsTable extends StatelessWidget {
|
||||||
const GrnItemsTable({super.key, required this.items});
|
const GrnItemsTable({super.key, required this.items});
|
||||||
|
|
||||||
@ -477,70 +573,211 @@ class GrnItemsTable extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AppDataTable<GrnItemModel>(
|
final theme = Theme.of(context);
|
||||||
wrapInCard: false,
|
final borderColor = theme.colorScheme.outline.withValues(alpha: 0.15);
|
||||||
shrinkWrap: true,
|
|
||||||
emptyMessage: 'No line items',
|
if (items.isEmpty) {
|
||||||
columns: [
|
return Text(
|
||||||
AppDataColumn(
|
'No line items',
|
||||||
label: '#',
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
flex: 1,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
cellBuilder: (_, item) => Text('${item.lineNo ?? '—'}'),
|
|
||||||
),
|
|
||||||
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!) : '—',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
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 ?? '—'),
|
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
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(
|
final result = await list(
|
||||||
definition,
|
definition,
|
||||||
page: page,
|
page: page,
|
||||||
limit: AppConstants.maxPageSize,
|
limit: AppConstants.defaultPageSize,
|
||||||
);
|
);
|
||||||
allItems.addAll(
|
allItems.addAll(
|
||||||
result.items.where((item) => item['is_active'] != false),
|
result.items.where((item) => item['is_active'] != false),
|
||||||
|
|||||||
@ -11,9 +11,12 @@ class MasterFieldDef {
|
|||||||
this.type = MasterFieldType.text,
|
this.type = MasterFieldType.text,
|
||||||
this.required = false,
|
this.required = false,
|
||||||
this.showInList = false,
|
this.showInList = false,
|
||||||
|
this.showInForm = true,
|
||||||
this.optionsMasterKey,
|
this.optionsMasterKey,
|
||||||
this.staticOptions,
|
this.staticOptions,
|
||||||
this.multiline = false,
|
this.multiline = false,
|
||||||
|
this.filterByFieldKey,
|
||||||
|
this.filterByOptionKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String key;
|
final String key;
|
||||||
@ -21,11 +24,17 @@ class MasterFieldDef {
|
|||||||
final MasterFieldType type;
|
final MasterFieldType type;
|
||||||
final bool required;
|
final bool required;
|
||||||
final bool showInList;
|
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).
|
/// Master key used to populate dropdown options (e.g. `plants` for plant_id).
|
||||||
final String? optionsMasterKey;
|
final String? optionsMasterKey;
|
||||||
/// Fixed dropdown choices (e.g. brand type) — no API lookup.
|
/// Fixed dropdown choices (e.g. brand type) — no API lookup.
|
||||||
final List<String>? staticOptions;
|
final List<String>? staticOptions;
|
||||||
final bool multiline;
|
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 {
|
class MasterDefinition {
|
||||||
@ -54,7 +63,8 @@ class MasterDefinition {
|
|||||||
List<MasterFieldDef> get listFields =>
|
List<MasterFieldDef> get listFields =>
|
||||||
fields.where((field) => field.showInList).toList();
|
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 listRoute(String base) => '$base/$routeKey';
|
||||||
String addRoute(String base) => '$base/$routeKey/add';
|
String addRoute(String base) => '$base/$routeKey/add';
|
||||||
@ -109,6 +119,19 @@ const masterDefinitions = <MasterDefinition>[
|
|||||||
fields: [
|
fields: [
|
||||||
MasterFieldDef(key: 'code', label: 'Code', required: true, showInList: true),
|
MasterFieldDef(key: 'code', label: 'Code', required: true, showInList: true),
|
||||||
MasterFieldDef(key: 'name', label: 'Name', 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,
|
_activeField,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -145,7 +168,13 @@ const masterDefinitions = <MasterDefinition>[
|
|||||||
module: 'items',
|
module: 'items',
|
||||||
icon: Icons.inventory_outlined,
|
icon: Icons.inventory_outlined,
|
||||||
fields: [
|
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_name', label: 'Item Name', required: true, showInList: true),
|
||||||
MasterFieldDef(
|
MasterFieldDef(
|
||||||
key: 'item_category_id',
|
key: 'item_category_id',
|
||||||
@ -159,6 +188,7 @@ const masterDefinitions = <MasterDefinition>[
|
|||||||
label: 'Sub Category',
|
label: 'Sub Category',
|
||||||
type: MasterFieldType.dropdown,
|
type: MasterFieldType.dropdown,
|
||||||
optionsMasterKey: 'item_subcategories',
|
optionsMasterKey: 'item_subcategories',
|
||||||
|
filterByFieldKey: 'item_category_id',
|
||||||
),
|
),
|
||||||
MasterFieldDef(
|
MasterFieldDef(
|
||||||
key: 'uom_id',
|
key: 'uom_id',
|
||||||
@ -167,6 +197,13 @@ const masterDefinitions = <MasterDefinition>[
|
|||||||
required: true,
|
required: true,
|
||||||
optionsMasterKey: 'uom',
|
optionsMasterKey: 'uom',
|
||||||
),
|
),
|
||||||
|
MasterFieldDef(
|
||||||
|
key: 'hsn_code_id',
|
||||||
|
label: 'HSN Code',
|
||||||
|
type: MasterFieldType.dropdown,
|
||||||
|
showInList: true,
|
||||||
|
optionsMasterKey: 'hsn_codes',
|
||||||
|
),
|
||||||
MasterFieldDef(
|
MasterFieldDef(
|
||||||
key: 'gst_rate_id',
|
key: 'gst_rate_id',
|
||||||
label: 'GST Rate',
|
label: 'GST Rate',
|
||||||
@ -202,6 +239,27 @@ const masterDefinitions = <MasterDefinition>[
|
|||||||
_activeField,
|
_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(
|
MasterDefinition(
|
||||||
id: 'brands',
|
id: 'brands',
|
||||||
title: 'Brands',
|
title: 'Brands',
|
||||||
@ -330,59 +388,6 @@ const masterDefinitions = <MasterDefinition>[
|
|||||||
_activeField,
|
_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(
|
MasterDefinition(
|
||||||
id: 'delivery_terms',
|
id: 'delivery_terms',
|
||||||
title: 'Delivery Terms',
|
title: 'Delivery Terms',
|
||||||
@ -455,16 +460,35 @@ String masterRecordLabel(Map<String, dynamic> row) {
|
|||||||
? ratePct.toDouble()
|
? ratePct.toDouble()
|
||||||
: double.tryParse(ratePct.toString());
|
: double.tryParse(ratePct.toString());
|
||||||
if (rate != null) {
|
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];
|
final value = row[key];
|
||||||
if (value != null && value.toString().trim().isNotEmpty) {
|
if (value != null && value.toString().trim().isNotEmpty) {
|
||||||
return value.toString();
|
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';
|
return row['id']?.toString() ?? 'Record';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -480,8 +504,8 @@ String masterCellValue(Map<String, dynamic> row, MasterFieldDef field) {
|
|||||||
final label = row['${field.key}_label'];
|
final label = row['${field.key}_label'];
|
||||||
if (label != null && label.toString().isNotEmpty) return label.toString();
|
if (label != null && label.toString().isNotEmpty) return label.toString();
|
||||||
|
|
||||||
// Prefer explicit "<base>_name" or nested "<base>.name" from API payloads
|
// Prefer explicit "<base>_name" or nested "<base>.name" / flat code from API
|
||||||
// (e.g. asset_category_id -> asset_category_name / asset_category.name)
|
// (e.g. asset_category_id -> asset_category_name; hsn_code_id -> hsn_code)
|
||||||
if (field.key.endsWith('_id')) {
|
if (field.key.endsWith('_id')) {
|
||||||
final baseKey = field.key.substring(0, field.key.length - 3);
|
final baseKey = field.key.substring(0, field.key.length - 3);
|
||||||
final explicitName = row['${baseKey}_name'];
|
final explicitName = row['${baseKey}_name'];
|
||||||
@ -490,11 +514,17 @@ String masterCellValue(Map<String, dynamic> row, MasterFieldDef field) {
|
|||||||
}
|
}
|
||||||
final nested = row[baseKey];
|
final nested = row[baseKey];
|
||||||
if (nested is Map) {
|
if (nested is Map) {
|
||||||
final nestedName = nested['name'];
|
for (final nestedKey in ['code', 'name', 'description']) {
|
||||||
if (nestedName != null && nestedName.toString().trim().isNotEmpty) {
|
final nestedValue = nested[nestedKey];
|
||||||
return nestedName.toString().trim();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (field.staticOptions != null) {
|
if (field.staticOptions != null) {
|
||||||
|
|||||||
@ -259,7 +259,7 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
|
|||||||
final result = await ref.read(masterRepositoryProvider).list(
|
final result = await ref.read(masterRepositoryProvider).list(
|
||||||
_definition,
|
_definition,
|
||||||
page: page,
|
page: page,
|
||||||
limit: AppConstants.maxPageSize,
|
limit: AppConstants.defaultPageSize,
|
||||||
);
|
);
|
||||||
if (result.failure != null) break;
|
if (result.failure != null) break;
|
||||||
|
|
||||||
@ -310,6 +310,27 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
|
|||||||
if (current == null) return;
|
if (current == null) return;
|
||||||
final values = Map<String, dynamic>.from(current.values);
|
final values = Map<String, dynamic>.from(current.values);
|
||||||
values[key] = value;
|
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));
|
state = AsyncData(current.copyWith(values: values));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -386,7 +386,7 @@ class _MasterListTable extends StatelessWidget {
|
|||||||
|
|
||||||
int _columnFlex(MasterFieldDef field) {
|
int _columnFlex(MasterFieldDef field) {
|
||||||
return switch (field.key) {
|
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,
|
'name' || 'item_name' || 'description' || 'term_name' => 3,
|
||||||
_ => 2,
|
_ => 2,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -99,8 +99,20 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
|||||||
if (field.staticOptions != null) {
|
if (field.staticOptions != null) {
|
||||||
dropdownOptions = stringDropdownOptions(field.staticOptions!);
|
dropdownOptions = stringDropdownOptions(field.staticOptions!);
|
||||||
} else {
|
} else {
|
||||||
final options = formState.dropdownOptions[field.optionsMasterKey] ??
|
var options = formState.dropdownOptions[field.optionsMasterKey] ??
|
||||||
const <Map<String, dynamic>>[];
|
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>>[];
|
dropdownOptions = <AppDropdownOption<String>>[];
|
||||||
for (final item in options) {
|
for (final item in options) {
|
||||||
final id = item['id']?.toString();
|
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>(
|
return AppSearchableDropdown<String>(
|
||||||
|
key: ValueKey(
|
||||||
|
'${field.key}-${filterField == null ? '' : formState.values[filterField]}',
|
||||||
|
),
|
||||||
label: _fieldLabel(field),
|
label: _fieldLabel(field),
|
||||||
value: value?.toString(),
|
value: value?.toString(),
|
||||||
options: dropdownOptions,
|
options: dropdownOptions,
|
||||||
hint: dropdownOptions.isEmpty
|
hint: !parentSelected
|
||||||
|
? 'Select $parentLabel first'
|
||||||
|
: dropdownOptions.isEmpty
|
||||||
? 'No options available'
|
? 'No options available'
|
||||||
: 'Select ${field.label.toLowerCase()}',
|
: 'Select ${field.label.toLowerCase()}',
|
||||||
searchHint: 'Search ${field.label.toLowerCase()}...',
|
searchHint: 'Search ${field.label.toLowerCase()}...',
|
||||||
enabled: dropdownOptions.isNotEmpty,
|
enabled: enabled,
|
||||||
onChanged: (selected) => notifier.updateValue(field.key, selected),
|
onChanged: (selected) => notifier.updateValue(field.key, selected),
|
||||||
validator: field.required
|
validator: field.required
|
||||||
? (v) => v == null ? '${field.label} is required' : null
|
? (v) => v == null ? '${field.label} is required' : null
|
||||||
@ -139,15 +173,29 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
case MasterFieldType.text:
|
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(
|
return TextFormField(
|
||||||
key: ValueKey(field.key),
|
key: ValueKey(field.key),
|
||||||
initialValue: value?.toString(),
|
initialValue: value?.toString(),
|
||||||
maxLines: field.multiline ? 3 : 1,
|
maxLines: field.multiline ? 3 : 1,
|
||||||
keyboardType: _keyboardTypeForFieldKey(field.key),
|
keyboardType: isHsnCodeField
|
||||||
|
? TextInputType.number
|
||||||
|
: _keyboardTypeForFieldKey(field.key),
|
||||||
inputFormatters: formatters.isEmpty ? null : formatters,
|
inputFormatters: formatters.isEmpty ? null : formatters,
|
||||||
decoration: InputDecoration(labelText: _fieldLabel(field)),
|
decoration: InputDecoration(labelText: _fieldLabel(field)),
|
||||||
validator: (v) {
|
validator: (v) {
|
||||||
|
if (isHsnCodeField) {
|
||||||
|
return Validators.uniqueHsnCode(
|
||||||
|
v,
|
||||||
|
existingRecords: formState.existingRecords,
|
||||||
|
currentRecordId: widget.recordId,
|
||||||
|
fieldName: field.label,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (Validators.isMasterNameFieldKey(field.key)) {
|
if (Validators.isMasterNameFieldKey(field.key)) {
|
||||||
return Validators.uniqueMasterName(
|
return Validators.uniqueMasterName(
|
||||||
v,
|
v,
|
||||||
|
|||||||
@ -44,62 +44,96 @@ class MasterRemoteDataSource {
|
|||||||
Future<List<FilterOptionModel>> listGstRates() =>
|
Future<List<FilterOptionModel>> listGstRates() =>
|
||||||
_listOptions(ApiEndpoints.gstRates);
|
_listOptions(ApiEndpoints.gstRates);
|
||||||
|
|
||||||
Future<List<FilterOptionModel>> listAssetCategories() =>
|
Future<List<FilterOptionModel>> listHsnCodes() =>
|
||||||
_listOptions(ApiEndpoints.assetCategories);
|
_listOptions(ApiEndpoints.hsnCodes);
|
||||||
|
|
||||||
Future<List<FilterOptionModel>> listAssetSubcategories({int? assetCategoryId}) async {
|
/// Active items with default HSN / UOM / GST for PO line autofill.
|
||||||
final response = await dio.get(
|
Future<
|
||||||
ApiEndpoints.assetSubcategories,
|
({
|
||||||
|
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 hsnByItemId = <String, int?>{};
|
||||||
|
final uomByItemId = <String, int?>{};
|
||||||
|
final gstRateByItemId = <String, int?>{};
|
||||||
|
final options = <FilterOptionModel>[];
|
||||||
|
|
||||||
|
for (final item in rows) {
|
||||||
|
if (item['is_active'] == false) continue;
|
||||||
|
final id = item['id']?.toString() ?? '';
|
||||||
|
if (id.isEmpty) continue;
|
||||||
|
|
||||||
|
hsnByItemId[id] = _asInt(item['hsn_code_id']);
|
||||||
|
uomByItemId[id] = _asInt(item['uom_id']);
|
||||||
|
gstRateByItemId[id] = _asInt(item['gst_rate_id']);
|
||||||
|
|
||||||
|
final name = _optionLabel(item);
|
||||||
|
if (name.isEmpty) continue;
|
||||||
|
options.add(FilterOptionModel(id: id, name: name));
|
||||||
|
}
|
||||||
|
|
||||||
|
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: {
|
queryParameters: {
|
||||||
'limit': AppConstants.maxPageSize,
|
|
||||||
'is_active': true,
|
'is_active': true,
|
||||||
if (assetCategoryId != null) 'asset_category_id': assetCategoryId,
|
if (itemCategoryId != null) 'item_category_id': itemCategoryId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
final categoryFilter = assetCategoryId?.toString();
|
final categoryFilter = itemCategoryId?.toString();
|
||||||
return _parseOptions(
|
return rows
|
||||||
response.data,
|
|
||||||
extraFilter: categoryFilter == null
|
|
||||||
? null
|
|
||||||
: (item) => item['asset_category_id']?.toString() == categoryFilter,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<FilterOptionModel>> _listOptions(String endpoint) async {
|
|
||||||
final response = await dio.get(
|
|
||||||
endpoint,
|
|
||||||
queryParameters: {
|
|
||||||
'limit': AppConstants.maxPageSize,
|
|
||||||
'is_active': true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return _parseOptions(response.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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'];
|
|
||||||
|
|
||||||
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 [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return list
|
|
||||||
.whereType<Map>()
|
|
||||||
.map((item) => Map<String, dynamic>.from(item))
|
|
||||||
.where((item) => item['is_active'] != false)
|
.where((item) => item['is_active'] != false)
|
||||||
.where((item) => extraFilter == null || extraFilter(item))
|
.where(
|
||||||
|
(item) =>
|
||||||
|
categoryFilter == null ||
|
||||||
|
item['item_category_id']?.toString() == categoryFilter,
|
||||||
|
)
|
||||||
.map(
|
.map(
|
||||||
(item) => FilterOptionModel(
|
(item) => FilterOptionModel(
|
||||||
id: item['id']?.toString() ?? '',
|
id: item['id']?.toString() ?? '',
|
||||||
@ -110,6 +144,101 @@ class MasterRemoteDataSource {
|
|||||||
.toList();
|
.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) {
|
String _optionLabel(Map<String, dynamic> item) {
|
||||||
final ratePct = item['rate_pct'];
|
final ratePct = item['rate_pct'];
|
||||||
if (ratePct != null) {
|
if (ratePct != null) {
|
||||||
@ -132,14 +261,26 @@ class MasterRemoteDataSource {
|
|||||||
'item_name',
|
'item_name',
|
||||||
'term_name',
|
'term_name',
|
||||||
'vendor_name',
|
'vendor_name',
|
||||||
'code',
|
|
||||||
'description',
|
|
||||||
]) {
|
]) {
|
||||||
final value = item[key];
|
final value = item[key];
|
||||||
if (value is String && value.trim().isNotEmpty) {
|
if (value is String && value.trim().isNotEmpty) {
|
||||||
return value.trim();
|
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 '';
|
return '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,9 +21,16 @@ class PurchaseOrderRemoteDataSource {
|
|||||||
|
|
||||||
Future<PurchaseOrderModel> getPurchaseOrderById(String id) async {
|
Future<PurchaseOrderModel> getPurchaseOrderById(String id) async {
|
||||||
final response = await dio.get(ApiEndpoints.purchaseOrderById(id));
|
final response = await dio.get(ApiEndpoints.purchaseOrderById(id));
|
||||||
return PurchaseOrderModel.fromJson(
|
final raw = response.data['data'];
|
||||||
response.data['data'] as Map<String, dynamic>,
|
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 {
|
Future<PurchaseOrderModel> createPurchaseOrder(Map<String, dynamic> data) async {
|
||||||
|
|||||||
@ -16,8 +16,13 @@ class PurchaseOrderLookups {
|
|||||||
this.paymentTerms = const [],
|
this.paymentTerms = const [],
|
||||||
this.deliveryTerms = const [],
|
this.deliveryTerms = const [],
|
||||||
this.items = const [],
|
this.items = const [],
|
||||||
|
this.itemHsnById = const {},
|
||||||
|
this.itemUomById = const {},
|
||||||
|
this.itemGstRateById = const {},
|
||||||
this.uom = const [],
|
this.uom = const [],
|
||||||
this.gstRates = const [],
|
this.gstRates = const [],
|
||||||
|
this.gstRatePctById = const {},
|
||||||
|
this.hsnCodes = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<FilterOptionModel> vendors;
|
final List<FilterOptionModel> vendors;
|
||||||
@ -27,8 +32,17 @@ class PurchaseOrderLookups {
|
|||||||
final List<FilterOptionModel> paymentTerms;
|
final List<FilterOptionModel> paymentTerms;
|
||||||
final List<FilterOptionModel> deliveryTerms;
|
final List<FilterOptionModel> deliveryTerms;
|
||||||
final List<FilterOptionModel> items;
|
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> uom;
|
||||||
final List<FilterOptionModel> gstRates;
|
final List<FilterOptionModel> gstRates;
|
||||||
|
/// GST rate id → `rate_pct` for tax calculations.
|
||||||
|
final Map<String, double> gstRatePctById;
|
||||||
|
final List<FilterOptionModel> hsnCodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
final purchaseOrderLookupsProvider =
|
final purchaseOrderLookupsProvider =
|
||||||
@ -38,15 +52,17 @@ final purchaseOrderLookupsProvider =
|
|||||||
|
|
||||||
final vendors = await _safeOptions(() => _fetchActiveVendors(vendorRepo));
|
final vendors = await _safeOptions(() => _fetchActiveVendors(vendorRepo));
|
||||||
|
|
||||||
|
final itemsWithDefaults = await _safeItemsWithDefaults(master.listItemsWithHsn);
|
||||||
|
final gstWithPct = await _safeGstRatesWithPct(master.listGstRatesWithPct);
|
||||||
|
|
||||||
final results = await Future.wait([
|
final results = await Future.wait([
|
||||||
_safeOptions(master.listPlants),
|
_safeOptions(master.listPlants),
|
||||||
_safeOptions(master.listWarehouses),
|
_safeOptions(master.listWarehouses),
|
||||||
_safeOptions(master.listBrands),
|
_safeOptions(master.listBrands),
|
||||||
_safeOptions(master.listPaymentTerms),
|
_safeOptions(master.listPaymentTerms),
|
||||||
_safeOptions(master.listDeliveryTerms),
|
_safeOptions(master.listDeliveryTerms),
|
||||||
_safeOptions(master.listItems),
|
|
||||||
_safeOptions(master.listUom),
|
_safeOptions(master.listUom),
|
||||||
_safeOptions(master.listGstRates),
|
_safeOptions(master.listHsnCodes),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return PurchaseOrderLookups(
|
return PurchaseOrderLookups(
|
||||||
@ -56,9 +72,14 @@ final purchaseOrderLookupsProvider =
|
|||||||
brands: results[2],
|
brands: results[2],
|
||||||
paymentTerms: results[3],
|
paymentTerms: results[3],
|
||||||
deliveryTerms: results[4],
|
deliveryTerms: results[4],
|
||||||
items: results[5],
|
items: itemsWithDefaults.options,
|
||||||
uom: results[6],
|
itemHsnById: itemsWithDefaults.hsnByItemId,
|
||||||
gstRates: results[7],
|
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(
|
Future<List<FilterOptionModel>> _fetchActiveVendors(
|
||||||
VendorRepository vendorRepo,
|
VendorRepository vendorRepo,
|
||||||
) async {
|
) async {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../../../shared/models/purchase_order_model.dart';
|
import '../../../../shared/models/purchase_order_model.dart';
|
||||||
|
import '../../../grn/presentation/providers/grn_lookups_provider.dart';
|
||||||
import '../../data/repositories/purchase_order_repository_impl.dart';
|
import '../../data/repositories/purchase_order_repository_impl.dart';
|
||||||
|
|
||||||
class PurchaseOrdersListState {
|
class PurchaseOrdersListState {
|
||||||
@ -131,6 +132,7 @@ class PurchaseOrdersListNotifier
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
ref.invalidate(grnLookupsProvider);
|
||||||
await refresh();
|
await refresh();
|
||||||
final current = state.valueOrNull;
|
final current = state.valueOrNull;
|
||||||
if (current != null) {
|
if (current != null) {
|
||||||
@ -166,6 +168,7 @@ class PurchaseOrderDetailNotifier
|
|||||||
if (result.failure != null) throw result.failure!;
|
if (result.failure != null) throw result.failure!;
|
||||||
state = AsyncData(result.data!);
|
state = AsyncData(result.data!);
|
||||||
ref.invalidate(purchaseOrdersListProvider);
|
ref.invalidate(purchaseOrdersListProvider);
|
||||||
|
ref.invalidate(grnLookupsProvider);
|
||||||
return result.data!;
|
return result.data!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -175,6 +178,7 @@ class PurchaseOrderDetailNotifier
|
|||||||
if (result.failure != null) throw result.failure!;
|
if (result.failure != null) throw result.failure!;
|
||||||
state = AsyncData(result.data!);
|
state = AsyncData(result.data!);
|
||||||
ref.invalidate(purchaseOrdersListProvider);
|
ref.invalidate(purchaseOrdersListProvider);
|
||||||
|
ref.invalidate(grnLookupsProvider);
|
||||||
return result.data!;
|
return result.data!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -184,6 +188,7 @@ class PurchaseOrderDetailNotifier
|
|||||||
if (result.failure != null) throw result.failure!;
|
if (result.failure != null) throw result.failure!;
|
||||||
state = AsyncData(result.data!);
|
state = AsyncData(result.data!);
|
||||||
ref.invalidate(purchaseOrdersListProvider);
|
ref.invalidate(purchaseOrdersListProvider);
|
||||||
|
ref.invalidate(grnLookupsProvider);
|
||||||
return result.data!;
|
return result.data!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -192,6 +197,7 @@ class PurchaseOrderDetailNotifier
|
|||||||
final result = await repository.amendPurchaseOrder(arg);
|
final result = await repository.amendPurchaseOrder(arg);
|
||||||
if (result.failure != null) throw result.failure!;
|
if (result.failure != null) throw result.failure!;
|
||||||
ref.invalidate(purchaseOrdersListProvider);
|
ref.invalidate(purchaseOrdersListProvider);
|
||||||
|
ref.invalidate(grnLookupsProvider);
|
||||||
return result.data!;
|
return result.data!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -201,6 +207,7 @@ class PurchaseOrderDetailNotifier
|
|||||||
if (result.failure != null) throw result.failure!;
|
if (result.failure != null) throw result.failure!;
|
||||||
state = AsyncData(result.data!);
|
state = AsyncData(result.data!);
|
||||||
ref.invalidate(purchaseOrdersListProvider);
|
ref.invalidate(purchaseOrdersListProvider);
|
||||||
|
ref.invalidate(grnLookupsProvider);
|
||||||
return result.data!;
|
return result.data!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -232,6 +239,7 @@ class PurchaseOrderFormNotifier extends FamilyAsyncNotifier<PurchaseOrderModel?,
|
|||||||
final result = await repository.createPurchaseOrder(data);
|
final result = await repository.createPurchaseOrder(data);
|
||||||
if (result.failure != null) throw result.failure!;
|
if (result.failure != null) throw result.failure!;
|
||||||
ref.invalidate(purchaseOrdersListProvider);
|
ref.invalidate(purchaseOrdersListProvider);
|
||||||
|
ref.invalidate(grnLookupsProvider);
|
||||||
return result.data!;
|
return result.data!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -241,6 +249,7 @@ class PurchaseOrderFormNotifier extends FamilyAsyncNotifier<PurchaseOrderModel?,
|
|||||||
if (result.failure != null) throw result.failure!;
|
if (result.failure != null) throw result.failure!;
|
||||||
ref.invalidate(purchaseOrdersListProvider);
|
ref.invalidate(purchaseOrdersListProvider);
|
||||||
ref.invalidate(purchaseOrderDetailProvider(id));
|
ref.invalidate(purchaseOrderDetailProvider(id));
|
||||||
|
ref.invalidate(grnLookupsProvider);
|
||||||
return result.data!;
|
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/constants/route_constants.dart';
|
||||||
import '../../../../core/errors/failure.dart';
|
import '../../../../core/errors/failure.dart';
|
||||||
import '../../../../core/network/api_handler.dart';
|
import '../../../../core/network/api_handler.dart';
|
||||||
|
import '../../../../core/theme/app_colors.dart';
|
||||||
import '../../../../core/utils/formatters.dart';
|
import '../../../../core/utils/formatters.dart';
|
||||||
import '../../../../shared/models/purchase_order_model.dart';
|
import '../../../../shared/models/purchase_order_model.dart';
|
||||||
import '../../../../shared/models/user_management_models.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/app_text_field.dart';
|
||||||
import '../../../../shared/widgets/error_view.dart';
|
import '../../../../shared/widgets/error_view.dart';
|
||||||
import '../../../../shared/widgets/app_side_panel.dart';
|
import '../../../../shared/widgets/app_side_panel.dart';
|
||||||
import '../../../../shared/widgets/page_header.dart';
|
|
||||||
import '../../data/repositories/purchase_order_repository_impl.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_order_lookups_provider.dart';
|
||||||
import '../providers/purchase_orders_provider.dart';
|
import '../providers/purchase_orders_provider.dart';
|
||||||
|
import '../widgets/po_status_chip.dart';
|
||||||
import '../widgets/purchase_order_line_items_editor.dart';
|
import '../widgets/purchase_order_line_items_editor.dart';
|
||||||
|
|
||||||
class PurchaseOrderFormScreen extends ConsumerStatefulWidget {
|
class PurchaseOrderFormScreen extends ConsumerStatefulWidget {
|
||||||
@ -37,9 +39,9 @@ class PurchaseOrderFormScreen extends ConsumerStatefulWidget {
|
|||||||
class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScreen> {
|
class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScreen> {
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
final _scrollController = ScrollController();
|
final _scrollController = ScrollController();
|
||||||
final _discountController = TextEditingController();
|
final _discountController = TextEditingController(text: '0.00');
|
||||||
final _freightController = TextEditingController();
|
final _freightController = TextEditingController(text: '0.00');
|
||||||
final _otherChargesController = TextEditingController();
|
final _otherChargesController = TextEditingController(text: '0.00');
|
||||||
final _termsController = TextEditingController();
|
final _termsController = TextEditingController();
|
||||||
final _remarksController = TextEditingController();
|
final _remarksController = TextEditingController();
|
||||||
|
|
||||||
@ -63,10 +65,16 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
|||||||
_poDate = DateTime.now();
|
_poDate = DateTime.now();
|
||||||
_lines.add(PoLineItemDraft(lineNo: 1));
|
_lines.add(PoLineItemDraft(lineNo: 1));
|
||||||
}
|
}
|
||||||
|
_discountController.addListener(_onChargesChanged);
|
||||||
|
_freightController.addListener(_onChargesChanged);
|
||||||
|
_otherChargesController.addListener(_onChargesChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_discountController.removeListener(_onChargesChanged);
|
||||||
|
_freightController.removeListener(_onChargesChanged);
|
||||||
|
_otherChargesController.removeListener(_onChargesChanged);
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
_discountController.dispose();
|
_discountController.dispose();
|
||||||
_freightController.dispose();
|
_freightController.dispose();
|
||||||
@ -79,6 +87,8 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onChargesChanged() => setState(() {});
|
||||||
|
|
||||||
String _orderSignature(PurchaseOrderModel order) =>
|
String _orderSignature(PurchaseOrderModel order) =>
|
||||||
'${order.id}:${order.updatedAt?.toIso8601String()}:${order.items.length}';
|
'${order.id}:${order.updatedAt?.toIso8601String()}:${order.items.length}';
|
||||||
|
|
||||||
@ -93,9 +103,12 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
|||||||
_brandId = order.brandId;
|
_brandId = order.brandId;
|
||||||
_paymentTermId = order.paymentTermId;
|
_paymentTermId = order.paymentTermId;
|
||||||
_deliveryTermId = order.deliveryTermId;
|
_deliveryTermId = order.deliveryTermId;
|
||||||
_discountController.text = order.discountAmount?.toString() ?? '';
|
_discountController.text =
|
||||||
_freightController.text = order.freightCharges?.toString() ?? '';
|
(order.discountAmount ?? 0).toStringAsFixed(2);
|
||||||
_otherChargesController.text = order.otherCharges?.toString() ?? '';
|
_freightController.text =
|
||||||
|
(order.freightCharges ?? 0).toStringAsFixed(2);
|
||||||
|
_otherChargesController.text =
|
||||||
|
(order.otherCharges ?? 0).toStringAsFixed(2);
|
||||||
_termsController.text = order.termsAndConditions ?? '';
|
_termsController.text = order.termsAndConditions ?? '';
|
||||||
_remarksController.text = order.remarks ?? '';
|
_remarksController.text = order.remarks ?? '';
|
||||||
for (final line in _lines) {
|
for (final line in _lines) {
|
||||||
@ -145,9 +158,11 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
List<AppDropdownOption<int?>> _nullableIntOptions(List<FilterOptionModel> options) {
|
List<AppDropdownOption<int?>> _nullableIntOptions(
|
||||||
|
List<FilterOptionModel> options,
|
||||||
|
) {
|
||||||
return [
|
return [
|
||||||
const AppDropdownOption<int?>(value: null, label: 'None'),
|
const AppDropdownOption<int?>(value: null, label: '—'),
|
||||||
...options.map(
|
...options.map(
|
||||||
(e) {
|
(e) {
|
||||||
final id = _parseId(e.id);
|
final id = _parseId(e.id);
|
||||||
@ -158,6 +173,17 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
|||||||
].whereType<AppDropdownOption<int?>>().toList();
|
].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() {
|
Map<String, dynamic> _buildPayload() {
|
||||||
return {
|
return {
|
||||||
'po_date': DateFormatter.toApiDate(_poDate ?? DateTime.now()),
|
'po_date': DateFormatter.toApiDate(_poDate ?? DateTime.now()),
|
||||||
@ -171,12 +197,12 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
|||||||
if (_expectedDeliveryDate != null)
|
if (_expectedDeliveryDate != null)
|
||||||
'expected_delivery_date':
|
'expected_delivery_date':
|
||||||
DateFormatter.toApiDate(_expectedDeliveryDate!),
|
DateFormatter.toApiDate(_expectedDeliveryDate!),
|
||||||
if (_discountController.text.trim().isNotEmpty)
|
'discount_amount':
|
||||||
'discount_amount': double.tryParse(_discountController.text.trim()),
|
double.tryParse(_discountController.text.trim()) ?? 0,
|
||||||
if (_freightController.text.trim().isNotEmpty)
|
'freight_charges':
|
||||||
'freight_charges': double.tryParse(_freightController.text.trim()),
|
double.tryParse(_freightController.text.trim()) ?? 0,
|
||||||
if (_otherChargesController.text.trim().isNotEmpty)
|
'other_charges':
|
||||||
'other_charges': double.tryParse(_otherChargesController.text.trim()),
|
double.tryParse(_otherChargesController.text.trim()) ?? 0,
|
||||||
if (_termsController.text.trim().isNotEmpty)
|
if (_termsController.text.trim().isNotEmpty)
|
||||||
'terms_and_conditions': _termsController.text.trim(),
|
'terms_and_conditions': _termsController.text.trim(),
|
||||||
if (_remarksController.text.trim().isNotEmpty)
|
if (_remarksController.text.trim().isNotEmpty)
|
||||||
@ -251,6 +277,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
|||||||
if (result.failure != null) throw result.failure!;
|
if (result.failure != null) throw result.failure!;
|
||||||
saved = result.data!;
|
saved = result.data!;
|
||||||
ref.invalidate(purchaseOrdersListProvider);
|
ref.invalidate(purchaseOrdersListProvider);
|
||||||
|
ref.invalidate(grnLookupsProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@ -294,6 +321,12 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
|||||||
if (picked != null) onPicked(picked);
|
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({
|
Widget _buildFormBody({
|
||||||
required PurchaseOrderLookups lookups,
|
required PurchaseOrderLookups lookups,
|
||||||
PurchaseOrderModel? existing,
|
PurchaseOrderModel? existing,
|
||||||
@ -323,41 +356,35 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
|||||||
lookups.vendors.map((e) => _parseId(e.id)).whereType<int>();
|
lookups.vendors.map((e) => _parseId(e.id)).whereType<int>();
|
||||||
final plantIds =
|
final plantIds =
|
||||||
lookups.plants.map((e) => _parseId(e.id)).whereType<int>();
|
lookups.plants.map((e) => _parseId(e.id)).whereType<int>();
|
||||||
|
final totals = _computeTotals(lookups.gstRatePctById);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.topCenter,
|
alignment: Alignment.topCenter,
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 1200),
|
constraints: const BoxConstraints(maxWidth: 1200),
|
||||||
child: Column(
|
child: Form(
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Form(
|
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
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: [
|
children: [
|
||||||
FormRowFour(
|
FormRowFour(
|
||||||
children: [
|
children: [
|
||||||
_DateField(
|
_DateField(
|
||||||
label: 'PO Date *',
|
label: 'PO date *',
|
||||||
value: _poDate,
|
value: _poDate,
|
||||||
onTap: () => _pickDate(
|
onTap: () => _pickDate(
|
||||||
current: _poDate,
|
current: _poDate,
|
||||||
@ -365,13 +392,16 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
AppSearchableDropdown<String>(
|
AppSearchableDropdown<String>(
|
||||||
label: 'PO Type *',
|
label: 'PO type *',
|
||||||
value: _poType,
|
value: _poType,
|
||||||
|
hint: 'Select PO type',
|
||||||
searchHint: 'Search type...',
|
searchHint: 'Search type...',
|
||||||
options: poTypeOptions
|
options: poTypeOptions
|
||||||
.map(
|
.map(
|
||||||
(e) =>
|
(e) => AppDropdownOption(
|
||||||
AppDropdownOption(value: e.$1, label: e.$2),
|
value: e.$1,
|
||||||
|
label: e.$2,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
onChanged: (v) => setState(() => _poType = v),
|
onChanged: (v) => setState(() => _poType = v),
|
||||||
@ -381,6 +411,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
|||||||
AppSearchableDropdown<int>(
|
AppSearchableDropdown<int>(
|
||||||
label: 'Vendor *',
|
label: 'Vendor *',
|
||||||
value: _dropdownValue(_vendorId, vendorIds),
|
value: _dropdownValue(_vendorId, vendorIds),
|
||||||
|
hint: 'Select vendor',
|
||||||
searchHint: 'Search vendor...',
|
searchHint: 'Search vendor...',
|
||||||
options: _intOptions(lookups.vendors),
|
options: _intOptions(lookups.vendors),
|
||||||
onChanged: (v) => setState(() => _vendorId = v),
|
onChanged: (v) => setState(() => _vendorId = v),
|
||||||
@ -390,6 +421,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
|||||||
AppSearchableDropdown<int>(
|
AppSearchableDropdown<int>(
|
||||||
label: 'Plant *',
|
label: 'Plant *',
|
||||||
value: _dropdownValue(_plantId, plantIds),
|
value: _dropdownValue(_plantId, plantIds),
|
||||||
|
hint: 'Select plant',
|
||||||
searchHint: 'Search plant...',
|
searchHint: 'Search plant...',
|
||||||
options: _intOptions(lookups.plants),
|
options: _intOptions(lookups.plants),
|
||||||
onChanged: (v) => setState(() => _plantId = v),
|
onChanged: (v) => setState(() => _plantId = v),
|
||||||
@ -403,117 +435,256 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
|||||||
AppSearchableDropdown<int?>(
|
AppSearchableDropdown<int?>(
|
||||||
label: 'Warehouse',
|
label: 'Warehouse',
|
||||||
value: _warehouseId,
|
value: _warehouseId,
|
||||||
|
hint: 'Select warehouse',
|
||||||
searchHint: 'Search warehouse...',
|
searchHint: 'Search warehouse...',
|
||||||
options: _nullableIntOptions(lookups.warehouses),
|
options: _nullableIntOptions(lookups.warehouses),
|
||||||
onChanged: (v) => setState(() => _warehouseId = v),
|
onChanged: (v) =>
|
||||||
|
setState(() => _warehouseId = v),
|
||||||
),
|
),
|
||||||
AppSearchableDropdown<int?>(
|
AppSearchableDropdown<int?>(
|
||||||
label: 'Brand',
|
label: 'Brand',
|
||||||
value: _brandId,
|
value: _brandId,
|
||||||
|
hint: 'Select brand',
|
||||||
searchHint: 'Search brand...',
|
searchHint: 'Search brand...',
|
||||||
options: _nullableIntOptions(lookups.brands),
|
options: _nullableIntOptions(lookups.brands),
|
||||||
onChanged: (v) => setState(() => _brandId = v),
|
onChanged: (v) => setState(() => _brandId = v),
|
||||||
),
|
),
|
||||||
AppSearchableDropdown<int?>(
|
AppSearchableDropdown<int?>(
|
||||||
label: 'Payment Term',
|
label: 'Payment term',
|
||||||
value: _paymentTermId,
|
value: _paymentTermId,
|
||||||
|
hint: 'Select payment term',
|
||||||
searchHint: 'Search payment term...',
|
searchHint: 'Search payment term...',
|
||||||
options: _nullableIntOptions(lookups.paymentTerms),
|
options:
|
||||||
onChanged: (v) => setState(() => _paymentTermId = v),
|
_nullableIntOptions(lookups.paymentTerms),
|
||||||
|
onChanged: (v) =>
|
||||||
|
setState(() => _paymentTermId = v),
|
||||||
),
|
),
|
||||||
AppSearchableDropdown<int?>(
|
AppSearchableDropdown<int?>(
|
||||||
label: 'Delivery Term',
|
label: 'Delivery term',
|
||||||
value: _deliveryTermId,
|
value: _deliveryTermId,
|
||||||
|
hint: 'Select delivery term',
|
||||||
searchHint: 'Search delivery term...',
|
searchHint: 'Search delivery term...',
|
||||||
options: _nullableIntOptions(lookups.deliveryTerms),
|
options:
|
||||||
onChanged: (v) => setState(() => _deliveryTermId = v),
|
_nullableIntOptions(lookups.deliveryTerms),
|
||||||
|
onChanged: (v) =>
|
||||||
|
setState(() => _deliveryTermId = v),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
FormRowFour(
|
FormRow(
|
||||||
|
columnCount: 4,
|
||||||
children: [
|
children: [
|
||||||
_DateField(
|
_DateField(
|
||||||
label: 'Expected Delivery',
|
label: 'Expected delivery',
|
||||||
value: _expectedDeliveryDate,
|
value: _expectedDeliveryDate,
|
||||||
onTap: () => _pickDate(
|
onTap: () => _pickDate(
|
||||||
current: _expectedDeliveryDate,
|
current: _expectedDeliveryDate,
|
||||||
onPicked: (d) =>
|
onPicked: (d) => setState(
|
||||||
setState(() => _expectedDeliveryDate = d),
|
() => _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],
|
),
|
||||||
|
),
|
||||||
|
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: [
|
children: [
|
||||||
AppTextField(
|
AppTextField(
|
||||||
controller: _termsController,
|
controller: _termsController,
|
||||||
label: 'Terms & Conditions',
|
label: 'Terms & conditions',
|
||||||
maxLines: 3,
|
hint: 'Payment terms, inspection conditions, etc.',
|
||||||
|
maxLines: 5,
|
||||||
),
|
),
|
||||||
AppTextField(
|
AppTextField(
|
||||||
controller: _remarksController,
|
controller: _remarksController,
|
||||||
label: 'Remarks',
|
label: 'Remarks',
|
||||||
maxLines: 3,
|
hint: 'Any additional notes for this order.',
|
||||||
|
maxLines: 4,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
);
|
||||||
PurchaseOrderLineItemsEditor(
|
final summary = _AmountSummaryCard(
|
||||||
lines: _lines,
|
totals: totals,
|
||||||
items: lookups.items,
|
freightController: _freightController,
|
||||||
uom: lookups.uom,
|
otherChargesController: _otherChargesController,
|
||||||
gstRates: lookups.gstRates,
|
discountController: _discountController,
|
||||||
onAddLine: _addLine,
|
isEditing: widget.isEditing,
|
||||||
onRemoveLine: _removeLine,
|
);
|
||||||
|
|
||||||
|
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: 24),
|
const SizedBox(height: 20),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
OutlinedButton(
|
Text(
|
||||||
onPressed: _isSubmitting
|
'Fields marked * are required',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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
|
? null
|
||||||
: () => context.pop(),
|
: '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'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
AppButton(
|
||||||
child: AppButton(
|
label: widget.isEditing
|
||||||
label: widget.isEditing ? 'Update PO' : 'Create PO',
|
? 'Update purchase order'
|
||||||
|
: 'Save purchase order',
|
||||||
|
icon: Icons.check,
|
||||||
expand: false,
|
expand: false,
|
||||||
isLoading: _isSubmitting,
|
isLoading: _isSubmitting,
|
||||||
onPressed: _isSubmitting ? null : _submit,
|
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,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -523,14 +694,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
|||||||
final lookupsAsync = ref.watch(purchaseOrderLookupsProvider);
|
final lookupsAsync = ref.watch(purchaseOrderLookupsProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||||
appBar: AppBar(
|
|
||||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
|
||||||
surfaceTintColor: Colors.transparent,
|
|
||||||
title: Text(
|
|
||||||
widget.isEditing ? 'Edit Purchase Order' : 'Create Purchase Order',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
body: lookupsAsync.when(
|
body: lookupsAsync.when(
|
||||||
loading: () => const AppLoadingView(message: 'Loading form options...'),
|
loading: () => const AppLoadingView(message: 'Loading form options...'),
|
||||||
error: (e, _) => ErrorView.fromFailure(
|
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 {
|
class _DateField extends StatelessWidget {
|
||||||
const _DateField({
|
const _DateField({
|
||||||
required this.label,
|
required this.label,
|
||||||
@ -584,10 +1006,15 @@ class _DateField extends StatelessWidget {
|
|||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: label,
|
labelText: label,
|
||||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||||
suffixIcon: const Icon(Icons.calendar_today_outlined),
|
suffixIcon: const Icon(Icons.calendar_today_outlined, size: 18),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
value != null ? DateFormatter.displayDate(value) : 'Select date',
|
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) {
|
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}');
|
context.push('${RouteConstants.purchaseOrders}/${order.id}');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -15,29 +15,29 @@ class PoStatusChip extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final (color, label) = _resolveStatus(status);
|
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) {
|
(Color, String) _resolveStatus(String raw) {
|
||||||
switch (raw.toUpperCase()) {
|
switch (raw.toUpperCase()) {
|
||||||
case 'DRAFT':
|
case 'DRAFT':
|
||||||
return (Colors.blueGrey.shade700, poStatusLabel(raw));
|
return (const Color(0xFF546E7A), poStatusLabel(raw));
|
||||||
case 'SUBMITTED':
|
case 'SUBMITTED':
|
||||||
case 'PENDING_APPROVAL':
|
case 'PENDING_APPROVAL':
|
||||||
case 'PENDING':
|
case 'PENDING':
|
||||||
return (Colors.orange.shade800, poStatusLabel(raw));
|
return (const Color(0xFFE65100), poStatusLabel(raw));
|
||||||
case 'APPROVED':
|
case 'APPROVED':
|
||||||
return (Colors.green.shade700, poStatusLabel(raw));
|
return (const Color(0xFF2E7D32), poStatusLabel(raw));
|
||||||
case 'REJECTED':
|
case 'REJECTED':
|
||||||
return (Colors.red.shade700, poStatusLabel(raw));
|
return (const Color(0xFFC62828), poStatusLabel(raw));
|
||||||
case 'CANCELLED':
|
case 'CANCELLED':
|
||||||
return (Colors.grey.shade700, poStatusLabel(raw));
|
return (const Color(0xFF616161), poStatusLabel(raw));
|
||||||
case 'PARTIALLY_RECEIVED':
|
case 'PARTIALLY_RECEIVED':
|
||||||
return (Colors.teal.shade700, poStatusLabel(raw));
|
return (const Color(0xFF00695C), poStatusLabel(raw));
|
||||||
case 'FULLY_RECEIVED':
|
case 'FULLY_RECEIVED':
|
||||||
return (Colors.indigo.shade700, poStatusLabel(raw));
|
return (const Color(0xFF283593), poStatusLabel(raw));
|
||||||
default:
|
default:
|
||||||
return (Colors.blueGrey, poStatusLabel(raw));
|
return (const Color(0xFF546E7A), poStatusLabel(raw));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -54,17 +54,16 @@ class PoRevisionChip extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final color = Theme.of(context).colorScheme.primary;
|
return _PoBadge(
|
||||||
return _PoChip(
|
|
||||||
label: 'Revision $revisionNo',
|
label: 'Revision $revisionNo',
|
||||||
color: color,
|
color: const Color(0xFF607D8B),
|
||||||
compact: compact,
|
compact: compact,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PoChip extends StatelessWidget {
|
class _PoBadge extends StatelessWidget {
|
||||||
const _PoChip({
|
const _PoBadge({
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.color,
|
required this.color,
|
||||||
this.compact = false,
|
this.compact = false,
|
||||||
@ -76,26 +75,29 @@ class _PoChip extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Chip(
|
// Avoid Container.alignment — it expands to max width inside Wrap/Row
|
||||||
label: Text(
|
// 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,
|
label,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: color,
|
color: color,
|
||||||
fontSize: compact ? 11 : 12,
|
fontSize: compact ? 11 : 12,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
height: 1.2,
|
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 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../../core/utils/formatters.dart';
|
||||||
import '../../../../shared/models/purchase_order_model.dart';
|
import '../../../../shared/models/purchase_order_model.dart';
|
||||||
import '../../../../shared/models/user_management_models.dart';
|
import '../../../../shared/models/user_management_models.dart';
|
||||||
import '../../../../shared/widgets/app_dropdown.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_side_panel.dart';
|
||||||
import '../../../../shared/widgets/app_text_field.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 {
|
class PoLineItemDraft {
|
||||||
PoLineItemDraft({
|
PoLineItemDraft({
|
||||||
this.itemId,
|
this.itemId,
|
||||||
@ -16,11 +103,11 @@ class PoLineItemDraft {
|
|||||||
TextEditingController? rateController,
|
TextEditingController? rateController,
|
||||||
TextEditingController? discountController,
|
TextEditingController? discountController,
|
||||||
this.gstRateId,
|
this.gstRateId,
|
||||||
TextEditingController? remarksController,
|
this.hsnCodeId,
|
||||||
}) : qtyController = qtyController ?? TextEditingController(),
|
}) : qtyController = qtyController ?? TextEditingController(),
|
||||||
rateController = rateController ?? TextEditingController(),
|
rateController = rateController ?? TextEditingController(),
|
||||||
discountController = discountController ?? TextEditingController(text: '0'),
|
discountController =
|
||||||
remarksController = remarksController ?? TextEditingController();
|
discountController ?? TextEditingController(text: '0');
|
||||||
|
|
||||||
int? itemId;
|
int? itemId;
|
||||||
int lineNo;
|
int lineNo;
|
||||||
@ -29,19 +116,20 @@ class PoLineItemDraft {
|
|||||||
final TextEditingController rateController;
|
final TextEditingController rateController;
|
||||||
final TextEditingController discountController;
|
final TextEditingController discountController;
|
||||||
int? gstRateId;
|
int? gstRateId;
|
||||||
final TextEditingController remarksController;
|
int? hsnCodeId;
|
||||||
|
|
||||||
factory PoLineItemDraft.fromModel(PurchaseOrderItemModel item) {
|
factory PoLineItemDraft.fromModel(PurchaseOrderItemModel item) {
|
||||||
return PoLineItemDraft(
|
return PoLineItemDraft(
|
||||||
itemId: item.itemId,
|
itemId: item.itemId,
|
||||||
lineNo: item.lineNo ?? 1,
|
lineNo: item.lineNo ?? 1,
|
||||||
qtyController: TextEditingController(text: item.orderedQty?.toString() ?? ''),
|
qtyController:
|
||||||
|
TextEditingController(text: item.orderedQty?.toString() ?? ''),
|
||||||
uomId: item.uomId,
|
uomId: item.uomId,
|
||||||
rateController: TextEditingController(text: item.rate?.toString() ?? ''),
|
rateController: TextEditingController(text: item.rate?.toString() ?? ''),
|
||||||
discountController:
|
discountController:
|
||||||
TextEditingController(text: item.discountPct?.toString() ?? '0'),
|
TextEditingController(text: item.discountPct?.toString() ?? '0'),
|
||||||
gstRateId: item.gstRateId,
|
gstRateId: item.gstRateId,
|
||||||
remarksController: TextEditingController(text: item.remarks ?? ''),
|
hsnCodeId: item.hsnCodeId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -49,7 +137,21 @@ class PoLineItemDraft {
|
|||||||
qtyController.dispose();
|
qtyController.dispose();
|
||||||
rateController.dispose();
|
rateController.dispose();
|
||||||
discountController.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() {
|
Map<String, dynamic> toPayload() {
|
||||||
@ -66,8 +168,7 @@ class PoLineItemDraft {
|
|||||||
'rate': rate,
|
'rate': rate,
|
||||||
'discount_pct': double.tryParse(discountController.text.trim()) ?? 0,
|
'discount_pct': double.tryParse(discountController.text.trim()) ?? 0,
|
||||||
if (gstRateId != null) 'gst_rate_id': gstRateId,
|
if (gstRateId != null) 'gst_rate_id': gstRateId,
|
||||||
if (remarksController.text.trim().isNotEmpty)
|
if (hsnCodeId != null) 'hsn_code_id': hsnCodeId,
|
||||||
'remarks': remarksController.text.trim(),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -77,40 +178,84 @@ class PurchaseOrderLineItemsEditor extends StatefulWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.lines,
|
required this.lines,
|
||||||
required this.items,
|
required this.items,
|
||||||
|
required this.itemHsnById,
|
||||||
|
required this.itemUomById,
|
||||||
|
required this.itemGstRateById,
|
||||||
required this.uom,
|
required this.uom,
|
||||||
required this.gstRates,
|
required this.gstRates,
|
||||||
|
required this.gstRatePctById,
|
||||||
required this.onAddLine,
|
required this.onAddLine,
|
||||||
required this.onRemoveLine,
|
required this.onRemoveLine,
|
||||||
|
this.onChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<PoLineItemDraft> lines;
|
final List<PoLineItemDraft> lines;
|
||||||
final List<FilterOptionModel> items;
|
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> uom;
|
||||||
final List<FilterOptionModel> gstRates;
|
final List<FilterOptionModel> gstRates;
|
||||||
|
final Map<String, double> gstRatePctById;
|
||||||
final VoidCallback onAddLine;
|
final VoidCallback onAddLine;
|
||||||
final ValueChanged<int> onRemoveLine;
|
final ValueChanged<int> onRemoveLine;
|
||||||
|
final VoidCallback? onChanged;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<PurchaseOrderLineItemsEditor> createState() =>
|
State<PurchaseOrderLineItemsEditor> createState() =>
|
||||||
_PurchaseOrderLineItemsEditorState();
|
_PurchaseOrderLineItemsEditorState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PurchaseOrderLineItemsEditorState extends State<PurchaseOrderLineItemsEditor> {
|
class _PurchaseOrderLineItemsEditorState
|
||||||
|
extends State<PurchaseOrderLineItemsEditor> {
|
||||||
|
void _notifyChanged() {
|
||||||
|
widget.onChanged?.call();
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(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(
|
return Container(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
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: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text('Line Items', style: theme.textTheme.titleMedium),
|
Text(
|
||||||
|
'LINE ITEMS',
|
||||||
|
style: theme.textTheme.labelMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
),
|
||||||
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: widget.onAddLine,
|
onPressed: () {
|
||||||
icon: const Icon(Icons.add),
|
widget.onAddLine();
|
||||||
|
widget.onChanged?.call();
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.add, size: 18),
|
||||||
label: const Text('Add line'),
|
label: const Text('Add line'),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -120,7 +265,7 @@ class _PurchaseOrderLineItemsEditorState extends State<PurchaseOrderLineItemsEdi
|
|||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(color: theme.dividerColor),
|
border: Border.all(color: borderColor),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
@ -136,20 +281,31 @@ class _PurchaseOrderLineItemsEditorState extends State<PurchaseOrderLineItemsEdi
|
|||||||
final index = entry.key;
|
final index = entry.key;
|
||||||
final line = entry.value;
|
final line = entry.value;
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
padding: EdgeInsets.only(
|
||||||
|
bottom: index == widget.lines.length - 1 ? 0 : 12,
|
||||||
|
),
|
||||||
child: _LineItemCard(
|
child: _LineItemCard(
|
||||||
key: ObjectKey(line),
|
key: ObjectKey(line),
|
||||||
line: line,
|
line: line,
|
||||||
items: widget.items,
|
items: widget.items,
|
||||||
|
itemHsnById: widget.itemHsnById,
|
||||||
|
itemUomById: widget.itemUomById,
|
||||||
|
itemGstRateById: widget.itemGstRateById,
|
||||||
uom: widget.uom,
|
uom: widget.uom,
|
||||||
gstRates: widget.gstRates,
|
gstRates: widget.gstRates,
|
||||||
|
gstRatePctById: widget.gstRatePctById,
|
||||||
|
onChanged: _notifyChanged,
|
||||||
onRemove: widget.lines.length > 1
|
onRemove: widget.lines.length > 1
|
||||||
? () => widget.onRemoveLine(index)
|
? () {
|
||||||
|
widget.onRemoveLine(index);
|
||||||
|
widget.onChanged?.call();
|
||||||
|
}
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -159,15 +315,25 @@ class _LineItemCard extends StatefulWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.line,
|
required this.line,
|
||||||
required this.items,
|
required this.items,
|
||||||
|
required this.itemHsnById,
|
||||||
|
required this.itemUomById,
|
||||||
|
required this.itemGstRateById,
|
||||||
required this.uom,
|
required this.uom,
|
||||||
required this.gstRates,
|
required this.gstRates,
|
||||||
|
required this.gstRatePctById,
|
||||||
|
required this.onChanged,
|
||||||
this.onRemove,
|
this.onRemove,
|
||||||
});
|
});
|
||||||
|
|
||||||
final PoLineItemDraft line;
|
final PoLineItemDraft line;
|
||||||
final List<FilterOptionModel> items;
|
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> uom;
|
||||||
final List<FilterOptionModel> gstRates;
|
final List<FilterOptionModel> gstRates;
|
||||||
|
final Map<String, double> gstRatePctById;
|
||||||
|
final VoidCallback onChanged;
|
||||||
final VoidCallback? onRemove;
|
final VoidCallback? onRemove;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -175,18 +341,69 @@ class _LineItemCard extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _LineItemCardState extends State<_LineItemCard> {
|
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());
|
int? _parseId(String value) => int.tryParse(value.trim());
|
||||||
|
|
||||||
void _updateLine(void Function() mutate) {
|
void _updateLine(void Function() mutate) {
|
||||||
mutate();
|
mutate();
|
||||||
|
widget.onChanged();
|
||||||
setState(() {});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final line = widget.line;
|
final line = widget.line;
|
||||||
final lineKey = 'po-line-${line.lineNo}';
|
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
|
final itemOptions = widget.items
|
||||||
.map((e) {
|
.map((e) {
|
||||||
final id = _parseId(e.id);
|
final id = _parseId(e.id);
|
||||||
@ -204,7 +421,7 @@ class _LineItemCardState extends State<_LineItemCard> {
|
|||||||
.whereType<AppDropdownOption<int>>()
|
.whereType<AppDropdownOption<int>>()
|
||||||
.toList();
|
.toList();
|
||||||
final gstOptions = [
|
final gstOptions = [
|
||||||
const AppDropdownOption<int?>(value: null, label: 'No GST'),
|
const AppDropdownOption<int?>(value: null, label: 'Select GST rate'),
|
||||||
...widget.gstRates.map((e) {
|
...widget.gstRates.map((e) {
|
||||||
final id = _parseId(e.id);
|
final id = _parseId(e.id);
|
||||||
if (id == null) return null;
|
if (id == null) return null;
|
||||||
@ -213,57 +430,40 @@ class _LineItemCardState extends State<_LineItemCard> {
|
|||||||
].whereType<AppDropdownOption<int?>>().toList();
|
].whereType<AppDropdownOption<int?>>().toList();
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(color: theme.dividerColor),
|
border: Border.all(color: borderColor),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: FormRow(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
columnCount: 12,
|
||||||
children: [
|
spans: const [3, 1, 2, 1, 1, 2, 2],
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
FormRow(
|
|
||||||
columnCount: 6,
|
|
||||||
horizontalPadding: 16,
|
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
stackBelowWidth: 992,
|
stackBelowWidth: 1100,
|
||||||
children: [
|
children: [
|
||||||
AppSearchableDropdown<int>(
|
AppSearchableDropdown<int>(
|
||||||
key: ValueKey('$lineKey-item'),
|
key: ValueKey('$lineKey-item'),
|
||||||
label: 'Item *',
|
label: 'Item *',
|
||||||
value: line.itemId,
|
value: line.itemId,
|
||||||
|
hint: 'Select item',
|
||||||
searchHint: 'Search item...',
|
searchHint: 'Search item...',
|
||||||
options: itemOptions,
|
options: itemOptions,
|
||||||
onChanged: (v) => _updateLine(() => line.itemId = v),
|
onChanged: _onItemChanged,
|
||||||
validator: (v) => v == null ? 'Item is required' : null,
|
validator: (v) => v == null ? 'Item is required' : null,
|
||||||
),
|
),
|
||||||
AppTextField(
|
AppTextField(
|
||||||
key: ValueKey('$lineKey-qty'),
|
key: ValueKey('$lineKey-qty'),
|
||||||
controller: line.qtyController,
|
controller: line.qtyController,
|
||||||
label: 'Quantity *',
|
label: 'Qty *',
|
||||||
|
hint: '0',
|
||||||
keyboardType:
|
keyboardType:
|
||||||
const TextInputType.numberWithOptions(decimal: true),
|
const TextInputType.numberWithOptions(decimal: true),
|
||||||
validator: (v) {
|
validator: (v) {
|
||||||
if (v == null || v.trim().isEmpty) {
|
if (v == null || v.trim().isEmpty) {
|
||||||
return 'Quantity is required';
|
return 'Required';
|
||||||
}
|
}
|
||||||
final qty = double.tryParse(v);
|
final qty = double.tryParse(v);
|
||||||
if (qty == null || qty <= 0) return 'Enter a valid quantity';
|
if (qty == null || qty <= 0) return 'Invalid';
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@ -271,58 +471,138 @@ class _LineItemCardState extends State<_LineItemCard> {
|
|||||||
key: ValueKey('$lineKey-uom'),
|
key: ValueKey('$lineKey-uom'),
|
||||||
label: 'UOM *',
|
label: 'UOM *',
|
||||||
value: line.uomId,
|
value: line.uomId,
|
||||||
|
hint: 'Select UOM',
|
||||||
searchHint: 'Search UOM...',
|
searchHint: 'Search UOM...',
|
||||||
options: uomOptions,
|
options: uomOptions,
|
||||||
onChanged: (v) => _updateLine(() => line.uomId = v),
|
onChanged: (v) => _updateLine(() => line.uomId = v),
|
||||||
validator: (v) => v == null ? 'UOM is required' : null,
|
validator: (v) => v == null ? 'Required' : null,
|
||||||
),
|
),
|
||||||
AppTextField(
|
AppTextField(
|
||||||
key: ValueKey('$lineKey-rate'),
|
key: ValueKey('$lineKey-rate'),
|
||||||
controller: line.rateController,
|
controller: line.rateController,
|
||||||
label: 'Rate *',
|
label: 'Rate *',
|
||||||
|
hint: '0.00',
|
||||||
keyboardType:
|
keyboardType:
|
||||||
const TextInputType.numberWithOptions(decimal: true),
|
const TextInputType.numberWithOptions(decimal: true),
|
||||||
validator: (v) {
|
validator: (v) {
|
||||||
if (v == null || v.trim().isEmpty) return 'Rate is required';
|
if (v == null || v.trim().isEmpty) return 'Required';
|
||||||
final rate = double.tryParse(v);
|
final rate = double.tryParse(v);
|
||||||
if (rate == null || rate < 0) return 'Enter a valid rate';
|
if (rate == null || rate < 0) return 'Invalid';
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
AppTextField(
|
AppTextField(
|
||||||
key: ValueKey('$lineKey-discount'),
|
key: ValueKey('$lineKey-discount'),
|
||||||
controller: line.discountController,
|
controller: line.discountController,
|
||||||
label: 'Discount %',
|
label: 'Disc %',
|
||||||
|
hint: '0',
|
||||||
keyboardType:
|
keyboardType:
|
||||||
const TextInputType.numberWithOptions(decimal: true),
|
const TextInputType.numberWithOptions(decimal: true),
|
||||||
),
|
),
|
||||||
AppSearchableDropdown<int?>(
|
AppSearchableDropdown<int?>(
|
||||||
key: ValueKey('$lineKey-gst'),
|
key: ValueKey('$lineKey-gst'),
|
||||||
label: 'GST Rate',
|
label: 'GST rate',
|
||||||
value: line.gstRateId,
|
value: line.gstRateId,
|
||||||
|
hint: 'Select GST rate',
|
||||||
searchHint: 'Search GST rate...',
|
searchHint: 'Search GST rate...',
|
||||||
options: gstOptions,
|
options: gstOptions,
|
||||||
onChanged: (v) => _updateLine(() => line.gstRateId = v),
|
onChanged: (v) => _updateLine(() => line.gstRateId = v),
|
||||||
),
|
),
|
||||||
],
|
_AmountWithRemove(
|
||||||
),
|
amount: CurrencyFormatter.format(calc.lineAmount),
|
||||||
FormRow(
|
backgroundColor: amountBg,
|
||||||
columnCount: 6,
|
onRemove: widget.onRemove,
|
||||||
spans: const [6],
|
|
||||||
horizontalPadding: 16,
|
|
||||||
spacing: 8,
|
|
||||||
stackBelowWidth: 992,
|
|
||||||
children: [
|
|
||||||
AppTextField(
|
|
||||||
key: ValueKey('$lineKey-remarks'),
|
|
||||||
controller: line.remarksController,
|
|
||||||
label: 'Remarks',
|
|
||||||
maxLines: 2,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../../shared/widgets/app_data_table.dart';
|
||||||
|
|
||||||
import '../../../../shared/models/user_management_models.dart';
|
import '../../../../shared/models/user_management_models.dart';
|
||||||
import '../../../../shared/widgets/app_card.dart';
|
import '../../../../shared/widgets/app_card.dart';
|
||||||
|
|
||||||
@ -221,12 +223,13 @@ class EmployeeCodeBadge extends StatelessWidget {
|
|||||||
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.7),
|
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.7),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: AppTableCell.text(
|
||||||
code,
|
code,
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
|
showTooltip: true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -246,24 +249,24 @@ class UserTableUserCell extends StatelessWidget {
|
|||||||
UserAvatarChip(
|
UserAvatarChip(
|
||||||
name: user.fullName,
|
name: user.fullName,
|
||||||
initials: user.initialsDisplay,
|
initials: user.initialsDisplay,
|
||||||
|
radius: 16,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
AppTableCell.text(
|
||||||
user.fullName,
|
user.fullName,
|
||||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
Text(
|
AppTableCell.text(
|
||||||
user.email,
|
user.email,
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -274,10 +277,16 @@ class UserTableUserCell extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class UserAvatarChip 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 name;
|
||||||
final String? initials;
|
final String? initials;
|
||||||
|
final double radius;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -293,14 +302,14 @@ class UserAvatarChip extends StatelessWidget {
|
|||||||
(name.isNotEmpty ? name.trim()[0].toUpperCase() : 'U');
|
(name.isNotEmpty ? name.trim()[0].toUpperCase() : 'U');
|
||||||
|
|
||||||
return CircleAvatar(
|
return CircleAvatar(
|
||||||
radius: 20,
|
radius: radius,
|
||||||
backgroundColor: color.withValues(alpha: 0.12),
|
backgroundColor: color.withValues(alpha: 0.12),
|
||||||
child: Text(
|
child: Text(
|
||||||
display.length > 2 ? display.substring(0, 2) : display,
|
display.length > 2 ? display.substring(0, 2) : display,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: color,
|
color: color,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
fontSize: 12,
|
fontSize: radius * 0.6,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import '../../../../shared/widgets/app_card.dart';
|
import '../../../../shared/widgets/app_card.dart';
|
||||||
|
import '../../../../shared/widgets/app_data_table.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
@ -98,7 +99,7 @@ class _MatrixGrid extends ConsumerWidget {
|
|||||||
.map(
|
.map(
|
||||||
(row) => DataRow(
|
(row) => DataRow(
|
||||||
cells: [
|
cells: [
|
||||||
DataCell(Text(row.name)),
|
DataCell(AppTableCell.text(row.name)),
|
||||||
...matrix.actionColumns.map(
|
...matrix.actionColumns.map(
|
||||||
(action) => DataCell(
|
(action) => DataCell(
|
||||||
_actionToggle(
|
_actionToggle(
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
import '../../../../core/constants/api_endpoints.dart';
|
import '../../../../core/constants/api_endpoints.dart';
|
||||||
|
import '../../../../core/utils/media_url.dart';
|
||||||
import '../../domain/entities/app_settings.dart';
|
import '../../domain/entities/app_settings.dart';
|
||||||
|
|
||||||
class SettingsRemoteDataSource {
|
class SettingsRemoteDataSource {
|
||||||
@ -8,36 +9,54 @@ class SettingsRemoteDataSource {
|
|||||||
|
|
||||||
final Dio _dio;
|
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 {
|
Future<AppSettings?> fetch() async {
|
||||||
final response = await _dio.get(ApiEndpoints.settings);
|
final response = await _dio.get(ApiEndpoints.settings);
|
||||||
final data = response.data['data'];
|
final data = _asDataMap(response.data);
|
||||||
if (data is! Map<String, dynamic>) return null;
|
if (data == null) return null;
|
||||||
return AppSettings.fromJson(data);
|
return AppSettings.fromJson(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<AppSettings> save(AppSettings settings) async {
|
Future<AppSettings> save(AppSettings settings) async {
|
||||||
final response = await _dio.put(ApiEndpoints.settings, data: settings.toJson());
|
final response = await _dio.put(ApiEndpoints.settings, data: settings.toJson());
|
||||||
final data = response.data['data'];
|
final data = _asDataMap(response.data);
|
||||||
if (data is Map<String, dynamic>) {
|
if (data != null) {
|
||||||
return AppSettings.fromJson(data);
|
return AppSettings.fromJson(data);
|
||||||
}
|
}
|
||||||
return settings;
|
return settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GET `/settings/company`
|
||||||
Future<CompanyProfileSettings?> fetchCompany() async {
|
Future<CompanyProfileSettings?> fetchCompany() async {
|
||||||
final response = await _dio.get(ApiEndpoints.settingsCompany);
|
final response = await _dio.get(ApiEndpoints.settingsCompany);
|
||||||
final data = response.data['data'];
|
final data = _asDataMap(response.data);
|
||||||
if (data is! Map<String, dynamic>) return null;
|
if (data == null) return null;
|
||||||
return CompanyProfileSettings.fromApiJson(data);
|
return CompanyProfileSettings.fromApiJson(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// PUT `/settings/company`
|
||||||
Future<CompanyProfileSettings> saveCompany(CompanyProfileSettings profile) async {
|
Future<CompanyProfileSettings> saveCompany(CompanyProfileSettings profile) async {
|
||||||
final response = await _dio.put(
|
final response = await _dio.put(
|
||||||
ApiEndpoints.settingsCompany,
|
ApiEndpoints.settingsCompany,
|
||||||
data: profile.toApiJson(),
|
data: profile.toApiJson(),
|
||||||
);
|
);
|
||||||
final data = response.data['data'];
|
final data = _asDataMap(response.data);
|
||||||
if (data is Map<String, dynamic>) {
|
if (data != null) {
|
||||||
return CompanyProfileSettings.fromApiJson(data).copyWith(
|
return CompanyProfileSettings.fromApiJson(data).copyWith(
|
||||||
companyCode: profile.companyCode,
|
companyCode: profile.companyCode,
|
||||||
registrationNumber: profile.registrationNumber,
|
registrationNumber: profile.registrationNumber,
|
||||||
@ -48,6 +67,7 @@ class SettingsRemoteDataSource {
|
|||||||
return profile;
|
return profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// POST `/settings/company/logo` (multipart field `logo`)
|
||||||
Future<String?> uploadCompanyLogo(List<int> bytes, String filename) async {
|
Future<String?> uploadCompanyLogo(List<int> bytes, String filename) async {
|
||||||
final formData = FormData.fromMap({
|
final formData = FormData.fromMap({
|
||||||
'logo': MultipartFile.fromBytes(bytes, filename: filename),
|
'logo': MultipartFile.fromBytes(bytes, filename: filename),
|
||||||
@ -56,20 +76,26 @@ class SettingsRemoteDataSource {
|
|||||||
ApiEndpoints.settingsCompanyLogo,
|
ApiEndpoints.settingsCompanyLogo,
|
||||||
data: formData,
|
data: formData,
|
||||||
);
|
);
|
||||||
final data = response.data['data'];
|
final data = _asDataMap(response.data);
|
||||||
if (data is Map<String, dynamic>) {
|
if (data != null) {
|
||||||
return data['logo_url'] as String? ?? data['logo'] as String?;
|
return resolveMediaUrl(
|
||||||
|
data['logo_url'] as String? ??
|
||||||
|
data['logoUrl'] as String? ??
|
||||||
|
data['logo'] as String?,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GET `/settings/email`
|
||||||
Future<EmailConfigurationSettings?> fetchEmail() async {
|
Future<EmailConfigurationSettings?> fetchEmail() async {
|
||||||
final response = await _dio.get(ApiEndpoints.settingsEmail);
|
final response = await _dio.get(ApiEndpoints.settingsEmail);
|
||||||
final data = response.data['data'];
|
final data = _asDataMap(response.data);
|
||||||
if (data is! Map<String, dynamic>) return null;
|
if (data == null) return null;
|
||||||
return EmailConfigurationSettings.fromApiJson(data);
|
return EmailConfigurationSettings.fromApiJson(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// PUT `/settings/email`
|
||||||
Future<EmailConfigurationSettings> saveEmail(
|
Future<EmailConfigurationSettings> saveEmail(
|
||||||
EmailConfigurationSettings email,
|
EmailConfigurationSettings email,
|
||||||
) async {
|
) async {
|
||||||
@ -77,8 +103,8 @@ class SettingsRemoteDataSource {
|
|||||||
ApiEndpoints.settingsEmail,
|
ApiEndpoints.settingsEmail,
|
||||||
data: email.toApiJson(),
|
data: email.toApiJson(),
|
||||||
);
|
);
|
||||||
final data = response.data['data'];
|
final data = _asDataMap(response.data);
|
||||||
if (data is Map<String, dynamic>) {
|
if (data != null) {
|
||||||
return EmailConfigurationSettings.fromApiJson(data).copyWith(
|
return EmailConfigurationSettings.fromApiJson(data).copyWith(
|
||||||
allocationTemplate: email.allocationTemplate,
|
allocationTemplate: email.allocationTemplate,
|
||||||
returnTemplate: email.returnTemplate,
|
returnTemplate: email.returnTemplate,
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../../core/utils/media_url.dart';
|
||||||
|
|
||||||
class GeneralSettings {
|
class GeneralSettings {
|
||||||
const GeneralSettings({
|
const GeneralSettings({
|
||||||
this.defaultBranch = '',
|
this.defaultBranch = '',
|
||||||
@ -145,18 +147,17 @@ class CompanyProfileSettings {
|
|||||||
'faviconUrl': faviconUrl,
|
'faviconUrl': faviconUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
Map<String, dynamic> toApiJson() {
|
/// Payload for `PUT /settings/company` ([CompanySettingsBody]).
|
||||||
final payload = <String, dynamic>{};
|
Map<String, dynamic> toApiJson() => {
|
||||||
if (companyName.isNotEmpty) payload['org_name'] = companyName;
|
'org_name': companyName,
|
||||||
if (phone.isNotEmpty) payload['mobile'] = phone;
|
'mobile': phone,
|
||||||
if (email.isNotEmpty) payload['email'] = email;
|
'email': email,
|
||||||
if (website.isNotEmpty) payload['website'] = website;
|
'website': website,
|
||||||
if (address.isNotEmpty) payload['address'] = address;
|
'address': address,
|
||||||
if (city.isNotEmpty) payload['city'] = city;
|
'city': city,
|
||||||
if (state.isNotEmpty) payload['state'] = state;
|
'state': state,
|
||||||
if (pincode.isNotEmpty) payload['pincode'] = pincode;
|
'pincode': pincode,
|
||||||
return payload;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
factory CompanyProfileSettings.fromJson(Map<String, dynamic> json) =>
|
factory CompanyProfileSettings.fromJson(Map<String, dynamic> json) =>
|
||||||
CompanyProfileSettings.fromApiJson(json);
|
CompanyProfileSettings.fromApiJson(json);
|
||||||
@ -176,11 +177,13 @@ class CompanyProfileSettings {
|
|||||||
email: json['email'] as String? ?? '',
|
email: json['email'] as String? ?? '',
|
||||||
phone: json['mobile'] as String? ?? json['phone'] as String? ?? '',
|
phone: json['mobile'] as String? ?? json['phone'] as String? ?? '',
|
||||||
website: json['website'] as String? ?? '',
|
website: json['website'] as String? ?? '',
|
||||||
logoUrl: json['logo_url'] as String? ??
|
logoUrl: resolveMediaUrl(
|
||||||
|
json['logo_url'] as String? ??
|
||||||
json['logoUrl'] as String? ??
|
json['logoUrl'] as String? ??
|
||||||
json['logo'] 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,
|
'warrantyTemplate': warrantyTemplate,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Payload for `PUT /settings/email` ([EmailSettingsBody]).
|
||||||
|
/// Omits blank password so an existing SMTP password is not cleared.
|
||||||
Map<String, dynamic> toApiJson() {
|
Map<String, dynamic> toApiJson() {
|
||||||
final payload = <String, dynamic>{};
|
final payload = <String, dynamic>{
|
||||||
if (smtpHost.isNotEmpty) payload['smtp_host'] = smtpHost;
|
'smtp_host': smtpHost,
|
||||||
if (smtpPort > 0) payload['smtp_port'] = smtpPort;
|
'smtp_port': smtpPort,
|
||||||
if (smtpUsername.isNotEmpty) payload['smtp_username'] = smtpUsername;
|
'smtp_username': smtpUsername,
|
||||||
if (smtpPassword.isNotEmpty) payload['smtp_password'] = smtpPassword;
|
'sender_email': senderEmail,
|
||||||
if (senderEmail.isNotEmpty) payload['sender_email'] = senderEmail;
|
'sender_name': senderName,
|
||||||
if (senderName.isNotEmpty) payload['sender_name'] = senderName;
|
};
|
||||||
|
if (smtpPassword.isNotEmpty) {
|
||||||
|
payload['smtp_password'] = smtpPassword;
|
||||||
|
}
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -659,6 +667,7 @@ class SettingsSection {
|
|||||||
required this.icon,
|
required this.icon,
|
||||||
required this.route,
|
required this.route,
|
||||||
this.phase = 1,
|
this.phase = 1,
|
||||||
|
this.hidden = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
@ -667,6 +676,8 @@ class SettingsSection {
|
|||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String route;
|
final String route;
|
||||||
final int phase;
|
final int phase;
|
||||||
|
/// When true, the card stays defined but is not shown on the Settings hub.
|
||||||
|
final bool hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
const phase1SettingsSections = [
|
const phase1SettingsSections = [
|
||||||
@ -696,7 +707,7 @@ const phase1SettingsSections = [
|
|||||||
title: 'Roles & Permissions',
|
title: 'Roles & Permissions',
|
||||||
subtitle: 'Manage roles, permissions, and menu access',
|
subtitle: 'Manage roles, permissions, and menu access',
|
||||||
icon: Icons.security_outlined,
|
icon: Icons.security_outlined,
|
||||||
route: '/settings/roles',
|
route: '/users-roles?tab=permissions',
|
||||||
),
|
),
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
id: 'asset',
|
id: 'asset',
|
||||||
@ -704,6 +715,7 @@ const phase1SettingsSections = [
|
|||||||
subtitle: 'Asset codes, statuses, warranty, and QR',
|
subtitle: 'Asset codes, statuses, warranty, and QR',
|
||||||
icon: Icons.inventory_2_outlined,
|
icon: Icons.inventory_2_outlined,
|
||||||
route: '/settings/asset',
|
route: '/settings/asset',
|
||||||
|
hidden: true,
|
||||||
),
|
),
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
id: 'notifications',
|
id: 'notifications',
|
||||||
@ -711,11 +723,12 @@ const phase1SettingsSections = [
|
|||||||
subtitle: 'Email, SMS, push, and in-app alerts',
|
subtitle: 'Email, SMS, push, and in-app alerts',
|
||||||
icon: Icons.notifications_outlined,
|
icon: Icons.notifications_outlined,
|
||||||
route: '/settings/notifications',
|
route: '/settings/notifications',
|
||||||
|
hidden: true,
|
||||||
),
|
),
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
id: 'email',
|
id: 'email',
|
||||||
title: 'Email Configuration',
|
title: 'Email Configuration',
|
||||||
subtitle: 'SMTP server and email templates',
|
subtitle: 'SMTP server settings',
|
||||||
icon: Icons.email_outlined,
|
icon: Icons.email_outlined,
|
||||||
route: '/settings/email',
|
route: '/settings/email',
|
||||||
),
|
),
|
||||||
@ -725,9 +738,11 @@ const phase1SettingsSections = [
|
|||||||
subtitle: 'Authentication, session, and audit policies',
|
subtitle: 'Authentication, session, and audit policies',
|
||||||
icon: Icons.lock_outline,
|
icon: Icons.lock_outline,
|
||||||
route: '/settings/security',
|
route: '/settings/security',
|
||||||
|
hidden: true,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Phase 2 cards are kept for later; currently hidden on the Settings hub.
|
||||||
const phase2SettingsSections = [
|
const phase2SettingsSections = [
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
id: 'workflow',
|
id: 'workflow',
|
||||||
@ -736,6 +751,7 @@ const phase2SettingsSections = [
|
|||||||
icon: Icons.account_tree_outlined,
|
icon: Icons.account_tree_outlined,
|
||||||
route: '/settings/workflow',
|
route: '/settings/workflow',
|
||||||
phase: 2,
|
phase: 2,
|
||||||
|
hidden: true,
|
||||||
),
|
),
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
id: 'dashboard',
|
id: 'dashboard',
|
||||||
@ -744,6 +760,7 @@ const phase2SettingsSections = [
|
|||||||
icon: Icons.dashboard_outlined,
|
icon: Icons.dashboard_outlined,
|
||||||
route: '/settings/dashboard',
|
route: '/settings/dashboard',
|
||||||
phase: 2,
|
phase: 2,
|
||||||
|
hidden: true,
|
||||||
),
|
),
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
id: 'reports',
|
id: 'reports',
|
||||||
@ -752,6 +769,7 @@ const phase2SettingsSections = [
|
|||||||
icon: Icons.assessment_outlined,
|
icon: Icons.assessment_outlined,
|
||||||
route: '/settings/reports',
|
route: '/settings/reports',
|
||||||
phase: 2,
|
phase: 2,
|
||||||
|
hidden: true,
|
||||||
),
|
),
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
id: 'storage',
|
id: 'storage',
|
||||||
@ -760,6 +778,7 @@ const phase2SettingsSections = [
|
|||||||
icon: Icons.cloud_upload_outlined,
|
icon: Icons.cloud_upload_outlined,
|
||||||
route: '/settings/storage',
|
route: '/settings/storage',
|
||||||
phase: 2,
|
phase: 2,
|
||||||
|
hidden: true,
|
||||||
),
|
),
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
id: 'audit',
|
id: 'audit',
|
||||||
@ -768,6 +787,7 @@ const phase2SettingsSections = [
|
|||||||
icon: Icons.history_outlined,
|
icon: Icons.history_outlined,
|
||||||
route: '/settings/audit',
|
route: '/settings/audit',
|
||||||
phase: 2,
|
phase: 2,
|
||||||
|
hidden: true,
|
||||||
),
|
),
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
id: 'mobile',
|
id: 'mobile',
|
||||||
@ -776,6 +796,7 @@ const phase2SettingsSections = [
|
|||||||
icon: Icons.phone_android_outlined,
|
icon: Icons.phone_android_outlined,
|
||||||
route: '/settings/mobile',
|
route: '/settings/mobile',
|
||||||
phase: 2,
|
phase: 2,
|
||||||
|
hidden: true,
|
||||||
),
|
),
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
id: 'integrations',
|
id: 'integrations',
|
||||||
@ -784,5 +805,14 @@ const phase2SettingsSections = [
|
|||||||
icon: Icons.extension_outlined,
|
icon: Icons.extension_outlined,
|
||||||
route: '/settings/integrations',
|
route: '/settings/integrations',
|
||||||
phase: 2,
|
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 '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/network/dio_client.dart';
|
||||||
|
import '../../../../core/theme/branding_config.dart';
|
||||||
import '../../../../core/theme/theme_provider.dart';
|
import '../../../../core/theme/theme_provider.dart';
|
||||||
import '../../../../core/utils/favicon_store.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_local_data_source.dart';
|
||||||
import '../../data/datasources/settings_remote_data_source.dart';
|
import '../../data/datasources/settings_remote_data_source.dart';
|
||||||
import '../../data/repositories/settings_repository_impl.dart';
|
import '../../data/repositories/settings_repository_impl.dart';
|
||||||
@ -42,6 +46,17 @@ final appSettingsProvider =
|
|||||||
saveSettings: ref.watch(saveSettingsUseCaseProvider),
|
saveSettings: ref.watch(saveSettingsUseCaseProvider),
|
||||||
repository: ref.watch(settingsRepositoryProvider),
|
repository: ref.watch(settingsRepositoryProvider),
|
||||||
faviconStore: FaviconStore(ref.watch(sharedPreferencesProvider)),
|
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 SaveSettingsUseCase saveSettings,
|
||||||
required SettingsRepository repository,
|
required SettingsRepository repository,
|
||||||
required FaviconStore faviconStore,
|
required FaviconStore faviconStore,
|
||||||
|
required Future<void> Function(String? logoUrl, String? companyName)
|
||||||
|
syncAppLogo,
|
||||||
}) : _getSettings = getSettings,
|
}) : _getSettings = getSettings,
|
||||||
_saveSettings = saveSettings,
|
_saveSettings = saveSettings,
|
||||||
_repository = repository,
|
_repository = repository,
|
||||||
_faviconStore = faviconStore,
|
_faviconStore = faviconStore,
|
||||||
|
_syncAppLogo = syncAppLogo,
|
||||||
super(const AppSettings()) {
|
super(const AppSettings()) {
|
||||||
_load();
|
_load();
|
||||||
}
|
}
|
||||||
@ -63,25 +81,38 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
|
|||||||
final SaveSettingsUseCase _saveSettings;
|
final SaveSettingsUseCase _saveSettings;
|
||||||
final SettingsRepository _repository;
|
final SettingsRepository _repository;
|
||||||
final FaviconStore _faviconStore;
|
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 {
|
Future<void> _load() async {
|
||||||
final result = await _getSettings();
|
final result = await _getSettings();
|
||||||
state = result.data ?? const AppSettings();
|
state = result.data ?? const AppSettings();
|
||||||
_faviconStore.apply();
|
_faviconStore.apply();
|
||||||
|
await _syncMainLogo(state.companyProfile);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> refreshCompanyProfile() async {
|
Future<Failure?> refreshCompanyProfile() async {
|
||||||
final result = await _repository.fetchCompanyProfile();
|
final result = await _repository.fetchCompanyProfile();
|
||||||
if (result.failure == null && result.data != null) {
|
if (result.failure == null && result.data != null) {
|
||||||
state = state.copyWith(companyProfile: result.data!);
|
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();
|
final result = await _repository.fetchEmailSettings();
|
||||||
if (result.failure == null && result.data != null) {
|
if (result.failure == null && result.data != null) {
|
||||||
state = state.copyWith(email: result.data!);
|
state = state.copyWith(email: result.data!);
|
||||||
}
|
}
|
||||||
|
return result.failure;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _persist(AppSettings settings) async {
|
Future<void> _persist(AppSettings settings) async {
|
||||||
@ -94,29 +125,44 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
|
|||||||
await _persist(state.copyWith(general: general));
|
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);
|
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!);
|
state = state.copyWith(companyProfile: result.data!);
|
||||||
return;
|
await _saveSettings(state);
|
||||||
}
|
await _syncMainLogo(result.data!);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
return null;
|
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 {
|
Future<void> updateUiPreferences(UiPreferencesSettings prefs) async {
|
||||||
await _persist(state.copyWith(uiPreferences: prefs));
|
await _persist(state.copyWith(uiPreferences: prefs));
|
||||||
}
|
}
|
||||||
@ -129,14 +175,15 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
|
|||||||
await _persist(state.copyWith(notifications: notifications));
|
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);
|
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!);
|
state = state.copyWith(email: result.data!);
|
||||||
await _saveSettings(state);
|
await _saveSettings(state);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
await _persist(state.copyWith(email: email));
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateSecurity(SecuritySettingsConfig security) async {
|
Future<void> updateSecurity(SecuritySettingsConfig security) async {
|
||||||
@ -145,5 +192,6 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
|
|||||||
|
|
||||||
Future<void> resetToDefaults() async {
|
Future<void> resetToDefaults() async {
|
||||||
await _persist(const AppSettings());
|
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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../../../core/network/api_handler.dart';
|
||||||
import '../../../../core/theme/theme_provider.dart';
|
import '../../../../core/theme/theme_provider.dart';
|
||||||
import '../../../../core/utils/favicon_store.dart';
|
import '../../../../core/utils/favicon_store.dart';
|
||||||
import '../../../../core/utils/favicon_updater.dart';
|
import '../../../../core/utils/favicon_updater.dart';
|
||||||
@ -41,15 +42,16 @@ class _CompanyProfileSettingsScreenState
|
|||||||
late final TextEditingController _logoUrlController;
|
late final TextEditingController _logoUrlController;
|
||||||
late final TextEditingController _faviconUrlController;
|
late final TextEditingController _faviconUrlController;
|
||||||
|
|
||||||
|
bool _loading = true;
|
||||||
|
bool _saving = false;
|
||||||
|
bool _uploadingLogo = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
final profile = ref.read(appSettingsProvider).companyProfile;
|
final profile = ref.read(appSettingsProvider).companyProfile;
|
||||||
final faviconFromPrefs =
|
final faviconFromPrefs =
|
||||||
FaviconStore(ref.read(sharedPreferencesProvider)).read();
|
FaviconStore(ref.read(sharedPreferencesProvider)).read();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
ref.read(appSettingsProvider.notifier).refreshCompanyProfile();
|
|
||||||
});
|
|
||||||
_nameController = TextEditingController(text: profile.companyName);
|
_nameController = TextEditingController(text: profile.companyName);
|
||||||
_codeController = TextEditingController(text: profile.companyCode);
|
_codeController = TextEditingController(text: profile.companyCode);
|
||||||
_registrationController =
|
_registrationController =
|
||||||
@ -68,6 +70,43 @@ class _CompanyProfileSettingsScreenState
|
|||||||
? profile.faviconUrl
|
? profile.faviconUrl
|
||||||
: faviconFromPrefs,
|
: 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
|
@override
|
||||||
@ -95,6 +134,8 @@ class _CompanyProfileSettingsScreenState
|
|||||||
final faviconUrl = _faviconUrlController.text.trim();
|
final faviconUrl = _faviconUrlController.text.trim();
|
||||||
final companyName = _nameController.text.trim();
|
final companyName = _nameController.text.trim();
|
||||||
|
|
||||||
|
setState(() => _saving = true);
|
||||||
|
final failure =
|
||||||
await ref.read(appSettingsProvider.notifier).updateCompanyProfile(
|
await ref.read(appSettingsProvider.notifier).updateCompanyProfile(
|
||||||
CompanyProfileSettings(
|
CompanyProfileSettings(
|
||||||
companyName: companyName,
|
companyName: companyName,
|
||||||
@ -112,6 +153,18 @@ class _CompanyProfileSettingsScreenState
|
|||||||
faviconUrl: faviconUrl,
|
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(
|
await ref.read(brandingProvider.notifier).updateBranding(
|
||||||
ref.read(brandingProvider).copyWith(
|
ref.read(brandingProvider).copyWith(
|
||||||
@ -165,14 +218,19 @@ class _CompanyProfileSettingsScreenState
|
|||||||
final bytes = file.bytes;
|
final bytes = file.bytes;
|
||||||
if (bytes == null) return;
|
if (bytes == null) return;
|
||||||
|
|
||||||
final uploadedUrl = await ref.read(appSettingsProvider.notifier).uploadCompanyLogo(
|
setState(() => _uploadingLogo = true);
|
||||||
|
final uploadResult =
|
||||||
|
await ref.read(appSettingsProvider.notifier).uploadCompanyLogo(
|
||||||
bytes,
|
bytes,
|
||||||
file.name,
|
file.name,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!mounted) return;
|
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);
|
setState(() => _logoUrlController.text = uploadedUrl);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Logo uploaded')),
|
const SnackBar(content: Text('Logo uploaded')),
|
||||||
@ -180,9 +238,21 @@ class _CompanyProfileSettingsScreenState
|
|||||||
return;
|
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 ext = (file.extension ?? 'png').toLowerCase();
|
||||||
final mime = ext == 'jpg' ? 'jpeg' : ext;
|
final mime = ext == 'jpg' ? 'jpeg' : ext;
|
||||||
final dataUri = 'data:image/$mime;base64,${base64Encode(bytes)}';
|
final dataUri = 'data:image/$mime;base64,${base64Encode(bytes)}';
|
||||||
|
await ref.read(appSettingsProvider.notifier).applyLocalCompanyLogo(dataUri);
|
||||||
|
if (!mounted) return;
|
||||||
setState(() => _logoUrlController.text = dataUri);
|
setState(() => _logoUrlController.text = dataUri);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -195,7 +265,12 @@ class _CompanyProfileSettingsScreenState
|
|||||||
return SettingsPageLayout(
|
return SettingsPageLayout(
|
||||||
title: 'Company Profile',
|
title: 'Company Profile',
|
||||||
subtitle: 'Company information and branding assets',
|
subtitle: 'Company information and branding assets',
|
||||||
child: Form(
|
child: _loading
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 48),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
)
|
||||||
|
: Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
@ -205,13 +280,13 @@ class _CompanyProfileSettingsScreenState
|
|||||||
AppTextField(
|
AppTextField(
|
||||||
controller: _nameController,
|
controller: _nameController,
|
||||||
label: 'Company Name',
|
label: 'Company Name',
|
||||||
validator: (v) => Validators.required(v, fieldName: 'Company name'),
|
validator: (v) =>
|
||||||
|
Validators.required(v, fieldName: 'Company name'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
AppTextField(
|
AppTextField(
|
||||||
controller: _codeController,
|
controller: _codeController,
|
||||||
label: 'Company Code',
|
label: 'Company Code',
|
||||||
validator: (v) => Validators.required(v, fieldName: 'Company code'),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
AppTextField(
|
AppTextField(
|
||||||
@ -253,7 +328,7 @@ class _CompanyProfileSettingsScreenState
|
|||||||
controller: _emailController,
|
controller: _emailController,
|
||||||
label: 'Email',
|
label: 'Email',
|
||||||
keyboardType: TextInputType.emailAddress,
|
keyboardType: TextInputType.emailAddress,
|
||||||
validator: Validators.email,
|
validator: Validators.optionalEmail,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
AppTextField(
|
AppTextField(
|
||||||
@ -295,16 +370,25 @@ class _CompanyProfileSettingsScreenState
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: _pickLogo,
|
onPressed: _uploadingLogo ? null : _pickLogo,
|
||||||
icon: const Icon(Icons.upload_file),
|
icon: _uploadingLogo
|
||||||
label: const Text('Upload Logo'),
|
? 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),
|
const SizedBox(height: 16),
|
||||||
SettingsFormCard(
|
SettingsFormCard(
|
||||||
title: 'Favicon Upload',
|
title: 'Favicon Upload',
|
||||||
subtitle: 'Upload an image or provide a favicon URL for the browser tab',
|
subtitle:
|
||||||
|
'Upload an image or provide a favicon URL for the browser tab',
|
||||||
children: [
|
children: [
|
||||||
Center(
|
Center(
|
||||||
child: SidebarLogo(
|
child: SidebarLogo(
|
||||||
@ -332,7 +416,11 @@ class _CompanyProfileSettingsScreenState
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
AppButton(label: 'Save Changes', onPressed: _save),
|
AppButton(
|
||||||
|
label: 'Save Changes',
|
||||||
|
onPressed: _save,
|
||||||
|
isLoading: _saving,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../../../core/network/api_handler.dart';
|
||||||
import '../../../../core/utils/validators.dart';
|
import '../../../../core/utils/validators.dart';
|
||||||
import '../../../../shared/widgets/app_button.dart';
|
import '../../../../shared/widgets/app_button.dart';
|
||||||
import '../../../../shared/widgets/app_text_field.dart';
|
import '../../../../shared/widgets/app_text_field.dart';
|
||||||
@ -25,31 +26,49 @@ class _EmailConfigurationScreenState
|
|||||||
late final TextEditingController _passwordController;
|
late final TextEditingController _passwordController;
|
||||||
late final TextEditingController _senderEmailController;
|
late final TextEditingController _senderEmailController;
|
||||||
late final TextEditingController _senderNameController;
|
late final TextEditingController _senderNameController;
|
||||||
late final TextEditingController _allocationTemplateController;
|
|
||||||
late final TextEditingController _returnTemplateController;
|
bool _loading = true;
|
||||||
late final TextEditingController _maintenanceTemplateController;
|
bool _saving = false;
|
||||||
late final TextEditingController _warrantyTemplateController;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
final email = ref.read(appSettingsProvider).email;
|
final email = ref.read(appSettingsProvider).email;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
ref.read(appSettingsProvider.notifier).refreshEmailSettings();
|
|
||||||
});
|
|
||||||
_hostController = TextEditingController(text: email.smtpHost);
|
_hostController = TextEditingController(text: email.smtpHost);
|
||||||
_portController = TextEditingController(text: '${email.smtpPort}');
|
_portController = TextEditingController(text: '${email.smtpPort}');
|
||||||
_usernameController = TextEditingController(text: email.smtpUsername);
|
_usernameController = TextEditingController(text: email.smtpUsername);
|
||||||
_passwordController = TextEditingController(text: email.smtpPassword);
|
_passwordController = TextEditingController(text: email.smtpPassword);
|
||||||
_senderEmailController = TextEditingController(text: email.senderEmail);
|
_senderEmailController = TextEditingController(text: email.senderEmail);
|
||||||
_senderNameController = TextEditingController(text: email.senderName);
|
_senderNameController = TextEditingController(text: email.senderName);
|
||||||
_allocationTemplateController =
|
WidgetsBinding.instance.addPostFrameCallback((_) => _loadFromApi());
|
||||||
TextEditingController(text: email.allocationTemplate);
|
}
|
||||||
_returnTemplateController = TextEditingController(text: email.returnTemplate);
|
|
||||||
_maintenanceTemplateController =
|
Future<void> _loadFromApi() async {
|
||||||
TextEditingController(text: email.maintenanceTemplate);
|
setState(() => _loading = true);
|
||||||
_warrantyTemplateController =
|
final failure =
|
||||||
TextEditingController(text: email.warrantyTemplate);
|
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
|
@override
|
||||||
@ -60,44 +79,61 @@ class _EmailConfigurationScreenState
|
|||||||
_passwordController.dispose();
|
_passwordController.dispose();
|
||||||
_senderEmailController.dispose();
|
_senderEmailController.dispose();
|
||||||
_senderNameController.dispose();
|
_senderNameController.dispose();
|
||||||
_allocationTemplateController.dispose();
|
|
||||||
_returnTemplateController.dispose();
|
|
||||||
_maintenanceTemplateController.dispose();
|
|
||||||
_warrantyTemplateController.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
if (!_formKey.currentState!.validate()) return;
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
await ref.read(appSettingsProvider.notifier).updateEmail(
|
final port = int.tryParse(_portController.text.trim());
|
||||||
EmailConfigurationSettings(
|
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(),
|
smtpHost: _hostController.text.trim(),
|
||||||
smtpPort: int.parse(_portController.text.trim()),
|
smtpPort: port,
|
||||||
smtpUsername: _usernameController.text.trim(),
|
smtpUsername: _usernameController.text.trim(),
|
||||||
smtpPassword: _passwordController.text.trim(),
|
smtpPassword: _passwordController.text.trim(),
|
||||||
senderEmail: _senderEmailController.text.trim(),
|
senderEmail: _senderEmailController.text.trim(),
|
||||||
senderName: _senderNameController.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 (failure != null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(validationErrorMessage(failure)),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Email configuration saved')),
|
const SnackBar(content: Text('Email configuration saved')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SettingsPageLayout(
|
return SettingsPageLayout(
|
||||||
title: 'Email Configuration',
|
title: 'Email Configuration',
|
||||||
subtitle: 'SMTP server settings and email templates',
|
subtitle: 'SMTP server settings',
|
||||||
child: Form(
|
child: _loading
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 48),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
)
|
||||||
|
: Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
@ -108,12 +144,16 @@ class _EmailConfigurationScreenState
|
|||||||
controller: _hostController,
|
controller: _hostController,
|
||||||
label: 'SMTP Host',
|
label: 'SMTP Host',
|
||||||
hint: 'smtp.gmail.com',
|
hint: 'smtp.gmail.com',
|
||||||
|
validator: (v) =>
|
||||||
|
Validators.required(v, fieldName: 'SMTP host'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
AppTextField(
|
AppTextField(
|
||||||
controller: _portController,
|
controller: _portController,
|
||||||
label: 'SMTP Port',
|
label: 'SMTP Port',
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
|
validator: (v) =>
|
||||||
|
Validators.required(v, fieldName: 'SMTP port'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
AppTextField(
|
AppTextField(
|
||||||
@ -125,6 +165,7 @@ class _EmailConfigurationScreenState
|
|||||||
controller: _passwordController,
|
controller: _passwordController,
|
||||||
label: 'Password',
|
label: 'Password',
|
||||||
obscureText: true,
|
obscureText: true,
|
||||||
|
hint: 'Leave blank to keep existing password',
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
AppTextField(
|
AppTextField(
|
||||||
@ -140,38 +181,12 @@ class _EmailConfigurationScreenState
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
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),
|
const SizedBox(height: 24),
|
||||||
AppButton(label: 'Save Changes', onPressed: _save),
|
AppButton(
|
||||||
|
label: 'Save Changes',
|
||||||
|
onPressed: _save,
|
||||||
|
isLoading: _saving,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -11,6 +11,9 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final visiblePhase1 = visiblePhase1SettingsSections;
|
||||||
|
final visiblePhase2 = visiblePhase2SettingsSections;
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Center(
|
child: Center(
|
||||||
@ -21,20 +24,15 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
const PageHeader(
|
const PageHeader(
|
||||||
title: 'Settings',
|
title: 'Settings',
|
||||||
subtitle: 'Configure company, security, assets, and system behavior',
|
subtitle:
|
||||||
|
'Configure company, security, assets, and system behavior',
|
||||||
),
|
),
|
||||||
Text(
|
if (visiblePhase1.isNotEmpty)
|
||||||
'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(
|
SettingsSectionGrid(
|
||||||
sections: phase1SettingsSections,
|
sections: visiblePhase1,
|
||||||
onSectionTap: (section) => context.go(section.route),
|
onSectionTap: (section) => context.go(section.route),
|
||||||
),
|
),
|
||||||
|
if (visiblePhase2.isNotEmpty) ...[
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
'Coming in Phase 2',
|
'Coming in Phase 2',
|
||||||
@ -45,12 +43,13 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
SettingsSectionGrid(
|
SettingsSectionGrid(
|
||||||
sections: phase2SettingsSections,
|
sections: visiblePhase2,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
badge: 'Phase 2',
|
badge: 'Phase 2',
|
||||||
onSectionTap: (_) {},
|
onSectionTap: (_) {},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -36,10 +36,13 @@ Object? _readNestedName(Map<dynamic, dynamic> json, String nestedKey) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Object? _readAssetCategoryName(Map<dynamic, dynamic> json, String key) {
|
Object? _readItemCategoryName(Map<dynamic, dynamic> json, String key) {
|
||||||
final flat = json['asset_category_name'];
|
for (final flatKey in ['item_category_name', 'asset_category_name']) {
|
||||||
|
final flat = json[flatKey];
|
||||||
if (flat is String && flat.isNotEmpty) return flat;
|
if (flat is String && flat.isNotEmpty) return flat;
|
||||||
return _readNestedName(json, 'asset_category');
|
}
|
||||||
|
return _readNestedName(json, 'item_category') ??
|
||||||
|
_readNestedName(json, 'asset_category');
|
||||||
}
|
}
|
||||||
|
|
||||||
Object? _readPlantName(Map<dynamic, dynamic> json, String key) {
|
Object? _readPlantName(Map<dynamic, dynamic> json, String key) {
|
||||||
@ -48,10 +51,12 @@ Object? _readPlantName(Map<dynamic, dynamic> json, String key) {
|
|||||||
return _readNestedName(json, 'plant');
|
return _readNestedName(json, 'plant');
|
||||||
}
|
}
|
||||||
|
|
||||||
Object? _readAssetCategoryId(Map<dynamic, dynamic> json, String key) {
|
Object? _readItemCategoryId(Map<dynamic, dynamic> json, String key) {
|
||||||
final flat = json['asset_category_id'];
|
for (final flatKey in ['item_category_id', 'asset_category_id']) {
|
||||||
|
final flat = json[flatKey];
|
||||||
if (flat != null) return flat;
|
if (flat != null) return flat;
|
||||||
final nested = json['asset_category'];
|
}
|
||||||
|
final nested = json['item_category'] ?? json['asset_category'];
|
||||||
if (nested is Map) return nested['id'];
|
if (nested is Map) return nested['id'];
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -64,16 +69,21 @@ Object? _readPlantId(Map<dynamic, dynamic> json, String key) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Object? _readAssetSubcategoryName(Map<dynamic, dynamic> json, String key) {
|
Object? _readItemSubcategoryName(Map<dynamic, dynamic> json, String key) {
|
||||||
final flat = json['asset_subcategory_name'];
|
for (final flatKey in ['item_subcategory_name', 'asset_subcategory_name']) {
|
||||||
|
final flat = json[flatKey];
|
||||||
if (flat is String && flat.isNotEmpty) return flat;
|
if (flat is String && flat.isNotEmpty) return flat;
|
||||||
return _readNestedName(json, 'asset_subcategory');
|
}
|
||||||
|
return _readNestedName(json, 'item_subcategory') ??
|
||||||
|
_readNestedName(json, 'asset_subcategory');
|
||||||
}
|
}
|
||||||
|
|
||||||
Object? _readAssetSubcategoryId(Map<dynamic, dynamic> json, String key) {
|
Object? _readItemSubcategoryId(Map<dynamic, dynamic> json, String key) {
|
||||||
final flat = json['asset_subcategory_id'];
|
for (final flatKey in ['item_subcategory_id', 'asset_subcategory_id']) {
|
||||||
|
final flat = json[flatKey];
|
||||||
if (flat != null) return flat;
|
if (flat != null) return flat;
|
||||||
final nested = json['asset_subcategory'];
|
}
|
||||||
|
final nested = json['item_subcategory'] ?? json['asset_subcategory'];
|
||||||
if (nested is Map) return nested['id'];
|
if (nested is Map) return nested['id'];
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -127,20 +137,20 @@ class AssetModel with _$AssetModel {
|
|||||||
@JsonKey(name: 'asset_name') required String assetName,
|
@JsonKey(name: 'asset_name') required String assetName,
|
||||||
@JsonKey(name: 'asset_code') String? assetCode,
|
@JsonKey(name: 'asset_code') String? assetCode,
|
||||||
@JsonKey(
|
@JsonKey(
|
||||||
name: 'asset_category_id',
|
name: 'item_category_id',
|
||||||
readValue: _readAssetCategoryId,
|
readValue: _readItemCategoryId,
|
||||||
fromJson: _intFromJsonNullable,
|
fromJson: _intFromJsonNullable,
|
||||||
)
|
)
|
||||||
int? assetCategoryId,
|
int? assetCategoryId,
|
||||||
@JsonKey(name: 'asset_category_name', readValue: _readAssetCategoryName)
|
@JsonKey(name: 'item_category_name', readValue: _readItemCategoryName)
|
||||||
String? assetCategoryName,
|
String? assetCategoryName,
|
||||||
@JsonKey(
|
@JsonKey(
|
||||||
name: 'asset_subcategory_id',
|
name: 'item_subcategory_id',
|
||||||
readValue: _readAssetSubcategoryId,
|
readValue: _readItemSubcategoryId,
|
||||||
fromJson: _intFromJsonNullable,
|
fromJson: _intFromJsonNullable,
|
||||||
)
|
)
|
||||||
int? assetSubcategoryId,
|
int? assetSubcategoryId,
|
||||||
@JsonKey(name: 'asset_subcategory_name', readValue: _readAssetSubcategoryName)
|
@JsonKey(name: 'item_subcategory_name', readValue: _readItemSubcategoryName)
|
||||||
String? assetSubcategoryName,
|
String? assetSubcategoryName,
|
||||||
@JsonKey(name: 'plant_id', readValue: _readPlantId, fromJson: _intFromJsonNullable)
|
@JsonKey(name: 'plant_id', readValue: _readPlantId, fromJson: _intFromJsonNullable)
|
||||||
int? plantId,
|
int? plantId,
|
||||||
|
|||||||
@ -397,20 +397,20 @@ mixin _$AssetModel {
|
|||||||
@JsonKey(name: 'asset_code')
|
@JsonKey(name: 'asset_code')
|
||||||
String? get assetCode => throw _privateConstructorUsedError;
|
String? get assetCode => throw _privateConstructorUsedError;
|
||||||
@JsonKey(
|
@JsonKey(
|
||||||
name: 'asset_category_id',
|
name: 'item_category_id',
|
||||||
readValue: _readAssetCategoryId,
|
readValue: _readItemCategoryId,
|
||||||
fromJson: _intFromJsonNullable,
|
fromJson: _intFromJsonNullable,
|
||||||
)
|
)
|
||||||
int? get assetCategoryId => throw _privateConstructorUsedError;
|
int? get assetCategoryId => throw _privateConstructorUsedError;
|
||||||
@JsonKey(name: 'asset_category_name', readValue: _readAssetCategoryName)
|
@JsonKey(name: 'item_category_name', readValue: _readItemCategoryName)
|
||||||
String? get assetCategoryName => throw _privateConstructorUsedError;
|
String? get assetCategoryName => throw _privateConstructorUsedError;
|
||||||
@JsonKey(
|
@JsonKey(
|
||||||
name: 'asset_subcategory_id',
|
name: 'item_subcategory_id',
|
||||||
readValue: _readAssetSubcategoryId,
|
readValue: _readItemSubcategoryId,
|
||||||
fromJson: _intFromJsonNullable,
|
fromJson: _intFromJsonNullable,
|
||||||
)
|
)
|
||||||
int? get assetSubcategoryId => throw _privateConstructorUsedError;
|
int? get assetSubcategoryId => throw _privateConstructorUsedError;
|
||||||
@JsonKey(name: 'asset_subcategory_name', readValue: _readAssetSubcategoryName)
|
@JsonKey(name: 'item_subcategory_name', readValue: _readItemSubcategoryName)
|
||||||
String? get assetSubcategoryName => throw _privateConstructorUsedError;
|
String? get assetSubcategoryName => throw _privateConstructorUsedError;
|
||||||
@JsonKey(
|
@JsonKey(
|
||||||
name: 'plant_id',
|
name: 'plant_id',
|
||||||
@ -503,23 +503,20 @@ abstract class $AssetModelCopyWith<$Res> {
|
|||||||
@JsonKey(name: 'asset_name') String assetName,
|
@JsonKey(name: 'asset_name') String assetName,
|
||||||
@JsonKey(name: 'asset_code') String? assetCode,
|
@JsonKey(name: 'asset_code') String? assetCode,
|
||||||
@JsonKey(
|
@JsonKey(
|
||||||
name: 'asset_category_id',
|
name: 'item_category_id',
|
||||||
readValue: _readAssetCategoryId,
|
readValue: _readItemCategoryId,
|
||||||
fromJson: _intFromJsonNullable,
|
fromJson: _intFromJsonNullable,
|
||||||
)
|
)
|
||||||
int? assetCategoryId,
|
int? assetCategoryId,
|
||||||
@JsonKey(name: 'asset_category_name', readValue: _readAssetCategoryName)
|
@JsonKey(name: 'item_category_name', readValue: _readItemCategoryName)
|
||||||
String? assetCategoryName,
|
String? assetCategoryName,
|
||||||
@JsonKey(
|
@JsonKey(
|
||||||
name: 'asset_subcategory_id',
|
name: 'item_subcategory_id',
|
||||||
readValue: _readAssetSubcategoryId,
|
readValue: _readItemSubcategoryId,
|
||||||
fromJson: _intFromJsonNullable,
|
fromJson: _intFromJsonNullable,
|
||||||
)
|
)
|
||||||
int? assetSubcategoryId,
|
int? assetSubcategoryId,
|
||||||
@JsonKey(
|
@JsonKey(name: 'item_subcategory_name', readValue: _readItemSubcategoryName)
|
||||||
name: 'asset_subcategory_name',
|
|
||||||
readValue: _readAssetSubcategoryName,
|
|
||||||
)
|
|
||||||
String? assetSubcategoryName,
|
String? assetSubcategoryName,
|
||||||
@JsonKey(
|
@JsonKey(
|
||||||
name: 'plant_id',
|
name: 'plant_id',
|
||||||
@ -823,23 +820,20 @@ abstract class _$$AssetModelImplCopyWith<$Res>
|
|||||||
@JsonKey(name: 'asset_name') String assetName,
|
@JsonKey(name: 'asset_name') String assetName,
|
||||||
@JsonKey(name: 'asset_code') String? assetCode,
|
@JsonKey(name: 'asset_code') String? assetCode,
|
||||||
@JsonKey(
|
@JsonKey(
|
||||||
name: 'asset_category_id',
|
name: 'item_category_id',
|
||||||
readValue: _readAssetCategoryId,
|
readValue: _readItemCategoryId,
|
||||||
fromJson: _intFromJsonNullable,
|
fromJson: _intFromJsonNullable,
|
||||||
)
|
)
|
||||||
int? assetCategoryId,
|
int? assetCategoryId,
|
||||||
@JsonKey(name: 'asset_category_name', readValue: _readAssetCategoryName)
|
@JsonKey(name: 'item_category_name', readValue: _readItemCategoryName)
|
||||||
String? assetCategoryName,
|
String? assetCategoryName,
|
||||||
@JsonKey(
|
@JsonKey(
|
||||||
name: 'asset_subcategory_id',
|
name: 'item_subcategory_id',
|
||||||
readValue: _readAssetSubcategoryId,
|
readValue: _readItemSubcategoryId,
|
||||||
fromJson: _intFromJsonNullable,
|
fromJson: _intFromJsonNullable,
|
||||||
)
|
)
|
||||||
int? assetSubcategoryId,
|
int? assetSubcategoryId,
|
||||||
@JsonKey(
|
@JsonKey(name: 'item_subcategory_name', readValue: _readItemSubcategoryName)
|
||||||
name: 'asset_subcategory_name',
|
|
||||||
readValue: _readAssetSubcategoryName,
|
|
||||||
)
|
|
||||||
String? assetSubcategoryName,
|
String? assetSubcategoryName,
|
||||||
@JsonKey(
|
@JsonKey(
|
||||||
name: 'plant_id',
|
name: 'plant_id',
|
||||||
@ -1135,23 +1129,20 @@ class _$AssetModelImpl implements _AssetModel {
|
|||||||
@JsonKey(name: 'asset_name') required this.assetName,
|
@JsonKey(name: 'asset_name') required this.assetName,
|
||||||
@JsonKey(name: 'asset_code') this.assetCode,
|
@JsonKey(name: 'asset_code') this.assetCode,
|
||||||
@JsonKey(
|
@JsonKey(
|
||||||
name: 'asset_category_id',
|
name: 'item_category_id',
|
||||||
readValue: _readAssetCategoryId,
|
readValue: _readItemCategoryId,
|
||||||
fromJson: _intFromJsonNullable,
|
fromJson: _intFromJsonNullable,
|
||||||
)
|
)
|
||||||
this.assetCategoryId,
|
this.assetCategoryId,
|
||||||
@JsonKey(name: 'asset_category_name', readValue: _readAssetCategoryName)
|
@JsonKey(name: 'item_category_name', readValue: _readItemCategoryName)
|
||||||
this.assetCategoryName,
|
this.assetCategoryName,
|
||||||
@JsonKey(
|
@JsonKey(
|
||||||
name: 'asset_subcategory_id',
|
name: 'item_subcategory_id',
|
||||||
readValue: _readAssetSubcategoryId,
|
readValue: _readItemSubcategoryId,
|
||||||
fromJson: _intFromJsonNullable,
|
fromJson: _intFromJsonNullable,
|
||||||
)
|
)
|
||||||
this.assetSubcategoryId,
|
this.assetSubcategoryId,
|
||||||
@JsonKey(
|
@JsonKey(name: 'item_subcategory_name', readValue: _readItemSubcategoryName)
|
||||||
name: 'asset_subcategory_name',
|
|
||||||
readValue: _readAssetSubcategoryName,
|
|
||||||
)
|
|
||||||
this.assetSubcategoryName,
|
this.assetSubcategoryName,
|
||||||
@JsonKey(
|
@JsonKey(
|
||||||
name: 'plant_id',
|
name: 'plant_id',
|
||||||
@ -1225,23 +1216,23 @@ class _$AssetModelImpl implements _AssetModel {
|
|||||||
final String? assetCode;
|
final String? assetCode;
|
||||||
@override
|
@override
|
||||||
@JsonKey(
|
@JsonKey(
|
||||||
name: 'asset_category_id',
|
name: 'item_category_id',
|
||||||
readValue: _readAssetCategoryId,
|
readValue: _readItemCategoryId,
|
||||||
fromJson: _intFromJsonNullable,
|
fromJson: _intFromJsonNullable,
|
||||||
)
|
)
|
||||||
final int? assetCategoryId;
|
final int? assetCategoryId;
|
||||||
@override
|
@override
|
||||||
@JsonKey(name: 'asset_category_name', readValue: _readAssetCategoryName)
|
@JsonKey(name: 'item_category_name', readValue: _readItemCategoryName)
|
||||||
final String? assetCategoryName;
|
final String? assetCategoryName;
|
||||||
@override
|
@override
|
||||||
@JsonKey(
|
@JsonKey(
|
||||||
name: 'asset_subcategory_id',
|
name: 'item_subcategory_id',
|
||||||
readValue: _readAssetSubcategoryId,
|
readValue: _readItemSubcategoryId,
|
||||||
fromJson: _intFromJsonNullable,
|
fromJson: _intFromJsonNullable,
|
||||||
)
|
)
|
||||||
final int? assetSubcategoryId;
|
final int? assetSubcategoryId;
|
||||||
@override
|
@override
|
||||||
@JsonKey(name: 'asset_subcategory_name', readValue: _readAssetSubcategoryName)
|
@JsonKey(name: 'item_subcategory_name', readValue: _readItemSubcategoryName)
|
||||||
final String? assetSubcategoryName;
|
final String? assetSubcategoryName;
|
||||||
@override
|
@override
|
||||||
@JsonKey(
|
@JsonKey(
|
||||||
@ -1501,23 +1492,20 @@ abstract class _AssetModel implements AssetModel {
|
|||||||
@JsonKey(name: 'asset_name') required final String assetName,
|
@JsonKey(name: 'asset_name') required final String assetName,
|
||||||
@JsonKey(name: 'asset_code') final String? assetCode,
|
@JsonKey(name: 'asset_code') final String? assetCode,
|
||||||
@JsonKey(
|
@JsonKey(
|
||||||
name: 'asset_category_id',
|
name: 'item_category_id',
|
||||||
readValue: _readAssetCategoryId,
|
readValue: _readItemCategoryId,
|
||||||
fromJson: _intFromJsonNullable,
|
fromJson: _intFromJsonNullable,
|
||||||
)
|
)
|
||||||
final int? assetCategoryId,
|
final int? assetCategoryId,
|
||||||
@JsonKey(name: 'asset_category_name', readValue: _readAssetCategoryName)
|
@JsonKey(name: 'item_category_name', readValue: _readItemCategoryName)
|
||||||
final String? assetCategoryName,
|
final String? assetCategoryName,
|
||||||
@JsonKey(
|
@JsonKey(
|
||||||
name: 'asset_subcategory_id',
|
name: 'item_subcategory_id',
|
||||||
readValue: _readAssetSubcategoryId,
|
readValue: _readItemSubcategoryId,
|
||||||
fromJson: _intFromJsonNullable,
|
fromJson: _intFromJsonNullable,
|
||||||
)
|
)
|
||||||
final int? assetSubcategoryId,
|
final int? assetSubcategoryId,
|
||||||
@JsonKey(
|
@JsonKey(name: 'item_subcategory_name', readValue: _readItemSubcategoryName)
|
||||||
name: 'asset_subcategory_name',
|
|
||||||
readValue: _readAssetSubcategoryName,
|
|
||||||
)
|
|
||||||
final String? assetSubcategoryName,
|
final String? assetSubcategoryName,
|
||||||
@JsonKey(
|
@JsonKey(
|
||||||
name: 'plant_id',
|
name: 'plant_id',
|
||||||
@ -1593,23 +1581,23 @@ abstract class _AssetModel implements AssetModel {
|
|||||||
String? get assetCode;
|
String? get assetCode;
|
||||||
@override
|
@override
|
||||||
@JsonKey(
|
@JsonKey(
|
||||||
name: 'asset_category_id',
|
name: 'item_category_id',
|
||||||
readValue: _readAssetCategoryId,
|
readValue: _readItemCategoryId,
|
||||||
fromJson: _intFromJsonNullable,
|
fromJson: _intFromJsonNullable,
|
||||||
)
|
)
|
||||||
int? get assetCategoryId;
|
int? get assetCategoryId;
|
||||||
@override
|
@override
|
||||||
@JsonKey(name: 'asset_category_name', readValue: _readAssetCategoryName)
|
@JsonKey(name: 'item_category_name', readValue: _readItemCategoryName)
|
||||||
String? get assetCategoryName;
|
String? get assetCategoryName;
|
||||||
@override
|
@override
|
||||||
@JsonKey(
|
@JsonKey(
|
||||||
name: 'asset_subcategory_id',
|
name: 'item_subcategory_id',
|
||||||
readValue: _readAssetSubcategoryId,
|
readValue: _readItemSubcategoryId,
|
||||||
fromJson: _intFromJsonNullable,
|
fromJson: _intFromJsonNullable,
|
||||||
)
|
)
|
||||||
int? get assetSubcategoryId;
|
int? get assetSubcategoryId;
|
||||||
@override
|
@override
|
||||||
@JsonKey(name: 'asset_subcategory_name', readValue: _readAssetSubcategoryName)
|
@JsonKey(name: 'item_subcategory_name', readValue: _readItemSubcategoryName)
|
||||||
String? get assetSubcategoryName;
|
String? get assetSubcategoryName;
|
||||||
@override
|
@override
|
||||||
@JsonKey(
|
@JsonKey(
|
||||||
|
|||||||
@ -46,15 +46,15 @@ _$AssetModelImpl _$$AssetModelImplFromJson(Map<String, dynamic> json) =>
|
|||||||
assetName: json['asset_name'] as String,
|
assetName: json['asset_name'] as String,
|
||||||
assetCode: json['asset_code'] as String?,
|
assetCode: json['asset_code'] as String?,
|
||||||
assetCategoryId: _intFromJsonNullable(
|
assetCategoryId: _intFromJsonNullable(
|
||||||
_readAssetCategoryId(json, 'asset_category_id'),
|
_readItemCategoryId(json, 'item_category_id'),
|
||||||
),
|
),
|
||||||
assetCategoryName:
|
assetCategoryName:
|
||||||
_readAssetCategoryName(json, 'asset_category_name') as String?,
|
_readItemCategoryName(json, 'item_category_name') as String?,
|
||||||
assetSubcategoryId: _intFromJsonNullable(
|
assetSubcategoryId: _intFromJsonNullable(
|
||||||
_readAssetSubcategoryId(json, 'asset_subcategory_id'),
|
_readItemSubcategoryId(json, 'item_subcategory_id'),
|
||||||
),
|
),
|
||||||
assetSubcategoryName:
|
assetSubcategoryName:
|
||||||
_readAssetSubcategoryName(json, 'asset_subcategory_name') as String?,
|
_readItemSubcategoryName(json, 'item_subcategory_name') as String?,
|
||||||
plantId: _intFromJsonNullable(_readPlantId(json, 'plant_id')),
|
plantId: _intFromJsonNullable(_readPlantId(json, 'plant_id')),
|
||||||
plantName: _readPlantName(json, 'plant_name') as String?,
|
plantName: _readPlantName(json, 'plant_name') as String?,
|
||||||
brandModel: json['brand_model'] as String?,
|
brandModel: json['brand_model'] as String?,
|
||||||
@ -96,10 +96,10 @@ Map<String, dynamic> _$$AssetModelImplToJson(_$AssetModelImpl instance) =>
|
|||||||
'id': instance.id,
|
'id': instance.id,
|
||||||
'asset_name': instance.assetName,
|
'asset_name': instance.assetName,
|
||||||
'asset_code': instance.assetCode,
|
'asset_code': instance.assetCode,
|
||||||
'asset_category_id': instance.assetCategoryId,
|
'item_category_id': instance.assetCategoryId,
|
||||||
'asset_category_name': instance.assetCategoryName,
|
'item_category_name': instance.assetCategoryName,
|
||||||
'asset_subcategory_id': instance.assetSubcategoryId,
|
'item_subcategory_id': instance.assetSubcategoryId,
|
||||||
'asset_subcategory_name': instance.assetSubcategoryName,
|
'item_subcategory_name': instance.assetSubcategoryName,
|
||||||
'plant_id': instance.plantId,
|
'plant_id': instance.plantId,
|
||||||
'plant_name': instance.plantName,
|
'plant_name': instance.plantName,
|
||||||
'brand_model': instance.brandModel,
|
'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;
|
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
|
@freezed
|
||||||
class GrnModel with _$GrnModel {
|
class GrnModel with _$GrnModel {
|
||||||
const GrnModel._();
|
const GrnModel._();
|
||||||
@ -118,6 +148,7 @@ class GrnModel with _$GrnModel {
|
|||||||
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable) DateTime? createdAt,
|
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable) DateTime? createdAt,
|
||||||
@JsonKey(name: 'updated_at', fromJson: _dateFromJsonNullable) DateTime? updatedAt,
|
@JsonKey(name: 'updated_at', fromJson: _dateFromJsonNullable) DateTime? updatedAt,
|
||||||
@Default([]) List<GrnItemModel> items,
|
@Default([]) List<GrnItemModel> items,
|
||||||
|
@Default([]) List<GrnAttachmentModel> attachments,
|
||||||
}) = _GrnModel;
|
}) = _GrnModel;
|
||||||
|
|
||||||
factory GrnModel.fromJson(Map<String, dynamic> json) => _$GrnModelFromJson(json);
|
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 canEdit => status.toUpperCase() == 'POSTED';
|
||||||
|
|
||||||
bool get canCancel => 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
|
@freezed
|
||||||
@ -149,10 +216,18 @@ class GrnItemModel with _$GrnItemModel {
|
|||||||
@JsonKey(name: 'mfg_date', fromJson: _dateFromJsonNullable) DateTime? mfgDate,
|
@JsonKey(name: 'mfg_date', fromJson: _dateFromJsonNullable) DateTime? mfgDate,
|
||||||
@JsonKey(name: 'expiry_date', fromJson: _dateFromJsonNullable) DateTime? expiryDate,
|
@JsonKey(name: 'expiry_date', fromJson: _dateFromJsonNullable) DateTime? expiryDate,
|
||||||
@JsonKey(name: 'storage_location') String? storageLocation,
|
@JsonKey(name: 'storage_location') String? storageLocation,
|
||||||
@JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable)
|
@JsonKey(
|
||||||
int? assetCategoryId,
|
name: 'item_category_id',
|
||||||
@JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable)
|
readValue: _readGrnItemCategoryId,
|
||||||
int? assetSubcategoryId,
|
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_id', fromJson: _intFromJsonNullable) int? uomId,
|
||||||
@JsonKey(name: 'uom_name', readValue: _readUomName) String? uomName,
|
@JsonKey(name: 'uom_name', readValue: _readUomName) String? uomName,
|
||||||
String? remarks,
|
String? remarks,
|
||||||
|
|||||||
@ -64,6 +64,8 @@ mixin _$GrnModel {
|
|||||||
@JsonKey(name: 'updated_at', fromJson: _dateFromJsonNullable)
|
@JsonKey(name: 'updated_at', fromJson: _dateFromJsonNullable)
|
||||||
DateTime? get updatedAt => throw _privateConstructorUsedError;
|
DateTime? get updatedAt => throw _privateConstructorUsedError;
|
||||||
List<GrnItemModel> get items => throw _privateConstructorUsedError;
|
List<GrnItemModel> get items => throw _privateConstructorUsedError;
|
||||||
|
List<GrnAttachmentModel> get attachments =>
|
||||||
|
throw _privateConstructorUsedError;
|
||||||
|
|
||||||
/// Serializes this GrnModel to a JSON map.
|
/// Serializes this GrnModel to a JSON map.
|
||||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||||
@ -114,6 +116,7 @@ abstract class $GrnModelCopyWith<$Res> {
|
|||||||
@JsonKey(name: 'updated_at', fromJson: _dateFromJsonNullable)
|
@JsonKey(name: 'updated_at', fromJson: _dateFromJsonNullable)
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
List<GrnItemModel> items,
|
List<GrnItemModel> items,
|
||||||
|
List<GrnAttachmentModel> attachments,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -155,6 +158,7 @@ class _$GrnModelCopyWithImpl<$Res, $Val extends GrnModel>
|
|||||||
Object? createdAt = freezed,
|
Object? createdAt = freezed,
|
||||||
Object? updatedAt = freezed,
|
Object? updatedAt = freezed,
|
||||||
Object? items = null,
|
Object? items = null,
|
||||||
|
Object? attachments = null,
|
||||||
}) {
|
}) {
|
||||||
return _then(
|
return _then(
|
||||||
_value.copyWith(
|
_value.copyWith(
|
||||||
@ -250,6 +254,10 @@ class _$GrnModelCopyWithImpl<$Res, $Val extends GrnModel>
|
|||||||
? _value.items
|
? _value.items
|
||||||
: items // ignore: cast_nullable_to_non_nullable
|
: items // ignore: cast_nullable_to_non_nullable
|
||||||
as List<GrnItemModel>,
|
as List<GrnItemModel>,
|
||||||
|
attachments: null == attachments
|
||||||
|
? _value.attachments
|
||||||
|
: attachments // ignore: cast_nullable_to_non_nullable
|
||||||
|
as List<GrnAttachmentModel>,
|
||||||
)
|
)
|
||||||
as $Val,
|
as $Val,
|
||||||
);
|
);
|
||||||
@ -299,6 +307,7 @@ abstract class _$$GrnModelImplCopyWith<$Res>
|
|||||||
@JsonKey(name: 'updated_at', fromJson: _dateFromJsonNullable)
|
@JsonKey(name: 'updated_at', fromJson: _dateFromJsonNullable)
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
List<GrnItemModel> items,
|
List<GrnItemModel> items,
|
||||||
|
List<GrnAttachmentModel> attachments,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -339,6 +348,7 @@ class __$$GrnModelImplCopyWithImpl<$Res>
|
|||||||
Object? createdAt = freezed,
|
Object? createdAt = freezed,
|
||||||
Object? updatedAt = freezed,
|
Object? updatedAt = freezed,
|
||||||
Object? items = null,
|
Object? items = null,
|
||||||
|
Object? attachments = null,
|
||||||
}) {
|
}) {
|
||||||
return _then(
|
return _then(
|
||||||
_$GrnModelImpl(
|
_$GrnModelImpl(
|
||||||
@ -434,6 +444,10 @@ class __$$GrnModelImplCopyWithImpl<$Res>
|
|||||||
? _value._items
|
? _value._items
|
||||||
: items // ignore: cast_nullable_to_non_nullable
|
: items // ignore: cast_nullable_to_non_nullable
|
||||||
as List<GrnItemModel>,
|
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)
|
@JsonKey(name: 'updated_at', fromJson: _dateFromJsonNullable)
|
||||||
this.updatedAt,
|
this.updatedAt,
|
||||||
final List<GrnItemModel> items = const [],
|
final List<GrnItemModel> items = const [],
|
||||||
|
final List<GrnAttachmentModel> attachments = const [],
|
||||||
}) : _items = items,
|
}) : _items = items,
|
||||||
|
_attachments = attachments,
|
||||||
super._();
|
super._();
|
||||||
|
|
||||||
factory _$GrnModelImpl.fromJson(Map<String, dynamic> json) =>
|
factory _$GrnModelImpl.fromJson(Map<String, dynamic> json) =>
|
||||||
@ -554,9 +570,18 @@ class _$GrnModelImpl extends _GrnModel {
|
|||||||
return EqualUnmodifiableListView(_items);
|
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
|
@override
|
||||||
String toString() {
|
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
|
@override
|
||||||
@ -601,7 +626,11 @@ class _$GrnModelImpl extends _GrnModel {
|
|||||||
other.createdAt == createdAt) &&
|
other.createdAt == createdAt) &&
|
||||||
(identical(other.updatedAt, updatedAt) ||
|
(identical(other.updatedAt, updatedAt) ||
|
||||||
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)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@ -631,6 +660,7 @@ class _$GrnModelImpl extends _GrnModel {
|
|||||||
createdAt,
|
createdAt,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
const DeepCollectionEquality().hash(_items),
|
const DeepCollectionEquality().hash(_items),
|
||||||
|
const DeepCollectionEquality().hash(_attachments),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/// Create a copy of GrnModel
|
/// Create a copy of GrnModel
|
||||||
@ -686,6 +716,7 @@ abstract class _GrnModel extends GrnModel {
|
|||||||
@JsonKey(name: 'updated_at', fromJson: _dateFromJsonNullable)
|
@JsonKey(name: 'updated_at', fromJson: _dateFromJsonNullable)
|
||||||
final DateTime? updatedAt,
|
final DateTime? updatedAt,
|
||||||
final List<GrnItemModel> items,
|
final List<GrnItemModel> items,
|
||||||
|
final List<GrnAttachmentModel> attachments,
|
||||||
}) = _$GrnModelImpl;
|
}) = _$GrnModelImpl;
|
||||||
const _GrnModel._() : super._();
|
const _GrnModel._() : super._();
|
||||||
|
|
||||||
@ -758,6 +789,8 @@ abstract class _GrnModel extends GrnModel {
|
|||||||
DateTime? get updatedAt;
|
DateTime? get updatedAt;
|
||||||
@override
|
@override
|
||||||
List<GrnItemModel> get items;
|
List<GrnItemModel> get items;
|
||||||
|
@override
|
||||||
|
List<GrnAttachmentModel> get attachments;
|
||||||
|
|
||||||
/// Create a copy of GrnModel
|
/// Create a copy of GrnModel
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@ -767,6 +800,337 @@ abstract class _GrnModel extends GrnModel {
|
|||||||
throw _privateConstructorUsedError;
|
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) {
|
GrnItemModel _$GrnItemModelFromJson(Map<String, dynamic> json) {
|
||||||
return _GrnItemModel.fromJson(json);
|
return _GrnItemModel.fromJson(json);
|
||||||
}
|
}
|
||||||
@ -811,10 +1175,18 @@ mixin _$GrnItemModel {
|
|||||||
DateTime? get expiryDate => throw _privateConstructorUsedError;
|
DateTime? get expiryDate => throw _privateConstructorUsedError;
|
||||||
@JsonKey(name: 'storage_location')
|
@JsonKey(name: 'storage_location')
|
||||||
String? get storageLocation => throw _privateConstructorUsedError;
|
String? get storageLocation => throw _privateConstructorUsedError;
|
||||||
@JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable)
|
@JsonKey(
|
||||||
int? get assetCategoryId => throw _privateConstructorUsedError;
|
name: 'item_category_id',
|
||||||
@JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable)
|
readValue: _readGrnItemCategoryId,
|
||||||
int? get assetSubcategoryId => throw _privateConstructorUsedError;
|
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)
|
@JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable)
|
||||||
int? get uomId => throw _privateConstructorUsedError;
|
int? get uomId => throw _privateConstructorUsedError;
|
||||||
@JsonKey(name: 'uom_name', readValue: _readUomName)
|
@JsonKey(name: 'uom_name', readValue: _readUomName)
|
||||||
@ -866,10 +1238,18 @@ abstract class $GrnItemModelCopyWith<$Res> {
|
|||||||
@JsonKey(name: 'expiry_date', fromJson: _dateFromJsonNullable)
|
@JsonKey(name: 'expiry_date', fromJson: _dateFromJsonNullable)
|
||||||
DateTime? expiryDate,
|
DateTime? expiryDate,
|
||||||
@JsonKey(name: 'storage_location') String? storageLocation,
|
@JsonKey(name: 'storage_location') String? storageLocation,
|
||||||
@JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable)
|
@JsonKey(
|
||||||
int? assetCategoryId,
|
name: 'item_category_id',
|
||||||
@JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable)
|
readValue: _readGrnItemCategoryId,
|
||||||
int? assetSubcategoryId,
|
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_id', fromJson: _intFromJsonNullable) int? uomId,
|
||||||
@JsonKey(name: 'uom_name', readValue: _readUomName) String? uomName,
|
@JsonKey(name: 'uom_name', readValue: _readUomName) String? uomName,
|
||||||
String? remarks,
|
String? remarks,
|
||||||
@ -910,8 +1290,8 @@ class _$GrnItemModelCopyWithImpl<$Res, $Val extends GrnItemModel>
|
|||||||
Object? mfgDate = freezed,
|
Object? mfgDate = freezed,
|
||||||
Object? expiryDate = freezed,
|
Object? expiryDate = freezed,
|
||||||
Object? storageLocation = freezed,
|
Object? storageLocation = freezed,
|
||||||
Object? assetCategoryId = freezed,
|
Object? itemCategoryId = freezed,
|
||||||
Object? assetSubcategoryId = freezed,
|
Object? itemSubcategoryId = freezed,
|
||||||
Object? uomId = freezed,
|
Object? uomId = freezed,
|
||||||
Object? uomName = freezed,
|
Object? uomName = freezed,
|
||||||
Object? remarks = freezed,
|
Object? remarks = freezed,
|
||||||
@ -994,13 +1374,13 @@ class _$GrnItemModelCopyWithImpl<$Res, $Val extends GrnItemModel>
|
|||||||
? _value.storageLocation
|
? _value.storageLocation
|
||||||
: storageLocation // ignore: cast_nullable_to_non_nullable
|
: storageLocation // ignore: cast_nullable_to_non_nullable
|
||||||
as String?,
|
as String?,
|
||||||
assetCategoryId: freezed == assetCategoryId
|
itemCategoryId: freezed == itemCategoryId
|
||||||
? _value.assetCategoryId
|
? _value.itemCategoryId
|
||||||
: assetCategoryId // ignore: cast_nullable_to_non_nullable
|
: itemCategoryId // ignore: cast_nullable_to_non_nullable
|
||||||
as int?,
|
as int?,
|
||||||
assetSubcategoryId: freezed == assetSubcategoryId
|
itemSubcategoryId: freezed == itemSubcategoryId
|
||||||
? _value.assetSubcategoryId
|
? _value.itemSubcategoryId
|
||||||
: assetSubcategoryId // ignore: cast_nullable_to_non_nullable
|
: itemSubcategoryId // ignore: cast_nullable_to_non_nullable
|
||||||
as int?,
|
as int?,
|
||||||
uomId: freezed == uomId
|
uomId: freezed == uomId
|
||||||
? _value.uomId
|
? _value.uomId
|
||||||
@ -1057,10 +1437,18 @@ abstract class _$$GrnItemModelImplCopyWith<$Res>
|
|||||||
@JsonKey(name: 'expiry_date', fromJson: _dateFromJsonNullable)
|
@JsonKey(name: 'expiry_date', fromJson: _dateFromJsonNullable)
|
||||||
DateTime? expiryDate,
|
DateTime? expiryDate,
|
||||||
@JsonKey(name: 'storage_location') String? storageLocation,
|
@JsonKey(name: 'storage_location') String? storageLocation,
|
||||||
@JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable)
|
@JsonKey(
|
||||||
int? assetCategoryId,
|
name: 'item_category_id',
|
||||||
@JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable)
|
readValue: _readGrnItemCategoryId,
|
||||||
int? assetSubcategoryId,
|
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_id', fromJson: _intFromJsonNullable) int? uomId,
|
||||||
@JsonKey(name: 'uom_name', readValue: _readUomName) String? uomName,
|
@JsonKey(name: 'uom_name', readValue: _readUomName) String? uomName,
|
||||||
String? remarks,
|
String? remarks,
|
||||||
@ -1100,8 +1488,8 @@ class __$$GrnItemModelImplCopyWithImpl<$Res>
|
|||||||
Object? mfgDate = freezed,
|
Object? mfgDate = freezed,
|
||||||
Object? expiryDate = freezed,
|
Object? expiryDate = freezed,
|
||||||
Object? storageLocation = freezed,
|
Object? storageLocation = freezed,
|
||||||
Object? assetCategoryId = freezed,
|
Object? itemCategoryId = freezed,
|
||||||
Object? assetSubcategoryId = freezed,
|
Object? itemSubcategoryId = freezed,
|
||||||
Object? uomId = freezed,
|
Object? uomId = freezed,
|
||||||
Object? uomName = freezed,
|
Object? uomName = freezed,
|
||||||
Object? remarks = freezed,
|
Object? remarks = freezed,
|
||||||
@ -1184,13 +1572,13 @@ class __$$GrnItemModelImplCopyWithImpl<$Res>
|
|||||||
? _value.storageLocation
|
? _value.storageLocation
|
||||||
: storageLocation // ignore: cast_nullable_to_non_nullable
|
: storageLocation // ignore: cast_nullable_to_non_nullable
|
||||||
as String?,
|
as String?,
|
||||||
assetCategoryId: freezed == assetCategoryId
|
itemCategoryId: freezed == itemCategoryId
|
||||||
? _value.assetCategoryId
|
? _value.itemCategoryId
|
||||||
: assetCategoryId // ignore: cast_nullable_to_non_nullable
|
: itemCategoryId // ignore: cast_nullable_to_non_nullable
|
||||||
as int?,
|
as int?,
|
||||||
assetSubcategoryId: freezed == assetSubcategoryId
|
itemSubcategoryId: freezed == itemSubcategoryId
|
||||||
? _value.assetSubcategoryId
|
? _value.itemSubcategoryId
|
||||||
: assetSubcategoryId // ignore: cast_nullable_to_non_nullable
|
: itemSubcategoryId // ignore: cast_nullable_to_non_nullable
|
||||||
as int?,
|
as int?,
|
||||||
uomId: freezed == uomId
|
uomId: freezed == uomId
|
||||||
? _value.uomId
|
? _value.uomId
|
||||||
@ -1239,10 +1627,18 @@ class _$GrnItemModelImpl implements _GrnItemModel {
|
|||||||
@JsonKey(name: 'expiry_date', fromJson: _dateFromJsonNullable)
|
@JsonKey(name: 'expiry_date', fromJson: _dateFromJsonNullable)
|
||||||
this.expiryDate,
|
this.expiryDate,
|
||||||
@JsonKey(name: 'storage_location') this.storageLocation,
|
@JsonKey(name: 'storage_location') this.storageLocation,
|
||||||
@JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable)
|
@JsonKey(
|
||||||
this.assetCategoryId,
|
name: 'item_category_id',
|
||||||
@JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable)
|
readValue: _readGrnItemCategoryId,
|
||||||
this.assetSubcategoryId,
|
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_id', fromJson: _intFromJsonNullable) this.uomId,
|
||||||
@JsonKey(name: 'uom_name', readValue: _readUomName) this.uomName,
|
@JsonKey(name: 'uom_name', readValue: _readUomName) this.uomName,
|
||||||
this.remarks,
|
this.remarks,
|
||||||
@ -1309,11 +1705,19 @@ class _$GrnItemModelImpl implements _GrnItemModel {
|
|||||||
@JsonKey(name: 'storage_location')
|
@JsonKey(name: 'storage_location')
|
||||||
final String? storageLocation;
|
final String? storageLocation;
|
||||||
@override
|
@override
|
||||||
@JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable)
|
@JsonKey(
|
||||||
final int? assetCategoryId;
|
name: 'item_category_id',
|
||||||
|
readValue: _readGrnItemCategoryId,
|
||||||
|
fromJson: _intFromJsonNullable,
|
||||||
|
)
|
||||||
|
final int? itemCategoryId;
|
||||||
@override
|
@override
|
||||||
@JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable)
|
@JsonKey(
|
||||||
final int? assetSubcategoryId;
|
name: 'item_subcategory_id',
|
||||||
|
readValue: _readGrnItemSubcategoryId,
|
||||||
|
fromJson: _intFromJsonNullable,
|
||||||
|
)
|
||||||
|
final int? itemSubcategoryId;
|
||||||
@override
|
@override
|
||||||
@JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable)
|
@JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable)
|
||||||
final int? uomId;
|
final int? uomId;
|
||||||
@ -1325,7 +1729,7 @@ class _$GrnItemModelImpl implements _GrnItemModel {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
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
|
@override
|
||||||
@ -1364,10 +1768,10 @@ class _$GrnItemModelImpl implements _GrnItemModel {
|
|||||||
other.expiryDate == expiryDate) &&
|
other.expiryDate == expiryDate) &&
|
||||||
(identical(other.storageLocation, storageLocation) ||
|
(identical(other.storageLocation, storageLocation) ||
|
||||||
other.storageLocation == storageLocation) &&
|
other.storageLocation == storageLocation) &&
|
||||||
(identical(other.assetCategoryId, assetCategoryId) ||
|
(identical(other.itemCategoryId, itemCategoryId) ||
|
||||||
other.assetCategoryId == assetCategoryId) &&
|
other.itemCategoryId == itemCategoryId) &&
|
||||||
(identical(other.assetSubcategoryId, assetSubcategoryId) ||
|
(identical(other.itemSubcategoryId, itemSubcategoryId) ||
|
||||||
other.assetSubcategoryId == assetSubcategoryId) &&
|
other.itemSubcategoryId == itemSubcategoryId) &&
|
||||||
(identical(other.uomId, uomId) || other.uomId == uomId) &&
|
(identical(other.uomId, uomId) || other.uomId == uomId) &&
|
||||||
(identical(other.uomName, uomName) || other.uomName == uomName) &&
|
(identical(other.uomName, uomName) || other.uomName == uomName) &&
|
||||||
(identical(other.remarks, remarks) || other.remarks == remarks));
|
(identical(other.remarks, remarks) || other.remarks == remarks));
|
||||||
@ -1396,8 +1800,8 @@ class _$GrnItemModelImpl implements _GrnItemModel {
|
|||||||
mfgDate,
|
mfgDate,
|
||||||
expiryDate,
|
expiryDate,
|
||||||
storageLocation,
|
storageLocation,
|
||||||
assetCategoryId,
|
itemCategoryId,
|
||||||
assetSubcategoryId,
|
itemSubcategoryId,
|
||||||
uomId,
|
uomId,
|
||||||
uomName,
|
uomName,
|
||||||
remarks,
|
remarks,
|
||||||
@ -1449,10 +1853,18 @@ abstract class _GrnItemModel implements GrnItemModel {
|
|||||||
@JsonKey(name: 'expiry_date', fromJson: _dateFromJsonNullable)
|
@JsonKey(name: 'expiry_date', fromJson: _dateFromJsonNullable)
|
||||||
final DateTime? expiryDate,
|
final DateTime? expiryDate,
|
||||||
@JsonKey(name: 'storage_location') final String? storageLocation,
|
@JsonKey(name: 'storage_location') final String? storageLocation,
|
||||||
@JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable)
|
@JsonKey(
|
||||||
final int? assetCategoryId,
|
name: 'item_category_id',
|
||||||
@JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable)
|
readValue: _readGrnItemCategoryId,
|
||||||
final int? assetSubcategoryId,
|
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_id', fromJson: _intFromJsonNullable) final int? uomId,
|
||||||
@JsonKey(name: 'uom_name', readValue: _readUomName) final String? uomName,
|
@JsonKey(name: 'uom_name', readValue: _readUomName) final String? uomName,
|
||||||
final String? remarks,
|
final String? remarks,
|
||||||
@ -1519,11 +1931,19 @@ abstract class _GrnItemModel implements GrnItemModel {
|
|||||||
@JsonKey(name: 'storage_location')
|
@JsonKey(name: 'storage_location')
|
||||||
String? get storageLocation;
|
String? get storageLocation;
|
||||||
@override
|
@override
|
||||||
@JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable)
|
@JsonKey(
|
||||||
int? get assetCategoryId;
|
name: 'item_category_id',
|
||||||
|
readValue: _readGrnItemCategoryId,
|
||||||
|
fromJson: _intFromJsonNullable,
|
||||||
|
)
|
||||||
|
int? get itemCategoryId;
|
||||||
@override
|
@override
|
||||||
@JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable)
|
@JsonKey(
|
||||||
int? get assetSubcategoryId;
|
name: 'item_subcategory_id',
|
||||||
|
readValue: _readGrnItemSubcategoryId,
|
||||||
|
fromJson: _intFromJsonNullable,
|
||||||
|
)
|
||||||
|
int? get itemSubcategoryId;
|
||||||
@override
|
@override
|
||||||
@JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable)
|
@JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable)
|
||||||
int? get uomId;
|
int? get uomId;
|
||||||
|
|||||||
@ -6,8 +6,9 @@ part of 'grn_model.dart';
|
|||||||
// JsonSerializableGenerator
|
// JsonSerializableGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
_$GrnModelImpl _$$GrnModelImplFromJson(Map<String, dynamic> json) =>
|
_$GrnModelImpl _$$GrnModelImplFromJson(
|
||||||
_$GrnModelImpl(
|
Map<String, dynamic> json,
|
||||||
|
) => _$GrnModelImpl(
|
||||||
id: _idFromJson(json['id']),
|
id: _idFromJson(json['id']),
|
||||||
grnNumber: _readPoNumber(json, 'grn_number') as String?,
|
grnNumber: _readPoNumber(json, 'grn_number') as String?,
|
||||||
grnDate: _dateFromJsonNullable(json['grn_date']),
|
grnDate: _dateFromJsonNullable(json['grn_date']),
|
||||||
@ -20,9 +21,7 @@ _$GrnModelImpl _$$GrnModelImplFromJson(Map<String, dynamic> json) =>
|
|||||||
warehouseName: _readWarehouseName(json, 'warehouse_name') as String?,
|
warehouseName: _readWarehouseName(json, 'warehouse_name') as String?,
|
||||||
vendorInvoiceNo: json['vendor_invoice_no'] as String?,
|
vendorInvoiceNo: json['vendor_invoice_no'] as String?,
|
||||||
vendorInvoiceDate: _dateFromJsonNullable(json['vendor_invoice_date']),
|
vendorInvoiceDate: _dateFromJsonNullable(json['vendor_invoice_date']),
|
||||||
vendorInvoiceAmount: _doubleFromJsonNullable(
|
vendorInvoiceAmount: _doubleFromJsonNullable(json['vendor_invoice_amount']),
|
||||||
json['vendor_invoice_amount'],
|
|
||||||
),
|
|
||||||
vehicleNo: json['vehicle_no'] as String?,
|
vehicleNo: json['vehicle_no'] as String?,
|
||||||
lrNo: json['lr_no'] as String?,
|
lrNo: json['lr_no'] as String?,
|
||||||
lrDate: _dateFromJsonNullable(json['lr_date']),
|
lrDate: _dateFromJsonNullable(json['lr_date']),
|
||||||
@ -37,6 +36,11 @@ _$GrnModelImpl _$$GrnModelImplFromJson(Map<String, dynamic> json) =>
|
|||||||
?.map((e) => GrnItemModel.fromJson(e as Map<String, dynamic>))
|
?.map((e) => GrnItemModel.fromJson(e as Map<String, dynamic>))
|
||||||
.toList() ??
|
.toList() ??
|
||||||
const [],
|
const [],
|
||||||
|
attachments:
|
||||||
|
(json['attachments'] as List<dynamic>?)
|
||||||
|
?.map((e) => GrnAttachmentModel.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList() ??
|
||||||
|
const [],
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$$GrnModelImplToJson(_$GrnModelImpl instance) =>
|
Map<String, dynamic> _$$GrnModelImplToJson(_$GrnModelImpl instance) =>
|
||||||
@ -64,6 +68,31 @@ Map<String, dynamic> _$$GrnModelImplToJson(_$GrnModelImpl instance) =>
|
|||||||
'created_at': instance.createdAt?.toIso8601String(),
|
'created_at': instance.createdAt?.toIso8601String(),
|
||||||
'updated_at': instance.updatedAt?.toIso8601String(),
|
'updated_at': instance.updatedAt?.toIso8601String(),
|
||||||
'items': instance.items,
|
'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 _$$GrnItemModelImplFromJson(Map<String, dynamic> json) =>
|
||||||
@ -87,8 +116,12 @@ _$GrnItemModelImpl _$$GrnItemModelImplFromJson(Map<String, dynamic> json) =>
|
|||||||
mfgDate: _dateFromJsonNullable(json['mfg_date']),
|
mfgDate: _dateFromJsonNullable(json['mfg_date']),
|
||||||
expiryDate: _dateFromJsonNullable(json['expiry_date']),
|
expiryDate: _dateFromJsonNullable(json['expiry_date']),
|
||||||
storageLocation: json['storage_location'] as String?,
|
storageLocation: json['storage_location'] as String?,
|
||||||
assetCategoryId: _intFromJsonNullable(json['asset_category_id']),
|
itemCategoryId: _intFromJsonNullable(
|
||||||
assetSubcategoryId: _intFromJsonNullable(json['asset_subcategory_id']),
|
_readGrnItemCategoryId(json, 'item_category_id'),
|
||||||
|
),
|
||||||
|
itemSubcategoryId: _intFromJsonNullable(
|
||||||
|
_readGrnItemSubcategoryId(json, 'item_subcategory_id'),
|
||||||
|
),
|
||||||
uomId: _intFromJsonNullable(json['uom_id']),
|
uomId: _intFromJsonNullable(json['uom_id']),
|
||||||
uomName: _readUomName(json, 'uom_name') as String?,
|
uomName: _readUomName(json, 'uom_name') as String?,
|
||||||
remarks: json['remarks'] as String?,
|
remarks: json['remarks'] as String?,
|
||||||
@ -115,8 +148,8 @@ Map<String, dynamic> _$$GrnItemModelImplToJson(_$GrnItemModelImpl instance) =>
|
|||||||
'mfg_date': instance.mfgDate?.toIso8601String(),
|
'mfg_date': instance.mfgDate?.toIso8601String(),
|
||||||
'expiry_date': instance.expiryDate?.toIso8601String(),
|
'expiry_date': instance.expiryDate?.toIso8601String(),
|
||||||
'storage_location': instance.storageLocation,
|
'storage_location': instance.storageLocation,
|
||||||
'asset_category_id': instance.assetCategoryId,
|
'item_category_id': instance.itemCategoryId,
|
||||||
'asset_subcategory_id': instance.assetSubcategoryId,
|
'item_subcategory_id': instance.itemSubcategoryId,
|
||||||
'uom_id': instance.uomId,
|
'uom_id': instance.uomId,
|
||||||
'uom_name': instance.uomName,
|
'uom_name': instance.uomName,
|
||||||
'remarks': instance.remarks,
|
'remarks': instance.remarks,
|
||||||
|
|||||||
@ -48,7 +48,10 @@ Object? _readItemName(Map<dynamic, dynamic> json, String key) {
|
|||||||
final flat = json['item_name'];
|
final flat = json['item_name'];
|
||||||
if (flat is String && flat.isNotEmpty) return flat;
|
if (flat is String && flat.isNotEmpty) return flat;
|
||||||
final nested = json['item'];
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -56,7 +59,10 @@ Object? _readItemCode(Map<dynamic, dynamic> json, String key) {
|
|||||||
final flat = json['item_code'];
|
final flat = json['item_code'];
|
||||||
if (flat is String && flat.isNotEmpty) return flat;
|
if (flat is String && flat.isNotEmpty) return flat;
|
||||||
final nested = json['item'];
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -64,10 +70,46 @@ Object? _readUomName(Map<dynamic, dynamic> json, String key) {
|
|||||||
final flat = json['uom_name'];
|
final flat = json['uom_name'];
|
||||||
if (flat is String && flat.isNotEmpty) return flat;
|
if (flat is String && flat.isNotEmpty) return flat;
|
||||||
final nested = json['uom'];
|
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;
|
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) {
|
Object? _readPoNumber(Map<dynamic, dynamic> json, String key) {
|
||||||
final poNumber = json['po_number'];
|
final poNumber = json['po_number'];
|
||||||
if (poNumber != null && poNumber.toString().trim().isNotEmpty) {
|
if (poNumber != null && poNumber.toString().trim().isNotEmpty) {
|
||||||
@ -176,9 +218,28 @@ class PurchaseOrderItemModel with _$PurchaseOrderItemModel {
|
|||||||
@JsonKey(name: 'discount_amount', fromJson: _doubleFromJsonNullable)
|
@JsonKey(name: 'discount_amount', fromJson: _doubleFromJsonNullable)
|
||||||
double? discountAmount,
|
double? discountAmount,
|
||||||
@JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable) int? gstRateId,
|
@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)
|
@JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable)
|
||||||
double? lineAmount,
|
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,
|
String? remarks,
|
||||||
}) = _PurchaseOrderItemModel;
|
}) = _PurchaseOrderItemModel;
|
||||||
|
|
||||||
|
|||||||
@ -922,10 +922,28 @@ mixin _$PurchaseOrderItemModel {
|
|||||||
double? get discountAmount => throw _privateConstructorUsedError;
|
double? get discountAmount => throw _privateConstructorUsedError;
|
||||||
@JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable)
|
@JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable)
|
||||||
int? get gstRateId => throw _privateConstructorUsedError;
|
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;
|
int? get hsnCodeId => throw _privateConstructorUsedError;
|
||||||
|
@JsonKey(name: 'hsn_code_name', readValue: _readHsnCodeName)
|
||||||
|
String? get hsnCodeName => throw _privateConstructorUsedError;
|
||||||
@JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable)
|
@JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable)
|
||||||
double? get lineAmount => throw _privateConstructorUsedError;
|
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;
|
String? get remarks => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
/// Serializes this PurchaseOrderItemModel to a JSON map.
|
/// Serializes this PurchaseOrderItemModel to a JSON map.
|
||||||
@ -965,10 +983,28 @@ abstract class $PurchaseOrderItemModelCopyWith<$Res> {
|
|||||||
double? discountAmount,
|
double? discountAmount,
|
||||||
@JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable)
|
@JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable)
|
||||||
int? gstRateId,
|
int? gstRateId,
|
||||||
@JsonKey(name: 'hsn_code_id', fromJson: _intFromJsonNullable)
|
@JsonKey(
|
||||||
|
name: 'hsn_code_id',
|
||||||
|
readValue: _readHsnCodeId,
|
||||||
|
fromJson: _intFromJsonNullable,
|
||||||
|
)
|
||||||
int? hsnCodeId,
|
int? hsnCodeId,
|
||||||
|
@JsonKey(name: 'hsn_code_name', readValue: _readHsnCodeName)
|
||||||
|
String? hsnCodeName,
|
||||||
@JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable)
|
@JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable)
|
||||||
double? lineAmount,
|
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,
|
String? remarks,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -1006,7 +1042,10 @@ class _$PurchaseOrderItemModelCopyWithImpl<
|
|||||||
Object? discountAmount = freezed,
|
Object? discountAmount = freezed,
|
||||||
Object? gstRateId = freezed,
|
Object? gstRateId = freezed,
|
||||||
Object? hsnCodeId = freezed,
|
Object? hsnCodeId = freezed,
|
||||||
|
Object? hsnCodeName = freezed,
|
||||||
Object? lineAmount = freezed,
|
Object? lineAmount = freezed,
|
||||||
|
Object? itemCategoryId = freezed,
|
||||||
|
Object? itemSubcategoryId = freezed,
|
||||||
Object? remarks = freezed,
|
Object? remarks = freezed,
|
||||||
}) {
|
}) {
|
||||||
return _then(
|
return _then(
|
||||||
@ -1071,10 +1110,22 @@ class _$PurchaseOrderItemModelCopyWithImpl<
|
|||||||
? _value.hsnCodeId
|
? _value.hsnCodeId
|
||||||
: hsnCodeId // ignore: cast_nullable_to_non_nullable
|
: hsnCodeId // ignore: cast_nullable_to_non_nullable
|
||||||
as int?,
|
as int?,
|
||||||
|
hsnCodeName: freezed == hsnCodeName
|
||||||
|
? _value.hsnCodeName
|
||||||
|
: hsnCodeName // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
lineAmount: freezed == lineAmount
|
lineAmount: freezed == lineAmount
|
||||||
? _value.lineAmount
|
? _value.lineAmount
|
||||||
: lineAmount // ignore: cast_nullable_to_non_nullable
|
: lineAmount // ignore: cast_nullable_to_non_nullable
|
||||||
as double?,
|
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
|
remarks: freezed == remarks
|
||||||
? _value.remarks
|
? _value.remarks
|
||||||
: remarks // ignore: cast_nullable_to_non_nullable
|
: remarks // ignore: cast_nullable_to_non_nullable
|
||||||
@ -1114,10 +1165,28 @@ abstract class _$$PurchaseOrderItemModelImplCopyWith<$Res>
|
|||||||
double? discountAmount,
|
double? discountAmount,
|
||||||
@JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable)
|
@JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable)
|
||||||
int? gstRateId,
|
int? gstRateId,
|
||||||
@JsonKey(name: 'hsn_code_id', fromJson: _intFromJsonNullable)
|
@JsonKey(
|
||||||
|
name: 'hsn_code_id',
|
||||||
|
readValue: _readHsnCodeId,
|
||||||
|
fromJson: _intFromJsonNullable,
|
||||||
|
)
|
||||||
int? hsnCodeId,
|
int? hsnCodeId,
|
||||||
|
@JsonKey(name: 'hsn_code_name', readValue: _readHsnCodeName)
|
||||||
|
String? hsnCodeName,
|
||||||
@JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable)
|
@JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable)
|
||||||
double? lineAmount,
|
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,
|
String? remarks,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -1152,7 +1221,10 @@ class __$$PurchaseOrderItemModelImplCopyWithImpl<$Res>
|
|||||||
Object? discountAmount = freezed,
|
Object? discountAmount = freezed,
|
||||||
Object? gstRateId = freezed,
|
Object? gstRateId = freezed,
|
||||||
Object? hsnCodeId = freezed,
|
Object? hsnCodeId = freezed,
|
||||||
|
Object? hsnCodeName = freezed,
|
||||||
Object? lineAmount = freezed,
|
Object? lineAmount = freezed,
|
||||||
|
Object? itemCategoryId = freezed,
|
||||||
|
Object? itemSubcategoryId = freezed,
|
||||||
Object? remarks = freezed,
|
Object? remarks = freezed,
|
||||||
}) {
|
}) {
|
||||||
return _then(
|
return _then(
|
||||||
@ -1217,10 +1289,22 @@ class __$$PurchaseOrderItemModelImplCopyWithImpl<$Res>
|
|||||||
? _value.hsnCodeId
|
? _value.hsnCodeId
|
||||||
: hsnCodeId // ignore: cast_nullable_to_non_nullable
|
: hsnCodeId // ignore: cast_nullable_to_non_nullable
|
||||||
as int?,
|
as int?,
|
||||||
|
hsnCodeName: freezed == hsnCodeName
|
||||||
|
? _value.hsnCodeName
|
||||||
|
: hsnCodeName // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
lineAmount: freezed == lineAmount
|
lineAmount: freezed == lineAmount
|
||||||
? _value.lineAmount
|
? _value.lineAmount
|
||||||
: lineAmount // ignore: cast_nullable_to_non_nullable
|
: lineAmount // ignore: cast_nullable_to_non_nullable
|
||||||
as double?,
|
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
|
remarks: freezed == remarks
|
||||||
? _value.remarks
|
? _value.remarks
|
||||||
: remarks // ignore: cast_nullable_to_non_nullable
|
: remarks // ignore: cast_nullable_to_non_nullable
|
||||||
@ -1253,10 +1337,28 @@ class _$PurchaseOrderItemModelImpl implements _PurchaseOrderItemModel {
|
|||||||
this.discountAmount,
|
this.discountAmount,
|
||||||
@JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable)
|
@JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable)
|
||||||
this.gstRateId,
|
this.gstRateId,
|
||||||
@JsonKey(name: 'hsn_code_id', fromJson: _intFromJsonNullable)
|
@JsonKey(
|
||||||
|
name: 'hsn_code_id',
|
||||||
|
readValue: _readHsnCodeId,
|
||||||
|
fromJson: _intFromJsonNullable,
|
||||||
|
)
|
||||||
this.hsnCodeId,
|
this.hsnCodeId,
|
||||||
|
@JsonKey(name: 'hsn_code_name', readValue: _readHsnCodeName)
|
||||||
|
this.hsnCodeName,
|
||||||
@JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable)
|
@JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable)
|
||||||
this.lineAmount,
|
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,
|
this.remarks,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1306,17 +1408,38 @@ class _$PurchaseOrderItemModelImpl implements _PurchaseOrderItemModel {
|
|||||||
@JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable)
|
@JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable)
|
||||||
final int? gstRateId;
|
final int? gstRateId;
|
||||||
@override
|
@override
|
||||||
@JsonKey(name: 'hsn_code_id', fromJson: _intFromJsonNullable)
|
@JsonKey(
|
||||||
|
name: 'hsn_code_id',
|
||||||
|
readValue: _readHsnCodeId,
|
||||||
|
fromJson: _intFromJsonNullable,
|
||||||
|
)
|
||||||
final int? hsnCodeId;
|
final int? hsnCodeId;
|
||||||
@override
|
@override
|
||||||
|
@JsonKey(name: 'hsn_code_name', readValue: _readHsnCodeName)
|
||||||
|
final String? hsnCodeName;
|
||||||
|
@override
|
||||||
@JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable)
|
@JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable)
|
||||||
final double? lineAmount;
|
final double? lineAmount;
|
||||||
@override
|
@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;
|
final String? remarks;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
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
|
@override
|
||||||
@ -1347,14 +1470,20 @@ class _$PurchaseOrderItemModelImpl implements _PurchaseOrderItemModel {
|
|||||||
other.gstRateId == gstRateId) &&
|
other.gstRateId == gstRateId) &&
|
||||||
(identical(other.hsnCodeId, hsnCodeId) ||
|
(identical(other.hsnCodeId, hsnCodeId) ||
|
||||||
other.hsnCodeId == hsnCodeId) &&
|
other.hsnCodeId == hsnCodeId) &&
|
||||||
|
(identical(other.hsnCodeName, hsnCodeName) ||
|
||||||
|
other.hsnCodeName == hsnCodeName) &&
|
||||||
(identical(other.lineAmount, lineAmount) ||
|
(identical(other.lineAmount, lineAmount) ||
|
||||||
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));
|
(identical(other.remarks, remarks) || other.remarks == remarks));
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(
|
int get hashCode => Object.hashAll([
|
||||||
runtimeType,
|
runtimeType,
|
||||||
id,
|
id,
|
||||||
poId,
|
poId,
|
||||||
@ -1371,9 +1500,12 @@ class _$PurchaseOrderItemModelImpl implements _PurchaseOrderItemModel {
|
|||||||
discountAmount,
|
discountAmount,
|
||||||
gstRateId,
|
gstRateId,
|
||||||
hsnCodeId,
|
hsnCodeId,
|
||||||
|
hsnCodeName,
|
||||||
lineAmount,
|
lineAmount,
|
||||||
|
itemCategoryId,
|
||||||
|
itemSubcategoryId,
|
||||||
remarks,
|
remarks,
|
||||||
);
|
]);
|
||||||
|
|
||||||
/// Create a copy of PurchaseOrderItemModel
|
/// Create a copy of PurchaseOrderItemModel
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@ -1416,10 +1548,28 @@ abstract class _PurchaseOrderItemModel implements PurchaseOrderItemModel {
|
|||||||
final double? discountAmount,
|
final double? discountAmount,
|
||||||
@JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable)
|
@JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable)
|
||||||
final int? gstRateId,
|
final int? gstRateId,
|
||||||
@JsonKey(name: 'hsn_code_id', fromJson: _intFromJsonNullable)
|
@JsonKey(
|
||||||
|
name: 'hsn_code_id',
|
||||||
|
readValue: _readHsnCodeId,
|
||||||
|
fromJson: _intFromJsonNullable,
|
||||||
|
)
|
||||||
final int? hsnCodeId,
|
final int? hsnCodeId,
|
||||||
|
@JsonKey(name: 'hsn_code_name', readValue: _readHsnCodeName)
|
||||||
|
final String? hsnCodeName,
|
||||||
@JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable)
|
@JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable)
|
||||||
final double? lineAmount,
|
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,
|
final String? remarks,
|
||||||
}) = _$PurchaseOrderItemModelImpl;
|
}) = _$PurchaseOrderItemModelImpl;
|
||||||
|
|
||||||
@ -1469,12 +1619,33 @@ abstract class _PurchaseOrderItemModel implements PurchaseOrderItemModel {
|
|||||||
@JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable)
|
@JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable)
|
||||||
int? get gstRateId;
|
int? get gstRateId;
|
||||||
@override
|
@override
|
||||||
@JsonKey(name: 'hsn_code_id', fromJson: _intFromJsonNullable)
|
@JsonKey(
|
||||||
|
name: 'hsn_code_id',
|
||||||
|
readValue: _readHsnCodeId,
|
||||||
|
fromJson: _intFromJsonNullable,
|
||||||
|
)
|
||||||
int? get hsnCodeId;
|
int? get hsnCodeId;
|
||||||
@override
|
@override
|
||||||
|
@JsonKey(name: 'hsn_code_name', readValue: _readHsnCodeName)
|
||||||
|
String? get hsnCodeName;
|
||||||
|
@override
|
||||||
@JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable)
|
@JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable)
|
||||||
double? get lineAmount;
|
double? get lineAmount;
|
||||||
@override
|
@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;
|
String? get remarks;
|
||||||
|
|
||||||
/// Create a copy of PurchaseOrderItemModel
|
/// Create a copy of PurchaseOrderItemModel
|
||||||
|
|||||||
@ -93,8 +93,15 @@ _$PurchaseOrderItemModelImpl _$$PurchaseOrderItemModelImplFromJson(
|
|||||||
discountPct: _doubleFromJsonNullable(json['discount_pct']),
|
discountPct: _doubleFromJsonNullable(json['discount_pct']),
|
||||||
discountAmount: _doubleFromJsonNullable(json['discount_amount']),
|
discountAmount: _doubleFromJsonNullable(json['discount_amount']),
|
||||||
gstRateId: _intFromJsonNullable(json['gst_rate_id']),
|
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']),
|
lineAmount: _doubleFromJsonNullable(json['line_amount']),
|
||||||
|
itemCategoryId: _intFromJsonNullable(
|
||||||
|
_readPoItemCategoryId(json, 'item_category_id'),
|
||||||
|
),
|
||||||
|
itemSubcategoryId: _intFromJsonNullable(
|
||||||
|
_readPoItemSubcategoryId(json, 'item_subcategory_id'),
|
||||||
|
),
|
||||||
remarks: json['remarks'] as String?,
|
remarks: json['remarks'] as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -116,6 +123,9 @@ Map<String, dynamic> _$$PurchaseOrderItemModelImplToJson(
|
|||||||
'discount_amount': instance.discountAmount,
|
'discount_amount': instance.discountAmount,
|
||||||
'gst_rate_id': instance.gstRateId,
|
'gst_rate_id': instance.gstRateId,
|
||||||
'hsn_code_id': instance.hsnCodeId,
|
'hsn_code_id': instance.hsnCodeId,
|
||||||
|
'hsn_code_name': instance.hsnCodeName,
|
||||||
'line_amount': instance.lineAmount,
|
'line_amount': instance.lineAmount,
|
||||||
|
'item_category_id': instance.itemCategoryId,
|
||||||
|
'item_subcategory_id': instance.itemSubcategoryId,
|
||||||
'remarks': instance.remarks,
|
'remarks': instance.remarks,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import '../../core/config/dev_config.dart';
|
|||||||
import '../../core/constants/route_constants.dart';
|
import '../../core/constants/route_constants.dart';
|
||||||
import '../../modules/dashboard/presentation/screens/dashboard_screen.dart';
|
import '../../modules/dashboard/presentation/screens/dashboard_screen.dart';
|
||||||
import '../../modules/assets/presentation/screens/asset_alerts_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_detail_screen.dart';
|
||||||
import '../../modules/assets/presentation/screens/asset_list_screen.dart';
|
import '../../modules/assets/presentation/screens/asset_list_screen.dart';
|
||||||
import '../../modules/auth/presentation/screens/change_password_screen.dart';
|
import '../../modules/auth/presentation/screens/change_password_screen.dart';
|
||||||
@ -215,20 +214,29 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'add',
|
path: 'add',
|
||||||
builder: (context, state) => const PurchaseOrderFormScreen(),
|
pageBuilder: (context, state) => shellPage(
|
||||||
|
state,
|
||||||
|
const PurchaseOrderFormScreen(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: ':id/edit',
|
path: ':id/edit',
|
||||||
builder: (context, state) => PurchaseOrderFormScreen(
|
pageBuilder: (context, state) => shellPage(
|
||||||
|
state,
|
||||||
|
PurchaseOrderFormScreen(
|
||||||
purchaseOrderId: state.pathParameters['id']!,
|
purchaseOrderId: state.pathParameters['id']!,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: ':id',
|
path: ':id',
|
||||||
builder: (context, state) => PurchaseOrderDetailScreen(
|
pageBuilder: (context, state) => shellPage(
|
||||||
|
state,
|
||||||
|
PurchaseOrderDetailScreen(
|
||||||
purchaseOrderId: state.pathParameters['id']!,
|
purchaseOrderId: state.pathParameters['id']!,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
@ -272,10 +280,6 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
pageBuilder: (context, state) =>
|
pageBuilder: (context, state) =>
|
||||||
shellPage(state, const AssetListScreen()),
|
shellPage(state, const AssetListScreen()),
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
|
||||||
path: 'categories',
|
|
||||||
builder: (context, state) => const AssetCategoriesScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'alerts',
|
path: 'alerts',
|
||||||
builder: (context, state) => const AssetAlertsScreen(),
|
builder: (context, state) => const AssetAlertsScreen(),
|
||||||
|
|||||||
@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import 'app_card.dart';
|
import 'app_card.dart';
|
||||||
|
|
||||||
|
/// Fixed height for every row in [AppDataTable] and themed [DataTable] widgets.
|
||||||
|
const double kAppTableRowHeight = 52;
|
||||||
|
|
||||||
class AppDataColumn<T> {
|
class AppDataColumn<T> {
|
||||||
const AppDataColumn({
|
const AppDataColumn({
|
||||||
required this.label,
|
required this.label,
|
||||||
@ -18,6 +21,32 @@ class AppDataColumn<T> {
|
|||||||
final Alignment alignment;
|
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 {
|
class AppDataTable<T> extends StatelessWidget {
|
||||||
const AppDataTable({
|
const AppDataTable({
|
||||||
super.key,
|
super.key,
|
||||||
@ -38,6 +67,7 @@ class AppDataTable<T> extends StatelessWidget {
|
|||||||
final void Function(String column, bool ascending)? onSort;
|
final void Function(String column, bool ascending)? onSort;
|
||||||
final String emptyMessage;
|
final String emptyMessage;
|
||||||
final bool wrapInCard;
|
final bool wrapInCard;
|
||||||
|
|
||||||
/// Set true when the table is placed inside another scrollable.
|
/// Set true when the table is placed inside another scrollable.
|
||||||
final bool shrinkWrap;
|
final bool shrinkWrap;
|
||||||
|
|
||||||
@ -64,9 +94,7 @@ class AppDataTable<T> extends StatelessWidget {
|
|||||||
return ListView(
|
return ListView(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
shrinkWrap: shrinkWrap,
|
shrinkWrap: shrinkWrap,
|
||||||
physics: shrinkWrap
|
physics: shrinkWrap ? const NeverScrollableScrollPhysics() : null,
|
||||||
? const NeverScrollableScrollPhysics()
|
|
||||||
: null,
|
|
||||||
children: [
|
children: [
|
||||||
_TableHeaderRow<T>(
|
_TableHeaderRow<T>(
|
||||||
columns: columns,
|
columns: columns,
|
||||||
@ -111,15 +139,20 @@ class _TableHeaderRow<T> extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return Container(
|
return SizedBox(
|
||||||
|
height: kAppTableRowHeight,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
|
child: ColoredBox(
|
||||||
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
|
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: columns.map((col) {
|
children: columns.map((col) {
|
||||||
final isSorted = col.sortKey != null && col.sortKey == sortColumn;
|
final isSorted = col.sortKey != null && col.sortKey == sortColumn;
|
||||||
final label = Text(
|
final label = Text(
|
||||||
col.label.toUpperCase(),
|
col.label.toUpperCase(),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: 0.6,
|
letterSpacing: 0.6,
|
||||||
@ -137,7 +170,7 @@ class _TableHeaderRow<T> extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
label,
|
Flexible(child: label),
|
||||||
if (isSorted) ...[
|
if (isSorted) ...[
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Icon(
|
Icon(
|
||||||
@ -162,6 +195,8 @@ class _TableHeaderRow<T> extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -179,8 +214,10 @@ class _TableDataRow<T> extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return Container(
|
return SizedBox(
|
||||||
|
height: kAppTableRowHeight,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
|
child: DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border(
|
border: Border(
|
||||||
bottom: BorderSide(
|
bottom: BorderSide(
|
||||||
@ -188,19 +225,114 @@ class _TableDataRow<T> extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: columns.map((col) {
|
children: columns.map((col) {
|
||||||
return Expanded(
|
return Expanded(
|
||||||
flex: col.flex,
|
flex: col.flex,
|
||||||
child: Align(
|
child: Align(
|
||||||
|
alignment: col.alignment,
|
||||||
|
child: _TableCellSlot(
|
||||||
alignment: col.alignment,
|
alignment: col.alignment,
|
||||||
child: col.cellBuilder(context, row),
|
child: col.cellBuilder(context, row),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).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>> {
|
class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
|
||||||
final _layerLink = LayerLink();
|
final _layerLink = LayerLink();
|
||||||
final _fieldKey = GlobalKey();
|
final _fieldKey = GlobalKey();
|
||||||
final _formFieldKey = UniqueKey();
|
final _formFieldStateKey = GlobalKey<FormFieldState<T>>();
|
||||||
OverlayEntry? _overlayEntry;
|
OverlayEntry? _overlayEntry;
|
||||||
bool _ignoreOutsideTap = false;
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_removeOverlay();
|
_removeOverlay();
|
||||||
@ -167,7 +184,7 @@ class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return FormField<T>(
|
return FormField<T>(
|
||||||
key: widget.key ?? _formFieldKey,
|
key: _formFieldStateKey,
|
||||||
initialValue: widget.value,
|
initialValue: widget.value,
|
||||||
validator: widget.validator,
|
validator: widget.validator,
|
||||||
builder: (field) {
|
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 _sidebarIconSize = 20.0;
|
||||||
const _sidebarChildIndent = 28.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 {
|
class AppSidebar extends ConsumerStatefulWidget {
|
||||||
const AppSidebar({
|
const AppSidebar({
|
||||||
super.key,
|
super.key,
|
||||||
@ -149,7 +153,10 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_buildHeader(context, isNarrow: isNarrow),
|
_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),
|
const SizedBox(height: 16),
|
||||||
_buildThemeToggle(context, isLightActive),
|
_buildThemeToggle(context, isLightActive),
|
||||||
],
|
],
|
||||||
@ -221,6 +228,7 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
|
|||||||
}),
|
}),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (!isNarrow) const _SectionLabel(label: 'SUPPORT'),
|
if (!isNarrow) const _SectionLabel(label: 'SUPPORT'),
|
||||||
|
if (AppConstants.showNotificationsMenu)
|
||||||
_SidebarNavItem(
|
_SidebarNavItem(
|
||||||
icon: Icons.notifications_outlined,
|
icon: Icons.notifications_outlined,
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
|
|||||||
@ -105,6 +105,7 @@ class AppTopNav extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (AppConstants.showNotificationsMenu)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.notifications_outlined, size: 22),
|
icon: const Icon(Icons.notifications_outlined, size: 22),
|
||||||
onPressed: () {},
|
onPressed: () {},
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import 'dart:convert';
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.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.
|
/// Displays company logo in the sidebar from URL, data URI, or fallback icon.
|
||||||
class SidebarLogo extends StatelessWidget {
|
class SidebarLogo extends StatelessWidget {
|
||||||
const SidebarLogo({
|
const SidebarLogo({
|
||||||
@ -53,7 +55,7 @@ class SidebarLogo extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildLogoContent(Widget fallback) {
|
Widget _buildLogoContent(Widget fallback) {
|
||||||
final url = logoUrl?.trim();
|
final url = resolveMediaUrl(logoUrl);
|
||||||
if (url == null || url.isEmpty) {
|
if (url == null || url.isEmpty) {
|
||||||
return Center(child: fallback);
|
return Center(child: fallback);
|
||||||
}
|
}
|
||||||
@ -99,9 +101,9 @@ String? resolveSidebarLogoUrl({
|
|||||||
required String companyProfileLogo,
|
required String companyProfileLogo,
|
||||||
required String? brandingLogo,
|
required String? brandingLogo,
|
||||||
}) {
|
}) {
|
||||||
if (companyProfileLogo.isNotEmpty) return companyProfileLogo;
|
final fromProfile = resolveMediaUrl(companyProfileLogo);
|
||||||
if (brandingLogo != null && brandingLogo.isNotEmpty) return brandingLogo;
|
if (fromProfile != null && fromProfile.isNotEmpty) return fromProfile;
|
||||||
return null;
|
return resolveMediaUrl(brandingLogo);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves sidebar title from company name or app tagline.
|
/// 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