Bug And Changes screen

This commit is contained in:
Surendiran 2026-07-10 18:49:28 +05:30
parent 83c73e31ae
commit b740edf0e8
69 changed files with 10652 additions and 2330 deletions

View File

@ -37,12 +37,6 @@ class ApiEndpoints {
static String rolePermissionMatrix(String roleId) =>
'/roles/$roleId/permission-matrix';
// Asset Categories
static const String assetCategories = '/masters/asset-categories';
static String assetCategoryById(String id) => '/masters/asset-categories/$id';
static const String assetSubcategories = '/masters/asset-subcategories';
static String assetSubcategoryById(String id) => '/masters/asset-subcategories/$id';
// Masters
static const String departments = '/masters/departments';
static String departmentById(String id) => '/masters/departments/$id';
@ -69,6 +63,8 @@ class ApiEndpoints {
static String paymentTermById(String id) => '/masters/payment-terms/$id';
static const String gstRates = '/masters/gst-rates';
static String gstRateById(String id) => '/masters/gst-rates/$id';
static const String hsnCodes = '/masters/hsn-codes';
static String hsnCodeById(String id) => '/masters/hsn-codes/$id';
static const String warehouses = '/masters/warehouses';
static String warehouseById(String id) => '/masters/warehouses/$id';
@ -107,6 +103,11 @@ class ApiEndpoints {
static String grnById(String id) => '/grn/$id';
static String grnCancel(String id) => '/grn/$id/cancel';
static String grnPdf(String id) => '/grn/$id/pdf';
static String grnAttachments(String grnId) => '/grn/$grnId/attachments';
static String grnAttachmentById(String grnId, String attachmentId) =>
'/grn/$grnId/attachments/$attachmentId';
static String grnAttachmentDownload(String grnId, String attachmentId) =>
'/grn/$grnId/attachments/$attachmentId/download';
// Assets
static const String assets = '/assets';
@ -165,6 +166,9 @@ class ApiEndpoints {
// Audit
static const String auditLogs = '/audit-logs';
static const String auditLogsFilters = '/audit-logs/filters';
static const String auditLogsExport = '/audit-logs/export';
static String auditLogById(String id) => '/audit-logs/$id';
// Notifications
static const String notifications = '/notifications';

View File

@ -10,4 +10,7 @@ class AppConstants {
static const Duration animationDuration = Duration(milliseconds: 300);
static const Duration snackBarDuration = Duration(seconds: 3);
/// Sidebar / top-bar Notifications entry. Kept in code; set true to show again.
static const bool showNotificationsMenu = false;
}

View File

@ -62,7 +62,6 @@ class RouteConstants {
static const String assetAdd = '/assets/add';
static const String assetEdit = '/assets/:id/edit';
static const String assetDetail = '/assets/:id';
static const String assetCategories = '/assets/categories';
static const String assetAlerts = '/assets/alerts';
// Master Data

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../../shared/widgets/app_data_table.dart';
import 'app_colors.dart';
import 'app_typography.dart';
import 'branding_config.dart';
@ -202,6 +203,9 @@ class AppTheme {
headingRowColor: WidgetStateProperty.all(
colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
),
headingRowHeight: kAppTableRowHeight,
dataRowMinHeight: kAppTableRowHeight,
dataRowMaxHeight: kAppTableRowHeight,
headingTextStyle: textTheme.labelLarge,
dataTextStyle: textTheme.bodyMedium,
),

View 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';
}

View File

@ -321,6 +321,49 @@ class Validators {
return normalizedKey == 'code' || normalizedKey == 'item_code';
}
/// HSN/SAC codes are 48 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 48 digits';
}
return null;
}
static String? uniqueHsnCode(
String? value, {
required Iterable<Map<String, dynamic>> existingRecords,
String? currentRecordId,
String fieldName = 'HSN/SAC Code',
}) {
final formatError = hsnCode(value, fieldName: fieldName);
if (formatError != null) return formatError;
final normalized = value!.trim();
for (final record in existingRecords) {
final recordId = record['id']?.toString();
if (currentRecordId != null && recordId == currentRecordId) continue;
final existingCode = record['code']?.toString().trim();
if (existingCode != null &&
existingCode.isNotEmpty &&
existingCode == normalized) {
return '$fieldName must be unique';
}
}
return null;
}
static List<TextInputFormatter> get hsnCodeInput => [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(8),
];
/// Required master code allowed: A-Z a-z 0-9 - _ /
static String? masterCode(String? value, {String fieldName = 'Code'}) {
final requiredError = required(value, fieldName: fieldName);

View File

@ -51,7 +51,7 @@ class AssetRemoteDataSource {
Future<List<AssetCategoryModel>> getCategories() async {
final response = await dio.get(
ApiEndpoints.assetCategories,
ApiEndpoints.itemCategories,
queryParameters: const {'limit': 100, 'is_active': true},
);
return _parseList(response.data, AssetCategoryModel.fromJson);

View File

@ -3,9 +3,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/repositories/asset_repository_impl.dart';
import '../../../../shared/models/asset_model.dart';
final assetCategoriesProvider = FutureProvider<List<AssetCategoryModel>>((ref) async {
/// Item categories from `/masters/item-categories` (shared for items and assets).
final itemCategoriesProvider = FutureProvider<List<AssetCategoryModel>>((ref) async {
final repository = ref.watch(assetRepositoryProvider);
final result = await repository.getCategories();
if (result.failure != null) throw result.failure!;
return result.data ?? [];
});
@Deprecated('Use itemCategoriesProvider')
final assetCategoriesProvider = itemCategoriesProvider;

View File

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

View File

@ -65,7 +65,7 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
onRetry: () => ref.invalidate(assetsListProvider),
),
data: (state) {
final allCategories = ref.watch(assetCategoriesProvider).valueOrNull ?? [];
final allCategories = ref.watch(itemCategoriesProvider).valueOrNull ?? [];
final filteredAssets = _filterAssets(state.assets);
final categories = _categoryOptions(state.assets, allCategories);
final plants = _plantOptions(state.assets);
@ -651,7 +651,7 @@ class _AssetCodeBadge extends StatelessWidget {
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(20),
),
child: Text(
child: AppTableCell.text(
code,
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w600,

View File

@ -306,8 +306,8 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
Map<String, dynamic> _buildPayload() {
final payload = <String, dynamic>{
'asset_name': _nameController.text.trim(),
'asset_category_id': _categoryId,
'asset_subcategory_id': _subcategoryId,
'item_category_id': _categoryId,
'item_subcategory_id': _subcategoryId,
'plant_id': _plantId,
'is_active': _isActive,
};
@ -461,7 +461,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
@override
Widget build(BuildContext context) {
final categoriesAsync = ref.watch(assetCategoriesProvider);
final categoriesAsync = ref.watch(itemCategoriesProvider);
final plantsAsync = ref.watch(assetPlantsProvider);
if (widget.isEditing) {
@ -518,7 +518,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
AsyncValue<List<AssetCategoryModel>> categoriesAsync,
AsyncValue<List<FilterOptionModel>> plantsAsync,
) {
final subcategoriesAsync = ref.watch(assetSubcategoriesProvider(_categoryId));
final subcategoriesAsync = ref.watch(itemSubcategoriesProvider(_categoryId));
final lookupsAsync = ref.watch(assetFormLookupsProvider);
final grnItemsAsync = ref.watch(assetGrnItemsProvider(_grnId));
final options = lookupsAsync.valueOrNull?.options ?? const AssetDropdownOptionsModel();
@ -1291,9 +1291,9 @@ final assetPlantsProvider = FutureProvider<List<FilterOptionModel>>((ref) async
return dataSource.listPlants();
});
final assetSubcategoriesProvider =
final itemSubcategoriesProvider =
FutureProvider.family<List<FilterOptionModel>, int?>((ref, categoryId) async {
if (categoryId == null) return [];
final dataSource = ref.watch(masterRemoteDataSourceProvider);
return dataSource.listAssetSubcategories(assetCategoryId: categoryId);
return dataSource.listItemSubcategories(itemCategoryId: categoryId);
});

View 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';
}
}

View File

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

View 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);
}

View 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);
}

View 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!;
}
}

View File

@ -1,6 +1,525 @@
import '../../../../shared/widgets/placeholder_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class AuditLogsScreen extends PlaceholderScreen {
const AuditLogsScreen({super.key})
: super(title: 'Audit Logs', description: 'System activity and audit trail');
import '../../../../core/constants/enums.dart';
import '../../../../core/errors/failure.dart';
import '../../../../core/utils/formatters.dart';
import '../../../../core/utils/responsive_utils.dart';
import '../../../../shared/models/audit_log_model.dart';
import '../../../../shared/providers/permissions_provider.dart';
import '../../../../shared/utils/file_download_helper.dart';
import '../../../../shared/widgets/app_card.dart';
import '../../../../shared/widgets/app_data_table.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_search_field.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/app_status_chip.dart';
import '../../../../shared/widgets/app_table_action_icon.dart';
import '../../../../shared/widgets/app_table_shell.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart';
import '../providers/audit_provider.dart';
import '../widgets/audit_log_detail_panel.dart';
class AuditLogsScreen extends ConsumerStatefulWidget {
const AuditLogsScreen({super.key});
@override
ConsumerState<AuditLogsScreen> createState() => _AuditLogsScreenState();
}
class _AuditLogsScreenState extends ConsumerState<AuditLogsScreen> {
final _searchController = TextEditingController();
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _exportLogs() async {
final file = await ref.read(auditLogsListProvider.notifier).exportLogs();
if (!mounted) return;
if (file == null) {
final error = ref.read(auditLogsListProvider).valueOrNull?.actionError;
if (error != null) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error)));
}
return;
}
final saved = await downloadFile(
bytes: file.bytes,
fileName: file.fileName,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(saved ? 'Downloaded ${file.fileName}' : 'Export cancelled'),
),
);
}
Future<void> _viewLog(AuditLogEntryModel log) async {
ref.invalidate(auditLogDetailProvider(log.id));
await showSidePanel(
context,
AuditLogDetailPanel(logId: log.id),
width: 560,
);
}
Future<void> _pickDateRange(AuditLogListQuery query) async {
final now = DateTime.now();
final initial = (query.dateFrom != null && query.dateTo != null)
? DateTimeRange(start: query.dateFrom!, end: query.dateTo!)
: null;
final picked = await showDateRangePicker(
context: context,
firstDate: DateTime(now.year - 5),
lastDate: DateTime(now.year + 1),
initialDateRange: initial,
helpText: 'Filter by date range',
);
if (picked == null) return;
final start = DateTime(picked.start.year, picked.start.month, picked.start.day);
final end = DateTime(
picked.end.year,
picked.end.month,
picked.end.day,
23,
59,
59,
);
ref.read(auditLogsListProvider.notifier).setDateRange(start, end);
}
@override
Widget build(BuildContext context) {
final logsAsync = ref.watch(auditLogsListProvider);
final canExport = ref.can('audit_logs', PermissionAction.export);
ref.listen(auditLogsListProvider, (prev, next) {
final error = next.valueOrNull?.actionError;
if (error != null && error != prev?.valueOrNull?.actionError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error)));
}
});
return Padding(
padding: const EdgeInsets.all(24),
child: logsAsync.when(
loading: () => const AppLoadingView(message: 'Loading audit logs...'),
error: (error, _) => ErrorView.fromFailure(
error is Failure ? error : Failure.unknown(message: error.toString()),
onRetry: () => ref.invalidate(auditLogsListProvider),
),
data: (state) {
final notifier = ref.read(auditLogsListProvider.notifier);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageHeader(
title: 'Audit Logs',
subtitle: 'System activity and change history',
actions: [
if (canExport)
OutlinedButton.icon(
onPressed: state.isExporting || !state.query.hasActiveFilter
? null
: _exportLogs,
icon: state.isExporting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.download_outlined),
label: Text(state.isExporting ? 'Exporting...' : 'Export'),
),
],
),
const SizedBox(height: 16),
Expanded(
child: AppTableShell(
toolbar: LayoutBuilder(
builder: (context, constraints) {
return _FiltersBar(
searchController: _searchController,
filters: state.filters,
query: state.query,
wrapped: constraints.maxWidth < 1100,
onSearch: notifier.setSearch,
onTableChanged: notifier.setTableName,
onActionChanged: notifier.setAction,
onPerformerChanged: notifier.setPerformedBy,
onPickDateRange: () => _pickDateRange(state.query),
onClearDateRange: () => notifier.setDateRange(null, null),
onReset: () {
_searchController.clear();
notifier.resetFilters();
},
);
},
),
footer: AppPagination(
currentPage: state.query.page,
totalPages: state.totalPages,
totalItems: state.total,
pageSize: state.query.limit,
itemLabel: 'audit logs',
onPageChanged: notifier.setPage,
onPageSizeChanged: notifier.setPageSize,
),
child: RefreshIndicator(
onRefresh: notifier.refresh,
child: state.filtersRequired && state.items.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(
height: 260,
child: AppEmptyState(
title: 'Apply a filter to view logs',
description:
'Select a table, action, user, or date range to load audit history.',
icon: Icons.filter_alt_outlined,
),
),
],
)
: state.items.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(
height: 260,
child: AppEmptyState(
title: 'No audit logs found',
description:
'Try adjusting filters or expanding the date range.',
icon: Icons.history_outlined,
),
),
],
)
: context.isMobile
? _AuditCardList(
items: state.items,
onView: _viewLog,
)
: _AuditDataTable(
items: state.items,
onView: _viewLog,
),
),
),
),
],
);
},
),
);
}
}
class _FiltersBar extends StatelessWidget {
const _FiltersBar({
required this.searchController,
required this.filters,
required this.query,
required this.wrapped,
required this.onSearch,
required this.onTableChanged,
required this.onActionChanged,
required this.onPerformerChanged,
required this.onPickDateRange,
required this.onClearDateRange,
required this.onReset,
});
final TextEditingController searchController;
final AuditLogFilterOptions filters;
final AuditLogListQuery query;
final bool wrapped;
final ValueChanged<String> onSearch;
final ValueChanged<String?> onTableChanged;
final ValueChanged<String?> onActionChanged;
final ValueChanged<int?> onPerformerChanged;
final VoidCallback onPickDateRange;
final VoidCallback onClearDateRange;
final VoidCallback onReset;
String get _dateLabel {
if (query.dateFrom == null && query.dateTo == null) return 'Date range';
final from = DateFormatter.displayDate(query.dateFrom);
final to = DateFormatter.displayDate(query.dateTo);
return '$from $to';
}
@override
Widget build(BuildContext context) {
final searchField = AppSearchField(
controller: searchController,
hint: 'Search table, action, request ID...',
onChanged: onSearch,
);
final tableDropdown = AppSearchableDropdown<String?>(
label: 'Table',
value: query.tableName,
searchHint: 'Search table...',
isDense: true,
options: [
const AppDropdownOption(value: null, label: 'All tables'),
...filters.tableNames.map(
(name) => AppDropdownOption(value: name, label: name),
),
],
onChanged: onTableChanged,
);
final actionDropdown = AppSearchableDropdown<String?>(
label: 'Action',
value: query.action,
searchHint: 'Search action...',
isDense: true,
options: [
const AppDropdownOption(value: null, label: 'All actions'),
...filters.actions.map(
(action) => AppDropdownOption(value: action, label: action),
),
],
onChanged: onActionChanged,
);
final performerDropdown = AppSearchableDropdown<int?>(
label: 'Performed by',
value: query.performedBy,
searchHint: 'Search user...',
isDense: true,
options: [
const AppDropdownOption(value: null, label: 'All users'),
...filters.performers.map(
(user) => AppDropdownOption(
value: int.tryParse(user.id),
label: user.label,
),
),
],
onChanged: onPerformerChanged,
);
final dateButton = OutlinedButton.icon(
onPressed: onPickDateRange,
icon: const Icon(Icons.date_range_outlined, size: 18),
label: Text(_dateLabel, overflow: TextOverflow.ellipsis),
);
final clearDate = query.dateFrom != null || query.dateTo != null
? IconButton(
tooltip: 'Clear date range',
onPressed: onClearDateRange,
icon: const Icon(Icons.clear, size: 18),
)
: null;
final resetButton = TextButton(
onPressed: query.hasActiveFilter ? onReset : null,
child: const Text('Reset'),
);
if (wrapped) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
searchField,
const SizedBox(height: 12),
tableDropdown,
const SizedBox(height: 12),
actionDropdown,
const SizedBox(height: 12),
performerDropdown,
const SizedBox(height: 12),
Row(
children: [
Expanded(child: dateButton),
if (clearDate != null) clearDate,
resetButton,
],
),
],
);
}
return Column(
children: [
Row(
children: [
Expanded(flex: 3, child: searchField),
const SizedBox(width: 12),
Expanded(flex: 2, child: tableDropdown),
const SizedBox(width: 12),
Expanded(flex: 2, child: actionDropdown),
const SizedBox(width: 12),
Expanded(flex: 2, child: performerDropdown),
],
),
const SizedBox(height: 12),
Row(
children: [
Flexible(child: dateButton),
if (clearDate != null) clearDate,
const Spacer(),
resetButton,
],
),
],
);
}
}
class _AuditDataTable extends StatelessWidget {
const _AuditDataTable({
required this.items,
required this.onView,
});
final List<AuditLogEntryModel> items;
final void Function(AuditLogEntryModel log) onView;
@override
Widget build(BuildContext context) {
return AppDataTable<AuditLogEntryModel>(
wrapInCard: false,
rows: items,
emptyMessage: 'No audit logs found',
columns: [
AppDataColumn(
label: 'When',
flex: 2,
cellBuilder: (_, row) => AppTableCell.text(
DateFormatter.displayDateTime(row.performedAt),
),
),
AppDataColumn(
label: 'Action',
flex: 1,
cellBuilder: (_, row) => AppTableCell.child(
AppStatusChip(status: row.action, compact: true),
),
),
AppDataColumn(
label: 'Table',
flex: 2,
cellBuilder: (_, row) => AppTableCell.text(row.tableName),
),
AppDataColumn(
label: 'Record',
flex: 1,
cellBuilder: (_, row) => AppTableCell.text(row.recordId),
),
AppDataColumn(
label: 'Performed by',
flex: 2,
cellBuilder: (_, row) => AppTableCell.text(row.performerLabel),
),
AppDataColumn(
label: 'Request ID',
flex: 2,
cellBuilder: (_, row) => AppTableCell.text(row.requestId),
),
AppDataColumn(
label: 'Changes',
flex: 1,
cellBuilder: (_, row) {
final parts = <String>[];
if (row.hasOldValue) parts.add('old');
if (row.hasNewValue) parts.add('new');
return AppTableCell.text(
parts.isEmpty ? '' : parts.join(' / '),
);
},
),
AppDataColumn(
label: 'Actions',
flex: 1,
alignment: Alignment.centerRight,
cellBuilder: (_, row) => AppTableActions(
children: [
AppTableActionIcon(
tooltip: 'View',
icon: Icons.visibility_outlined,
onPressed: () => onView(row),
),
],
),
),
],
);
}
}
class _AuditCardList extends StatelessWidget {
const _AuditCardList({
required this.items,
required this.onView,
});
final List<AuditLogEntryModel> items;
final void Function(AuditLogEntryModel log) onView;
@override
Widget build(BuildContext context) {
return ListView.separated(
itemCount: items.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final log = items[index];
return AppCard(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
log.tableName,
style: Theme.of(context).textTheme.titleMedium,
),
),
AppStatusChip(status: log.action, compact: true),
],
),
const SizedBox(height: 4),
Text(DateFormatter.displayDateTime(log.performedAt)),
Text('Record: ${log.recordId ?? ''}'),
Text(log.performerLabel),
if (log.requestId != null) Text('Request: ${log.requestId}'),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: () => onView(log),
icon: const Icon(Icons.visibility_outlined, size: 18),
label: const Text('View'),
),
),
],
),
),
);
},
);
}
}

View File

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

View File

@ -78,8 +78,6 @@ final _entries = [
// Assets
_GalleryEntry(title: 'Asset List', route: RouteConstants.assets, group: 'Assets'),
_GalleryEntry(title: 'Asset Detail', route: '/assets/demo-asset', group: 'Assets'),
_GalleryEntry(title: 'Asset Categories', route: RouteConstants.assetCategories, group: 'Assets'),
_GalleryEntry(title: 'Categories', route: RouteConstants.assetCategories, group: 'Assets'),
_GalleryEntry(title: 'Alerts', route: RouteConstants.assetAlerts, group: 'Assets'),
// Master data
_GalleryEntry(title: 'Master Data Hub', route: RouteConstants.masterData, group: 'Master Data'),

View File

@ -48,6 +48,60 @@ class GrnRemoteDataSource {
return response.data ?? [];
}
Future<List<GrnAttachmentModel>> listAttachments(String grnId) async {
final response = await dio.get(ApiEndpoints.grnAttachments(grnId));
final data = response.data['data'];
if (data is! List) return const [];
return data
.whereType<Map<String, dynamic>>()
.map(GrnAttachmentModel.fromJson)
.toList();
}
Future<GrnAttachmentModel> uploadAttachment(
String grnId, {
required List<int> bytes,
required String filename,
}) async {
final formData = FormData.fromMap({
'file': MultipartFile.fromBytes(bytes, filename: filename),
});
final response = await dio.post(
ApiEndpoints.grnAttachments(grnId),
data: formData,
);
return GrnAttachmentModel.fromJson(
response.data['data'] as Map<String, dynamic>,
);
}
Future<GrnAttachmentModel> getAttachment(
String grnId,
String attachmentId,
) async {
final response = await dio.get(
ApiEndpoints.grnAttachmentById(grnId, attachmentId),
);
return GrnAttachmentModel.fromJson(
response.data['data'] as Map<String, dynamic>,
);
}
Future<List<int>> downloadAttachment(
String grnId,
String attachmentId,
) async {
final response = await dio.get<List<int>>(
ApiEndpoints.grnAttachmentDownload(grnId, attachmentId),
options: Options(responseType: ResponseType.bytes),
);
return response.data ?? [];
}
Future<void> deleteAttachment(String grnId, String attachmentId) async {
await dio.delete(ApiEndpoints.grnAttachmentById(grnId, attachmentId));
}
Map<String, dynamic> _queryToMap(GrnListQuery query) {
return {
'page': query.page,

View File

@ -54,4 +54,41 @@ class GrnRepositoryImpl implements GrnRepository {
Future<Result<List<int>>> downloadGrnPdf(String id) {
return safeApiCall(() => dataSource.downloadGrnPdf(id));
}
@override
Future<Result<List<GrnAttachmentModel>>> listAttachments(String grnId) {
return safeApiCall(() => dataSource.listAttachments(grnId));
}
@override
Future<Result<GrnAttachmentModel>> uploadAttachment(
String grnId, {
required List<int> bytes,
required String filename,
}) {
return safeApiCall(
() => dataSource.uploadAttachment(
grnId,
bytes: bytes,
filename: filename,
),
);
}
@override
Future<Result<List<int>>> downloadAttachment(
String grnId,
String attachmentId,
) {
return safeApiCall(
() => dataSource.downloadAttachment(grnId, attachmentId),
);
}
@override
Future<Result<void>> deleteAttachment(String grnId, String attachmentId) {
return safeApiCall(
() => dataSource.deleteAttachment(grnId, attachmentId),
);
}
}

View File

@ -9,4 +9,16 @@ abstract class GrnRepository {
Future<Result<GrnModel>> updateGrn(String id, Map<String, dynamic> data);
Future<Result<GrnModel>> cancelGrn(String id, {required String cancellationReason});
Future<Result<List<int>>> downloadGrnPdf(String id);
Future<Result<List<GrnAttachmentModel>>> listAttachments(String grnId);
Future<Result<GrnAttachmentModel>> uploadAttachment(
String grnId, {
required List<int> bytes,
required String filename,
});
Future<Result<List<int>>> downloadAttachment(
String grnId,
String attachmentId,
);
Future<Result<void>> deleteAttachment(String grnId, String attachmentId);
}

View File

@ -11,13 +11,11 @@ class GrnLookups {
const GrnLookups({
this.warehouses = const [],
this.receivablePurchaseOrders = const [],
this.assetCategories = const [],
this.users = const [],
});
final List<FilterOptionModel> warehouses;
final List<PurchaseOrderModel> receivablePurchaseOrders;
final List<FilterOptionModel> assetCategories;
final List<FilterOptionModel> users;
}
@ -26,7 +24,6 @@ final grnLookupsProvider = FutureProvider.autoDispose<GrnLookups>((ref) async {
final poRepo = ref.watch(purchaseOrderRepositoryProvider);
final warehouses = await _safeOptions(master.listWarehouses);
final assetCategories = await _safeOptions(master.listAssetCategories);
final users = await _safeUserOptions(ref);
final receivablePos = <PurchaseOrderModel>[];
@ -46,7 +43,6 @@ final grnLookupsProvider = FutureProvider.autoDispose<GrnLookups>((ref) async {
return GrnLookups(
warehouses: warehouses,
receivablePurchaseOrders: receivablePos,
assetCategories: assetCategories,
users: users,
);
});
@ -89,17 +85,6 @@ Future<List<FilterOptionModel>> _safeUserOptions(Ref ref) async {
}
}
final grnAssetSubcategoriesProvider =
FutureProvider.autoDispose.family<List<FilterOptionModel>, int?>(
(ref, categoryId) async {
if (categoryId == null) return const [];
final master = ref.watch(masterRemoteDataSourceProvider);
return _safeOptions(
() => master.listAssetSubcategories(assetCategoryId: categoryId),
);
},
);
final grnPurchaseOrderProvider =
FutureProvider.autoDispose.family<PurchaseOrderModel?, String>((ref, poId) async {
final result =

View File

@ -152,6 +152,55 @@ class GrnDetailNotifier extends FamilyAsyncNotifier<GrnModel, String> {
if (result.failure != null) throw result.failure!;
return result.data ?? [];
}
Future<GrnAttachmentModel> uploadAttachment({
required List<int> bytes,
required String filename,
}) async {
final repository = ref.read(grnRepositoryProvider);
final result = await repository.uploadAttachment(
arg,
bytes: bytes,
filename: filename,
);
if (result.failure != null) throw result.failure!;
final current = state.valueOrNull;
if (current != null) {
state = AsyncData(
current.copyWith(
attachments: [...current.attachments, result.data!],
),
);
} else {
await reload();
}
return result.data!;
}
Future<List<int>> downloadAttachment(String attachmentId) async {
final repository = ref.read(grnRepositoryProvider);
final result = await repository.downloadAttachment(arg, attachmentId);
if (result.failure != null) throw result.failure!;
return result.data ?? [];
}
Future<void> deleteAttachment(String attachmentId) async {
final repository = ref.read(grnRepositoryProvider);
final result = await repository.deleteAttachment(arg, attachmentId);
if (result.failure != null) throw result.failure!;
final current = state.valueOrNull;
if (current != null) {
state = AsyncData(
current.copyWith(
attachments: current.attachments
.where((a) => a.id != attachmentId)
.toList(),
),
);
} else {
await reload();
}
}
}
final grnFormProvider =

View File

@ -10,13 +10,12 @@ import '../../../../shared/models/grn_model.dart';
import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/providers/permissions_provider.dart';
import '../../../../shared/utils/file_download_helper.dart';
import '../../../../shared/widgets/app_card.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart';
import '../providers/grn_lookups_provider.dart';
import '../providers/grn_provider.dart';
import '../widgets/grn_attachments_card.dart';
import '../widgets/grn_line_items_editor.dart';
import '../widgets/grn_status_chip.dart';
@ -35,120 +34,93 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
@override
Widget build(BuildContext context) {
final detailAsync = ref.watch(grnDetailProvider(widget.grnId));
final lookupsAsync = ref.watch(grnLookupsProvider);
final canEdit = ref.can('grn', PermissionAction.update);
final canDelete = ref.can('grn', PermissionAction.delete);
final canExport = ref.can('grn', PermissionAction.export);
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go(RouteConstants.grn),
),
title: detailAsync.maybeWhen(
data: (grn) => Text(grn.grnNumber ?? 'GRN #${grn.id}'),
orElse: () => const Text('GRN'),
),
),
body: detailAsync.when(
loading: () => const AppLoadingView(message: 'Loading GRN...'),
error: (e, _) => ErrorView.fromFailure(
e is Failure ? e : Failure.unknown(message: e.toString()),
onRetry: () => ref.invalidate(grnDetailProvider(widget.grnId)),
),
data: (grn) => SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1200),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageHeader(
title: grn.grnNumber ?? 'GRN #${grn.id}',
subtitle:
'PO ${grn.poNumber ?? ''} · ${grn.vendorName ?? ''}',
actions: [
if (canExport)
OutlinedButton.icon(
onPressed: _isWorking ? null : () => _downloadPdf(grn),
icon: const Icon(Icons.picture_as_pdf_outlined),
label: const Text('PDF'),
),
if (canEdit && grn.canEdit) ...[
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: _isWorking
? null
: () => context.push(
'${RouteConstants.grn}/${grn.id}/edit',
),
icon: const Icon(Icons.edit_outlined),
label: const Text('Edit'),
),
],
if (canEdit && grn.canCancel) ...[
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: _isWorking ? null : () => _cancel(grn),
icon: const Icon(Icons.block_outlined),
label: const Text('Cancel'),
),
],
],
),
const SizedBox(height: 16),
GrnStatusChip(status: grn.status),
if (grn.cancellationReason != null &&
grn.cancellationReason!.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'Cancellation reason: ${grn.cancellationReason}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
],
const SizedBox(height: 16),
_OverviewCard(grn: grn),
const SizedBox(height: 16),
AppCard(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Line Items',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
GrnItemsTable(items: grn.items),
],
data: (grn) {
final lookups = lookupsAsync.asData?.value;
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1200),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_DetailHeader(
grn: grn,
isWorking: _isWorking,
canEdit: canEdit,
canExport: canExport,
onBack: () => context.go(RouteConstants.grn),
onPdf: () => _downloadPdf(grn),
onEdit: () => context.push(
'${RouteConstants.grn}/${grn.id}/edit',
),
onCancel: () => _cancel(grn),
),
),
],
if (grn.cancellationReason != null &&
grn.cancellationReason!.isNotEmpty) ...[
const SizedBox(height: 12),
_CancellationBanner(reason: grn.cancellationReason!),
],
const SizedBox(height: 16),
_ReceiptDetailsCard(grn: grn, lookups: lookups),
const SizedBox(height: 16),
_LineItemsCard(grn: grn),
const SizedBox(height: 16),
GrnAttachmentsCard(
grn: grn,
canUpload: canEdit,
canDelete: canDelete,
),
if (grn.remarks?.trim().isNotEmpty == true) ...[
const SizedBox(height: 16),
_SectionCard(
title: 'REMARKS',
child: Text(
grn.remarks!.trim(),
style: Theme.of(context).textTheme.bodyLarge,
),
),
],
const SizedBox(height: 20),
_DetailFooter(grn: grn),
],
),
),
),
),
),
);
},
),
);
}
Future<void> _runWorkflow(Future<void> Function() action, String success) async {
Future<void> _runWorkflow(
Future<void> Function() action,
String success,
) async {
setState(() => _isWorking = true);
try {
await action();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(success)));
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(success)));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(e.toString())));
}
} finally {
if (mounted) setState(() => _isWorking = false);
@ -164,7 +136,9 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Cancel ${grn.grnNumber ?? grn.id}? This will reverse PO receipts.'),
Text(
'Cancel ${grn.grnNumber ?? grn.id}? This will reverse PO receipts.',
),
const SizedBox(height: 16),
AppTextField(
label: 'Cancellation reason *',
@ -201,8 +175,9 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
Future<void> _downloadPdf(GrnModel grn) async {
await _runWorkflow(() async {
final bytes =
await ref.read(grnDetailProvider(widget.grnId).notifier).downloadPdf();
final bytes = await ref
.read(grnDetailProvider(widget.grnId).notifier)
.downloadPdf();
await downloadFile(
bytes: bytes,
fileName: '${grn.grnNumber ?? 'GRN-${grn.id}'}.pdf',
@ -211,122 +186,381 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
}
}
class _OverviewCard extends ConsumerWidget {
const _OverviewCard({required this.grn});
final GrnModel grn;
String _userLabel(List<FilterOptionModel> users, int? userId) {
if (userId == null) return '';
final match = users.where((u) => int.tryParse(u.id) == userId);
if (match.isNotEmpty) return match.first.name;
return 'User #$userId';
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final users = ref.watch(grnLookupsProvider).maybeWhen(
data: (lookups) => lookups.users,
orElse: () => const <FilterOptionModel>[],
);
return AppCard(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Overview',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 12),
_GrnInfoGrid(
columns: 4,
items: [
_GrnInfo('GRN Date', DateFormatter.displayDate(grn.grnDate)),
_GrnInfo('PO Number', grn.poNumber ?? ''),
_GrnInfo('Vendor', grn.vendorName ?? ''),
_GrnInfo('Warehouse', grn.warehouseName ?? ''),
_GrnInfo('Vendor Invoice No', grn.vendorInvoiceNo ?? ''),
_GrnInfo(
'Vendor Invoice Date',
DateFormatter.displayDate(grn.vendorInvoiceDate),
),
_GrnInfo(
'Vendor Invoice Amount',
grn.vendorInvoiceAmount != null
? CurrencyFormatter.format(grn.vendorInvoiceAmount!)
: '',
),
_GrnInfo('Vehicle No', grn.vehicleNo ?? ''),
_GrnInfo('LR No', grn.lrNo ?? ''),
_GrnInfo('LR Date', DateFormatter.displayDate(grn.lrDate)),
_GrnInfo('Received By', _userLabel(users, grn.receivedBy)),
_GrnInfo(
'Quality Checked By',
_userLabel(users, grn.qualityCheckedBy),
),
_GrnInfo('Remarks', grn.remarks?.trim().isNotEmpty == true
? grn.remarks!
: ''),
],
),
],
),
),
);
String _userLabel(List<FilterOptionModel>? users, int? userId) {
if (userId == null || users == null) return '';
for (final user in users) {
if (int.tryParse(user.id) == userId) return user.name;
}
return 'User #$userId';
}
class _GrnInfoGrid extends StatelessWidget {
const _GrnInfoGrid({
required this.items,
this.columns = 4,
String _displayOrDash(String? value) {
final trimmed = value?.trim();
if (trimmed == null || trimmed.isEmpty) return '';
return trimmed;
}
class _DetailHeader extends StatelessWidget {
const _DetailHeader({
required this.grn,
required this.isWorking,
required this.canEdit,
required this.canExport,
required this.onBack,
required this.onPdf,
required this.onEdit,
required this.onCancel,
});
final List<_GrnInfo> items;
final int columns;
final GrnModel grn;
final bool isWorking;
final bool canEdit;
final bool canExport;
final VoidCallback onBack;
final VoidCallback onPdf;
final VoidCallback onEdit;
final VoidCallback onCancel;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final subtitleParts = [
if (grn.poNumber?.trim().isNotEmpty == true) 'PO ${grn.poNumber!.trim()}',
if (grn.vendorName?.trim().isNotEmpty == true) grn.vendorName!.trim(),
if (grn.warehouseName?.trim().isNotEmpty == true)
grn.warehouseName!.trim(),
];
final actions = Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.end,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
if (canExport)
_HeaderActionButton(
label: 'PDF',
icon: Icons.description_outlined,
onPressed: isWorking ? null : onPdf,
),
if (canEdit && grn.canEdit)
_HeaderActionButton(
label: 'Edit',
icon: Icons.edit_outlined,
onPressed: isWorking ? null : onEdit,
),
if (canEdit && grn.canCancel)
_HeaderActionButton(
label: 'Cancel',
icon: Icons.block_outlined,
destructive: true,
onPressed: isWorking ? null : onCancel,
),
],
);
final titleBlock = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
grn.grnNumber ?? 'GRN #${grn.id}',
style: theme.textTheme.headlineSmall,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 10),
GrnStatusChip(status: grn.status, compact: true),
],
),
if (subtitleParts.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
subtitleParts.join(' · '),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
],
);
return LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth;
final cols = maxWidth < 600
? 1
: maxWidth < 900
? 2
: columns;
const spacing = 16.0;
final colWidth = (maxWidth - spacing * (cols - 1)) / cols;
return Wrap(
spacing: spacing,
runSpacing: 16,
children: items
.map(
(item) => SizedBox(
width: colWidth,
child: _GrnDetailTile(
label: item.label,
value: item.value,
final stack = constraints.maxWidth < 800;
if (stack) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
IconButton(
tooltip: 'Back',
onPressed: onBack,
icon: const Icon(Icons.arrow_back),
),
),
)
.toList(),
const SizedBox(width: 4),
Expanded(child: titleBlock),
],
),
const SizedBox(height: 12),
actions,
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
IconButton(
tooltip: 'Back',
onPressed: onBack,
icon: const Icon(Icons.arrow_back),
),
const SizedBox(width: 4),
Expanded(child: titleBlock),
const SizedBox(width: 12),
actions,
],
);
},
);
}
}
class _GrnDetailTile extends StatelessWidget {
const _GrnDetailTile({
class _HeaderActionButton extends StatelessWidget {
const _HeaderActionButton({
required this.label,
required this.icon,
required this.onPressed,
this.destructive = false,
});
final String label;
final IconData icon;
final VoidCallback? onPressed;
final bool destructive;
static const double _height = 40;
static const double _radius = 8;
static const EdgeInsets _padding =
EdgeInsets.symmetric(horizontal: 14, vertical: 0);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final error = theme.colorScheme.error;
final style = ButtonStyle(
minimumSize: const WidgetStatePropertyAll(Size(0, _height)),
fixedSize: const WidgetStatePropertyAll(Size.fromHeight(_height)),
padding: const WidgetStatePropertyAll(_padding),
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(_radius)),
),
visualDensity: VisualDensity.standard,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
);
final child = Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 18),
const SizedBox(width: 8),
Text(label),
],
);
if (destructive) {
return OutlinedButton(
onPressed: onPressed,
style: style.copyWith(
foregroundColor: WidgetStatePropertyAll(error),
side: WidgetStatePropertyAll(BorderSide(color: error)),
),
child: child,
);
}
return OutlinedButton(
onPressed: onPressed,
style: style,
child: child,
);
}
}
class _CancellationBanner extends StatelessWidget {
const _CancellationBanner({required this.reason});
final String reason;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.error.withValues(alpha: 0.35),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.info_outline, color: theme.colorScheme.error, size: 20),
const SizedBox(width: 10),
Expanded(
child: Text(
'Cancellation reason: $reason',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.error,
),
),
),
],
),
);
}
}
class _SectionCard extends StatelessWidget {
const _SectionCard({
required this.title,
required this.child,
});
final String title;
final Widget child;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: theme.colorScheme.outline.withValues(alpha: 0.2),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
title,
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
letterSpacing: 0.8,
),
),
const SizedBox(height: 16),
child,
],
),
);
}
}
class _ReceiptDetailsCard extends StatelessWidget {
const _ReceiptDetailsCard({
required this.grn,
required this.lookups,
});
final GrnModel grn;
final GrnLookups? lookups;
@override
Widget build(BuildContext context) {
return _SectionCard(
title: 'RECEIPT DETAILS',
child: LayoutBuilder(
builder: (context, constraints) {
final cols = constraints.maxWidth < 600
? 1
: constraints.maxWidth < 900
? 2
: 4;
const spacing = 20.0;
final width = (constraints.maxWidth - spacing * (cols - 1)) / cols;
final items = [
_DetailField(
label: 'GRN date',
value: DateFormatter.displayDate(grn.grnDate),
),
_DetailField(
label: 'PO number',
value: _displayOrDash(grn.poNumber),
),
_DetailField(
label: 'Vendor',
value: _displayOrDash(grn.vendorName),
),
_DetailField(
label: 'Warehouse',
value: _displayOrDash(grn.warehouseName),
),
_DetailField(
label: 'Vendor invoice no',
value: _displayOrDash(grn.vendorInvoiceNo),
),
_DetailField(
label: 'Vendor invoice date',
value: DateFormatter.displayDate(grn.vendorInvoiceDate),
),
_DetailField(
label: 'Vendor invoice amount',
value: grn.vendorInvoiceAmount != null
? CurrencyFormatter.format(grn.vendorInvoiceAmount!)
: '',
),
_DetailField(
label: 'Vehicle no',
value: _displayOrDash(grn.vehicleNo),
),
_DetailField(
label: 'LR no',
value: _displayOrDash(grn.lrNo),
),
_DetailField(
label: 'LR date',
value: DateFormatter.displayDate(grn.lrDate),
),
_DetailField(
label: 'Received by',
value: _userLabel(lookups?.users, grn.receivedBy),
),
_DetailField(
label: 'Quality checked by',
value: _userLabel(lookups?.users, grn.qualityCheckedBy),
),
];
return Wrap(
spacing: spacing,
runSpacing: 16,
children: items
.map((item) => SizedBox(width: width, child: item))
.toList(),
);
},
),
);
}
}
class _DetailField extends StatelessWidget {
const _DetailField({
required this.label,
required this.value,
});
@ -337,7 +571,6 @@ class _GrnDetailTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -349,15 +582,53 @@ class _GrnDetailTile extends StatelessWidget {
),
),
const SizedBox(height: 4),
Text(value, style: theme.textTheme.bodyLarge),
Text(
value,
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
);
}
}
class _GrnInfo {
const _GrnInfo(this.label, this.value);
class _LineItemsCard extends StatelessWidget {
const _LineItemsCard({required this.grn});
final String label;
final String value;
final GrnModel grn;
@override
Widget build(BuildContext context) {
return _SectionCard(
title: 'LINE ITEMS · ${grn.items.length}',
child: GrnItemsTable(items: grn.items),
);
}
}
class _DetailFooter extends StatelessWidget {
const _DetailFooter({required this.grn});
final GrnModel grn;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final parts = <String>[
if (grn.createdAt != null)
'Created ${DateFormatter.displayDateTime(grn.createdAt)}',
if (grn.updatedAt != null)
'Updated ${DateFormatter.displayDateTime(grn.updatedAt)}',
];
if (parts.isEmpty) return const SizedBox.shrink();
return Text(
parts.join(' · '),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
);
}
}

View File

@ -14,13 +14,13 @@ import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/page_header.dart';
import '../providers/grn_lookups_provider.dart';
import '../providers/grn_provider.dart';
import '../widgets/grn_line_items_editor.dart';
import '../widgets/grn_status_chip.dart';
class GrnFormScreen extends ConsumerStatefulWidget {
const GrnFormScreen({super.key, this.grnId});
@ -58,6 +58,9 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
super.initState();
if (!widget.isEditing) {
_grnDate = DateTime.now();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) ref.invalidate(grnLookupsProvider);
});
}
}
@ -90,8 +93,8 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
_vehicleNoController.text = grn.vehicleNo ?? '';
_lrNoController.text = grn.lrNo ?? '';
_lrDate = grn.lrDate;
_receivedById = grn.receivedBy;
_qualityCheckedById = grn.qualityCheckedBy;
_receivedById = _normalizeUserId(grn.receivedBy);
_qualityCheckedById = _normalizeUserId(grn.qualityCheckedBy);
_remarksController.text = grn.remarks ?? '';
});
}
@ -112,11 +115,18 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
int? _parseId(String value) => int.tryParse(value.trim());
int? _normalizeUserId(int? id) => id != null && id > 0 ? id : null;
int? _dropdownValue(int? selected, Iterable<int> validIds) {
if (selected == null) return null;
return validIds.contains(selected) ? selected : null;
}
void _putOptionalUserId(Map<String, dynamic> payload, String key, int? id) {
final normalized = _normalizeUserId(id);
if (normalized != null) payload[key] = normalized;
}
List<AppDropdownOption<int>> _intOptions(List<FilterOptionModel> options) {
return options
.map((e) {
@ -130,7 +140,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
Map<String, dynamic> _buildCreatePayload() {
final poId = int.tryParse(_selectedPoId ?? '');
return {
final payload = <String, dynamic>{
'grn_date': DateFormatter.toApiDate(_grnDate ?? DateTime.now()),
'po_id': poId,
'warehouse_id': _warehouseId,
@ -143,17 +153,19 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
double.tryParse(_vendorInvoiceAmountController.text.trim()),
if (_vehicleNoController.text.trim().isNotEmpty)
'vehicle_no': _vehicleNoController.text.trim(),
if (_lrNoController.text.trim().isNotEmpty) 'lr_no': _lrNoController.text.trim(),
if (_lrNoController.text.trim().isNotEmpty)
'lr_no': _lrNoController.text.trim(),
if (_lrDate != null) 'lr_date': DateFormatter.toApiDate(_lrDate!),
if (_receivedById != null) 'received_by': _receivedById,
if (_qualityCheckedById != null) 'quality_checked_by': _qualityCheckedById,
'remarks': _remarksController.text.trim(),
'items': _lines.map((line) => line.toPayload()).toList(),
};
_putOptionalUserId(payload, 'received_by', _receivedById);
_putOptionalUserId(payload, 'quality_checked_by', _qualityCheckedById);
payload['remarks'] = _remarksController.text.trim();
payload['items'] = _lines.map((line) => line.toPayload()).toList();
return payload;
}
Map<String, dynamic> _buildUpdatePayload() {
return {
final payload = <String, dynamic>{
if (_vendorInvoiceNoController.text.trim().isNotEmpty)
'vendor_invoice_no': _vendorInvoiceNoController.text.trim(),
if (_vendorInvoiceDate != null)
@ -163,12 +175,14 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
double.tryParse(_vendorInvoiceAmountController.text.trim()),
if (_vehicleNoController.text.trim().isNotEmpty)
'vehicle_no': _vehicleNoController.text.trim(),
if (_lrNoController.text.trim().isNotEmpty) 'lr_no': _lrNoController.text.trim(),
if (_lrNoController.text.trim().isNotEmpty)
'lr_no': _lrNoController.text.trim(),
if (_lrDate != null) 'lr_date': DateFormatter.toApiDate(_lrDate!),
if (_receivedById != null) 'received_by': _receivedById,
if (_qualityCheckedById != null) 'quality_checked_by': _qualityCheckedById,
'remarks': _remarksController.text.trim(),
};
_putOptionalUserId(payload, 'received_by', _receivedById);
_putOptionalUserId(payload, 'quality_checked_by', _qualityCheckedById);
payload['remarks'] = _remarksController.text.trim();
return payload;
}
String? _lineItemsError() {
@ -180,6 +194,10 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
if (line.acceptedQty < 0) {
return 'Accepted quantity must be zero or more for line ${line.lineNo}';
}
if (line.rejectedQty > 0 &&
line.rejectionReasonController.text.trim().isEmpty) {
return 'Rejection reason is required for line ${line.lineNo}';
}
}
return null;
}
@ -206,7 +224,8 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
}
final lineError = _lineItemsError();
if (lineError != null) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(lineError)));
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(lineError)));
return;
}
}
@ -241,7 +260,8 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
if (!mounted) return;
final message =
e is Failure ? validationErrorMessage(e) : e.toString();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
} finally {
if (mounted) setState(() => _isSubmitting = false);
}
@ -260,6 +280,17 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
if (picked != null) onPicked(picked);
}
void _goBack(GrnModel? existing) {
if (_isSubmitting) return;
if (widget.isEditing && existing != null) {
context.go('${RouteConstants.grn}/${existing.id}');
} else if (widget.isEditing) {
context.go('${RouteConstants.grn}/${widget.grnId}');
} else {
context.go(RouteConstants.grn);
}
}
@override
Widget build(BuildContext context) {
final lookupsAsync = ref.watch(grnLookupsProvider);
@ -268,33 +299,33 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
: const AsyncData<GrnModel?>(null);
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go(
widget.isEditing
? '${RouteConstants.grn}/${widget.grnId}'
: RouteConstants.grn,
),
),
title: Text(widget.isEditing ? 'Edit GRN' : 'Create GRN'),
),
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: lookupsAsync.when(
loading: () => const AppLoadingView(message: 'Loading form...'),
skipLoadingOnReload: true,
loading: () => lookupsAsync.hasValue
? _buildFormBody(
lookups: lookupsAsync.value!,
existing: existingAsync.valueOrNull,
)
: const AppLoadingView(message: 'Loading form...'),
error: (e, _) => ErrorView.fromFailure(
e is Failure ? e : Failure.unknown(message: e.toString()),
onRetry: () => ref.invalidate(grnLookupsProvider),
),
data: (lookups) => existingAsync.when(
loading: () => const AppLoadingView(message: 'Loading GRN...'),
skipLoadingOnReload: true,
loading: () => existingAsync.hasValue
? _buildFormBody(
lookups: lookups,
existing: existingAsync.valueOrNull,
)
: const AppLoadingView(message: 'Loading GRN...'),
error: (e, _) => ErrorView.fromFailure(
e is Failure ? e : Failure.unknown(message: e.toString()),
onRetry: () => ref.invalidate(grnFormProvider(widget.grnId)),
),
data: (existing) => _buildFormBody(lookups: lookups, existing: existing),
data: (existing) =>
_buildFormBody(lookups: lookups, existing: existing),
),
),
);
@ -323,6 +354,8 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
final warehouseIds =
lookups.warehouses.map((e) => _parseId(e.id)).whereType<int>();
final userIds =
lookups.users.map((e) => _parseId(e.id)).whereType<int>();
final poOptions = lookups.receivablePurchaseOrders
.map(
(po) => AppDropdownOption(
@ -331,220 +364,401 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
),
)
.toList();
final theme = Theme.of(context);
return SingleChildScrollView(
controller: _scrollController,
padding: const EdgeInsets.all(24),
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1200),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!widget.isEditing)
const PageHeader(
title: 'Create Goods Received Note',
subtitle: 'Receive items against an approved purchase order',
)
else if (existing?.grnNumber != null)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
existing!.grnNumber!,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ResponsiveFormGrid(
children: [
_DateField(
label: 'GRN Date *',
value: _grnDate,
enabled: !widget.isEditing,
onTap: widget.isEditing
? null
: () => _pickDate(
current: _grnDate,
onPicked: (d) => setState(() => _grnDate = d),
),
),
if (!widget.isEditing)
AppSearchableDropdown<String>(
label: 'Purchase Order *',
value: _selectedPoId,
searchHint: 'Search PO...',
isDense: true,
options: poOptions,
onChanged: (v) async {
setState(() => _selectedPoId = v);
if (v == null) {
for (final line in _lines) {
line.dispose();
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(existing),
const SizedBox(height: 16),
_SectionCard(
title: 'RECEIPT DETAILS',
child: Column(
children: [
FormRowFour(
children: [
_DateField(
label: 'GRN date *',
value: _grnDate,
enabled: !widget.isEditing,
onTap: widget.isEditing
? null
: () => _pickDate(
current: _grnDate,
onPicked: (d) =>
setState(() => _grnDate = d),
),
),
if (!widget.isEditing)
AppSearchableDropdown<String>(
label: 'Purchase order *',
value: _selectedPoId,
hint: 'Select PO',
searchHint: 'Search PO...',
options: poOptions,
onChanged: (v) async {
setState(() => _selectedPoId = v);
if (v == null) {
for (final line in _lines) {
line.dispose();
}
setState(() => _lines.clear());
return;
}
setState(() => _lines.clear());
return;
}
try {
final po = await ref.read(
grnPurchaseOrderProvider(v).future,
);
if (mounted && po != null) _loadLinesFromPo(po);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),
);
}
},
validator: (v) =>
v == null ? 'Purchase order is required' : null,
)
else
Padding(
padding: const EdgeInsets.only(top: 8),
child: InputDecorator(
decoration: const InputDecoration(
labelText: 'Purchase Order',
floatingLabelBehavior: FloatingLabelBehavior.always,
isDense: true,
),
child: Text(existing?.poNumber ?? ''),
try {
final po = await ref.read(
grnPurchaseOrderProvider(v).future,
);
if (mounted && po != null) {
_loadLinesFromPo(po);
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),
);
}
},
validator: (v) => v == null
? 'Purchase order is required'
: null,
)
else
_ReadOnlyField(
label: 'Purchase order',
value: existing?.poNumber ?? '',
),
AppSearchableDropdown<int>(
label: 'Warehouse *',
value: _dropdownValue(_warehouseId, warehouseIds),
hint: 'Select warehouse',
searchHint: 'Search warehouse...',
options: _intOptions(lookups.warehouses),
onChanged: widget.isEditing
? (_) {}
: (v) => setState(() => _warehouseId = v),
validator: widget.isEditing
? null
: (v) =>
v == null ? 'Warehouse is required' : null,
enabled: !widget.isEditing,
),
AppSearchableDropdown<int>(
label: 'Warehouse *',
value: _dropdownValue(_warehouseId, warehouseIds),
searchHint: 'Search warehouse...',
isDense: true,
options: _intOptions(lookups.warehouses),
onChanged: widget.isEditing
? (_) {}
: (v) => setState(() => _warehouseId = v),
validator: widget.isEditing
? null
: (v) => v == null ? 'Warehouse is required' : null,
enabled: !widget.isEditing,
),
AppTextField(
label: 'Vendor Invoice No',
controller: _vendorInvoiceNoController,
isDense: true,
),
_DateField(
label: 'Vendor Invoice Date',
value: _vendorInvoiceDate,
onTap: () => _pickDate(
current: _vendorInvoiceDate,
onPicked: (d) =>
setState(() => _vendorInvoiceDate = d),
AppTextField(
label: 'Vendor invoice no',
controller: _vendorInvoiceNoController,
),
),
AppTextField(
label: 'Vendor Invoice Amount',
controller: _vendorInvoiceAmountController,
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
isDense: true,
),
AppTextField(
label: 'Vehicle No',
controller: _vehicleNoController,
isDense: true,
),
AppTextField(
label: 'LR No',
controller: _lrNoController,
isDense: true,
),
_DateField(
label: 'LR Date',
value: _lrDate,
onTap: () => _pickDate(
current: _lrDate,
onPicked: (d) => setState(() => _lrDate = d),
),
),
AppSearchableDropdown<int>(
label: 'Received By',
value: _dropdownValue(
_receivedById,
lookups.users.map((e) => _parseId(e.id)).whereType<int>(),
),
searchHint: 'Search user...',
isDense: true,
options: _intOptions(lookups.users),
onChanged: (v) => setState(() => _receivedById = v),
),
AppSearchableDropdown<int>(
label: 'Quality Checked By',
value: _dropdownValue(
_qualityCheckedById,
lookups.users.map((e) => _parseId(e.id)).whereType<int>(),
),
searchHint: 'Search user...',
isDense: true,
options: _intOptions(lookups.users),
onChanged: (v) =>
setState(() => _qualityCheckedById = v),
),
AppTextField(
label: 'Remarks',
controller: _remarksController,
maxLines: 3,
isDense: true,
),
],
),
if (!widget.isEditing) ...[
const SizedBox(height: 24),
Text(
'Line Items',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
],
),
const SizedBox(height: 12),
GrnLineItemsEditor(
items: _lines,
onChanged: () => setState(() {}),
),
] else ...[
const SizedBox(height: 16),
Text(
'Line items cannot be changed after posting.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
FormRowFour(
children: [
_DateField(
label: 'Vendor invoice date',
value: _vendorInvoiceDate,
onTap: () => _pickDate(
current: _vendorInvoiceDate,
onPicked: (d) =>
setState(() => _vendorInvoiceDate = d),
),
),
AppTextField(
label: 'Vendor invoice amount',
controller: _vendorInvoiceAmountController,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
),
AppTextField(
label: 'Vehicle no',
controller: _vehicleNoController,
),
AppTextField(
label: 'LR no',
controller: _lrNoController,
),
],
),
FormRowFour(
children: [
_DateField(
label: 'LR date',
value: _lrDate,
onTap: () => _pickDate(
current: _lrDate,
onPicked: (d) => setState(() => _lrDate = d),
),
),
AppSearchableDropdown<int>(
label: 'Received by',
value: _dropdownValue(
_normalizeUserId(_receivedById),
userIds,
),
hint: 'Select user',
searchHint: 'Search user...',
options: _intOptions(lookups.users),
onChanged: (v) =>
setState(() => _receivedById = v),
),
AppSearchableDropdown<int>(
label: 'Quality checked by',
value: _dropdownValue(
_normalizeUserId(_qualityCheckedById),
userIds,
),
hint: 'Select user',
searchHint: 'Search user...',
options: _intOptions(lookups.users),
onChanged: (v) =>
setState(() => _qualityCheckedById = v),
),
const SizedBox.shrink(),
],
),
],
const SizedBox(height: 24),
Row(
),
),
const SizedBox(height: 16),
if (!widget.isEditing)
GrnLineItemsEditor(
items: _lines,
onChanged: () => setState(() {}),
)
else ...[
_SectionCard(
title: 'LINE ITEMS · ${existing?.items.length ?? 0}',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: AppButton(
label: widget.isEditing ? 'Save Changes' : 'Create GRN',
onPressed: _isSubmitting ? null : _submit,
isLoading: _isSubmitting,
Text(
'Line items cannot be changed after posting.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
if (existing?.items.isNotEmpty == true) ...[
const SizedBox(height: 12),
GrnItemsTable(items: existing!.items),
],
],
),
),
],
const SizedBox(height: 16),
_SectionCard(
title: 'ADDITIONAL DETAILS',
child: AppTextField(
controller: _remarksController,
label: 'Remarks',
hint: 'Any additional notes for this receipt.',
maxLines: 4,
),
),
const SizedBox(height: 20),
Row(
children: [
Text(
'Fields marked * are required',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const Spacer(),
Text(
widget.isEditing
? '${existing?.items.length ?? 0} line item${(existing?.items.length ?? 0) == 1 ? '' : 's'} · editing header only'
: '${_lines.length} line item${_lines.length == 1 ? '' : 's'} · draft not yet saved',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
],
],
),
),
),
),
);
}
Widget _buildHeader(GrnModel? existing) {
final theme = Theme.of(context);
final title = widget.isEditing
? 'Edit ${existing?.grnNumber ?? 'GRN'}'
: 'Create goods received note';
final subtitle = widget.isEditing
? null
: 'Select an approved purchase order, enter receipt details, then confirm quantities.';
final actions = Row(
mainAxisSize: MainAxisSize.min,
children: [
OutlinedButton(
onPressed: _isSubmitting ? null : () => _goBack(existing),
child: const Text('Cancel'),
),
const SizedBox(width: 12),
AppButton(
label: widget.isEditing ? 'Update GRN' : 'Save GRN',
icon: Icons.check,
expand: false,
isLoading: _isSubmitting,
onPressed: _isSubmitting ? null : _submit,
),
],
);
final titleBlock = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
Text(title, style: theme.textTheme.headlineSmall),
if (widget.isEditing && existing != null)
GrnStatusChip(status: existing.status, compact: true),
],
),
if (subtitle != null) ...[
const SizedBox(height: 4),
Text(
subtitle,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
],
);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: LayoutBuilder(
builder: (context, constraints) {
final stack = constraints.maxWidth < 720;
if (stack) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
IconButton(
tooltip: 'Back',
onPressed:
_isSubmitting ? null : () => _goBack(existing),
icon: const Icon(Icons.arrow_back),
),
const SizedBox(width: 4),
Expanded(child: titleBlock),
],
),
const SizedBox(height: 12),
Align(alignment: Alignment.centerRight, child: actions),
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
IconButton(
tooltip: 'Back',
onPressed: _isSubmitting ? null : () => _goBack(existing),
icon: const Icon(Icons.arrow_back),
),
const SizedBox(width: 4),
Expanded(child: titleBlock),
const SizedBox(width: 12),
actions,
],
);
},
),
);
}
}
class _SectionCard extends StatelessWidget {
const _SectionCard({
required this.title,
required this.child,
});
final String title;
final Widget child;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final cardColor = isDark
? theme.colorScheme.surfaceContainerHighest
: theme.colorScheme.surface;
final borderColor = theme.colorScheme.outline.withValues(
alpha: isDark ? 0.35 : 0.2,
);
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: cardColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: borderColor),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
title,
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
letterSpacing: 0.8,
),
),
const SizedBox(height: 12),
child,
],
),
);
}
}
class _ReadOnlyField extends StatelessWidget {
const _ReadOnlyField({
required this.label,
required this.value,
});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: InputDecorator(
decoration: InputDecoration(
labelText: label,
floatingLabelBehavior: FloatingLabelBehavior.always,
),
child: Text(value),
),
);
}
}
class _DateField extends StatelessWidget {
@ -571,16 +785,15 @@ class _DateField extends StatelessWidget {
decoration: InputDecoration(
labelText: label,
floatingLabelBehavior: FloatingLabelBehavior.always,
isDense: true,
suffixIcon: const Icon(Icons.calendar_today_outlined, size: 18),
enabled: enabled,
),
child: Text(
value != null ? DateFormatter.displayDate(value) : 'Select date',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: value != null
? null
: Theme.of(context).hintColor,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: value == null
? Theme.of(context).colorScheme.onSurfaceVariant
: null,
),
),
),

View 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,
),
],
],
),
);
}
}

View File

@ -1,20 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/utils/formatters.dart';
import '../../../../shared/models/asset_model.dart';
import '../../../../shared/models/grn_model.dart';
import '../../../../shared/models/purchase_order_model.dart';
import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/widgets/app_data_table.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../../../assets/presentation/providers/asset_categories_provider.dart';
import '../providers/grn_lookups_provider.dart';
String _formatQty(double value) {
if (value % 1 == 0) return value.toInt().toString();
@ -38,8 +29,6 @@ class GrnLineItemDraft {
TextEditingController? remarksController,
this.mfgDate,
this.expiryDate,
this.assetCategoryId,
this.assetSubcategoryId,
}) : acceptedQtyController = acceptedQtyController ??
TextEditingController(
text: remainingQty > 0 ? _formatQty(remainingQty) : '',
@ -68,11 +57,11 @@ class GrnLineItemDraft {
final TextEditingController remarksController;
DateTime? mfgDate;
DateTime? expiryDate;
int? assetCategoryId;
int? assetSubcategoryId;
double get acceptedQty => double.tryParse(acceptedQtyController.text.trim()) ?? 0;
double get rejectedQty => double.tryParse(rejectedQtyController.text.trim()) ?? 0;
double get acceptedQty =>
double.tryParse(acceptedQtyController.text.trim()) ?? 0;
double get rejectedQty =>
double.tryParse(rejectedQtyController.text.trim()) ?? 0;
double get currentQty => acceptedQty + rejectedQty;
void dispose() {
@ -92,8 +81,8 @@ class GrnLineItemDraft {
'line_no': lineNo,
'current_qty': currentQty,
'accepted_qty': acceptedQty,
if (rejectedQty > 0) 'rejected_qty': rejectedQty,
if (rejectionReasonController.text.trim().isNotEmpty)
'rejected_qty': rejectedQty,
if (rejectedQty > 0 && rejectionReasonController.text.trim().isNotEmpty)
'rejection_reason': rejectionReasonController.text.trim(),
if (rateController.text.trim().isNotEmpty)
'rate': double.tryParse(rateController.text.trim()),
@ -103,8 +92,6 @@ class GrnLineItemDraft {
if (expiryDate != null) 'expiry_date': DateFormatter.toApiDate(expiryDate!),
if (storageLocationController.text.trim().isNotEmpty)
'storage_location': storageLocationController.text.trim(),
if (assetCategoryId != null) 'asset_category_id': assetCategoryId,
if (assetSubcategoryId != null) 'asset_subcategory_id': assetSubcategoryId,
if (remarksController.text.trim().isNotEmpty)
'remarks': remarksController.text.trim(),
};
@ -145,44 +132,78 @@ class GrnLineItemsEditor extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (items.isEmpty) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: AppColors.lightSurface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.textSecondary.withValues(alpha: 0.2)),
),
child: Text(
readOnly
? 'No line items.'
: 'Select a purchase order to load receivable line items.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppColors.textSecondary,
),
),
);
}
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final cardColor = isDark
? theme.colorScheme.surfaceContainerHighest
: theme.colorScheme.surface;
final borderColor = theme.colorScheme.outline.withValues(
alpha: isDark ? 0.35 : 0.2,
);
return Column(
children: [
for (var i = 0; i < items.length; i++)
Padding(
padding: EdgeInsets.only(bottom: i == items.length - 1 ? 0 : 12),
child: _GrnLineItemCard(
item: items[i],
readOnly: readOnly,
onChanged: onChanged,
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: cardColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: borderColor),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'LINE ITEMS',
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
letterSpacing: 0.8,
),
),
],
const SizedBox(height: 8),
if (items.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
border: Border.all(color: borderColor),
borderRadius: BorderRadius.circular(8),
),
child: Text(
readOnly
? 'No line items.'
: 'Select a purchase order to load receivable line items.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
)
else
...items.asMap().entries.map((entry) {
final index = entry.key;
final item = entry.value;
return Padding(
padding: EdgeInsets.only(
bottom: index == items.length - 1 ? 0 : 12,
),
child: _GrnLineItemCard(
key: ObjectKey(item),
item: item,
readOnly: readOnly,
onChanged: onChanged,
),
);
}),
],
),
);
}
}
class _GrnLineItemCard extends ConsumerWidget {
class _GrnLineItemCard extends StatefulWidget {
const _GrnLineItemCard({
super.key,
required this.item,
required this.onChanged,
required this.readOnly,
@ -192,41 +213,35 @@ class _GrnLineItemCard extends ConsumerWidget {
final VoidCallback onChanged;
final bool readOnly;
@override
State<_GrnLineItemCard> createState() => _GrnLineItemCardState();
}
class _GrnLineItemCardState extends State<_GrnLineItemCard> {
static final _qtyFormatters = [
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,4}')),
];
int? _dropdownValue(int? selected, Iterable<int> validIds) {
if (selected == null) return null;
return validIds.contains(selected) ? selected : null;
@override
void initState() {
super.initState();
widget.item.acceptedQtyController.addListener(_onFieldChanged);
widget.item.rejectedQtyController.addListener(_onFieldChanged);
}
List<AppDropdownOption<int>> _intOptions(List<FilterOptionModel> options) {
return options
.map((e) {
final id = int.tryParse(e.id);
if (id == null) return null;
return AppDropdownOption(value: id, label: e.name);
})
.whereType<AppDropdownOption<int>>()
.toList();
@override
void dispose() {
widget.item.acceptedQtyController.removeListener(_onFieldChanged);
widget.item.rejectedQtyController.removeListener(_onFieldChanged);
super.dispose();
}
List<AppDropdownOption<int>> _categoryOptions(
List<AssetCategoryModel> categories,
) {
return categories
.map((c) {
final id = int.tryParse(c.id);
if (id == null) return null;
return AppDropdownOption(value: id, label: c.name);
})
.whereType<AppDropdownOption<int>>()
.toList();
void _onFieldChanged() {
widget.onChanged();
setState(() {});
}
Future<void> _pickDate(
BuildContext context, {
Future<void> _pickDate({
required DateTime? current,
required ValueChanged<DateTime?> onPicked,
}) async {
@ -240,201 +255,279 @@ class _GrnLineItemCard extends ConsumerWidget {
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final categoryId = item.assetCategoryId;
final categoriesAsync = ref.watch(assetCategoriesProvider);
final categoryOptions = categoriesAsync.maybeWhen(
data: _categoryOptions,
orElse: () => const <AppDropdownOption<int>>[],
Widget build(BuildContext context) {
final theme = Theme.of(context);
final item = widget.item;
final lineKey = 'grn-line-${item.lineNo}';
final isDark = theme.brightness == Brightness.dark;
final borderColor = theme.colorScheme.outline.withValues(
alpha: isDark ? 0.35 : 0.18,
);
final categoryIds = categoryOptions.map((e) => e.value);
final subcategoriesAsync = ref.watch(grnAssetSubcategoriesProvider(categoryId));
final subcategoryOptions = subcategoriesAsync.maybeWhen(
data: _intOptions,
orElse: () => const <AppDropdownOption<int>>[],
final currentBg = theme.colorScheme.primary.withValues(
alpha: isDark ? 0.18 : 0.08,
);
final subcategoryIds = subcategoryOptions.map((e) => e.value);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.lightSurface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.textSecondary.withValues(alpha: 0.2)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Line ${item.lineNo}',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
item.itemName,
style: Theme.of(context).textTheme.bodyMedium,
),
),
],
),
const SizedBox(height: 8),
Text(
'Ordered: ${_formatQty(item.orderedQty)} · '
'Already received: ${_formatQty(item.receivedQty)} · '
'Remaining: ${_formatQty(item.remainingQty)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppColors.textSecondary,
),
),
if (!readOnly) ...[
ResponsiveFormGrid(
children: _buildLineItemFields(
context: context,
categoryOptions: categoryOptions,
categoryIds: categoryIds,
subcategoryOptions: subcategoryOptions,
subcategoryIds: subcategoryIds,
),
if (widget.readOnly) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: borderColor),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_LineItemTitle(
lineNo: item.lineNo,
itemName: item.itemName,
orderedQty: item.orderedQty,
receivedQty: item.receivedQty,
remainingQty: item.remainingQty,
),
] else ...[
const SizedBox(height: 8),
Text(
'Accepted: ${_formatQty(item.acceptedQty)} · '
'Rejected: ${_formatQty(item.rejectedQty)} · '
'Current: ${_formatQty(item.currentQty)}',
style: Theme.of(context).textTheme.bodySmall,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
);
}
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: borderColor),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_LineItemTitle(
lineNo: item.lineNo,
itemName: item.itemName,
orderedQty: item.orderedQty,
receivedQty: item.receivedQty,
remainingQty: item.remainingQty,
),
const SizedBox(height: 8),
FormRow(
columnCount: 12,
spans: const [1, 1, 1, 2, 3, 4],
spacing: 8,
stackBelowWidth: 1100,
children: [
AppTextField(
key: ValueKey('$lineKey-accepted'),
controller: item.acceptedQtyController,
label: 'Accepted *',
hint: '0',
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
inputFormatters: _qtyFormatters,
isDense: true,
),
AppTextField(
key: ValueKey('$lineKey-rejected'),
controller: item.rejectedQtyController,
label: 'Rejected',
hint: '0',
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
inputFormatters: _qtyFormatters,
isDense: true,
),
_GrnLineReadOnlyField(
key: ValueKey('$lineKey-current'),
label: 'Current',
value: _formatQty(item.currentQty),
backgroundColor: currentBg,
),
AppTextField(
key: ValueKey('$lineKey-rate'),
controller: item.rateController,
label: 'Rate',
hint: '0.00',
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
inputFormatters: _qtyFormatters,
isDense: true,
onChanged: (_) => widget.onChanged(),
),
AppTextField(
key: ValueKey('$lineKey-batch'),
controller: item.batchNoController,
label: 'Batch No',
isDense: true,
onChanged: (_) => widget.onChanged(),
),
AppTextField(
key: ValueKey('$lineKey-storage'),
controller: item.storageLocationController,
label: 'Storage',
isDense: true,
onChanged: (_) => widget.onChanged(),
),
],
),
Theme(
data: theme.copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
tilePadding: EdgeInsets.zero,
childrenPadding: EdgeInsets.zero,
title: Text(
'More details',
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
children: [
FormRow(
columnCount: 12,
spans: const [3, 3, 3, 3],
spacing: 8,
stackBelowWidth: 1100,
children: [
_GrnLineDateField(
key: ValueKey('$lineKey-mfg'),
label: 'Mfg Date',
value: item.mfgDate,
onTap: () => _pickDate(
current: item.mfgDate,
onPicked: (date) {
item.mfgDate = date;
widget.onChanged();
setState(() {});
},
),
),
_GrnLineDateField(
key: ValueKey('$lineKey-expiry'),
label: 'Expiry Date',
value: item.expiryDate,
onTap: () => _pickDate(
current: item.expiryDate,
onPicked: (date) {
item.expiryDate = date;
widget.onChanged();
setState(() {});
},
),
),
AppTextField(
key: ValueKey('$lineKey-rejection'),
controller: item.rejectionReasonController,
label: 'Rejection Reason',
isDense: true,
onChanged: (_) => widget.onChanged(),
),
AppTextField(
key: ValueKey('$lineKey-remarks'),
controller: item.remarksController,
label: 'Remarks',
isDense: true,
onChanged: (_) => widget.onChanged(),
),
],
),
],
),
),
],
),
);
}
}
List<Widget> _buildLineItemFields({
required BuildContext context,
required List<AppDropdownOption<int>> categoryOptions,
required Iterable<int> categoryIds,
required List<AppDropdownOption<int>> subcategoryOptions,
required Iterable<int> subcategoryIds,
}) {
final hasCategory = item.assetCategoryId != null;
return [
AppTextField(
label: 'Accepted Qty *',
controller: item.acceptedQtyController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: _qtyFormatters,
isDense: true,
onChanged: (_) => onChanged(),
),
AppTextField(
label: 'Rejected Qty',
controller: item.rejectedQtyController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: _qtyFormatters,
isDense: true,
onChanged: (_) => onChanged(),
),
AppTextField(
label: 'Rate',
controller: item.rateController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: _qtyFormatters,
isDense: true,
onChanged: (_) => onChanged(),
),
AppTextField(
label: 'Batch No',
controller: item.batchNoController,
isDense: true,
onChanged: (_) => onChanged(),
),
_GrnLineDateField(
label: 'Mfg Date',
value: item.mfgDate,
onTap: () => _pickDate(
context,
current: item.mfgDate,
onPicked: (date) {
item.mfgDate = date;
onChanged();
},
/// Title for a line item name + ordered/received/remaining, not a form field.
class _LineItemTitle extends StatelessWidget {
const _LineItemTitle({
required this.lineNo,
required this.itemName,
required this.orderedQty,
required this.receivedQty,
required this.remainingQty,
});
final int lineNo;
final String itemName;
final double orderedQty;
final double receivedQty;
final double remainingQty;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Line $lineNo · $itemName',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
'Ordered ${_formatQty(orderedQty)} · '
'Received ${_formatQty(receivedQty)} · '
'Remaining ${_formatQty(remainingQty)}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
);
}
}
class _GrnLineReadOnlyField extends StatelessWidget {
const _GrnLineReadOnlyField({
super.key,
required this.label,
required this.value,
this.backgroundColor,
});
final String label;
final String value;
final Color? backgroundColor;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(top: 8),
child: InputDecorator(
decoration: InputDecoration(
labelText: label,
floatingLabelBehavior: FloatingLabelBehavior.always,
isDense: true,
filled: backgroundColor != null,
fillColor: backgroundColor,
enabled: false,
),
child: Text(
value,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
_GrnLineDateField(
label: 'Expiry Date',
value: item.expiryDate,
onTap: () => _pickDate(
context,
current: item.expiryDate,
onPicked: (date) {
item.expiryDate = date;
onChanged();
},
),
),
AppTextField(
label: 'Storage Location',
controller: item.storageLocationController,
isDense: true,
onChanged: (_) => onChanged(),
),
AppTextField(
label: 'Rejection Reason',
controller: item.rejectionReasonController,
isDense: true,
onChanged: (_) => onChanged(),
),
AppSearchableDropdown<int>(
key: ValueKey('line_${item.lineNo}_asset_category'),
label: 'Asset Category',
value: _dropdownValue(item.assetCategoryId, categoryIds),
searchHint: 'Search asset category...',
isDense: true,
enabled: categoryOptions.isNotEmpty,
options: categoryOptions,
onChanged: (v) {
item.assetCategoryId = v;
item.assetSubcategoryId = null;
onChanged();
},
),
AppSearchableDropdown<int>(
key: ValueKey('line_${item.lineNo}_asset_subcategory'),
label: 'Asset Subcategory',
value: _dropdownValue(item.assetSubcategoryId, subcategoryIds),
searchHint: 'Search subcategory...',
isDense: true,
enabled: hasCategory && subcategoryOptions.isNotEmpty,
hint: !hasCategory
? 'Select category first'
: subcategoryOptions.isEmpty
? 'No subcategories found'
: null,
options: subcategoryOptions,
onChanged: (v) {
item.assetSubcategoryId = v;
onChanged();
},
),
AppTextField(
label: 'Remarks',
controller: item.remarksController,
isDense: true,
maxLines: 2,
onChanged: (_) => onChanged(),
),
];
);
}
}
class _GrnLineDateField extends StatelessWidget {
const _GrnLineDateField({
super.key,
required this.label,
required this.value,
required this.onTap,
@ -460,8 +553,10 @@ class _GrnLineDateField extends StatelessWidget {
),
child: Text(
value != null ? DateFormatter.displayDate(value) : 'Select date',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: value != null ? null : Theme.of(context).hintColor,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: value != null
? null
: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
@ -470,6 +565,7 @@ class _GrnLineDateField extends StatelessWidget {
}
}
/// Read-only single-row line items table for GRN detail / edit view.
class GrnItemsTable extends StatelessWidget {
const GrnItemsTable({super.key, required this.items});
@ -477,70 +573,211 @@ class GrnItemsTable extends StatelessWidget {
@override
Widget build(BuildContext context) {
return AppDataTable<GrnItemModel>(
wrapInCard: false,
shrinkWrap: true,
emptyMessage: 'No line items',
columns: [
AppDataColumn(
label: '#',
flex: 1,
cellBuilder: (_, item) => Text('${item.lineNo ?? ''}'),
final theme = Theme.of(context);
final borderColor = theme.colorScheme.outline.withValues(alpha: 0.15);
if (items.isEmpty) {
return Text(
'No line items',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
AppDataColumn(
label: 'Item',
flex: 3,
cellBuilder: (_, item) => Text(item.itemName ?? item.itemCode ?? ''),
),
AppDataColumn(
label: 'Accepted',
flex: 1,
cellBuilder: (_, item) => Text(_formatQty(item.acceptedQty ?? 0)),
),
AppDataColumn(
label: 'Rejected',
flex: 1,
cellBuilder: (_, item) => Text(_formatQty(item.rejectedQty ?? 0)),
),
AppDataColumn(
label: 'Rate',
flex: 1,
cellBuilder: (_, item) => Text(
item.rate != null ? _formatQty(item.rate!) : '',
);
}
return LayoutBuilder(
builder: (context, constraints) {
final tableWidth =
constraints.maxWidth < 1100 ? 1100.0 : constraints.maxWidth;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SizedBox(
width: tableWidth,
child: Column(
children: [
_GrnItemsHeader(borderColor: borderColor),
...items.asMap().entries.map((entry) {
final index = entry.key;
final item = entry.value;
return _GrnItemRow(
item: item,
showDivider: index < items.length - 1,
borderColor: borderColor,
);
}),
],
),
),
),
AppDataColumn(
label: 'Batch',
flex: 1,
cellBuilder: (_, item) => Text(item.batchNo ?? ''),
),
AppDataColumn(
label: 'Mfg Date',
flex: 1,
cellBuilder: (_, item) => Text(DateFormatter.displayDate(item.mfgDate)),
),
AppDataColumn(
label: 'Expiry',
flex: 1,
cellBuilder: (_, item) => Text(DateFormatter.displayDate(item.expiryDate)),
),
AppDataColumn(
label: 'Storage',
flex: 1,
cellBuilder: (_, item) => Text(item.storageLocation ?? ''),
),
AppDataColumn(
label: 'Rejection Reason',
flex: 2,
cellBuilder: (_, item) => Text(item.rejectionReason ?? ''),
),
AppDataColumn(
label: 'Remarks',
flex: 2,
cellBuilder: (_, item) => Text(item.remarks ?? ''),
),
],
rows: items,
);
},
);
}
}
class _GrnItemsHeader extends StatelessWidget {
const _GrnItemsHeader({required this.borderColor});
final Color borderColor;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final style = theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
);
return Container(
padding: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: borderColor)),
),
child: Row(
children: [
SizedBox(width: 40, child: Text('#', style: style)),
Expanded(flex: 3, child: Text('ITEM', style: style)),
const SizedBox(width: 12),
SizedBox(
width: 72,
child: Text('ACCEPTED', style: style, textAlign: TextAlign.right),
),
const SizedBox(width: 12),
SizedBox(
width: 72,
child: Text('REJECTED', style: style, textAlign: TextAlign.right),
),
const SizedBox(width: 12),
SizedBox(
width: 72,
child: Text('CURRENT', style: style, textAlign: TextAlign.right),
),
const SizedBox(width: 12),
SizedBox(
width: 80,
child: Text('RATE', style: style, textAlign: TextAlign.right),
),
const SizedBox(width: 12),
SizedBox(width: 90, child: Text('BATCH', style: style)),
const SizedBox(width: 12),
SizedBox(width: 90, child: Text('MFG', style: style)),
const SizedBox(width: 12),
SizedBox(width: 90, child: Text('EXPIRY', style: style)),
const SizedBox(width: 12),
Expanded(flex: 2, child: Text('STORAGE', style: style)),
],
),
);
}
}
class _GrnItemRow extends StatelessWidget {
const _GrnItemRow({
required this.item,
required this.showDivider,
required this.borderColor,
});
final GrnItemModel item;
final bool showDivider;
final Color borderColor;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final body = theme.textTheme.bodyMedium;
final strong = body?.copyWith(fontWeight: FontWeight.w600);
return Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: showDivider
? BoxDecoration(
border: Border(bottom: BorderSide(color: borderColor)),
)
: null,
child: Row(
children: [
SizedBox(
width: 40,
child: Text('${item.lineNo ?? ''}', style: body),
),
Expanded(
flex: 3,
child: Text(
item.itemName ?? item.itemCode ?? '',
style: strong,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 12),
SizedBox(
width: 72,
child: Text(
_formatQty(item.acceptedQty ?? 0),
style: body,
textAlign: TextAlign.right,
),
),
const SizedBox(width: 12),
SizedBox(
width: 72,
child: Text(
_formatQty(item.rejectedQty ?? 0),
style: body,
textAlign: TextAlign.right,
),
),
const SizedBox(width: 12),
SizedBox(
width: 72,
child: Text(
_formatQty(item.currentQty ?? 0),
style: strong,
textAlign: TextAlign.right,
),
),
const SizedBox(width: 12),
SizedBox(
width: 80,
child: Text(
item.rate != null ? _formatQty(item.rate!) : '',
style: body,
textAlign: TextAlign.right,
),
),
const SizedBox(width: 12),
SizedBox(
width: 90,
child: Text(
item.batchNo ?? '',
style: body,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 12),
SizedBox(
width: 90,
child: Text(DateFormatter.displayDate(item.mfgDate), style: body),
),
const SizedBox(width: 12),
SizedBox(
width: 90,
child: Text(DateFormatter.displayDate(item.expiryDate), style: body),
),
const SizedBox(width: 12),
Expanded(
flex: 2,
child: Text(
item.storageLocation ?? '',
style: body,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}

View File

@ -84,7 +84,7 @@ class MasterCrudRemoteDataSource {
final result = await list(
definition,
page: page,
limit: AppConstants.maxPageSize,
limit: AppConstants.defaultPageSize,
);
allItems.addAll(
result.items.where((item) => item['is_active'] != false),

View File

@ -11,9 +11,12 @@ class MasterFieldDef {
this.type = MasterFieldType.text,
this.required = false,
this.showInList = false,
this.showInForm = true,
this.optionsMasterKey,
this.staticOptions,
this.multiline = false,
this.filterByFieldKey,
this.filterByOptionKey,
});
final String key;
@ -21,11 +24,17 @@ class MasterFieldDef {
final MasterFieldType type;
final bool required;
final bool showInList;
/// When false, field is list/display-only and excluded from create/update payloads.
final bool showInForm;
/// Master key used to populate dropdown options (e.g. `plants` for plant_id).
final String? optionsMasterKey;
/// Fixed dropdown choices (e.g. brand type) no API lookup.
final List<String>? staticOptions;
final bool multiline;
/// Form field whose value filters this dropdown (e.g. `item_category_id`).
final String? filterByFieldKey;
/// Option-row key matched against [filterByFieldKey] (defaults to same key).
final String? filterByOptionKey;
}
class MasterDefinition {
@ -54,7 +63,8 @@ class MasterDefinition {
List<MasterFieldDef> get listFields =>
fields.where((field) => field.showInList).toList();
List<MasterFieldDef> get formFields => fields;
List<MasterFieldDef> get formFields =>
fields.where((field) => field.showInForm).toList();
String listRoute(String base) => '$base/$routeKey';
String addRoute(String base) => '$base/$routeKey/add';
@ -109,6 +119,19 @@ const masterDefinitions = <MasterDefinition>[
fields: [
MasterFieldDef(key: 'code', label: 'Code', required: true, showInList: true),
MasterFieldDef(key: 'name', label: 'Name', required: true, showInList: true),
MasterFieldDef(key: 'code_prefix', label: 'Code Prefix', showInList: true),
MasterFieldDef(
key: 'default_useful_life_years',
label: 'Useful Life (Years)',
type: MasterFieldType.number,
),
MasterFieldDef(
key: 'default_depreciation_method',
label: 'Depreciation Method',
type: MasterFieldType.dropdown,
showInList: true,
optionsMasterKey: 'asset_depreciation_methods',
),
_activeField,
],
),
@ -145,7 +168,13 @@ const masterDefinitions = <MasterDefinition>[
module: 'items',
icon: Icons.inventory_outlined,
fields: [
MasterFieldDef(key: 'item_code', label: 'Item Code', required: true, showInList: true),
// Auto-generated by backend list only; never sent on create/update.
MasterFieldDef(
key: 'item_code',
label: 'Item Code',
showInList: true,
showInForm: false,
),
MasterFieldDef(key: 'item_name', label: 'Item Name', required: true, showInList: true),
MasterFieldDef(
key: 'item_category_id',
@ -159,6 +188,7 @@ const masterDefinitions = <MasterDefinition>[
label: 'Sub Category',
type: MasterFieldType.dropdown,
optionsMasterKey: 'item_subcategories',
filterByFieldKey: 'item_category_id',
),
MasterFieldDef(
key: 'uom_id',
@ -167,6 +197,13 @@ const masterDefinitions = <MasterDefinition>[
required: true,
optionsMasterKey: 'uom',
),
MasterFieldDef(
key: 'hsn_code_id',
label: 'HSN Code',
type: MasterFieldType.dropdown,
showInList: true,
optionsMasterKey: 'hsn_codes',
),
MasterFieldDef(
key: 'gst_rate_id',
label: 'GST Rate',
@ -202,6 +239,27 @@ const masterDefinitions = <MasterDefinition>[
_activeField,
],
),
MasterDefinition(
id: 'hsn_codes',
title: 'HSN Codes',
subtitle: 'HSN/SAC tax classification codes',
category: 'Finance & Terms',
routeKey: 'hsn-codes',
apiPath: '/masters/hsn-codes',
module: 'hsn_codes',
icon: Icons.qr_code_outlined,
fields: [
MasterFieldDef(key: 'code', label: 'HSN/SAC Code', required: true, showInList: true),
MasterFieldDef(
key: 'description',
label: 'Description',
required: true,
showInList: true,
multiline: true,
),
_activeField,
],
),
MasterDefinition(
id: 'brands',
title: 'Brands',
@ -330,59 +388,6 @@ const masterDefinitions = <MasterDefinition>[
_activeField,
],
),
MasterDefinition(
id: 'asset_categories',
title: 'Asset Categories',
subtitle: 'Fixed asset classification',
category: 'Assets',
routeKey: 'asset-categories',
apiPath: '/masters/asset-categories',
module: 'asset_categories',
icon: Icons.precision_manufacturing_outlined,
fields: [
MasterFieldDef(key: 'code', label: 'Code', required: true, showInList: true),
MasterFieldDef(key: 'name', label: 'Name', required: true, showInList: true),
MasterFieldDef(key: 'code_prefix', label: 'Code Prefix', required: true, showInList: true),
MasterFieldDef(
key: 'default_useful_life_years',
label: 'Useful Life (Years)',
type: MasterFieldType.number,
required: true,
),
MasterFieldDef(
key: 'default_depreciation_method',
label: 'Depreciation Method',
type: MasterFieldType.dropdown,
required: true,
showInList: true,
optionsMasterKey: 'asset_depreciation_methods',
),
_activeField,
],
),
MasterDefinition(
id: 'asset_subcategories',
title: 'Asset Subcategories',
subtitle: 'Sub-classification under asset categories',
category: 'Assets',
routeKey: 'asset-subcategories',
apiPath: '/masters/asset-subcategories',
module: 'asset_subcategories',
icon: Icons.category_outlined,
fields: [
MasterFieldDef(
key: 'asset_category_id',
label: 'Asset Category',
type: MasterFieldType.dropdown,
required: true,
showInList: true,
optionsMasterKey: 'asset_categories',
),
MasterFieldDef(key: 'code', label: 'Code', required: true, showInList: true),
MasterFieldDef(key: 'name', label: 'Name', required: true, showInList: true),
_activeField,
],
),
MasterDefinition(
id: 'delivery_terms',
title: 'Delivery Terms',
@ -455,16 +460,35 @@ String masterRecordLabel(Map<String, dynamic> row) {
? ratePct.toDouble()
: double.tryParse(ratePct.toString());
if (rate != null) {
return rate % 1 == 0 ? '${rate.toInt()}%' : '$rate%';
final rateLabel = rate % 1 == 0 ? '${rate.toInt()}%' : '$rate%';
final desc = row['description'];
if (desc != null && desc.toString().trim().isNotEmpty) {
return '$rateLabel${desc.toString().trim()}';
}
return rateLabel;
}
}
for (final key in ['name', 'item_name', 'code', 'item_code', 'description']) {
for (final key in ['name', 'item_name', 'item_code']) {
final value = row[key];
if (value != null && value.toString().trim().isNotEmpty) {
return value.toString();
}
}
final code = row['code'];
if (code != null && code.toString().trim().isNotEmpty) {
final desc = row['description'];
if (desc != null && desc.toString().trim().isNotEmpty) {
return '${code.toString().trim()}${desc.toString().trim()}';
}
return code.toString().trim();
}
final description = row['description'];
if (description != null && description.toString().trim().isNotEmpty) {
return description.toString().trim();
}
return row['id']?.toString() ?? 'Record';
}
@ -480,8 +504,8 @@ String masterCellValue(Map<String, dynamic> row, MasterFieldDef field) {
final label = row['${field.key}_label'];
if (label != null && label.toString().isNotEmpty) return label.toString();
// Prefer explicit "<base>_name" or nested "<base>.name" from API payloads
// (e.g. asset_category_id -> asset_category_name / asset_category.name)
// Prefer explicit "<base>_name" or nested "<base>.name" / flat code from API
// (e.g. asset_category_id -> asset_category_name; hsn_code_id -> hsn_code)
if (field.key.endsWith('_id')) {
final baseKey = field.key.substring(0, field.key.length - 3);
final explicitName = row['${baseKey}_name'];
@ -490,10 +514,16 @@ String masterCellValue(Map<String, dynamic> row, MasterFieldDef field) {
}
final nested = row[baseKey];
if (nested is Map) {
final nestedName = nested['name'];
if (nestedName != null && nestedName.toString().trim().isNotEmpty) {
return nestedName.toString().trim();
for (final nestedKey in ['code', 'name', 'description']) {
final nestedValue = nested[nestedKey];
if (nestedValue != null &&
nestedValue.toString().trim().isNotEmpty) {
return nestedValue.toString().trim();
}
}
} else if (nested != null && nested.toString().trim().isNotEmpty) {
// Flat denormalized value (e.g. items.hsn_code string)
return nested.toString().trim();
}
}

View File

@ -259,7 +259,7 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
final result = await ref.read(masterRepositoryProvider).list(
_definition,
page: page,
limit: AppConstants.maxPageSize,
limit: AppConstants.defaultPageSize,
);
if (result.failure != null) break;
@ -310,6 +310,27 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
if (current == null) return;
final values = Map<String, dynamic>.from(current.values);
values[key] = value;
// Clear dependent dropdowns when their parent filter value changes
// (e.g. item_category_id item_subcategory_id).
for (final field in _definition.formFields) {
if (field.filterByFieldKey != key) continue;
final dependentValue = values[field.key];
if (dependentValue == null || dependentValue == '') continue;
final optionKey = field.filterByOptionKey ?? field.filterByFieldKey!;
final options =
current.dropdownOptions[field.optionsMasterKey] ?? const [];
final stillValid = options.any(
(item) =>
item['id']?.toString() == dependentValue.toString() &&
item[optionKey]?.toString() == value?.toString(),
);
if (!stillValid) {
values[field.key] = null;
}
}
state = AsyncData(current.copyWith(values: values));
}

View File

@ -386,7 +386,7 @@ class _MasterListTable extends StatelessWidget {
int _columnFlex(MasterFieldDef field) {
return switch (field.key) {
'code' || 'item_code' || 'series_code' => 1,
'code' || 'item_code' || 'series_code' || 'hsn_code_id' => 1,
'name' || 'item_name' || 'description' || 'term_name' => 3,
_ => 2,
};

View File

@ -99,8 +99,20 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
if (field.staticOptions != null) {
dropdownOptions = stringDropdownOptions(field.staticOptions!);
} else {
final options = formState.dropdownOptions[field.optionsMasterKey] ??
var options = formState.dropdownOptions[field.optionsMasterKey] ??
const <Map<String, dynamic>>[];
final filterField = field.filterByFieldKey;
if (filterField != null) {
final parentId = formState.values[filterField]?.toString();
final optionKey = field.filterByOptionKey ?? filterField;
if (parentId == null || parentId.isEmpty) {
options = const [];
} else {
options = options
.where((item) => item[optionKey]?.toString() == parentId)
.toList();
}
}
dropdownOptions = <AppDropdownOption<String>>[];
for (final item in options) {
final id = item['id']?.toString();
@ -111,15 +123,37 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
}
}
final filterField = field.filterByFieldKey;
final parentSelected = filterField == null ||
(formState.values[filterField] != null &&
formState.values[filterField].toString().isNotEmpty);
final enabled = field.staticOptions != null
? true
: parentSelected && dropdownOptions.isNotEmpty;
String parentLabel = 'parent';
if (filterField != null) {
for (final f in _definition.formFields) {
if (f.key == filterField) {
parentLabel = f.label.toLowerCase();
break;
}
}
}
return AppSearchableDropdown<String>(
key: ValueKey(
'${field.key}-${filterField == null ? '' : formState.values[filterField]}',
),
label: _fieldLabel(field),
value: value?.toString(),
options: dropdownOptions,
hint: dropdownOptions.isEmpty
? 'No options available'
: 'Select ${field.label.toLowerCase()}',
hint: !parentSelected
? 'Select $parentLabel first'
: dropdownOptions.isEmpty
? 'No options available'
: 'Select ${field.label.toLowerCase()}',
searchHint: 'Search ${field.label.toLowerCase()}...',
enabled: dropdownOptions.isNotEmpty,
enabled: enabled,
onChanged: (selected) => notifier.updateValue(field.key, selected),
validator: field.required
? (v) => v == null ? '${field.label} is required' : null
@ -139,15 +173,29 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
);
case MasterFieldType.text:
final formatters = Validators.inputFormattersForFieldKey(field.key);
final isHsnCodeField =
widget.masterId == 'hsn_codes' && field.key == 'code';
final formatters = isHsnCodeField
? Validators.hsnCodeInput
: Validators.inputFormattersForFieldKey(field.key);
return TextFormField(
key: ValueKey(field.key),
initialValue: value?.toString(),
maxLines: field.multiline ? 3 : 1,
keyboardType: _keyboardTypeForFieldKey(field.key),
keyboardType: isHsnCodeField
? TextInputType.number
: _keyboardTypeForFieldKey(field.key),
inputFormatters: formatters.isEmpty ? null : formatters,
decoration: InputDecoration(labelText: _fieldLabel(field)),
validator: (v) {
if (isHsnCodeField) {
return Validators.uniqueHsnCode(
v,
existingRecords: formState.existingRecords,
currentRecordId: widget.recordId,
fieldName: field.label,
);
}
if (Validators.isMasterNameFieldKey(field.key)) {
return Validators.uniqueMasterName(
v,

View File

@ -44,62 +44,96 @@ class MasterRemoteDataSource {
Future<List<FilterOptionModel>> listGstRates() =>
_listOptions(ApiEndpoints.gstRates);
Future<List<FilterOptionModel>> listAssetCategories() =>
_listOptions(ApiEndpoints.assetCategories);
Future<List<FilterOptionModel>> listHsnCodes() =>
_listOptions(ApiEndpoints.hsnCodes);
Future<List<FilterOptionModel>> listAssetSubcategories({int? assetCategoryId}) async {
final response = await dio.get(
ApiEndpoints.assetSubcategories,
queryParameters: {
'limit': AppConstants.maxPageSize,
'is_active': true,
if (assetCategoryId != null) 'asset_category_id': assetCategoryId,
},
/// Active items with default HSN / UOM / GST for PO line autofill.
Future<
({
List<FilterOptionModel> options,
Map<String, int?> hsnByItemId,
Map<String, int?> uomByItemId,
Map<String, int?> gstRateByItemId,
})> listItemsWithHsn() async {
final rows = await _listAllMaps(
ApiEndpoints.items,
queryParameters: {'is_active': true},
);
final categoryFilter = assetCategoryId?.toString();
return _parseOptions(
response.data,
extraFilter: categoryFilter == null
? null
: (item) => item['asset_category_id']?.toString() == categoryFilter,
);
}
final hsnByItemId = <String, int?>{};
final uomByItemId = <String, int?>{};
final gstRateByItemId = <String, int?>{};
final options = <FilterOptionModel>[];
Future<List<FilterOptionModel>> _listOptions(String endpoint) async {
final response = await dio.get(
endpoint,
queryParameters: {
'limit': AppConstants.maxPageSize,
'is_active': true,
},
);
return _parseOptions(response.data);
}
for (final item in rows) {
if (item['is_active'] == false) continue;
final id = item['id']?.toString() ?? '';
if (id.isEmpty) continue;
/// Parses masters list payloads. Uses `is List` (not `List<dynamic>`) so
/// Flutter web JSON arrays are not dropped as empty.
List<FilterOptionModel> _parseOptions(
dynamic body, {
bool Function(Map<String, dynamic> item)? extraFilter,
}) {
if (body is! Map) return const [];
final raw = body['data'];
hsnByItemId[id] = _asInt(item['hsn_code_id']);
uomByItemId[id] = _asInt(item['uom_id']);
gstRateByItemId[id] = _asInt(item['gst_rate_id']);
final List list;
if (raw is List) {
list = raw;
} else if (raw is Map) {
final items = raw['items'];
list = items is List ? items : const [];
} else {
list = const [];
final name = _optionLabel(item);
if (name.isEmpty) continue;
options.add(FilterOptionModel(id: id, name: name));
}
return list
.whereType<Map>()
.map((item) => Map<String, dynamic>.from(item))
return (
options: options,
hsnByItemId: hsnByItemId,
uomByItemId: uomByItemId,
gstRateByItemId: gstRateByItemId,
);
}
/// GST rate options with numeric `rate_pct` for tax calculations.
Future<({List<FilterOptionModel> options, Map<String, double> pctById})>
listGstRatesWithPct() async {
final rows = await _listAllMaps(
ApiEndpoints.gstRates,
queryParameters: {'is_active': true},
);
final options = <FilterOptionModel>[];
final pctById = <String, double>{};
for (final item in rows) {
if (item['is_active'] == false) continue;
final id = item['id']?.toString() ?? '';
if (id.isEmpty) continue;
final pctRaw = item['rate_pct'];
final pct = pctRaw is num
? pctRaw.toDouble()
: double.tryParse(pctRaw?.toString() ?? '');
if (pct != null) pctById[id] = pct;
final name = _optionLabel(item);
if (name.isEmpty) continue;
options.add(FilterOptionModel(id: id, name: name));
}
return (options: options, pctById: pctById);
}
Future<List<FilterOptionModel>> listItemCategories() =>
_listOptions(ApiEndpoints.itemCategories);
Future<List<FilterOptionModel>> listItemSubcategories({int? itemCategoryId}) async {
final rows = await _listAllMaps(
ApiEndpoints.itemSubcategories,
queryParameters: {
'is_active': true,
if (itemCategoryId != null) 'item_category_id': itemCategoryId,
},
);
final categoryFilter = itemCategoryId?.toString();
return rows
.where((item) => item['is_active'] != false)
.where((item) => extraFilter == null || extraFilter(item))
.where(
(item) =>
categoryFilter == null ||
item['item_category_id']?.toString() == categoryFilter,
)
.map(
(item) => FilterOptionModel(
id: item['id']?.toString() ?? '',
@ -110,6 +144,101 @@ class MasterRemoteDataSource {
.toList();
}
Future<List<FilterOptionModel>> _listOptions(String endpoint) async {
final rows = await _listAllMaps(
endpoint,
queryParameters: {'is_active': true},
);
return rows
.where((item) => item['is_active'] != false)
.map(
(item) => FilterOptionModel(
id: item['id']?.toString() ?? '',
name: _optionLabel(item),
),
)
.where((item) => item.id.isNotEmpty && item.name.isNotEmpty)
.toList();
}
/// Loads every page of a masters list (API default page size = 20).
Future<List<Map<String, dynamic>>> _listAllMaps(
String endpoint, {
Map<String, dynamic>? queryParameters,
}) async {
final all = <Map<String, dynamic>>[];
var page = 1;
var totalPages = 1;
final pageSize = AppConstants.defaultPageSize;
while (page <= totalPages) {
final response = await dio.get(
endpoint,
queryParameters: {
'page': page,
'limit': pageSize,
...?queryParameters,
},
);
final parsed = _parsePage(response.data, fallbackLimit: pageSize);
all.addAll(parsed.items);
totalPages = parsed.totalPages;
if (parsed.items.isEmpty) break;
page++;
}
return all;
}
({List<Map<String, dynamic>> items, int totalPages}) _parsePage(
dynamic body, {
required int fallbackLimit,
}) {
if (body is! Map) {
return (items: const <Map<String, dynamic>>[], totalPages: 1);
}
final raw = body['data'];
final meta = body['meta'] is Map
? Map<String, dynamic>.from(body['meta'] as Map)
: <String, dynamic>{};
List list;
Map<String, dynamic> pageMeta = meta;
if (raw is List) {
list = raw;
} else if (raw is Map) {
final map = Map<String, dynamic>.from(raw);
final items = map['items'];
list = items is List ? items : const [];
pageMeta = {...meta, ...map};
} else {
list = const [];
}
final items = list
.whereType<Map>()
.map((item) => Map<String, dynamic>.from(item))
.toList();
final total = _asInt(pageMeta['total']) ?? items.length;
final limit = _asInt(pageMeta['limit']) ?? fallbackLimit;
final explicitTotalPages = _asInt(pageMeta['totalPages']) ??
_asInt(pageMeta['total_pages']);
final totalPages = explicitTotalPages ??
(limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1);
return (items: items, totalPages: totalPages < 1 ? 1 : totalPages);
}
int? _asInt(dynamic value) {
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value);
return null;
}
String _optionLabel(Map<String, dynamic> item) {
final ratePct = item['rate_pct'];
if (ratePct != null) {
@ -132,14 +261,26 @@ class MasterRemoteDataSource {
'item_name',
'term_name',
'vendor_name',
'code',
'description',
]) {
final value = item[key];
if (value is String && value.trim().isNotEmpty) {
return value.trim();
}
}
final code = item['code'];
if (code is String && code.trim().isNotEmpty) {
final desc = item['description'];
if (desc is String && desc.trim().isNotEmpty) {
return '${code.trim()}${desc.trim()}';
}
return code.trim();
}
final description = item['description'];
if (description is String && description.trim().isNotEmpty) {
return description.trim();
}
return '';
}
}

View File

@ -21,9 +21,16 @@ class PurchaseOrderRemoteDataSource {
Future<PurchaseOrderModel> getPurchaseOrderById(String id) async {
final response = await dio.get(ApiEndpoints.purchaseOrderById(id));
return PurchaseOrderModel.fromJson(
response.data['data'] as Map<String, dynamic>,
);
final raw = response.data['data'];
if (raw is! Map) {
throw StateError('Invalid purchase order detail response');
}
final map = Map<String, dynamic>.from(raw);
// Support alternate line-item key from API payloads.
if (map['items'] == null && map['line_items'] is List) {
map['items'] = map['line_items'];
}
return PurchaseOrderModel.fromJson(map);
}
Future<PurchaseOrderModel> createPurchaseOrder(Map<String, dynamic> data) async {

View File

@ -16,8 +16,13 @@ class PurchaseOrderLookups {
this.paymentTerms = const [],
this.deliveryTerms = const [],
this.items = const [],
this.itemHsnById = const {},
this.itemUomById = const {},
this.itemGstRateById = const {},
this.uom = const [],
this.gstRates = const [],
this.gstRatePctById = const {},
this.hsnCodes = const [],
});
final List<FilterOptionModel> vendors;
@ -27,8 +32,17 @@ class PurchaseOrderLookups {
final List<FilterOptionModel> paymentTerms;
final List<FilterOptionModel> deliveryTerms;
final List<FilterOptionModel> items;
/// Item id default `hsn_code_id` from item master.
final Map<String, int?> itemHsnById;
/// Item id default `uom_id` from item master.
final Map<String, int?> itemUomById;
/// Item id default `gst_rate_id` from item master.
final Map<String, int?> itemGstRateById;
final List<FilterOptionModel> uom;
final List<FilterOptionModel> gstRates;
/// GST rate id `rate_pct` for tax calculations.
final Map<String, double> gstRatePctById;
final List<FilterOptionModel> hsnCodes;
}
final purchaseOrderLookupsProvider =
@ -38,15 +52,17 @@ final purchaseOrderLookupsProvider =
final vendors = await _safeOptions(() => _fetchActiveVendors(vendorRepo));
final itemsWithDefaults = await _safeItemsWithDefaults(master.listItemsWithHsn);
final gstWithPct = await _safeGstRatesWithPct(master.listGstRatesWithPct);
final results = await Future.wait([
_safeOptions(master.listPlants),
_safeOptions(master.listWarehouses),
_safeOptions(master.listBrands),
_safeOptions(master.listPaymentTerms),
_safeOptions(master.listDeliveryTerms),
_safeOptions(master.listItems),
_safeOptions(master.listUom),
_safeOptions(master.listGstRates),
_safeOptions(master.listHsnCodes),
]);
return PurchaseOrderLookups(
@ -56,9 +72,14 @@ final purchaseOrderLookupsProvider =
brands: results[2],
paymentTerms: results[3],
deliveryTerms: results[4],
items: results[5],
uom: results[6],
gstRates: results[7],
items: itemsWithDefaults.options,
itemHsnById: itemsWithDefaults.hsnByItemId,
itemUomById: itemsWithDefaults.uomByItemId,
itemGstRateById: itemsWithDefaults.gstRateByItemId,
uom: results[5],
gstRates: gstWithPct.options,
gstRatePctById: gstWithPct.pctById,
hsnCodes: results[6],
);
});
@ -72,6 +93,48 @@ Future<List<FilterOptionModel>> _safeOptions(
}
}
Future<
({
List<FilterOptionModel> options,
Map<String, int?> hsnByItemId,
Map<String, int?> uomByItemId,
Map<String, int?> gstRateByItemId,
})> _safeItemsWithDefaults(
Future<
({
List<FilterOptionModel> options,
Map<String, int?> hsnByItemId,
Map<String, int?> uomByItemId,
Map<String, int?> gstRateByItemId,
})>
Function()
load,
) async {
try {
return await load();
} catch (_) {
return (
options: <FilterOptionModel>[],
hsnByItemId: <String, int?>{},
uomByItemId: <String, int?>{},
gstRateByItemId: <String, int?>{},
);
}
}
Future<({List<FilterOptionModel> options, Map<String, double> pctById})>
_safeGstRatesWithPct(
Future<({List<FilterOptionModel> options, Map<String, double> pctById})>
Function()
load,
) async {
try {
return await load();
} catch (_) {
return (options: <FilterOptionModel>[], pctById: <String, double>{});
}
}
Future<List<FilterOptionModel>> _fetchActiveVendors(
VendorRepository vendorRepo,
) async {

View File

@ -1,6 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../shared/models/purchase_order_model.dart';
import '../../../grn/presentation/providers/grn_lookups_provider.dart';
import '../../data/repositories/purchase_order_repository_impl.dart';
class PurchaseOrdersListState {
@ -131,6 +132,7 @@ class PurchaseOrdersListNotifier
}
return false;
}
ref.invalidate(grnLookupsProvider);
await refresh();
final current = state.valueOrNull;
if (current != null) {
@ -166,6 +168,7 @@ class PurchaseOrderDetailNotifier
if (result.failure != null) throw result.failure!;
state = AsyncData(result.data!);
ref.invalidate(purchaseOrdersListProvider);
ref.invalidate(grnLookupsProvider);
return result.data!;
}
@ -175,6 +178,7 @@ class PurchaseOrderDetailNotifier
if (result.failure != null) throw result.failure!;
state = AsyncData(result.data!);
ref.invalidate(purchaseOrdersListProvider);
ref.invalidate(grnLookupsProvider);
return result.data!;
}
@ -184,6 +188,7 @@ class PurchaseOrderDetailNotifier
if (result.failure != null) throw result.failure!;
state = AsyncData(result.data!);
ref.invalidate(purchaseOrdersListProvider);
ref.invalidate(grnLookupsProvider);
return result.data!;
}
@ -192,6 +197,7 @@ class PurchaseOrderDetailNotifier
final result = await repository.amendPurchaseOrder(arg);
if (result.failure != null) throw result.failure!;
ref.invalidate(purchaseOrdersListProvider);
ref.invalidate(grnLookupsProvider);
return result.data!;
}
@ -201,6 +207,7 @@ class PurchaseOrderDetailNotifier
if (result.failure != null) throw result.failure!;
state = AsyncData(result.data!);
ref.invalidate(purchaseOrdersListProvider);
ref.invalidate(grnLookupsProvider);
return result.data!;
}
@ -232,6 +239,7 @@ class PurchaseOrderFormNotifier extends FamilyAsyncNotifier<PurchaseOrderModel?,
final result = await repository.createPurchaseOrder(data);
if (result.failure != null) throw result.failure!;
ref.invalidate(purchaseOrdersListProvider);
ref.invalidate(grnLookupsProvider);
return result.data!;
}
@ -241,6 +249,7 @@ class PurchaseOrderFormNotifier extends FamilyAsyncNotifier<PurchaseOrderModel?,
if (result.failure != null) throw result.failure!;
ref.invalidate(purchaseOrdersListProvider);
ref.invalidate(purchaseOrderDetailProvider(id));
ref.invalidate(grnLookupsProvider);
return result.data!;
}
}

View File

@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
import '../../../../core/constants/route_constants.dart';
import '../../../../core/errors/failure.dart';
import '../../../../core/network/api_handler.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/utils/formatters.dart';
import '../../../../shared/models/purchase_order_model.dart';
import '../../../../shared/models/user_management_models.dart';
@ -16,10 +17,11 @@ import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/page_header.dart';
import '../../data/repositories/purchase_order_repository_impl.dart';
import '../../../grn/presentation/providers/grn_lookups_provider.dart';
import '../providers/purchase_order_lookups_provider.dart';
import '../providers/purchase_orders_provider.dart';
import '../widgets/po_status_chip.dart';
import '../widgets/purchase_order_line_items_editor.dart';
class PurchaseOrderFormScreen extends ConsumerStatefulWidget {
@ -37,9 +39,9 @@ class PurchaseOrderFormScreen extends ConsumerStatefulWidget {
class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScreen> {
final _formKey = GlobalKey<FormState>();
final _scrollController = ScrollController();
final _discountController = TextEditingController();
final _freightController = TextEditingController();
final _otherChargesController = TextEditingController();
final _discountController = TextEditingController(text: '0.00');
final _freightController = TextEditingController(text: '0.00');
final _otherChargesController = TextEditingController(text: '0.00');
final _termsController = TextEditingController();
final _remarksController = TextEditingController();
@ -63,10 +65,16 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
_poDate = DateTime.now();
_lines.add(PoLineItemDraft(lineNo: 1));
}
_discountController.addListener(_onChargesChanged);
_freightController.addListener(_onChargesChanged);
_otherChargesController.addListener(_onChargesChanged);
}
@override
void dispose() {
_discountController.removeListener(_onChargesChanged);
_freightController.removeListener(_onChargesChanged);
_otherChargesController.removeListener(_onChargesChanged);
_scrollController.dispose();
_discountController.dispose();
_freightController.dispose();
@ -79,6 +87,8 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
super.dispose();
}
void _onChargesChanged() => setState(() {});
String _orderSignature(PurchaseOrderModel order) =>
'${order.id}:${order.updatedAt?.toIso8601String()}:${order.items.length}';
@ -93,9 +103,12 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
_brandId = order.brandId;
_paymentTermId = order.paymentTermId;
_deliveryTermId = order.deliveryTermId;
_discountController.text = order.discountAmount?.toString() ?? '';
_freightController.text = order.freightCharges?.toString() ?? '';
_otherChargesController.text = order.otherCharges?.toString() ?? '';
_discountController.text =
(order.discountAmount ?? 0).toStringAsFixed(2);
_freightController.text =
(order.freightCharges ?? 0).toStringAsFixed(2);
_otherChargesController.text =
(order.otherCharges ?? 0).toStringAsFixed(2);
_termsController.text = order.termsAndConditions ?? '';
_remarksController.text = order.remarks ?? '';
for (final line in _lines) {
@ -145,9 +158,11 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
.toList();
}
List<AppDropdownOption<int?>> _nullableIntOptions(List<FilterOptionModel> options) {
List<AppDropdownOption<int?>> _nullableIntOptions(
List<FilterOptionModel> options,
) {
return [
const AppDropdownOption<int?>(value: null, label: 'None'),
const AppDropdownOption<int?>(value: null, label: ''),
...options.map(
(e) {
final id = _parseId(e.id);
@ -158,6 +173,17 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
].whereType<AppDropdownOption<int?>>().toList();
}
PoOrderTotals _computeTotals(Map<String, double> gstRatePctById) {
final lineCalcs =
_lines.map((line) => line.calculate(gstRatePctById)).toList();
return PoOrderTotals.compute(
lines: lineCalcs,
freight: double.tryParse(_freightController.text.trim()) ?? 0,
otherCharges: double.tryParse(_otherChargesController.text.trim()) ?? 0,
discountAmount: double.tryParse(_discountController.text.trim()) ?? 0,
);
}
Map<String, dynamic> _buildPayload() {
return {
'po_date': DateFormatter.toApiDate(_poDate ?? DateTime.now()),
@ -171,12 +197,12 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
if (_expectedDeliveryDate != null)
'expected_delivery_date':
DateFormatter.toApiDate(_expectedDeliveryDate!),
if (_discountController.text.trim().isNotEmpty)
'discount_amount': double.tryParse(_discountController.text.trim()),
if (_freightController.text.trim().isNotEmpty)
'freight_charges': double.tryParse(_freightController.text.trim()),
if (_otherChargesController.text.trim().isNotEmpty)
'other_charges': double.tryParse(_otherChargesController.text.trim()),
'discount_amount':
double.tryParse(_discountController.text.trim()) ?? 0,
'freight_charges':
double.tryParse(_freightController.text.trim()) ?? 0,
'other_charges':
double.tryParse(_otherChargesController.text.trim()) ?? 0,
if (_termsController.text.trim().isNotEmpty)
'terms_and_conditions': _termsController.text.trim(),
if (_remarksController.text.trim().isNotEmpty)
@ -251,6 +277,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
if (result.failure != null) throw result.failure!;
saved = result.data!;
ref.invalidate(purchaseOrdersListProvider);
ref.invalidate(grnLookupsProvider);
}
if (!mounted) return;
@ -294,6 +321,12 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
if (picked != null) onPicked(picked);
}
bool _showReapprovalWarning(PurchaseOrderModel? existing) {
if (existing == null) return false;
final s = existing.status.toUpperCase();
return s == 'SUBMITTED' || s == 'PENDING_APPROVAL' || s == 'PENDING';
}
Widget _buildFormBody({
required PurchaseOrderLookups lookups,
PurchaseOrderModel? existing,
@ -323,214 +356,345 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
lookups.vendors.map((e) => _parseId(e.id)).whereType<int>();
final plantIds =
lookups.plants.map((e) => _parseId(e.id)).whereType<int>();
final totals = _computeTotals(lookups.gstRatePctById);
final theme = Theme.of(context);
return SingleChildScrollView(
controller: _scrollController,
padding: const EdgeInsets.all(24),
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1200),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!widget.isEditing)
PageHeader(
title: 'Create Purchase Order',
subtitle: 'Fill header details and add line items',
)
else if (existing?.poNo != null)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
existing!.poNo!,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(existing),
if (_showReapprovalWarning(existing)) ...[
const SizedBox(height: 8),
_ReapprovalBanner(),
],
const SizedBox(height: 16),
_SectionCard(
title: 'ORDER DETAILS',
child: Column(
children: [
FormRowFour(
children: [
_DateField(
label: 'PO date *',
value: _poDate,
onTap: () => _pickDate(
current: _poDate,
onPicked: (d) => setState(() => _poDate = d),
),
),
AppSearchableDropdown<String>(
label: 'PO type *',
value: _poType,
hint: 'Select PO type',
searchHint: 'Search type...',
options: poTypeOptions
.map(
(e) => AppDropdownOption(
value: e.$1,
label: e.$2,
),
)
.toList(),
onChanged: (v) => setState(() => _poType = v),
validator: (v) =>
v == null ? 'PO type is required' : null,
),
AppSearchableDropdown<int>(
label: 'Vendor *',
value: _dropdownValue(_vendorId, vendorIds),
hint: 'Select vendor',
searchHint: 'Search vendor...',
options: _intOptions(lookups.vendors),
onChanged: (v) => setState(() => _vendorId = v),
validator: (v) =>
v == null ? 'Vendor is required' : null,
),
AppSearchableDropdown<int>(
label: 'Plant *',
value: _dropdownValue(_plantId, plantIds),
hint: 'Select plant',
searchHint: 'Search plant...',
options: _intOptions(lookups.plants),
onChanged: (v) => setState(() => _plantId = v),
validator: (v) =>
v == null ? 'Plant is required' : null,
),
],
),
FormRowFour(
children: [
AppSearchableDropdown<int?>(
label: 'Warehouse',
value: _warehouseId,
hint: 'Select warehouse',
searchHint: 'Search warehouse...',
options: _nullableIntOptions(lookups.warehouses),
onChanged: (v) =>
setState(() => _warehouseId = v),
),
AppSearchableDropdown<int?>(
label: 'Brand',
value: _brandId,
hint: 'Select brand',
searchHint: 'Search brand...',
options: _nullableIntOptions(lookups.brands),
onChanged: (v) => setState(() => _brandId = v),
),
AppSearchableDropdown<int?>(
label: 'Payment term',
value: _paymentTermId,
hint: 'Select payment term',
searchHint: 'Search payment term...',
options:
_nullableIntOptions(lookups.paymentTerms),
onChanged: (v) =>
setState(() => _paymentTermId = v),
),
AppSearchableDropdown<int?>(
label: 'Delivery term',
value: _deliveryTermId,
hint: 'Select delivery term',
searchHint: 'Search delivery term...',
options:
_nullableIntOptions(lookups.deliveryTerms),
onChanged: (v) =>
setState(() => _deliveryTermId = v),
),
],
),
FormRow(
columnCount: 4,
children: [
_DateField(
label: 'Expected delivery',
value: _expectedDeliveryDate,
onTap: () => _pickDate(
current: _expectedDeliveryDate,
onPicked: (d) => setState(
() => _expectedDeliveryDate = d,
),
),
),
],
),
],
),
),
Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
const SizedBox(height: 16),
PurchaseOrderLineItemsEditor(
lines: _lines,
items: lookups.items,
itemHsnById: lookups.itemHsnById,
itemUomById: lookups.itemUomById,
itemGstRateById: lookups.itemGstRateById,
uom: lookups.uom,
gstRates: lookups.gstRates,
gstRatePctById: lookups.gstRatePctById,
onAddLine: _addLine,
onRemoveLine: _removeLine,
onChanged: () => setState(() {}),
),
const SizedBox(height: 16),
LayoutBuilder(
builder: (context, constraints) {
final stack = constraints.maxWidth < 900;
final additional = _SectionCard(
title: 'ADDITIONAL DETAILS',
child: Column(
children: [
AppTextField(
controller: _termsController,
label: 'Terms & conditions',
hint: 'Payment terms, inspection conditions, etc.',
maxLines: 5,
),
AppTextField(
controller: _remarksController,
label: 'Remarks',
hint: 'Any additional notes for this order.',
maxLines: 4,
),
],
),
);
final summary = _AmountSummaryCard(
totals: totals,
freightController: _freightController,
otherChargesController: _otherChargesController,
discountController: _discountController,
isEditing: widget.isEditing,
);
if (stack) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
additional,
const SizedBox(height: 16),
summary,
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 3, child: additional),
const SizedBox(width: 16),
Expanded(flex: 2, child: summary),
],
);
},
),
const SizedBox(height: 20),
Row(
children: [
FormRowFour(
children: [
_DateField(
label: 'PO Date *',
value: _poDate,
onTap: () => _pickDate(
current: _poDate,
onPicked: (d) => setState(() => _poDate = d),
),
),
AppSearchableDropdown<String>(
label: 'PO Type *',
value: _poType,
searchHint: 'Search type...',
options: poTypeOptions
.map(
(e) =>
AppDropdownOption(value: e.$1, label: e.$2),
)
.toList(),
onChanged: (v) => setState(() => _poType = v),
validator: (v) =>
v == null ? 'PO type is required' : null,
),
AppSearchableDropdown<int>(
label: 'Vendor *',
value: _dropdownValue(_vendorId, vendorIds),
searchHint: 'Search vendor...',
options: _intOptions(lookups.vendors),
onChanged: (v) => setState(() => _vendorId = v),
validator: (v) =>
v == null ? 'Vendor is required' : null,
),
AppSearchableDropdown<int>(
label: 'Plant *',
value: _dropdownValue(_plantId, plantIds),
searchHint: 'Search plant...',
options: _intOptions(lookups.plants),
onChanged: (v) => setState(() => _plantId = v),
validator: (v) =>
v == null ? 'Plant is required' : null,
),
],
Text(
'Fields marked * are required',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
FormRowFour(
children: [
AppSearchableDropdown<int?>(
label: 'Warehouse',
value: _warehouseId,
searchHint: 'Search warehouse...',
options: _nullableIntOptions(lookups.warehouses),
onChanged: (v) => setState(() => _warehouseId = v),
),
AppSearchableDropdown<int?>(
label: 'Brand',
value: _brandId,
searchHint: 'Search brand...',
options: _nullableIntOptions(lookups.brands),
onChanged: (v) => setState(() => _brandId = v),
),
AppSearchableDropdown<int?>(
label: 'Payment Term',
value: _paymentTermId,
searchHint: 'Search payment term...',
options: _nullableIntOptions(lookups.paymentTerms),
onChanged: (v) => setState(() => _paymentTermId = v),
),
AppSearchableDropdown<int?>(
label: 'Delivery Term',
value: _deliveryTermId,
searchHint: 'Search delivery term...',
options: _nullableIntOptions(lookups.deliveryTerms),
onChanged: (v) => setState(() => _deliveryTermId = v),
),
],
),
FormRowFour(
children: [
_DateField(
label: 'Expected Delivery',
value: _expectedDeliveryDate,
onTap: () => _pickDate(
current: _expectedDeliveryDate,
onPicked: (d) =>
setState(() => _expectedDeliveryDate = d),
),
),
AppTextField(
controller: _discountController,
label: 'Discount Amount',
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
),
AppTextField(
controller: _freightController,
label: 'Freight Charges',
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
),
AppTextField(
controller: _otherChargesController,
label: 'Other Charges',
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
),
],
),
FormRowFour(
spans: const [2, 2],
children: [
AppTextField(
controller: _termsController,
label: 'Terms & Conditions',
maxLines: 3,
),
AppTextField(
controller: _remarksController,
label: 'Remarks',
maxLines: 3,
),
],
),
const SizedBox(height: 24),
PurchaseOrderLineItemsEditor(
lines: _lines,
items: lookups.items,
uom: lookups.uom,
gstRates: lookups.gstRates,
onAddLine: _addLine,
onRemoveLine: _removeLine,
),
const SizedBox(height: 24),
Row(
children: [
OutlinedButton(
onPressed: _isSubmitting
? null
: () => context.pop(),
child: const Text('Cancel'),
),
const SizedBox(width: 12),
Expanded(
child: AppButton(
label: widget.isEditing ? 'Update PO' : 'Create PO',
expand: false,
isLoading: _isSubmitting,
onPressed: _isSubmitting ? null : _submit,
),
),
],
const Spacer(),
Text(
widget.isEditing
? '${_lines.length} line item${_lines.length == 1 ? '' : 's'} · editing existing draft'
: '${_lines.length} line item${_lines.length == 1 ? '' : 's'} · draft not yet saved',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
],
],
),
),
),
),
);
}
Widget _buildHeader(PurchaseOrderModel? existing) {
final theme = Theme.of(context);
final title = widget.isEditing
? 'Edit ${existing?.poNo ?? 'purchase order'}'
: 'Create purchase order';
final subtitle = widget.isEditing
? null
: 'Fill in order details, add line items, then review the totals before saving.';
final actions = Row(
mainAxisSize: MainAxisSize.min,
children: [
OutlinedButton(
onPressed: _isSubmitting ? null : () => context.pop(),
child: const Text('Cancel'),
),
const SizedBox(width: 12),
AppButton(
label: widget.isEditing
? 'Update purchase order'
: 'Save purchase order',
icon: Icons.check,
expand: false,
isLoading: _isSubmitting,
onPressed: _isSubmitting ? null : _submit,
),
],
);
final titleBlock = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
Text(title, style: theme.textTheme.headlineSmall),
if (widget.isEditing && existing != null) ...[
PoStatusChip(status: existing.status, compact: true),
if (existing.revisionNo != null && existing.revisionNo! > 0)
PoRevisionChip(
revisionNo: existing.revisionNo!,
compact: true,
),
],
],
),
if (subtitle != null) ...[
const SizedBox(height: 4),
Text(
subtitle,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
],
);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: LayoutBuilder(
builder: (context, constraints) {
final stack = constraints.maxWidth < 720;
if (stack) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
IconButton(
tooltip: 'Back',
onPressed: _isSubmitting ? null : () => context.pop(),
icon: const Icon(Icons.arrow_back),
),
const SizedBox(width: 4),
Expanded(child: titleBlock),
],
),
const SizedBox(height: 12),
Align(alignment: Alignment.centerRight, child: actions),
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
IconButton(
tooltip: 'Back',
onPressed: _isSubmitting ? null : () => context.pop(),
icon: const Icon(Icons.arrow_back),
),
const SizedBox(width: 4),
Expanded(child: titleBlock),
const SizedBox(width: 12),
actions,
],
);
},
),
);
}
@override
Widget build(BuildContext context) {
final lookupsAsync = ref.watch(purchaseOrderLookupsProvider);
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.surface,
surfaceTintColor: Colors.transparent,
title: Text(
widget.isEditing ? 'Edit Purchase Order' : 'Create Purchase Order',
),
),
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: lookupsAsync.when(
loading: () => const AppLoadingView(message: 'Loading form options...'),
error: (e, _) => ErrorView.fromFailure(
@ -562,6 +726,264 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
}
}
class _SectionCard extends StatelessWidget {
const _SectionCard({
required this.title,
required this.child,
});
final String title;
final Widget child;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final cardColor = isDark
? theme.colorScheme.surfaceContainerHighest
: theme.colorScheme.surface;
final borderColor = theme.colorScheme.outline.withValues(
alpha: isDark ? 0.35 : 0.2,
);
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: cardColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: borderColor),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
title,
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
letterSpacing: 0.8,
),
),
const SizedBox(height: 12),
child,
],
),
);
}
}
class _AmountSummaryCard extends StatelessWidget {
const _AmountSummaryCard({
required this.totals,
required this.freightController,
required this.otherChargesController,
required this.discountController,
required this.isEditing,
});
final PoOrderTotals totals;
final TextEditingController freightController;
final TextEditingController otherChargesController;
final TextEditingController discountController;
final bool isEditing;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final discountValue = double.tryParse(discountController.text.trim()) ?? 0;
final isDark = theme.brightness == Brightness.dark;
final primaryTint = theme.colorScheme.primary.withValues(
alpha: isDark ? 0.22 : 0.1,
);
return _SectionCard(
title: 'AMOUNT SUMMARY',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_SummaryReadOnlyRow(
label: 'Taxable amount',
value: CurrencyFormatter.format(totals.taxableAmount),
),
const SizedBox(height: 10),
_SummaryReadOnlyRow(
label: 'Tax (GST)',
value: CurrencyFormatter.format(totals.taxAmount),
),
const SizedBox(height: 4),
_SummaryInputRow(
label: 'Freight charges',
controller: freightController,
),
_SummaryInputRow(
label: 'Other charges',
controller: otherChargesController,
),
_SummaryInputRow(
label: 'Discount amount',
controller: discountController,
valueColor: discountValue > 0 ? AppColors.error : null,
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: primaryTint,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Text(
'Grand total',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const Spacer(),
Text(
CurrencyFormatter.format(totals.grandTotal),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
color: theme.colorScheme.primary,
),
),
],
),
),
const SizedBox(height: 12),
Text(
isEditing
? 'Editing charges or discount recalculates the grand total immediately — matches what will print on the PDF.'
: 'Taxable amount and tax are calculated automatically from line items. Grand total updates as you edit freight, other charges or discount.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
height: 1.4,
),
),
],
),
);
}
}
class _SummaryReadOnlyRow extends StatelessWidget {
const _SummaryReadOnlyRow({
required this.label,
required this.value,
});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: [
Expanded(
child: Text(
label,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Text(
value,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
);
}
}
class _SummaryInputRow extends StatelessWidget {
const _SummaryInputRow({
required this.label,
required this.controller,
this.valueColor,
});
final String label;
final TextEditingController controller;
final Color? valueColor;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(
children: [
Expanded(
child: Text(
label,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
SizedBox(
width: 120,
child: TextFormField(
controller: controller,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
textAlign: TextAlign.right,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: valueColor,
),
decoration: InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 10,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
),
);
}
}
class _ReapprovalBanner extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: const Color(0xFFFFF4E5),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFFFCC80)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.warning_amber_rounded, color: Colors.orange.shade800),
const SizedBox(width: 10),
Expanded(
child: Text(
'This order is already Pending approval. Saving changes will reset it to Draft and require re-approval.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.orange.shade900,
),
),
),
],
),
);
}
}
class _DateField extends StatelessWidget {
const _DateField({
required this.label,
@ -584,10 +1006,15 @@ class _DateField extends StatelessWidget {
decoration: InputDecoration(
labelText: label,
floatingLabelBehavior: FloatingLabelBehavior.always,
suffixIcon: const Icon(Icons.calendar_today_outlined),
suffixIcon: const Icon(Icons.calendar_today_outlined, size: 18),
),
child: Text(
value != null ? DateFormatter.displayDate(value) : 'Select date',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: value == null
? Theme.of(context).colorScheme.onSurfaceVariant
: null,
),
),
),
),

View File

@ -169,6 +169,8 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
}
void _viewOrder(PurchaseOrderModel order) {
// Always hit GET /purchase-orders/{id} for the detail screen.
ref.invalidate(purchaseOrderDetailProvider(order.id));
context.push('${RouteConstants.purchaseOrders}/${order.id}');
}

View File

@ -15,29 +15,29 @@ class PoStatusChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
final (color, label) = _resolveStatus(status);
return _PoChip(label: label, color: color, compact: compact);
return _PoBadge(label: label, color: color, compact: compact);
}
(Color, String) _resolveStatus(String raw) {
switch (raw.toUpperCase()) {
case 'DRAFT':
return (Colors.blueGrey.shade700, poStatusLabel(raw));
return (const Color(0xFF546E7A), poStatusLabel(raw));
case 'SUBMITTED':
case 'PENDING_APPROVAL':
case 'PENDING':
return (Colors.orange.shade800, poStatusLabel(raw));
return (const Color(0xFFE65100), poStatusLabel(raw));
case 'APPROVED':
return (Colors.green.shade700, poStatusLabel(raw));
return (const Color(0xFF2E7D32), poStatusLabel(raw));
case 'REJECTED':
return (Colors.red.shade700, poStatusLabel(raw));
return (const Color(0xFFC62828), poStatusLabel(raw));
case 'CANCELLED':
return (Colors.grey.shade700, poStatusLabel(raw));
return (const Color(0xFF616161), poStatusLabel(raw));
case 'PARTIALLY_RECEIVED':
return (Colors.teal.shade700, poStatusLabel(raw));
return (const Color(0xFF00695C), poStatusLabel(raw));
case 'FULLY_RECEIVED':
return (Colors.indigo.shade700, poStatusLabel(raw));
return (const Color(0xFF283593), poStatusLabel(raw));
default:
return (Colors.blueGrey, poStatusLabel(raw));
return (const Color(0xFF546E7A), poStatusLabel(raw));
}
}
}
@ -54,17 +54,16 @@ class PoRevisionChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
final color = Theme.of(context).colorScheme.primary;
return _PoChip(
return _PoBadge(
label: 'Revision $revisionNo',
color: color,
color: const Color(0xFF607D8B),
compact: compact,
);
}
}
class _PoChip extends StatelessWidget {
const _PoChip({
class _PoBadge extends StatelessWidget {
const _PoBadge({
required this.label,
required this.color,
this.compact = false,
@ -76,26 +75,29 @@ class _PoChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Chip(
label: Text(
label,
style: TextStyle(
color: color,
fontSize: compact ? 11 : 12,
fontWeight: FontWeight.w600,
height: 1.2,
// Avoid Container.alignment it expands to max width inside Wrap/Row
// and turns the chip into a full-width bar under the PO number.
return Container(
height: compact ? 22 : 26,
padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 10),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: color.withValues(alpha: 0.28)),
),
child: Center(
widthFactor: 1,
child: Text(
label,
style: TextStyle(
color: color,
fontSize: compact ? 11 : 12,
fontWeight: FontWeight.w600,
height: 1,
letterSpacing: 0.1,
),
),
),
backgroundColor: color.withValues(alpha: 0.12),
side: BorderSide(color: color.withValues(alpha: 0.3)),
visualDensity: compact ? VisualDensity.compact : VisualDensity.standard,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: compact
? EdgeInsets.zero
: const EdgeInsets.symmetric(horizontal: 4),
labelPadding: compact
? EdgeInsets.zero
: const EdgeInsets.symmetric(horizontal: 4),
);
}
}

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../../../../core/utils/formatters.dart';
import '../../../../shared/models/purchase_order_model.dart';
import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/widgets/app_dropdown.dart';
@ -7,6 +8,92 @@ import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/app_text_field.dart';
/// Per-line amount breakdown for PO calculations.
class PoLineCalculation {
const PoLineCalculation({
required this.baseAmount,
required this.discountAmount,
required this.lineAmount,
required this.gstAmount,
});
final double baseAmount;
final double discountAmount;
final double lineAmount;
final double gstAmount;
static const zero = PoLineCalculation(
baseAmount: 0,
discountAmount: 0,
lineAmount: 0,
gstAmount: 0,
);
/// 3.1 Base = Qty × Rate
/// 3.2 Discount = Base × Disc% ÷ 100
/// 3.3 Line Amount = Base Discount
/// 3.4 GST Amount = Line Amount × GST% ÷ 100
factory PoLineCalculation.compute({
required double qty,
required double rate,
required double discPct,
required double gstPct,
}) {
final baseAmount = qty * rate;
final discountAmount = baseAmount * discPct / 100;
final lineAmount = baseAmount - discountAmount;
final gstAmount = lineAmount * gstPct / 100;
return PoLineCalculation(
baseAmount: baseAmount,
discountAmount: discountAmount,
lineAmount: lineAmount,
gstAmount: gstAmount,
);
}
}
/// Order-level totals derived from line calculations + header charges.
class PoOrderTotals {
const PoOrderTotals({
required this.taxableAmount,
required this.taxAmount,
required this.grandTotal,
});
final double taxableAmount;
final double taxAmount;
final double grandTotal;
static const zero = PoOrderTotals(
taxableAmount: 0,
taxAmount: 0,
grandTotal: 0,
);
/// 3.5 Taxable = sum of Line Amounts
/// 3.6 Tax = sum of GST Amounts
/// 3.7 Grand Total = Taxable + Tax + Freight + Other Discount
factory PoOrderTotals.compute({
required Iterable<PoLineCalculation> lines,
required double freight,
required double otherCharges,
required double discountAmount,
}) {
var taxable = 0.0;
var tax = 0.0;
for (final line in lines) {
taxable += line.lineAmount;
tax += line.gstAmount;
}
final grandTotal = taxable + tax + freight + otherCharges - discountAmount;
return PoOrderTotals(
taxableAmount: taxable,
taxAmount: tax,
grandTotal: grandTotal,
);
}
}
class PoLineItemDraft {
PoLineItemDraft({
this.itemId,
@ -16,11 +103,11 @@ class PoLineItemDraft {
TextEditingController? rateController,
TextEditingController? discountController,
this.gstRateId,
TextEditingController? remarksController,
this.hsnCodeId,
}) : qtyController = qtyController ?? TextEditingController(),
rateController = rateController ?? TextEditingController(),
discountController = discountController ?? TextEditingController(text: '0'),
remarksController = remarksController ?? TextEditingController();
discountController =
discountController ?? TextEditingController(text: '0');
int? itemId;
int lineNo;
@ -29,19 +116,20 @@ class PoLineItemDraft {
final TextEditingController rateController;
final TextEditingController discountController;
int? gstRateId;
final TextEditingController remarksController;
int? hsnCodeId;
factory PoLineItemDraft.fromModel(PurchaseOrderItemModel item) {
return PoLineItemDraft(
itemId: item.itemId,
lineNo: item.lineNo ?? 1,
qtyController: TextEditingController(text: item.orderedQty?.toString() ?? ''),
qtyController:
TextEditingController(text: item.orderedQty?.toString() ?? ''),
uomId: item.uomId,
rateController: TextEditingController(text: item.rate?.toString() ?? ''),
discountController:
TextEditingController(text: item.discountPct?.toString() ?? '0'),
gstRateId: item.gstRateId,
remarksController: TextEditingController(text: item.remarks ?? ''),
hsnCodeId: item.hsnCodeId,
);
}
@ -49,7 +137,21 @@ class PoLineItemDraft {
qtyController.dispose();
rateController.dispose();
discountController.dispose();
remarksController.dispose();
}
PoLineCalculation calculate(Map<String, double> gstRatePctById) {
final qty = double.tryParse(qtyController.text.trim()) ?? 0;
final rate = double.tryParse(rateController.text.trim()) ?? 0;
final discPct = double.tryParse(discountController.text.trim()) ?? 0;
final gstPct = gstRateId == null
? 0.0
: (gstRatePctById[gstRateId.toString()] ?? 0.0);
return PoLineCalculation.compute(
qty: qty,
rate: rate,
discPct: discPct,
gstPct: gstPct,
);
}
Map<String, dynamic> toPayload() {
@ -66,8 +168,7 @@ class PoLineItemDraft {
'rate': rate,
'discount_pct': double.tryParse(discountController.text.trim()) ?? 0,
if (gstRateId != null) 'gst_rate_id': gstRateId,
if (remarksController.text.trim().isNotEmpty)
'remarks': remarksController.text.trim(),
if (hsnCodeId != null) 'hsn_code_id': hsnCodeId,
};
}
}
@ -77,79 +178,134 @@ class PurchaseOrderLineItemsEditor extends StatefulWidget {
super.key,
required this.lines,
required this.items,
required this.itemHsnById,
required this.itemUomById,
required this.itemGstRateById,
required this.uom,
required this.gstRates,
required this.gstRatePctById,
required this.onAddLine,
required this.onRemoveLine,
this.onChanged,
});
final List<PoLineItemDraft> lines;
final List<FilterOptionModel> items;
final Map<String, int?> itemHsnById;
final Map<String, int?> itemUomById;
final Map<String, int?> itemGstRateById;
final List<FilterOptionModel> uom;
final List<FilterOptionModel> gstRates;
final Map<String, double> gstRatePctById;
final VoidCallback onAddLine;
final ValueChanged<int> onRemoveLine;
final VoidCallback? onChanged;
@override
State<PurchaseOrderLineItemsEditor> createState() =>
_PurchaseOrderLineItemsEditorState();
}
class _PurchaseOrderLineItemsEditorState extends State<PurchaseOrderLineItemsEditor> {
class _PurchaseOrderLineItemsEditorState
extends State<PurchaseOrderLineItemsEditor> {
void _notifyChanged() {
widget.onChanged?.call();
setState(() {});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final cardColor = isDark
? theme.colorScheme.surfaceContainerHighest
: theme.colorScheme.surface;
final borderColor = theme.colorScheme.outline.withValues(
alpha: isDark ? 0.35 : 0.2,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text('Line Items', style: theme.textTheme.titleMedium),
const Spacer(),
TextButton.icon(
onPressed: widget.onAddLine,
icon: const Icon(Icons.add),
label: const Text('Add line'),
),
],
),
const SizedBox(height: 8),
if (widget.lines.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
border: Border.all(color: theme.dividerColor),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Add at least one line item',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: cardColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: borderColor),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Text(
'LINE ITEMS',
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
letterSpacing: 0.8,
),
),
textAlign: TextAlign.center,
),
)
else
...widget.lines.asMap().entries.map((entry) {
final index = entry.key;
final line = entry.value;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _LineItemCard(
key: ObjectKey(line),
line: line,
items: widget.items,
uom: widget.uom,
gstRates: widget.gstRates,
onRemove: widget.lines.length > 1
? () => widget.onRemoveLine(index)
: null,
const Spacer(),
TextButton.icon(
onPressed: () {
widget.onAddLine();
widget.onChanged?.call();
},
icon: const Icon(Icons.add, size: 18),
label: const Text('Add line'),
style: TextButton.styleFrom(
foregroundColor: theme.colorScheme.primary,
),
),
);
}),
],
],
),
const SizedBox(height: 8),
if (widget.lines.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
border: Border.all(color: borderColor),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Add at least one line item',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
)
else
...widget.lines.asMap().entries.map((entry) {
final index = entry.key;
final line = entry.value;
return Padding(
padding: EdgeInsets.only(
bottom: index == widget.lines.length - 1 ? 0 : 12,
),
child: _LineItemCard(
key: ObjectKey(line),
line: line,
items: widget.items,
itemHsnById: widget.itemHsnById,
itemUomById: widget.itemUomById,
itemGstRateById: widget.itemGstRateById,
uom: widget.uom,
gstRates: widget.gstRates,
gstRatePctById: widget.gstRatePctById,
onChanged: _notifyChanged,
onRemove: widget.lines.length > 1
? () {
widget.onRemoveLine(index);
widget.onChanged?.call();
}
: null,
),
);
}),
],
),
);
}
}
@ -159,15 +315,25 @@ class _LineItemCard extends StatefulWidget {
super.key,
required this.line,
required this.items,
required this.itemHsnById,
required this.itemUomById,
required this.itemGstRateById,
required this.uom,
required this.gstRates,
required this.gstRatePctById,
required this.onChanged,
this.onRemove,
});
final PoLineItemDraft line;
final List<FilterOptionModel> items;
final Map<String, int?> itemHsnById;
final Map<String, int?> itemUomById;
final Map<String, int?> itemGstRateById;
final List<FilterOptionModel> uom;
final List<FilterOptionModel> gstRates;
final Map<String, double> gstRatePctById;
final VoidCallback onChanged;
final VoidCallback? onRemove;
@override
@ -175,18 +341,69 @@ class _LineItemCard extends StatefulWidget {
}
class _LineItemCardState extends State<_LineItemCard> {
@override
void initState() {
super.initState();
widget.line.qtyController.addListener(_onFieldChanged);
widget.line.rateController.addListener(_onFieldChanged);
widget.line.discountController.addListener(_onFieldChanged);
}
@override
void dispose() {
widget.line.qtyController.removeListener(_onFieldChanged);
widget.line.rateController.removeListener(_onFieldChanged);
widget.line.discountController.removeListener(_onFieldChanged);
super.dispose();
}
void _onFieldChanged() {
widget.onChanged();
setState(() {});
}
int? _parseId(String value) => int.tryParse(value.trim());
void _updateLine(void Function() mutate) {
mutate();
widget.onChanged();
setState(() {});
}
void _onItemChanged(int? itemId) {
_updateLine(() {
widget.line.itemId = itemId;
if (itemId == null) return;
final key = itemId.toString();
final defaultUom = widget.itemUomById[key];
if (defaultUom != null) {
widget.line.uomId = defaultUom;
}
final defaultGst = widget.itemGstRateById[key];
if (defaultGst != null) {
widget.line.gstRateId = defaultGst;
}
final defaultHsn = widget.itemHsnById[key];
if (defaultHsn != null) {
widget.line.hsnCodeId = defaultHsn;
}
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final line = widget.line;
final lineKey = 'po-line-${line.lineNo}';
final calc = line.calculate(widget.gstRatePctById);
final isDark = theme.brightness == Brightness.dark;
final borderColor = theme.colorScheme.outline.withValues(
alpha: isDark ? 0.35 : 0.18,
);
final amountBg = theme.colorScheme.primary.withValues(
alpha: isDark ? 0.18 : 0.08,
);
final itemOptions = widget.items
.map((e) {
final id = _parseId(e.id);
@ -204,7 +421,7 @@ class _LineItemCardState extends State<_LineItemCard> {
.whereType<AppDropdownOption<int>>()
.toList();
final gstOptions = [
const AppDropdownOption<int?>(value: null, label: 'No GST'),
const AppDropdownOption<int?>(value: null, label: 'Select GST rate'),
...widget.gstRates.map((e) {
final id = _parseId(e.id);
if (id == null) return null;
@ -213,116 +430,179 @@ class _LineItemCardState extends State<_LineItemCard> {
].whereType<AppDropdownOption<int?>>().toList();
return Container(
padding: const EdgeInsets.symmetric(vertical: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: theme.dividerColor),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: borderColor),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
child: FormRow(
columnCount: 12,
spans: const [3, 1, 2, 1, 1, 2, 2],
spacing: 8,
stackBelowWidth: 1100,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Text('Line ${line.lineNo}', style: theme.textTheme.titleSmall),
const Spacer(),
if (widget.onRemove != null)
IconButton(
tooltip: 'Remove line',
icon: const Icon(Icons.delete_outline),
onPressed: widget.onRemove,
),
],
),
AppSearchableDropdown<int>(
key: ValueKey('$lineKey-item'),
label: 'Item *',
value: line.itemId,
hint: 'Select item',
searchHint: 'Search item...',
options: itemOptions,
onChanged: _onItemChanged,
validator: (v) => v == null ? 'Item is required' : null,
),
const SizedBox(height: 12),
FormRow(
columnCount: 6,
horizontalPadding: 16,
spacing: 8,
stackBelowWidth: 992,
children: [
AppSearchableDropdown<int>(
key: ValueKey('$lineKey-item'),
label: 'Item *',
value: line.itemId,
searchHint: 'Search item...',
options: itemOptions,
onChanged: (v) => _updateLine(() => line.itemId = v),
validator: (v) => v == null ? 'Item is required' : null,
),
AppTextField(
key: ValueKey('$lineKey-qty'),
controller: line.qtyController,
label: 'Quantity *',
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
validator: (v) {
if (v == null || v.trim().isEmpty) {
return 'Quantity is required';
}
final qty = double.tryParse(v);
if (qty == null || qty <= 0) return 'Enter a valid quantity';
return null;
},
),
AppSearchableDropdown<int>(
key: ValueKey('$lineKey-uom'),
label: 'UOM *',
value: line.uomId,
searchHint: 'Search UOM...',
options: uomOptions,
onChanged: (v) => _updateLine(() => line.uomId = v),
validator: (v) => v == null ? 'UOM is required' : null,
),
AppTextField(
key: ValueKey('$lineKey-rate'),
controller: line.rateController,
label: 'Rate *',
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
validator: (v) {
if (v == null || v.trim().isEmpty) return 'Rate is required';
final rate = double.tryParse(v);
if (rate == null || rate < 0) return 'Enter a valid rate';
return null;
},
),
AppTextField(
key: ValueKey('$lineKey-discount'),
controller: line.discountController,
label: 'Discount %',
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
),
AppSearchableDropdown<int?>(
key: ValueKey('$lineKey-gst'),
label: 'GST Rate',
value: line.gstRateId,
searchHint: 'Search GST rate...',
options: gstOptions,
onChanged: (v) => _updateLine(() => line.gstRateId = v),
),
],
AppTextField(
key: ValueKey('$lineKey-qty'),
controller: line.qtyController,
label: 'Qty *',
hint: '0',
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
validator: (v) {
if (v == null || v.trim().isEmpty) {
return 'Required';
}
final qty = double.tryParse(v);
if (qty == null || qty <= 0) return 'Invalid';
return null;
},
),
FormRow(
columnCount: 6,
spans: const [6],
horizontalPadding: 16,
spacing: 8,
stackBelowWidth: 992,
children: [
AppTextField(
key: ValueKey('$lineKey-remarks'),
controller: line.remarksController,
label: 'Remarks',
maxLines: 2,
),
],
AppSearchableDropdown<int>(
key: ValueKey('$lineKey-uom'),
label: 'UOM *',
value: line.uomId,
hint: 'Select UOM',
searchHint: 'Search UOM...',
options: uomOptions,
onChanged: (v) => _updateLine(() => line.uomId = v),
validator: (v) => v == null ? 'Required' : null,
),
AppTextField(
key: ValueKey('$lineKey-rate'),
controller: line.rateController,
label: 'Rate *',
hint: '0.00',
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
validator: (v) {
if (v == null || v.trim().isEmpty) return 'Required';
final rate = double.tryParse(v);
if (rate == null || rate < 0) return 'Invalid';
return null;
},
),
AppTextField(
key: ValueKey('$lineKey-discount'),
controller: line.discountController,
label: 'Disc %',
hint: '0',
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
),
AppSearchableDropdown<int?>(
key: ValueKey('$lineKey-gst'),
label: 'GST rate',
value: line.gstRateId,
hint: 'Select GST rate',
searchHint: 'Search GST rate...',
options: gstOptions,
onChanged: (v) => _updateLine(() => line.gstRateId = v),
),
_AmountWithRemove(
amount: CurrencyFormatter.format(calc.lineAmount),
backgroundColor: amountBg,
onRemove: widget.onRemove,
),
],
),
);
}
}
class _AmountWithRemove extends StatelessWidget {
const _AmountWithRemove({
required this.amount,
required this.backgroundColor,
this.onRemove,
});
final String amount;
final Color backgroundColor;
final VoidCallback? onRemove;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _AmountDisplay(
label: 'Amount',
value: amount,
backgroundColor: backgroundColor,
),
),
if (onRemove != null) ...[
const SizedBox(width: 4),
Padding(
padding: const EdgeInsets.only(top: 20),
child: IconButton(
tooltip: 'Remove line',
onPressed: onRemove,
icon: const Icon(Icons.delete_outline, size: 20),
color: theme.colorScheme.error,
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
padding: EdgeInsets.zero,
),
),
],
],
);
}
}
class _AmountDisplay extends StatelessWidget {
const _AmountDisplay({
required this.label,
required this.value,
required this.backgroundColor,
});
final String label;
final String value;
final Color backgroundColor;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(top: 8),
child: InputDecorator(
decoration: InputDecoration(
labelText: label,
floatingLabelBehavior: FloatingLabelBehavior.always,
filled: true,
fillColor: backgroundColor,
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: theme.colorScheme.primary.withValues(alpha: 0.2),
),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Text(
value,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface,
),
),
),
);
}
}

View File

@ -1,6 +1,8 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../../../../shared/widgets/app_data_table.dart';
import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/widgets/app_card.dart';
@ -221,12 +223,13 @@ class EmployeeCodeBadge extends StatelessWidget {
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(20),
),
child: Text(
child: AppTableCell.text(
code,
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurfaceVariant,
),
showTooltip: true,
),
);
}
@ -246,24 +249,24 @@ class UserTableUserCell extends StatelessWidget {
UserAvatarChip(
name: user.fullName,
initials: user.initialsDisplay,
radius: 16,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text(
AppTableCell.text(
user.fullName,
style: const TextStyle(fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis,
),
Text(
AppTableCell.text(
user.email,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
overflow: TextOverflow.ellipsis,
),
],
),
@ -274,10 +277,16 @@ class UserTableUserCell extends StatelessWidget {
}
class UserAvatarChip extends StatelessWidget {
const UserAvatarChip({super.key, required this.name, this.initials});
const UserAvatarChip({
super.key,
required this.name,
this.initials,
this.radius = 20,
});
final String name;
final String? initials;
final double radius;
@override
Widget build(BuildContext context) {
@ -293,14 +302,14 @@ class UserAvatarChip extends StatelessWidget {
(name.isNotEmpty ? name.trim()[0].toUpperCase() : 'U');
return CircleAvatar(
radius: 20,
radius: radius,
backgroundColor: color.withValues(alpha: 0.12),
child: Text(
display.length > 2 ? display.substring(0, 2) : display,
style: TextStyle(
color: color,
fontWeight: FontWeight.w700,
fontSize: 12,
fontSize: radius * 0.6,
),
),
);

View File

@ -1,4 +1,5 @@
import '../../../../shared/widgets/app_card.dart';
import '../../../../shared/widgets/app_data_table.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -98,7 +99,7 @@ class _MatrixGrid extends ConsumerWidget {
.map(
(row) => DataRow(
cells: [
DataCell(Text(row.name)),
DataCell(AppTableCell.text(row.name)),
...matrix.actionColumns.map(
(action) => DataCell(
_actionToggle(

View File

@ -1,6 +1,7 @@
import 'package:dio/dio.dart';
import '../../../../core/constants/api_endpoints.dart';
import '../../../../core/utils/media_url.dart';
import '../../domain/entities/app_settings.dart';
class SettingsRemoteDataSource {
@ -8,36 +9,54 @@ class SettingsRemoteDataSource {
final Dio _dio;
Map<String, dynamic>? _asDataMap(dynamic responseData) {
if (responseData is! Map) return null;
final root = Map<String, dynamic>.from(responseData);
final data = root['data'];
if (data is Map<String, dynamic>) return data;
if (data is Map) return Map<String, dynamic>.from(data);
// Some responses return the entity at the root alongside success/message.
if (root.containsKey('org_name') ||
root.containsKey('smtp_host') ||
root.containsKey('logo_url') ||
root.containsKey('logo')) {
return root;
}
return null;
}
Future<AppSettings?> fetch() async {
final response = await _dio.get(ApiEndpoints.settings);
final data = response.data['data'];
if (data is! Map<String, dynamic>) return null;
final data = _asDataMap(response.data);
if (data == null) return null;
return AppSettings.fromJson(data);
}
Future<AppSettings> save(AppSettings settings) async {
final response = await _dio.put(ApiEndpoints.settings, data: settings.toJson());
final data = response.data['data'];
if (data is Map<String, dynamic>) {
final data = _asDataMap(response.data);
if (data != null) {
return AppSettings.fromJson(data);
}
return settings;
}
/// GET `/settings/company`
Future<CompanyProfileSettings?> fetchCompany() async {
final response = await _dio.get(ApiEndpoints.settingsCompany);
final data = response.data['data'];
if (data is! Map<String, dynamic>) return null;
final data = _asDataMap(response.data);
if (data == null) return null;
return CompanyProfileSettings.fromApiJson(data);
}
/// PUT `/settings/company`
Future<CompanyProfileSettings> saveCompany(CompanyProfileSettings profile) async {
final response = await _dio.put(
ApiEndpoints.settingsCompany,
data: profile.toApiJson(),
);
final data = response.data['data'];
if (data is Map<String, dynamic>) {
final data = _asDataMap(response.data);
if (data != null) {
return CompanyProfileSettings.fromApiJson(data).copyWith(
companyCode: profile.companyCode,
registrationNumber: profile.registrationNumber,
@ -48,6 +67,7 @@ class SettingsRemoteDataSource {
return profile;
}
/// POST `/settings/company/logo` (multipart field `logo`)
Future<String?> uploadCompanyLogo(List<int> bytes, String filename) async {
final formData = FormData.fromMap({
'logo': MultipartFile.fromBytes(bytes, filename: filename),
@ -56,20 +76,26 @@ class SettingsRemoteDataSource {
ApiEndpoints.settingsCompanyLogo,
data: formData,
);
final data = response.data['data'];
if (data is Map<String, dynamic>) {
return data['logo_url'] as String? ?? data['logo'] as String?;
final data = _asDataMap(response.data);
if (data != null) {
return resolveMediaUrl(
data['logo_url'] as String? ??
data['logoUrl'] as String? ??
data['logo'] as String?,
);
}
return null;
}
/// GET `/settings/email`
Future<EmailConfigurationSettings?> fetchEmail() async {
final response = await _dio.get(ApiEndpoints.settingsEmail);
final data = response.data['data'];
if (data is! Map<String, dynamic>) return null;
final data = _asDataMap(response.data);
if (data == null) return null;
return EmailConfigurationSettings.fromApiJson(data);
}
/// PUT `/settings/email`
Future<EmailConfigurationSettings> saveEmail(
EmailConfigurationSettings email,
) async {
@ -77,8 +103,8 @@ class SettingsRemoteDataSource {
ApiEndpoints.settingsEmail,
data: email.toApiJson(),
);
final data = response.data['data'];
if (data is Map<String, dynamic>) {
final data = _asDataMap(response.data);
if (data != null) {
return EmailConfigurationSettings.fromApiJson(data).copyWith(
allocationTemplate: email.allocationTemplate,
returnTemplate: email.returnTemplate,

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import '../../../../core/utils/media_url.dart';
class GeneralSettings {
const GeneralSettings({
this.defaultBranch = '',
@ -145,18 +147,17 @@ class CompanyProfileSettings {
'faviconUrl': faviconUrl,
};
Map<String, dynamic> toApiJson() {
final payload = <String, dynamic>{};
if (companyName.isNotEmpty) payload['org_name'] = companyName;
if (phone.isNotEmpty) payload['mobile'] = phone;
if (email.isNotEmpty) payload['email'] = email;
if (website.isNotEmpty) payload['website'] = website;
if (address.isNotEmpty) payload['address'] = address;
if (city.isNotEmpty) payload['city'] = city;
if (state.isNotEmpty) payload['state'] = state;
if (pincode.isNotEmpty) payload['pincode'] = pincode;
return payload;
}
/// Payload for `PUT /settings/company` ([CompanySettingsBody]).
Map<String, dynamic> toApiJson() => {
'org_name': companyName,
'mobile': phone,
'email': email,
'website': website,
'address': address,
'city': city,
'state': state,
'pincode': pincode,
};
factory CompanyProfileSettings.fromJson(Map<String, dynamic> json) =>
CompanyProfileSettings.fromApiJson(json);
@ -176,11 +177,13 @@ class CompanyProfileSettings {
email: json['email'] as String? ?? '',
phone: json['mobile'] as String? ?? json['phone'] as String? ?? '',
website: json['website'] as String? ?? '',
logoUrl: json['logo_url'] as String? ??
json['logoUrl'] as String? ??
json['logo'] as String? ??
logoUrl: resolveMediaUrl(
json['logo_url'] as String? ??
json['logoUrl'] as String? ??
json['logo'] as String?,
) ??
'',
faviconUrl: json['faviconUrl'] as String? ?? '',
faviconUrl: resolveMediaUrl(json['faviconUrl'] as String?) ?? '',
);
}
@ -450,14 +453,19 @@ class EmailConfigurationSettings {
'warrantyTemplate': warrantyTemplate,
};
/// Payload for `PUT /settings/email` ([EmailSettingsBody]).
/// Omits blank password so an existing SMTP password is not cleared.
Map<String, dynamic> toApiJson() {
final payload = <String, dynamic>{};
if (smtpHost.isNotEmpty) payload['smtp_host'] = smtpHost;
if (smtpPort > 0) payload['smtp_port'] = smtpPort;
if (smtpUsername.isNotEmpty) payload['smtp_username'] = smtpUsername;
if (smtpPassword.isNotEmpty) payload['smtp_password'] = smtpPassword;
if (senderEmail.isNotEmpty) payload['sender_email'] = senderEmail;
if (senderName.isNotEmpty) payload['sender_name'] = senderName;
final payload = <String, dynamic>{
'smtp_host': smtpHost,
'smtp_port': smtpPort,
'smtp_username': smtpUsername,
'sender_email': senderEmail,
'sender_name': senderName,
};
if (smtpPassword.isNotEmpty) {
payload['smtp_password'] = smtpPassword;
}
return payload;
}
@ -659,6 +667,7 @@ class SettingsSection {
required this.icon,
required this.route,
this.phase = 1,
this.hidden = false,
});
final String id;
@ -667,6 +676,8 @@ class SettingsSection {
final IconData icon;
final String route;
final int phase;
/// When true, the card stays defined but is not shown on the Settings hub.
final bool hidden;
}
const phase1SettingsSections = [
@ -696,7 +707,7 @@ const phase1SettingsSections = [
title: 'Roles & Permissions',
subtitle: 'Manage roles, permissions, and menu access',
icon: Icons.security_outlined,
route: '/settings/roles',
route: '/users-roles?tab=permissions',
),
SettingsSection(
id: 'asset',
@ -704,6 +715,7 @@ const phase1SettingsSections = [
subtitle: 'Asset codes, statuses, warranty, and QR',
icon: Icons.inventory_2_outlined,
route: '/settings/asset',
hidden: true,
),
SettingsSection(
id: 'notifications',
@ -711,11 +723,12 @@ const phase1SettingsSections = [
subtitle: 'Email, SMS, push, and in-app alerts',
icon: Icons.notifications_outlined,
route: '/settings/notifications',
hidden: true,
),
SettingsSection(
id: 'email',
title: 'Email Configuration',
subtitle: 'SMTP server and email templates',
subtitle: 'SMTP server settings',
icon: Icons.email_outlined,
route: '/settings/email',
),
@ -725,9 +738,11 @@ const phase1SettingsSections = [
subtitle: 'Authentication, session, and audit policies',
icon: Icons.lock_outline,
route: '/settings/security',
hidden: true,
),
];
/// Phase 2 cards are kept for later; currently hidden on the Settings hub.
const phase2SettingsSections = [
SettingsSection(
id: 'workflow',
@ -736,6 +751,7 @@ const phase2SettingsSections = [
icon: Icons.account_tree_outlined,
route: '/settings/workflow',
phase: 2,
hidden: true,
),
SettingsSection(
id: 'dashboard',
@ -744,6 +760,7 @@ const phase2SettingsSections = [
icon: Icons.dashboard_outlined,
route: '/settings/dashboard',
phase: 2,
hidden: true,
),
SettingsSection(
id: 'reports',
@ -752,6 +769,7 @@ const phase2SettingsSections = [
icon: Icons.assessment_outlined,
route: '/settings/reports',
phase: 2,
hidden: true,
),
SettingsSection(
id: 'storage',
@ -760,6 +778,7 @@ const phase2SettingsSections = [
icon: Icons.cloud_upload_outlined,
route: '/settings/storage',
phase: 2,
hidden: true,
),
SettingsSection(
id: 'audit',
@ -768,6 +787,7 @@ const phase2SettingsSections = [
icon: Icons.history_outlined,
route: '/settings/audit',
phase: 2,
hidden: true,
),
SettingsSection(
id: 'mobile',
@ -776,6 +796,7 @@ const phase2SettingsSections = [
icon: Icons.phone_android_outlined,
route: '/settings/mobile',
phase: 2,
hidden: true,
),
SettingsSection(
id: 'integrations',
@ -784,5 +805,14 @@ const phase2SettingsSections = [
icon: Icons.extension_outlined,
route: '/settings/integrations',
phase: 2,
hidden: true,
),
];
/// Visible Phase 1 cards for the Settings hub.
List<SettingsSection> get visiblePhase1SettingsSections =>
phase1SettingsSections.where((s) => !s.hidden).toList();
/// Visible Phase 2 cards for the Settings hub (empty while all are hidden).
List<SettingsSection> get visiblePhase2SettingsSections =>
phase2SettingsSections.where((s) => !s.hidden).toList();

View File

@ -1,8 +1,12 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/errors/failure.dart';
import '../../../../core/network/api_handler.dart';
import '../../../../core/network/dio_client.dart';
import '../../../../core/theme/branding_config.dart';
import '../../../../core/theme/theme_provider.dart';
import '../../../../core/utils/favicon_store.dart';
import '../../../../core/utils/media_url.dart';
import '../../data/datasources/settings_local_data_source.dart';
import '../../data/datasources/settings_remote_data_source.dart';
import '../../data/repositories/settings_repository_impl.dart';
@ -42,6 +46,17 @@ final appSettingsProvider =
saveSettings: ref.watch(saveSettingsUseCaseProvider),
repository: ref.watch(settingsRepositoryProvider),
faviconStore: FaviconStore(ref.watch(sharedPreferencesProvider)),
syncAppLogo: (logoUrl, companyName) async {
final branding = ref.read(brandingProvider);
await ref.read(brandingProvider.notifier).updateBranding(
BrandingConfig(
logoUrl: logoUrl,
primaryColorValue: branding.primaryColorValue,
secondaryColorValue: branding.secondaryColorValue,
companyName: companyName ?? branding.companyName,
),
);
},
);
});
@ -51,10 +66,13 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
required SaveSettingsUseCase saveSettings,
required SettingsRepository repository,
required FaviconStore faviconStore,
required Future<void> Function(String? logoUrl, String? companyName)
syncAppLogo,
}) : _getSettings = getSettings,
_saveSettings = saveSettings,
_repository = repository,
_faviconStore = faviconStore,
_syncAppLogo = syncAppLogo,
super(const AppSettings()) {
_load();
}
@ -63,25 +81,38 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
final SaveSettingsUseCase _saveSettings;
final SettingsRepository _repository;
final FaviconStore _faviconStore;
final Future<void> Function(String? logoUrl, String? companyName) _syncAppLogo;
Future<void> _syncMainLogo(CompanyProfileSettings profile) async {
final logo = resolveMediaUrl(profile.logoUrl);
await _syncAppLogo(
(logo == null || logo.isEmpty) ? null : logo,
profile.companyName.isEmpty ? null : profile.companyName,
);
}
Future<void> _load() async {
final result = await _getSettings();
state = result.data ?? const AppSettings();
_faviconStore.apply();
await _syncMainLogo(state.companyProfile);
}
Future<void> refreshCompanyProfile() async {
Future<Failure?> refreshCompanyProfile() async {
final result = await _repository.fetchCompanyProfile();
if (result.failure == null && result.data != null) {
state = state.copyWith(companyProfile: result.data!);
await _syncMainLogo(result.data!);
}
return result.failure;
}
Future<void> refreshEmailSettings() async {
Future<Failure?> refreshEmailSettings() async {
final result = await _repository.fetchEmailSettings();
if (result.failure == null && result.data != null) {
state = state.copyWith(email: result.data!);
}
return result.failure;
}
Future<void> _persist(AppSettings settings) async {
@ -94,29 +125,44 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
await _persist(state.copyWith(general: general));
}
Future<void> updateCompanyProfile(CompanyProfileSettings profile) async {
/// PUT `/settings/company` returns failure when the API call fails.
Future<Failure?> updateCompanyProfile(CompanyProfileSettings profile) async {
final result = await _repository.saveCompanyProfile(profile);
if (result.failure == null && result.data != null) {
if (result.failure != null) return result.failure;
if (result.data != null) {
state = state.copyWith(companyProfile: result.data!);
return;
}
await _persist(state.copyWith(companyProfile: profile));
}
Future<String?> uploadCompanyLogo(List<int> bytes, String filename) async {
final result = await _repository.uploadCompanyLogo(bytes, filename);
if (result.failure == null && result.data != null) {
final logoUrl = result.data!;
await _persist(
state.copyWith(
companyProfile: state.companyProfile.copyWith(logoUrl: logoUrl),
),
);
return logoUrl;
await _saveSettings(state);
await _syncMainLogo(result.data!);
}
return null;
}
/// POST `/settings/company/logo` also updates the app main logo.
Future<Result<String?>> uploadCompanyLogo(
List<int> bytes,
String filename,
) async {
final result = await _repository.uploadCompanyLogo(bytes, filename);
if (result.failure == null && result.data != null && result.data!.isNotEmpty) {
final logoUrl = resolveMediaUrl(result.data!) ?? result.data!;
final profile = state.companyProfile.copyWith(logoUrl: logoUrl);
state = state.copyWith(companyProfile: profile);
await _saveSettings(state);
await _syncMainLogo(profile);
return (data: logoUrl, failure: null);
}
return result;
}
/// Applies a local/preview logo to company profile + main app branding.
Future<void> applyLocalCompanyLogo(String logoUrl) async {
final resolved = resolveMediaUrl(logoUrl) ?? logoUrl;
final profile = state.companyProfile.copyWith(logoUrl: resolved);
state = state.copyWith(companyProfile: profile);
await _saveSettings(state);
await _syncMainLogo(profile);
}
Future<void> updateUiPreferences(UiPreferencesSettings prefs) async {
await _persist(state.copyWith(uiPreferences: prefs));
}
@ -129,14 +175,15 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
await _persist(state.copyWith(notifications: notifications));
}
Future<void> updateEmail(EmailConfigurationSettings email) async {
/// PUT `/settings/email` returns failure when the API call fails.
Future<Failure?> updateEmail(EmailConfigurationSettings email) async {
final result = await _repository.saveEmailSettings(email);
if (result.failure == null && result.data != null) {
if (result.failure != null) return result.failure;
if (result.data != null) {
state = state.copyWith(email: result.data!);
await _saveSettings(state);
return;
}
await _persist(state.copyWith(email: email));
return null;
}
Future<void> updateSecurity(SecuritySettingsConfig security) async {
@ -145,5 +192,6 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
Future<void> resetToDefaults() async {
await _persist(const AppSettings());
await _syncAppLogo(null, null);
}
}

View File

@ -4,6 +4,7 @@ import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/network/api_handler.dart';
import '../../../../core/theme/theme_provider.dart';
import '../../../../core/utils/favicon_store.dart';
import '../../../../core/utils/favicon_updater.dart';
@ -41,15 +42,16 @@ class _CompanyProfileSettingsScreenState
late final TextEditingController _logoUrlController;
late final TextEditingController _faviconUrlController;
bool _loading = true;
bool _saving = false;
bool _uploadingLogo = false;
@override
void initState() {
super.initState();
final profile = ref.read(appSettingsProvider).companyProfile;
final faviconFromPrefs =
FaviconStore(ref.read(sharedPreferencesProvider)).read();
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(appSettingsProvider.notifier).refreshCompanyProfile();
});
_nameController = TextEditingController(text: profile.companyName);
_codeController = TextEditingController(text: profile.companyCode);
_registrationController =
@ -68,6 +70,43 @@ class _CompanyProfileSettingsScreenState
? profile.faviconUrl
: faviconFromPrefs,
);
WidgetsBinding.instance.addPostFrameCallback((_) => _loadFromApi());
}
Future<void> _loadFromApi() async {
setState(() => _loading = true);
final failure =
await ref.read(appSettingsProvider.notifier).refreshCompanyProfile();
if (!mounted) return;
_applyProfile(ref.read(appSettingsProvider).companyProfile);
setState(() => _loading = false);
if (failure != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(failure.message),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
}
void _applyProfile(CompanyProfileSettings profile) {
_nameController.text = profile.companyName;
_codeController.text = profile.companyCode;
_registrationController.text = profile.registrationNumber;
_gstController.text = profile.gstNumber;
_addressController.text = profile.address;
_cityController.text = profile.city;
_stateController.text = profile.state;
_pincodeController.text = profile.pincode;
_emailController.text = profile.email;
_phoneController.text = profile.phone;
_websiteController.text = profile.website;
_logoUrlController.text = profile.logoUrl;
if (profile.faviconUrl.isNotEmpty) {
_faviconUrlController.text = profile.faviconUrl;
}
setState(() {});
}
@override
@ -95,23 +134,37 @@ class _CompanyProfileSettingsScreenState
final faviconUrl = _faviconUrlController.text.trim();
final companyName = _nameController.text.trim();
await ref.read(appSettingsProvider.notifier).updateCompanyProfile(
CompanyProfileSettings(
companyName: companyName,
companyCode: _codeController.text.trim(),
registrationNumber: _registrationController.text.trim(),
gstNumber: _gstController.text.trim(),
address: _addressController.text.trim(),
city: _cityController.text.trim(),
state: _stateController.text.trim(),
pincode: _pincodeController.text.trim(),
email: _emailController.text.trim(),
phone: _phoneController.text.trim(),
website: _websiteController.text.trim(),
logoUrl: logoUrl,
faviconUrl: faviconUrl,
),
);
setState(() => _saving = true);
final failure =
await ref.read(appSettingsProvider.notifier).updateCompanyProfile(
CompanyProfileSettings(
companyName: companyName,
companyCode: _codeController.text.trim(),
registrationNumber: _registrationController.text.trim(),
gstNumber: _gstController.text.trim(),
address: _addressController.text.trim(),
city: _cityController.text.trim(),
state: _stateController.text.trim(),
pincode: _pincodeController.text.trim(),
email: _emailController.text.trim(),
phone: _phoneController.text.trim(),
website: _websiteController.text.trim(),
logoUrl: logoUrl,
faviconUrl: faviconUrl,
),
);
if (!mounted) return;
setState(() => _saving = false);
if (failure != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(validationErrorMessage(failure)),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
return;
}
await ref.read(brandingProvider.notifier).updateBranding(
ref.read(brandingProvider).copyWith(
@ -165,14 +218,19 @@ class _CompanyProfileSettingsScreenState
final bytes = file.bytes;
if (bytes == null) return;
final uploadedUrl = await ref.read(appSettingsProvider.notifier).uploadCompanyLogo(
bytes,
file.name,
);
setState(() => _uploadingLogo = true);
final uploadResult =
await ref.read(appSettingsProvider.notifier).uploadCompanyLogo(
bytes,
file.name,
);
if (!mounted) return;
setState(() => _uploadingLogo = false);
if (uploadedUrl != null && uploadedUrl.isNotEmpty) {
final uploadedUrl = uploadResult.data;
if (uploadResult.failure == null &&
uploadedUrl != null &&
uploadedUrl.isNotEmpty) {
setState(() => _logoUrlController.text = uploadedUrl);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Logo uploaded')),
@ -180,9 +238,21 @@ class _CompanyProfileSettingsScreenState
return;
}
if (uploadResult.failure != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(uploadResult.failure!.message),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
// Local preview fallback when upload is unavailable still apply as main logo.
final ext = (file.extension ?? 'png').toLowerCase();
final mime = ext == 'jpg' ? 'jpeg' : ext;
final dataUri = 'data:image/$mime;base64,${base64Encode(bytes)}';
await ref.read(appSettingsProvider.notifier).applyLocalCompanyLogo(dataUri);
if (!mounted) return;
setState(() => _logoUrlController.text = dataUri);
}
@ -195,147 +265,165 @@ class _CompanyProfileSettingsScreenState
return SettingsPageLayout(
title: 'Company Profile',
subtitle: 'Company information and branding assets',
child: Form(
key: _formKey,
child: Column(
children: [
SettingsFormCard(
title: 'Company Information',
children: [
AppTextField(
controller: _nameController,
label: 'Company Name',
validator: (v) => Validators.required(v, fieldName: 'Company name'),
),
const SizedBox(height: 16),
AppTextField(
controller: _codeController,
label: 'Company Code',
validator: (v) => Validators.required(v, fieldName: 'Company code'),
),
const SizedBox(height: 16),
AppTextField(
controller: _registrationController,
label: 'Registration Number',
),
const SizedBox(height: 16),
AppTextField(
controller: _gstController,
label: 'GST/VAT Number',
validator: Validators.optionalGstin,
inputFormatters: Validators.gstinInput,
),
const SizedBox(height: 16),
AppTextField(
controller: _addressController,
label: 'Address',
maxLines: 2,
),
const SizedBox(height: 16),
SidePanelFormRow(
left: AppTextField(
controller: _cityController,
label: 'City',
child: _loading
? const Padding(
padding: EdgeInsets.symmetric(vertical: 48),
child: Center(child: CircularProgressIndicator()),
)
: Form(
key: _formKey,
child: Column(
children: [
SettingsFormCard(
title: 'Company Information',
children: [
AppTextField(
controller: _nameController,
label: 'Company Name',
validator: (v) =>
Validators.required(v, fieldName: 'Company name'),
),
const SizedBox(height: 16),
AppTextField(
controller: _codeController,
label: 'Company Code',
),
const SizedBox(height: 16),
AppTextField(
controller: _registrationController,
label: 'Registration Number',
),
const SizedBox(height: 16),
AppTextField(
controller: _gstController,
label: 'GST/VAT Number',
validator: Validators.optionalGstin,
inputFormatters: Validators.gstinInput,
),
const SizedBox(height: 16),
AppTextField(
controller: _addressController,
label: 'Address',
maxLines: 2,
),
const SizedBox(height: 16),
SidePanelFormRow(
left: AppTextField(
controller: _cityController,
label: 'City',
),
right: AppTextField(
controller: _stateController,
label: 'State',
),
),
const SizedBox(height: 16),
AppTextField(
controller: _pincodeController,
label: 'Pincode',
keyboardType: TextInputType.number,
),
const SizedBox(height: 16),
AppTextField(
controller: _emailController,
label: 'Email',
keyboardType: TextInputType.emailAddress,
validator: Validators.optionalEmail,
),
const SizedBox(height: 16),
AppTextField(
controller: _phoneController,
label: 'Phone',
keyboardType: TextInputType.phone,
validator: Validators.optionalMobile,
inputFormatters: Validators.mobileInput,
),
const SizedBox(height: 16),
AppTextField(
controller: _websiteController,
label: 'Website',
keyboardType: TextInputType.url,
),
],
),
right: AppTextField(
controller: _stateController,
label: 'State',
const SizedBox(height: 16),
SettingsFormCard(
title: 'Logo Upload',
subtitle: 'Upload an image or provide a logo URL',
children: [
Center(
child: SidebarLogo(
logoUrl: _logoUrlController.text.trim().isEmpty
? null
: _logoUrlController.text.trim(),
width: 240,
height: 80,
fit: BoxFit.contain,
),
),
const SizedBox(height: 16),
AppTextField(
controller: _logoUrlController,
label: 'Logo URL',
hint: 'https://example.com/logo.png',
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: _uploadingLogo ? null : _pickLogo,
icon: _uploadingLogo
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.upload_file),
label: Text(
_uploadingLogo ? 'Uploading…' : 'Upload Logo',
),
),
],
),
),
const SizedBox(height: 16),
AppTextField(
controller: _pincodeController,
label: 'Pincode',
keyboardType: TextInputType.number,
),
const SizedBox(height: 16),
AppTextField(
controller: _emailController,
label: 'Email',
keyboardType: TextInputType.emailAddress,
validator: Validators.email,
),
const SizedBox(height: 16),
AppTextField(
controller: _phoneController,
label: 'Phone',
keyboardType: TextInputType.phone,
validator: Validators.optionalMobile,
inputFormatters: Validators.mobileInput,
),
const SizedBox(height: 16),
AppTextField(
controller: _websiteController,
label: 'Website',
keyboardType: TextInputType.url,
),
],
const SizedBox(height: 16),
SettingsFormCard(
title: 'Favicon Upload',
subtitle:
'Upload an image or provide a favicon URL for the browser tab',
children: [
Center(
child: SidebarLogo(
logoUrl: _faviconUrlController.text.trim().isEmpty
? null
: _faviconUrlController.text.trim(),
width: 64,
height: 64,
fit: BoxFit.contain,
),
),
const SizedBox(height: 16),
AppTextField(
controller: _faviconUrlController,
label: 'Favicon URL',
hint: 'https://example.com/favicon.ico',
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: _pickFavicon,
icon: const Icon(Icons.upload_file),
label: const Text('Upload Favicon'),
),
],
),
const SizedBox(height: 24),
AppButton(
label: 'Save Changes',
onPressed: _save,
isLoading: _saving,
),
],
),
),
const SizedBox(height: 16),
SettingsFormCard(
title: 'Logo Upload',
subtitle: 'Upload an image or provide a logo URL',
children: [
Center(
child: SidebarLogo(
logoUrl: _logoUrlController.text.trim().isEmpty
? null
: _logoUrlController.text.trim(),
width: 240,
height: 80,
fit: BoxFit.contain,
),
),
const SizedBox(height: 16),
AppTextField(
controller: _logoUrlController,
label: 'Logo URL',
hint: 'https://example.com/logo.png',
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: _pickLogo,
icon: const Icon(Icons.upload_file),
label: const Text('Upload Logo'),
),
],
),
const SizedBox(height: 16),
SettingsFormCard(
title: 'Favicon Upload',
subtitle: 'Upload an image or provide a favicon URL for the browser tab',
children: [
Center(
child: SidebarLogo(
logoUrl: _faviconUrlController.text.trim().isEmpty
? null
: _faviconUrlController.text.trim(),
width: 64,
height: 64,
fit: BoxFit.contain,
),
),
const SizedBox(height: 16),
AppTextField(
controller: _faviconUrlController,
label: 'Favicon URL',
hint: 'https://example.com/favicon.ico',
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: _pickFavicon,
icon: const Icon(Icons.upload_file),
label: const Text('Upload Favicon'),
),
],
),
const SizedBox(height: 24),
AppButton(label: 'Save Changes', onPressed: _save),
],
),
),
);
}
}

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/network/api_handler.dart';
import '../../../../core/utils/validators.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_text_field.dart';
@ -25,31 +26,49 @@ class _EmailConfigurationScreenState
late final TextEditingController _passwordController;
late final TextEditingController _senderEmailController;
late final TextEditingController _senderNameController;
late final TextEditingController _allocationTemplateController;
late final TextEditingController _returnTemplateController;
late final TextEditingController _maintenanceTemplateController;
late final TextEditingController _warrantyTemplateController;
bool _loading = true;
bool _saving = false;
@override
void initState() {
super.initState();
final email = ref.read(appSettingsProvider).email;
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(appSettingsProvider.notifier).refreshEmailSettings();
});
_hostController = TextEditingController(text: email.smtpHost);
_portController = TextEditingController(text: '${email.smtpPort}');
_usernameController = TextEditingController(text: email.smtpUsername);
_passwordController = TextEditingController(text: email.smtpPassword);
_senderEmailController = TextEditingController(text: email.senderEmail);
_senderNameController = TextEditingController(text: email.senderName);
_allocationTemplateController =
TextEditingController(text: email.allocationTemplate);
_returnTemplateController = TextEditingController(text: email.returnTemplate);
_maintenanceTemplateController =
TextEditingController(text: email.maintenanceTemplate);
_warrantyTemplateController =
TextEditingController(text: email.warrantyTemplate);
WidgetsBinding.instance.addPostFrameCallback((_) => _loadFromApi());
}
Future<void> _loadFromApi() async {
setState(() => _loading = true);
final failure =
await ref.read(appSettingsProvider.notifier).refreshEmailSettings();
if (!mounted) return;
_applyEmail(ref.read(appSettingsProvider).email);
setState(() => _loading = false);
if (failure != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(failure.message),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
}
void _applyEmail(EmailConfigurationSettings email) {
_hostController.text = email.smtpHost;
_portController.text = '${email.smtpPort}';
_usernameController.text = email.smtpUsername;
if (email.smtpPassword.isNotEmpty) {
_passwordController.text = email.smtpPassword;
}
_senderEmailController.text = email.senderEmail;
_senderNameController.text = email.senderName;
}
@override
@ -60,121 +79,117 @@ class _EmailConfigurationScreenState
_passwordController.dispose();
_senderEmailController.dispose();
_senderNameController.dispose();
_allocationTemplateController.dispose();
_returnTemplateController.dispose();
_maintenanceTemplateController.dispose();
_warrantyTemplateController.dispose();
super.dispose();
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
await ref.read(appSettingsProvider.notifier).updateEmail(
EmailConfigurationSettings(
final port = int.tryParse(_portController.text.trim());
if (port == null || port <= 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Enter a valid SMTP port')),
);
return;
}
final current = ref.read(appSettingsProvider).email;
setState(() => _saving = true);
final failure = await ref.read(appSettingsProvider.notifier).updateEmail(
current.copyWith(
smtpHost: _hostController.text.trim(),
smtpPort: int.parse(_portController.text.trim()),
smtpPort: port,
smtpUsername: _usernameController.text.trim(),
smtpPassword: _passwordController.text.trim(),
senderEmail: _senderEmailController.text.trim(),
senderName: _senderNameController.text.trim(),
allocationTemplate: _allocationTemplateController.text.trim(),
returnTemplate: _returnTemplateController.text.trim(),
maintenanceTemplate: _maintenanceTemplateController.text.trim(),
warrantyTemplate: _warrantyTemplateController.text.trim(),
),
);
if (!mounted) return;
setState(() => _saving = false);
if (mounted) {
if (failure != null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Email configuration saved')),
SnackBar(
content: Text(validationErrorMessage(failure)),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Email configuration saved')),
);
}
@override
Widget build(BuildContext context) {
return SettingsPageLayout(
title: 'Email Configuration',
subtitle: 'SMTP server settings and email templates',
child: Form(
key: _formKey,
child: Column(
children: [
SettingsFormCard(
title: 'SMTP Settings',
children: [
AppTextField(
controller: _hostController,
label: 'SMTP Host',
hint: 'smtp.gmail.com',
),
const SizedBox(height: 16),
AppTextField(
controller: _portController,
label: 'SMTP Port',
keyboardType: TextInputType.number,
),
const SizedBox(height: 16),
AppTextField(
controller: _usernameController,
label: 'Username',
),
const SizedBox(height: 16),
AppTextField(
controller: _passwordController,
label: 'Password',
obscureText: true,
),
const SizedBox(height: 16),
AppTextField(
controller: _senderEmailController,
label: 'Sender Email',
keyboardType: TextInputType.emailAddress,
validator: Validators.email,
),
const SizedBox(height: 16),
AppTextField(
controller: _senderNameController,
label: 'Sender Name',
),
],
subtitle: 'SMTP server settings',
child: _loading
? const Padding(
padding: EdgeInsets.symmetric(vertical: 48),
child: Center(child: CircularProgressIndicator()),
)
: Form(
key: _formKey,
child: Column(
children: [
SettingsFormCard(
title: 'SMTP Settings',
children: [
AppTextField(
controller: _hostController,
label: 'SMTP Host',
hint: 'smtp.gmail.com',
validator: (v) =>
Validators.required(v, fieldName: 'SMTP host'),
),
const SizedBox(height: 16),
AppTextField(
controller: _portController,
label: 'SMTP Port',
keyboardType: TextInputType.number,
validator: (v) =>
Validators.required(v, fieldName: 'SMTP port'),
),
const SizedBox(height: 16),
AppTextField(
controller: _usernameController,
label: 'Username',
),
const SizedBox(height: 16),
AppTextField(
controller: _passwordController,
label: 'Password',
obscureText: true,
hint: 'Leave blank to keep existing password',
),
const SizedBox(height: 16),
AppTextField(
controller: _senderEmailController,
label: 'Sender Email',
keyboardType: TextInputType.emailAddress,
validator: Validators.email,
),
const SizedBox(height: 16),
AppTextField(
controller: _senderNameController,
label: 'Sender Name',
),
],
),
const SizedBox(height: 24),
AppButton(
label: 'Save Changes',
onPressed: _save,
isLoading: _saving,
),
],
),
),
const SizedBox(height: 16),
SettingsFormCard(
title: 'Email Templates',
subtitle: 'Use {{asset_name}} and {{date}} as placeholders',
children: [
AppTextField(
controller: _allocationTemplateController,
label: 'Asset Allocation Email',
maxLines: 2,
),
const SizedBox(height: 16),
AppTextField(
controller: _returnTemplateController,
label: 'Asset Return Email',
maxLines: 2,
),
const SizedBox(height: 16),
AppTextField(
controller: _maintenanceTemplateController,
label: 'Maintenance Email',
maxLines: 2,
),
const SizedBox(height: 16),
AppTextField(
controller: _warrantyTemplateController,
label: 'Warranty Expiry Email',
maxLines: 2,
),
],
),
const SizedBox(height: 24),
AppButton(label: 'Save Changes', onPressed: _save),
],
),
),
);
}
}

View File

@ -11,6 +11,9 @@ class SettingsScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final visiblePhase1 = visiblePhase1SettingsSections;
final visiblePhase2 = visiblePhase2SettingsSections;
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Center(
@ -21,35 +24,31 @@ class SettingsScreen extends StatelessWidget {
children: [
const PageHeader(
title: 'Settings',
subtitle: 'Configure company, security, assets, and system behavior',
),
Text(
'Phase 1 — Asset Management MVP',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
SettingsSectionGrid(
sections: phase1SettingsSections,
onSectionTap: (section) => context.go(section.route),
),
const SizedBox(height: 24),
Text(
'Coming in Phase 2',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
SettingsSectionGrid(
sections: phase2SettingsSections,
enabled: false,
badge: 'Phase 2',
onSectionTap: (_) {},
subtitle:
'Configure company, security, assets, and system behavior',
),
if (visiblePhase1.isNotEmpty)
SettingsSectionGrid(
sections: visiblePhase1,
onSectionTap: (section) => context.go(section.route),
),
if (visiblePhase2.isNotEmpty) ...[
const SizedBox(height: 24),
Text(
'Coming in Phase 2',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
SettingsSectionGrid(
sections: visiblePhase2,
enabled: false,
badge: 'Phase 2',
onSectionTap: (_) {},
),
],
],
),
),

View File

@ -36,10 +36,13 @@ Object? _readNestedName(Map<dynamic, dynamic> json, String nestedKey) {
return null;
}
Object? _readAssetCategoryName(Map<dynamic, dynamic> json, String key) {
final flat = json['asset_category_name'];
if (flat is String && flat.isNotEmpty) return flat;
return _readNestedName(json, 'asset_category');
Object? _readItemCategoryName(Map<dynamic, dynamic> json, String key) {
for (final flatKey in ['item_category_name', 'asset_category_name']) {
final flat = json[flatKey];
if (flat is String && flat.isNotEmpty) return flat;
}
return _readNestedName(json, 'item_category') ??
_readNestedName(json, 'asset_category');
}
Object? _readPlantName(Map<dynamic, dynamic> json, String key) {
@ -48,10 +51,12 @@ Object? _readPlantName(Map<dynamic, dynamic> json, String key) {
return _readNestedName(json, 'plant');
}
Object? _readAssetCategoryId(Map<dynamic, dynamic> json, String key) {
final flat = json['asset_category_id'];
if (flat != null) return flat;
final nested = json['asset_category'];
Object? _readItemCategoryId(Map<dynamic, dynamic> json, String key) {
for (final flatKey in ['item_category_id', 'asset_category_id']) {
final flat = json[flatKey];
if (flat != null) return flat;
}
final nested = json['item_category'] ?? json['asset_category'];
if (nested is Map) return nested['id'];
return null;
}
@ -64,16 +69,21 @@ Object? _readPlantId(Map<dynamic, dynamic> json, String key) {
return null;
}
Object? _readAssetSubcategoryName(Map<dynamic, dynamic> json, String key) {
final flat = json['asset_subcategory_name'];
if (flat is String && flat.isNotEmpty) return flat;
return _readNestedName(json, 'asset_subcategory');
Object? _readItemSubcategoryName(Map<dynamic, dynamic> json, String key) {
for (final flatKey in ['item_subcategory_name', 'asset_subcategory_name']) {
final flat = json[flatKey];
if (flat is String && flat.isNotEmpty) return flat;
}
return _readNestedName(json, 'item_subcategory') ??
_readNestedName(json, 'asset_subcategory');
}
Object? _readAssetSubcategoryId(Map<dynamic, dynamic> json, String key) {
final flat = json['asset_subcategory_id'];
if (flat != null) return flat;
final nested = json['asset_subcategory'];
Object? _readItemSubcategoryId(Map<dynamic, dynamic> json, String key) {
for (final flatKey in ['item_subcategory_id', 'asset_subcategory_id']) {
final flat = json[flatKey];
if (flat != null) return flat;
}
final nested = json['item_subcategory'] ?? json['asset_subcategory'];
if (nested is Map) return nested['id'];
return null;
}
@ -127,20 +137,20 @@ class AssetModel with _$AssetModel {
@JsonKey(name: 'asset_name') required String assetName,
@JsonKey(name: 'asset_code') String? assetCode,
@JsonKey(
name: 'asset_category_id',
readValue: _readAssetCategoryId,
name: 'item_category_id',
readValue: _readItemCategoryId,
fromJson: _intFromJsonNullable,
)
int? assetCategoryId,
@JsonKey(name: 'asset_category_name', readValue: _readAssetCategoryName)
@JsonKey(name: 'item_category_name', readValue: _readItemCategoryName)
String? assetCategoryName,
@JsonKey(
name: 'asset_subcategory_id',
readValue: _readAssetSubcategoryId,
name: 'item_subcategory_id',
readValue: _readItemSubcategoryId,
fromJson: _intFromJsonNullable,
)
int? assetSubcategoryId,
@JsonKey(name: 'asset_subcategory_name', readValue: _readAssetSubcategoryName)
@JsonKey(name: 'item_subcategory_name', readValue: _readItemSubcategoryName)
String? assetSubcategoryName,
@JsonKey(name: 'plant_id', readValue: _readPlantId, fromJson: _intFromJsonNullable)
int? plantId,

View File

@ -397,20 +397,20 @@ mixin _$AssetModel {
@JsonKey(name: 'asset_code')
String? get assetCode => throw _privateConstructorUsedError;
@JsonKey(
name: 'asset_category_id',
readValue: _readAssetCategoryId,
name: 'item_category_id',
readValue: _readItemCategoryId,
fromJson: _intFromJsonNullable,
)
int? get assetCategoryId => throw _privateConstructorUsedError;
@JsonKey(name: 'asset_category_name', readValue: _readAssetCategoryName)
@JsonKey(name: 'item_category_name', readValue: _readItemCategoryName)
String? get assetCategoryName => throw _privateConstructorUsedError;
@JsonKey(
name: 'asset_subcategory_id',
readValue: _readAssetSubcategoryId,
name: 'item_subcategory_id',
readValue: _readItemSubcategoryId,
fromJson: _intFromJsonNullable,
)
int? get assetSubcategoryId => throw _privateConstructorUsedError;
@JsonKey(name: 'asset_subcategory_name', readValue: _readAssetSubcategoryName)
@JsonKey(name: 'item_subcategory_name', readValue: _readItemSubcategoryName)
String? get assetSubcategoryName => throw _privateConstructorUsedError;
@JsonKey(
name: 'plant_id',
@ -503,23 +503,20 @@ abstract class $AssetModelCopyWith<$Res> {
@JsonKey(name: 'asset_name') String assetName,
@JsonKey(name: 'asset_code') String? assetCode,
@JsonKey(
name: 'asset_category_id',
readValue: _readAssetCategoryId,
name: 'item_category_id',
readValue: _readItemCategoryId,
fromJson: _intFromJsonNullable,
)
int? assetCategoryId,
@JsonKey(name: 'asset_category_name', readValue: _readAssetCategoryName)
@JsonKey(name: 'item_category_name', readValue: _readItemCategoryName)
String? assetCategoryName,
@JsonKey(
name: 'asset_subcategory_id',
readValue: _readAssetSubcategoryId,
name: 'item_subcategory_id',
readValue: _readItemSubcategoryId,
fromJson: _intFromJsonNullable,
)
int? assetSubcategoryId,
@JsonKey(
name: 'asset_subcategory_name',
readValue: _readAssetSubcategoryName,
)
@JsonKey(name: 'item_subcategory_name', readValue: _readItemSubcategoryName)
String? assetSubcategoryName,
@JsonKey(
name: 'plant_id',
@ -823,23 +820,20 @@ abstract class _$$AssetModelImplCopyWith<$Res>
@JsonKey(name: 'asset_name') String assetName,
@JsonKey(name: 'asset_code') String? assetCode,
@JsonKey(
name: 'asset_category_id',
readValue: _readAssetCategoryId,
name: 'item_category_id',
readValue: _readItemCategoryId,
fromJson: _intFromJsonNullable,
)
int? assetCategoryId,
@JsonKey(name: 'asset_category_name', readValue: _readAssetCategoryName)
@JsonKey(name: 'item_category_name', readValue: _readItemCategoryName)
String? assetCategoryName,
@JsonKey(
name: 'asset_subcategory_id',
readValue: _readAssetSubcategoryId,
name: 'item_subcategory_id',
readValue: _readItemSubcategoryId,
fromJson: _intFromJsonNullable,
)
int? assetSubcategoryId,
@JsonKey(
name: 'asset_subcategory_name',
readValue: _readAssetSubcategoryName,
)
@JsonKey(name: 'item_subcategory_name', readValue: _readItemSubcategoryName)
String? assetSubcategoryName,
@JsonKey(
name: 'plant_id',
@ -1135,23 +1129,20 @@ class _$AssetModelImpl implements _AssetModel {
@JsonKey(name: 'asset_name') required this.assetName,
@JsonKey(name: 'asset_code') this.assetCode,
@JsonKey(
name: 'asset_category_id',
readValue: _readAssetCategoryId,
name: 'item_category_id',
readValue: _readItemCategoryId,
fromJson: _intFromJsonNullable,
)
this.assetCategoryId,
@JsonKey(name: 'asset_category_name', readValue: _readAssetCategoryName)
@JsonKey(name: 'item_category_name', readValue: _readItemCategoryName)
this.assetCategoryName,
@JsonKey(
name: 'asset_subcategory_id',
readValue: _readAssetSubcategoryId,
name: 'item_subcategory_id',
readValue: _readItemSubcategoryId,
fromJson: _intFromJsonNullable,
)
this.assetSubcategoryId,
@JsonKey(
name: 'asset_subcategory_name',
readValue: _readAssetSubcategoryName,
)
@JsonKey(name: 'item_subcategory_name', readValue: _readItemSubcategoryName)
this.assetSubcategoryName,
@JsonKey(
name: 'plant_id',
@ -1225,23 +1216,23 @@ class _$AssetModelImpl implements _AssetModel {
final String? assetCode;
@override
@JsonKey(
name: 'asset_category_id',
readValue: _readAssetCategoryId,
name: 'item_category_id',
readValue: _readItemCategoryId,
fromJson: _intFromJsonNullable,
)
final int? assetCategoryId;
@override
@JsonKey(name: 'asset_category_name', readValue: _readAssetCategoryName)
@JsonKey(name: 'item_category_name', readValue: _readItemCategoryName)
final String? assetCategoryName;
@override
@JsonKey(
name: 'asset_subcategory_id',
readValue: _readAssetSubcategoryId,
name: 'item_subcategory_id',
readValue: _readItemSubcategoryId,
fromJson: _intFromJsonNullable,
)
final int? assetSubcategoryId;
@override
@JsonKey(name: 'asset_subcategory_name', readValue: _readAssetSubcategoryName)
@JsonKey(name: 'item_subcategory_name', readValue: _readItemSubcategoryName)
final String? assetSubcategoryName;
@override
@JsonKey(
@ -1501,23 +1492,20 @@ abstract class _AssetModel implements AssetModel {
@JsonKey(name: 'asset_name') required final String assetName,
@JsonKey(name: 'asset_code') final String? assetCode,
@JsonKey(
name: 'asset_category_id',
readValue: _readAssetCategoryId,
name: 'item_category_id',
readValue: _readItemCategoryId,
fromJson: _intFromJsonNullable,
)
final int? assetCategoryId,
@JsonKey(name: 'asset_category_name', readValue: _readAssetCategoryName)
@JsonKey(name: 'item_category_name', readValue: _readItemCategoryName)
final String? assetCategoryName,
@JsonKey(
name: 'asset_subcategory_id',
readValue: _readAssetSubcategoryId,
name: 'item_subcategory_id',
readValue: _readItemSubcategoryId,
fromJson: _intFromJsonNullable,
)
final int? assetSubcategoryId,
@JsonKey(
name: 'asset_subcategory_name',
readValue: _readAssetSubcategoryName,
)
@JsonKey(name: 'item_subcategory_name', readValue: _readItemSubcategoryName)
final String? assetSubcategoryName,
@JsonKey(
name: 'plant_id',
@ -1593,23 +1581,23 @@ abstract class _AssetModel implements AssetModel {
String? get assetCode;
@override
@JsonKey(
name: 'asset_category_id',
readValue: _readAssetCategoryId,
name: 'item_category_id',
readValue: _readItemCategoryId,
fromJson: _intFromJsonNullable,
)
int? get assetCategoryId;
@override
@JsonKey(name: 'asset_category_name', readValue: _readAssetCategoryName)
@JsonKey(name: 'item_category_name', readValue: _readItemCategoryName)
String? get assetCategoryName;
@override
@JsonKey(
name: 'asset_subcategory_id',
readValue: _readAssetSubcategoryId,
name: 'item_subcategory_id',
readValue: _readItemSubcategoryId,
fromJson: _intFromJsonNullable,
)
int? get assetSubcategoryId;
@override
@JsonKey(name: 'asset_subcategory_name', readValue: _readAssetSubcategoryName)
@JsonKey(name: 'item_subcategory_name', readValue: _readItemSubcategoryName)
String? get assetSubcategoryName;
@override
@JsonKey(

View File

@ -46,15 +46,15 @@ _$AssetModelImpl _$$AssetModelImplFromJson(Map<String, dynamic> json) =>
assetName: json['asset_name'] as String,
assetCode: json['asset_code'] as String?,
assetCategoryId: _intFromJsonNullable(
_readAssetCategoryId(json, 'asset_category_id'),
_readItemCategoryId(json, 'item_category_id'),
),
assetCategoryName:
_readAssetCategoryName(json, 'asset_category_name') as String?,
_readItemCategoryName(json, 'item_category_name') as String?,
assetSubcategoryId: _intFromJsonNullable(
_readAssetSubcategoryId(json, 'asset_subcategory_id'),
_readItemSubcategoryId(json, 'item_subcategory_id'),
),
assetSubcategoryName:
_readAssetSubcategoryName(json, 'asset_subcategory_name') as String?,
_readItemSubcategoryName(json, 'item_subcategory_name') as String?,
plantId: _intFromJsonNullable(_readPlantId(json, 'plant_id')),
plantName: _readPlantName(json, 'plant_name') as String?,
brandModel: json['brand_model'] as String?,
@ -96,10 +96,10 @@ Map<String, dynamic> _$$AssetModelImplToJson(_$AssetModelImpl instance) =>
'id': instance.id,
'asset_name': instance.assetName,
'asset_code': instance.assetCode,
'asset_category_id': instance.assetCategoryId,
'asset_category_name': instance.assetCategoryName,
'asset_subcategory_id': instance.assetSubcategoryId,
'asset_subcategory_name': instance.assetSubcategoryName,
'item_category_id': instance.assetCategoryId,
'item_category_name': instance.assetCategoryName,
'item_subcategory_id': instance.assetSubcategoryId,
'item_subcategory_name': instance.assetSubcategoryName,
'plant_id': instance.plantId,
'plant_name': instance.plantName,
'brand_model': instance.brandModel,

View 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;
}

File diff suppressed because it is too large Load Diff

View 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,
};

View File

@ -87,6 +87,36 @@ Object? _readUomName(Map<dynamic, dynamic> json, String key) {
return null;
}
Object? _readGrnItemCategoryId(Map<dynamic, dynamic> json, String key) {
for (final flatKey in ['item_category_id', 'asset_category_id']) {
final flat = json[flatKey];
if (flat != null) return flat;
}
final nested = json['item_category'] ?? json['asset_category'];
if (nested is Map) return nested['id'];
return null;
}
Object? _readGrnItemSubcategoryId(Map<dynamic, dynamic> json, String key) {
for (final flatKey in ['item_subcategory_id', 'asset_subcategory_id']) {
final flat = json[flatKey];
if (flat != null) return flat;
}
final nested = json['item_subcategory'] ?? json['asset_subcategory'];
if (nested is Map) return nested['id'];
return null;
}
Object? _readUploadedByName(Map<dynamic, dynamic> json, String key) {
final flat = json['uploaded_by_name'];
if (flat is String && flat.trim().isNotEmpty) return flat;
final nested = json['uploaded_by_user'] ?? json['uploaded_by'];
if (nested is Map) {
return nested['full_name'] ?? nested['name'];
}
return null;
}
@freezed
class GrnModel with _$GrnModel {
const GrnModel._();
@ -118,6 +148,7 @@ class GrnModel with _$GrnModel {
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable) DateTime? createdAt,
@JsonKey(name: 'updated_at', fromJson: _dateFromJsonNullable) DateTime? updatedAt,
@Default([]) List<GrnItemModel> items,
@Default([]) List<GrnAttachmentModel> attachments,
}) = _GrnModel;
factory GrnModel.fromJson(Map<String, dynamic> json) => _$GrnModelFromJson(json);
@ -125,6 +156,42 @@ class GrnModel with _$GrnModel {
bool get canEdit => status.toUpperCase() == 'POSTED';
bool get canCancel => status.toUpperCase() == 'POSTED';
/// Upload/delete attachments only while GRN is POSTED.
bool get canManageAttachments => status.toUpperCase() == 'POSTED';
}
@freezed
class GrnAttachmentModel with _$GrnAttachmentModel {
const GrnAttachmentModel._();
const factory GrnAttachmentModel({
@JsonKey(fromJson: _idFromJson) required String id,
@JsonKey(name: 'grn_id', fromJson: _idFromJson) String? grnId,
@JsonKey(name: 'file_name') String? fileName,
@JsonKey(name: 'file_type') String? fileType,
@JsonKey(name: 'file_size', fromJson: _intFromJsonNullable) int? fileSize,
@JsonKey(name: 'uploaded_by_name', readValue: _readUploadedByName)
String? uploadedByName,
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable) DateTime? createdAt,
}) = _GrnAttachmentModel;
factory GrnAttachmentModel.fromJson(Map<String, dynamic> json) =>
_$GrnAttachmentModelFromJson(json);
bool get isPdf =>
(fileType ?? '').toLowerCase().contains('pdf') ||
(fileName ?? '').toLowerCase().endsWith('.pdf');
bool get isImage {
final type = (fileType ?? '').toLowerCase();
final name = (fileName ?? '').toLowerCase();
return type.startsWith('image/') ||
name.endsWith('.jpg') ||
name.endsWith('.jpeg') ||
name.endsWith('.png') ||
name.endsWith('.webp');
}
}
@freezed
@ -149,10 +216,18 @@ class GrnItemModel with _$GrnItemModel {
@JsonKey(name: 'mfg_date', fromJson: _dateFromJsonNullable) DateTime? mfgDate,
@JsonKey(name: 'expiry_date', fromJson: _dateFromJsonNullable) DateTime? expiryDate,
@JsonKey(name: 'storage_location') String? storageLocation,
@JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable)
int? assetCategoryId,
@JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable)
int? assetSubcategoryId,
@JsonKey(
name: 'item_category_id',
readValue: _readGrnItemCategoryId,
fromJson: _intFromJsonNullable,
)
int? itemCategoryId,
@JsonKey(
name: 'item_subcategory_id',
readValue: _readGrnItemSubcategoryId,
fromJson: _intFromJsonNullable,
)
int? itemSubcategoryId,
@JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable) int? uomId,
@JsonKey(name: 'uom_name', readValue: _readUomName) String? uomName,
String? remarks,

View File

@ -64,6 +64,8 @@ mixin _$GrnModel {
@JsonKey(name: 'updated_at', fromJson: _dateFromJsonNullable)
DateTime? get updatedAt => throw _privateConstructorUsedError;
List<GrnItemModel> get items => throw _privateConstructorUsedError;
List<GrnAttachmentModel> get attachments =>
throw _privateConstructorUsedError;
/// Serializes this GrnModel to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@ -114,6 +116,7 @@ abstract class $GrnModelCopyWith<$Res> {
@JsonKey(name: 'updated_at', fromJson: _dateFromJsonNullable)
DateTime? updatedAt,
List<GrnItemModel> items,
List<GrnAttachmentModel> attachments,
});
}
@ -155,6 +158,7 @@ class _$GrnModelCopyWithImpl<$Res, $Val extends GrnModel>
Object? createdAt = freezed,
Object? updatedAt = freezed,
Object? items = null,
Object? attachments = null,
}) {
return _then(
_value.copyWith(
@ -250,6 +254,10 @@ class _$GrnModelCopyWithImpl<$Res, $Val extends GrnModel>
? _value.items
: items // ignore: cast_nullable_to_non_nullable
as List<GrnItemModel>,
attachments: null == attachments
? _value.attachments
: attachments // ignore: cast_nullable_to_non_nullable
as List<GrnAttachmentModel>,
)
as $Val,
);
@ -299,6 +307,7 @@ abstract class _$$GrnModelImplCopyWith<$Res>
@JsonKey(name: 'updated_at', fromJson: _dateFromJsonNullable)
DateTime? updatedAt,
List<GrnItemModel> items,
List<GrnAttachmentModel> attachments,
});
}
@ -339,6 +348,7 @@ class __$$GrnModelImplCopyWithImpl<$Res>
Object? createdAt = freezed,
Object? updatedAt = freezed,
Object? items = null,
Object? attachments = null,
}) {
return _then(
_$GrnModelImpl(
@ -434,6 +444,10 @@ class __$$GrnModelImplCopyWithImpl<$Res>
? _value._items
: items // ignore: cast_nullable_to_non_nullable
as List<GrnItemModel>,
attachments: null == attachments
? _value._attachments
: attachments // ignore: cast_nullable_to_non_nullable
as List<GrnAttachmentModel>,
),
);
}
@ -474,7 +488,9 @@ class _$GrnModelImpl extends _GrnModel {
@JsonKey(name: 'updated_at', fromJson: _dateFromJsonNullable)
this.updatedAt,
final List<GrnItemModel> items = const [],
final List<GrnAttachmentModel> attachments = const [],
}) : _items = items,
_attachments = attachments,
super._();
factory _$GrnModelImpl.fromJson(Map<String, dynamic> json) =>
@ -554,9 +570,18 @@ class _$GrnModelImpl extends _GrnModel {
return EqualUnmodifiableListView(_items);
}
final List<GrnAttachmentModel> _attachments;
@override
@JsonKey()
List<GrnAttachmentModel> get attachments {
if (_attachments is EqualUnmodifiableListView) return _attachments;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_attachments);
}
@override
String toString() {
return 'GrnModel(id: $id, grnNumber: $grnNumber, grnDate: $grnDate, status: $status, poId: $poId, poNumber: $poNumber, vendorId: $vendorId, vendorName: $vendorName, warehouseId: $warehouseId, warehouseName: $warehouseName, vendorInvoiceNo: $vendorInvoiceNo, vendorInvoiceDate: $vendorInvoiceDate, vendorInvoiceAmount: $vendorInvoiceAmount, vehicleNo: $vehicleNo, lrNo: $lrNo, lrDate: $lrDate, receivedBy: $receivedBy, qualityCheckedBy: $qualityCheckedBy, remarks: $remarks, cancellationReason: $cancellationReason, createdAt: $createdAt, updatedAt: $updatedAt, items: $items)';
return 'GrnModel(id: $id, grnNumber: $grnNumber, grnDate: $grnDate, status: $status, poId: $poId, poNumber: $poNumber, vendorId: $vendorId, vendorName: $vendorName, warehouseId: $warehouseId, warehouseName: $warehouseName, vendorInvoiceNo: $vendorInvoiceNo, vendorInvoiceDate: $vendorInvoiceDate, vendorInvoiceAmount: $vendorInvoiceAmount, vehicleNo: $vehicleNo, lrNo: $lrNo, lrDate: $lrDate, receivedBy: $receivedBy, qualityCheckedBy: $qualityCheckedBy, remarks: $remarks, cancellationReason: $cancellationReason, createdAt: $createdAt, updatedAt: $updatedAt, items: $items, attachments: $attachments)';
}
@override
@ -601,7 +626,11 @@ class _$GrnModelImpl extends _GrnModel {
other.createdAt == createdAt) &&
(identical(other.updatedAt, updatedAt) ||
other.updatedAt == updatedAt) &&
const DeepCollectionEquality().equals(other._items, _items));
const DeepCollectionEquality().equals(other._items, _items) &&
const DeepCollectionEquality().equals(
other._attachments,
_attachments,
));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@ -631,6 +660,7 @@ class _$GrnModelImpl extends _GrnModel {
createdAt,
updatedAt,
const DeepCollectionEquality().hash(_items),
const DeepCollectionEquality().hash(_attachments),
]);
/// Create a copy of GrnModel
@ -686,6 +716,7 @@ abstract class _GrnModel extends GrnModel {
@JsonKey(name: 'updated_at', fromJson: _dateFromJsonNullable)
final DateTime? updatedAt,
final List<GrnItemModel> items,
final List<GrnAttachmentModel> attachments,
}) = _$GrnModelImpl;
const _GrnModel._() : super._();
@ -758,6 +789,8 @@ abstract class _GrnModel extends GrnModel {
DateTime? get updatedAt;
@override
List<GrnItemModel> get items;
@override
List<GrnAttachmentModel> get attachments;
/// Create a copy of GrnModel
/// with the given fields replaced by the non-null parameter values.
@ -767,6 +800,337 @@ abstract class _GrnModel extends GrnModel {
throw _privateConstructorUsedError;
}
GrnAttachmentModel _$GrnAttachmentModelFromJson(Map<String, dynamic> json) {
return _GrnAttachmentModel.fromJson(json);
}
/// @nodoc
mixin _$GrnAttachmentModel {
@JsonKey(fromJson: _idFromJson)
String get id => throw _privateConstructorUsedError;
@JsonKey(name: 'grn_id', fromJson: _idFromJson)
String? get grnId => throw _privateConstructorUsedError;
@JsonKey(name: 'file_name')
String? get fileName => throw _privateConstructorUsedError;
@JsonKey(name: 'file_type')
String? get fileType => throw _privateConstructorUsedError;
@JsonKey(name: 'file_size', fromJson: _intFromJsonNullable)
int? get fileSize => throw _privateConstructorUsedError;
@JsonKey(name: 'uploaded_by_name', readValue: _readUploadedByName)
String? get uploadedByName => throw _privateConstructorUsedError;
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable)
DateTime? get createdAt => throw _privateConstructorUsedError;
/// Serializes this GrnAttachmentModel to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of GrnAttachmentModel
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$GrnAttachmentModelCopyWith<GrnAttachmentModel> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $GrnAttachmentModelCopyWith<$Res> {
factory $GrnAttachmentModelCopyWith(
GrnAttachmentModel value,
$Res Function(GrnAttachmentModel) then,
) = _$GrnAttachmentModelCopyWithImpl<$Res, GrnAttachmentModel>;
@useResult
$Res call({
@JsonKey(fromJson: _idFromJson) String id,
@JsonKey(name: 'grn_id', fromJson: _idFromJson) String? grnId,
@JsonKey(name: 'file_name') String? fileName,
@JsonKey(name: 'file_type') String? fileType,
@JsonKey(name: 'file_size', fromJson: _intFromJsonNullable) int? fileSize,
@JsonKey(name: 'uploaded_by_name', readValue: _readUploadedByName)
String? uploadedByName,
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable)
DateTime? createdAt,
});
}
/// @nodoc
class _$GrnAttachmentModelCopyWithImpl<$Res, $Val extends GrnAttachmentModel>
implements $GrnAttachmentModelCopyWith<$Res> {
_$GrnAttachmentModelCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of GrnAttachmentModel
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? grnId = freezed,
Object? fileName = freezed,
Object? fileType = freezed,
Object? fileSize = freezed,
Object? uploadedByName = freezed,
Object? createdAt = freezed,
}) {
return _then(
_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String,
grnId: freezed == grnId
? _value.grnId
: grnId // ignore: cast_nullable_to_non_nullable
as String?,
fileName: freezed == fileName
? _value.fileName
: fileName // ignore: cast_nullable_to_non_nullable
as String?,
fileType: freezed == fileType
? _value.fileType
: fileType // ignore: cast_nullable_to_non_nullable
as String?,
fileSize: freezed == fileSize
? _value.fileSize
: fileSize // ignore: cast_nullable_to_non_nullable
as int?,
uploadedByName: freezed == uploadedByName
? _value.uploadedByName
: uploadedByName // ignore: cast_nullable_to_non_nullable
as String?,
createdAt: freezed == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
)
as $Val,
);
}
}
/// @nodoc
abstract class _$$GrnAttachmentModelImplCopyWith<$Res>
implements $GrnAttachmentModelCopyWith<$Res> {
factory _$$GrnAttachmentModelImplCopyWith(
_$GrnAttachmentModelImpl value,
$Res Function(_$GrnAttachmentModelImpl) then,
) = __$$GrnAttachmentModelImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({
@JsonKey(fromJson: _idFromJson) String id,
@JsonKey(name: 'grn_id', fromJson: _idFromJson) String? grnId,
@JsonKey(name: 'file_name') String? fileName,
@JsonKey(name: 'file_type') String? fileType,
@JsonKey(name: 'file_size', fromJson: _intFromJsonNullable) int? fileSize,
@JsonKey(name: 'uploaded_by_name', readValue: _readUploadedByName)
String? uploadedByName,
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable)
DateTime? createdAt,
});
}
/// @nodoc
class __$$GrnAttachmentModelImplCopyWithImpl<$Res>
extends _$GrnAttachmentModelCopyWithImpl<$Res, _$GrnAttachmentModelImpl>
implements _$$GrnAttachmentModelImplCopyWith<$Res> {
__$$GrnAttachmentModelImplCopyWithImpl(
_$GrnAttachmentModelImpl _value,
$Res Function(_$GrnAttachmentModelImpl) _then,
) : super(_value, _then);
/// Create a copy of GrnAttachmentModel
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? grnId = freezed,
Object? fileName = freezed,
Object? fileType = freezed,
Object? fileSize = freezed,
Object? uploadedByName = freezed,
Object? createdAt = freezed,
}) {
return _then(
_$GrnAttachmentModelImpl(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String,
grnId: freezed == grnId
? _value.grnId
: grnId // ignore: cast_nullable_to_non_nullable
as String?,
fileName: freezed == fileName
? _value.fileName
: fileName // ignore: cast_nullable_to_non_nullable
as String?,
fileType: freezed == fileType
? _value.fileType
: fileType // ignore: cast_nullable_to_non_nullable
as String?,
fileSize: freezed == fileSize
? _value.fileSize
: fileSize // ignore: cast_nullable_to_non_nullable
as int?,
uploadedByName: freezed == uploadedByName
? _value.uploadedByName
: uploadedByName // ignore: cast_nullable_to_non_nullable
as String?,
createdAt: freezed == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
),
);
}
}
/// @nodoc
@JsonSerializable()
class _$GrnAttachmentModelImpl extends _GrnAttachmentModel {
const _$GrnAttachmentModelImpl({
@JsonKey(fromJson: _idFromJson) required this.id,
@JsonKey(name: 'grn_id', fromJson: _idFromJson) this.grnId,
@JsonKey(name: 'file_name') this.fileName,
@JsonKey(name: 'file_type') this.fileType,
@JsonKey(name: 'file_size', fromJson: _intFromJsonNullable) this.fileSize,
@JsonKey(name: 'uploaded_by_name', readValue: _readUploadedByName)
this.uploadedByName,
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable)
this.createdAt,
}) : super._();
factory _$GrnAttachmentModelImpl.fromJson(Map<String, dynamic> json) =>
_$$GrnAttachmentModelImplFromJson(json);
@override
@JsonKey(fromJson: _idFromJson)
final String id;
@override
@JsonKey(name: 'grn_id', fromJson: _idFromJson)
final String? grnId;
@override
@JsonKey(name: 'file_name')
final String? fileName;
@override
@JsonKey(name: 'file_type')
final String? fileType;
@override
@JsonKey(name: 'file_size', fromJson: _intFromJsonNullable)
final int? fileSize;
@override
@JsonKey(name: 'uploaded_by_name', readValue: _readUploadedByName)
final String? uploadedByName;
@override
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable)
final DateTime? createdAt;
@override
String toString() {
return 'GrnAttachmentModel(id: $id, grnId: $grnId, fileName: $fileName, fileType: $fileType, fileSize: $fileSize, uploadedByName: $uploadedByName, createdAt: $createdAt)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$GrnAttachmentModelImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.grnId, grnId) || other.grnId == grnId) &&
(identical(other.fileName, fileName) ||
other.fileName == fileName) &&
(identical(other.fileType, fileType) ||
other.fileType == fileType) &&
(identical(other.fileSize, fileSize) ||
other.fileSize == fileSize) &&
(identical(other.uploadedByName, uploadedByName) ||
other.uploadedByName == uploadedByName) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
grnId,
fileName,
fileType,
fileSize,
uploadedByName,
createdAt,
);
/// Create a copy of GrnAttachmentModel
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$GrnAttachmentModelImplCopyWith<_$GrnAttachmentModelImpl> get copyWith =>
__$$GrnAttachmentModelImplCopyWithImpl<_$GrnAttachmentModelImpl>(
this,
_$identity,
);
@override
Map<String, dynamic> toJson() {
return _$$GrnAttachmentModelImplToJson(this);
}
}
abstract class _GrnAttachmentModel extends GrnAttachmentModel {
const factory _GrnAttachmentModel({
@JsonKey(fromJson: _idFromJson) required final String id,
@JsonKey(name: 'grn_id', fromJson: _idFromJson) final String? grnId,
@JsonKey(name: 'file_name') final String? fileName,
@JsonKey(name: 'file_type') final String? fileType,
@JsonKey(name: 'file_size', fromJson: _intFromJsonNullable)
final int? fileSize,
@JsonKey(name: 'uploaded_by_name', readValue: _readUploadedByName)
final String? uploadedByName,
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable)
final DateTime? createdAt,
}) = _$GrnAttachmentModelImpl;
const _GrnAttachmentModel._() : super._();
factory _GrnAttachmentModel.fromJson(Map<String, dynamic> json) =
_$GrnAttachmentModelImpl.fromJson;
@override
@JsonKey(fromJson: _idFromJson)
String get id;
@override
@JsonKey(name: 'grn_id', fromJson: _idFromJson)
String? get grnId;
@override
@JsonKey(name: 'file_name')
String? get fileName;
@override
@JsonKey(name: 'file_type')
String? get fileType;
@override
@JsonKey(name: 'file_size', fromJson: _intFromJsonNullable)
int? get fileSize;
@override
@JsonKey(name: 'uploaded_by_name', readValue: _readUploadedByName)
String? get uploadedByName;
@override
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable)
DateTime? get createdAt;
/// Create a copy of GrnAttachmentModel
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$GrnAttachmentModelImplCopyWith<_$GrnAttachmentModelImpl> get copyWith =>
throw _privateConstructorUsedError;
}
GrnItemModel _$GrnItemModelFromJson(Map<String, dynamic> json) {
return _GrnItemModel.fromJson(json);
}
@ -811,10 +1175,18 @@ mixin _$GrnItemModel {
DateTime? get expiryDate => throw _privateConstructorUsedError;
@JsonKey(name: 'storage_location')
String? get storageLocation => throw _privateConstructorUsedError;
@JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable)
int? get assetCategoryId => throw _privateConstructorUsedError;
@JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable)
int? get assetSubcategoryId => throw _privateConstructorUsedError;
@JsonKey(
name: 'item_category_id',
readValue: _readGrnItemCategoryId,
fromJson: _intFromJsonNullable,
)
int? get itemCategoryId => throw _privateConstructorUsedError;
@JsonKey(
name: 'item_subcategory_id',
readValue: _readGrnItemSubcategoryId,
fromJson: _intFromJsonNullable,
)
int? get itemSubcategoryId => throw _privateConstructorUsedError;
@JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable)
int? get uomId => throw _privateConstructorUsedError;
@JsonKey(name: 'uom_name', readValue: _readUomName)
@ -866,10 +1238,18 @@ abstract class $GrnItemModelCopyWith<$Res> {
@JsonKey(name: 'expiry_date', fromJson: _dateFromJsonNullable)
DateTime? expiryDate,
@JsonKey(name: 'storage_location') String? storageLocation,
@JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable)
int? assetCategoryId,
@JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable)
int? assetSubcategoryId,
@JsonKey(
name: 'item_category_id',
readValue: _readGrnItemCategoryId,
fromJson: _intFromJsonNullable,
)
int? itemCategoryId,
@JsonKey(
name: 'item_subcategory_id',
readValue: _readGrnItemSubcategoryId,
fromJson: _intFromJsonNullable,
)
int? itemSubcategoryId,
@JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable) int? uomId,
@JsonKey(name: 'uom_name', readValue: _readUomName) String? uomName,
String? remarks,
@ -910,8 +1290,8 @@ class _$GrnItemModelCopyWithImpl<$Res, $Val extends GrnItemModel>
Object? mfgDate = freezed,
Object? expiryDate = freezed,
Object? storageLocation = freezed,
Object? assetCategoryId = freezed,
Object? assetSubcategoryId = freezed,
Object? itemCategoryId = freezed,
Object? itemSubcategoryId = freezed,
Object? uomId = freezed,
Object? uomName = freezed,
Object? remarks = freezed,
@ -994,13 +1374,13 @@ class _$GrnItemModelCopyWithImpl<$Res, $Val extends GrnItemModel>
? _value.storageLocation
: storageLocation // ignore: cast_nullable_to_non_nullable
as String?,
assetCategoryId: freezed == assetCategoryId
? _value.assetCategoryId
: assetCategoryId // ignore: cast_nullable_to_non_nullable
itemCategoryId: freezed == itemCategoryId
? _value.itemCategoryId
: itemCategoryId // ignore: cast_nullable_to_non_nullable
as int?,
assetSubcategoryId: freezed == assetSubcategoryId
? _value.assetSubcategoryId
: assetSubcategoryId // ignore: cast_nullable_to_non_nullable
itemSubcategoryId: freezed == itemSubcategoryId
? _value.itemSubcategoryId
: itemSubcategoryId // ignore: cast_nullable_to_non_nullable
as int?,
uomId: freezed == uomId
? _value.uomId
@ -1057,10 +1437,18 @@ abstract class _$$GrnItemModelImplCopyWith<$Res>
@JsonKey(name: 'expiry_date', fromJson: _dateFromJsonNullable)
DateTime? expiryDate,
@JsonKey(name: 'storage_location') String? storageLocation,
@JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable)
int? assetCategoryId,
@JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable)
int? assetSubcategoryId,
@JsonKey(
name: 'item_category_id',
readValue: _readGrnItemCategoryId,
fromJson: _intFromJsonNullable,
)
int? itemCategoryId,
@JsonKey(
name: 'item_subcategory_id',
readValue: _readGrnItemSubcategoryId,
fromJson: _intFromJsonNullable,
)
int? itemSubcategoryId,
@JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable) int? uomId,
@JsonKey(name: 'uom_name', readValue: _readUomName) String? uomName,
String? remarks,
@ -1100,8 +1488,8 @@ class __$$GrnItemModelImplCopyWithImpl<$Res>
Object? mfgDate = freezed,
Object? expiryDate = freezed,
Object? storageLocation = freezed,
Object? assetCategoryId = freezed,
Object? assetSubcategoryId = freezed,
Object? itemCategoryId = freezed,
Object? itemSubcategoryId = freezed,
Object? uomId = freezed,
Object? uomName = freezed,
Object? remarks = freezed,
@ -1184,13 +1572,13 @@ class __$$GrnItemModelImplCopyWithImpl<$Res>
? _value.storageLocation
: storageLocation // ignore: cast_nullable_to_non_nullable
as String?,
assetCategoryId: freezed == assetCategoryId
? _value.assetCategoryId
: assetCategoryId // ignore: cast_nullable_to_non_nullable
itemCategoryId: freezed == itemCategoryId
? _value.itemCategoryId
: itemCategoryId // ignore: cast_nullable_to_non_nullable
as int?,
assetSubcategoryId: freezed == assetSubcategoryId
? _value.assetSubcategoryId
: assetSubcategoryId // ignore: cast_nullable_to_non_nullable
itemSubcategoryId: freezed == itemSubcategoryId
? _value.itemSubcategoryId
: itemSubcategoryId // ignore: cast_nullable_to_non_nullable
as int?,
uomId: freezed == uomId
? _value.uomId
@ -1239,10 +1627,18 @@ class _$GrnItemModelImpl implements _GrnItemModel {
@JsonKey(name: 'expiry_date', fromJson: _dateFromJsonNullable)
this.expiryDate,
@JsonKey(name: 'storage_location') this.storageLocation,
@JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable)
this.assetCategoryId,
@JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable)
this.assetSubcategoryId,
@JsonKey(
name: 'item_category_id',
readValue: _readGrnItemCategoryId,
fromJson: _intFromJsonNullable,
)
this.itemCategoryId,
@JsonKey(
name: 'item_subcategory_id',
readValue: _readGrnItemSubcategoryId,
fromJson: _intFromJsonNullable,
)
this.itemSubcategoryId,
@JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable) this.uomId,
@JsonKey(name: 'uom_name', readValue: _readUomName) this.uomName,
this.remarks,
@ -1309,11 +1705,19 @@ class _$GrnItemModelImpl implements _GrnItemModel {
@JsonKey(name: 'storage_location')
final String? storageLocation;
@override
@JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable)
final int? assetCategoryId;
@JsonKey(
name: 'item_category_id',
readValue: _readGrnItemCategoryId,
fromJson: _intFromJsonNullable,
)
final int? itemCategoryId;
@override
@JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable)
final int? assetSubcategoryId;
@JsonKey(
name: 'item_subcategory_id',
readValue: _readGrnItemSubcategoryId,
fromJson: _intFromJsonNullable,
)
final int? itemSubcategoryId;
@override
@JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable)
final int? uomId;
@ -1325,7 +1729,7 @@ class _$GrnItemModelImpl implements _GrnItemModel {
@override
String toString() {
return 'GrnItemModel(id: $id, grnId: $grnId, poItemId: $poItemId, itemId: $itemId, itemCode: $itemCode, itemName: $itemName, lineNo: $lineNo, currentQty: $currentQty, acceptedQty: $acceptedQty, damagedQty: $damagedQty, shortQty: $shortQty, excessQty: $excessQty, rejectedQty: $rejectedQty, rejectionReason: $rejectionReason, rate: $rate, batchNo: $batchNo, mfgDate: $mfgDate, expiryDate: $expiryDate, storageLocation: $storageLocation, assetCategoryId: $assetCategoryId, assetSubcategoryId: $assetSubcategoryId, uomId: $uomId, uomName: $uomName, remarks: $remarks)';
return 'GrnItemModel(id: $id, grnId: $grnId, poItemId: $poItemId, itemId: $itemId, itemCode: $itemCode, itemName: $itemName, lineNo: $lineNo, currentQty: $currentQty, acceptedQty: $acceptedQty, damagedQty: $damagedQty, shortQty: $shortQty, excessQty: $excessQty, rejectedQty: $rejectedQty, rejectionReason: $rejectionReason, rate: $rate, batchNo: $batchNo, mfgDate: $mfgDate, expiryDate: $expiryDate, storageLocation: $storageLocation, itemCategoryId: $itemCategoryId, itemSubcategoryId: $itemSubcategoryId, uomId: $uomId, uomName: $uomName, remarks: $remarks)';
}
@override
@ -1364,10 +1768,10 @@ class _$GrnItemModelImpl implements _GrnItemModel {
other.expiryDate == expiryDate) &&
(identical(other.storageLocation, storageLocation) ||
other.storageLocation == storageLocation) &&
(identical(other.assetCategoryId, assetCategoryId) ||
other.assetCategoryId == assetCategoryId) &&
(identical(other.assetSubcategoryId, assetSubcategoryId) ||
other.assetSubcategoryId == assetSubcategoryId) &&
(identical(other.itemCategoryId, itemCategoryId) ||
other.itemCategoryId == itemCategoryId) &&
(identical(other.itemSubcategoryId, itemSubcategoryId) ||
other.itemSubcategoryId == itemSubcategoryId) &&
(identical(other.uomId, uomId) || other.uomId == uomId) &&
(identical(other.uomName, uomName) || other.uomName == uomName) &&
(identical(other.remarks, remarks) || other.remarks == remarks));
@ -1396,8 +1800,8 @@ class _$GrnItemModelImpl implements _GrnItemModel {
mfgDate,
expiryDate,
storageLocation,
assetCategoryId,
assetSubcategoryId,
itemCategoryId,
itemSubcategoryId,
uomId,
uomName,
remarks,
@ -1449,10 +1853,18 @@ abstract class _GrnItemModel implements GrnItemModel {
@JsonKey(name: 'expiry_date', fromJson: _dateFromJsonNullable)
final DateTime? expiryDate,
@JsonKey(name: 'storage_location') final String? storageLocation,
@JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable)
final int? assetCategoryId,
@JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable)
final int? assetSubcategoryId,
@JsonKey(
name: 'item_category_id',
readValue: _readGrnItemCategoryId,
fromJson: _intFromJsonNullable,
)
final int? itemCategoryId,
@JsonKey(
name: 'item_subcategory_id',
readValue: _readGrnItemSubcategoryId,
fromJson: _intFromJsonNullable,
)
final int? itemSubcategoryId,
@JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable) final int? uomId,
@JsonKey(name: 'uom_name', readValue: _readUomName) final String? uomName,
final String? remarks,
@ -1519,11 +1931,19 @@ abstract class _GrnItemModel implements GrnItemModel {
@JsonKey(name: 'storage_location')
String? get storageLocation;
@override
@JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable)
int? get assetCategoryId;
@JsonKey(
name: 'item_category_id',
readValue: _readGrnItemCategoryId,
fromJson: _intFromJsonNullable,
)
int? get itemCategoryId;
@override
@JsonKey(name: 'asset_subcategory_id', fromJson: _intFromJsonNullable)
int? get assetSubcategoryId;
@JsonKey(
name: 'item_subcategory_id',
readValue: _readGrnItemSubcategoryId,
fromJson: _intFromJsonNullable,
)
int? get itemSubcategoryId;
@override
@JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable)
int? get uomId;

View File

@ -6,38 +6,42 @@ part of 'grn_model.dart';
// JsonSerializableGenerator
// **************************************************************************
_$GrnModelImpl _$$GrnModelImplFromJson(Map<String, dynamic> json) =>
_$GrnModelImpl(
id: _idFromJson(json['id']),
grnNumber: _readPoNumber(json, 'grn_number') as String?,
grnDate: _dateFromJsonNullable(json['grn_date']),
status: json['status'] as String? ?? 'POSTED',
poId: _intFromJsonNullable(json['po_id']),
poNumber: _readPoRefNumber(json, 'po_number') as String?,
vendorId: _intFromJsonNullable(json['vendor_id']),
vendorName: _readVendorName(json, 'vendor_name') as String?,
warehouseId: _intFromJsonNullable(json['warehouse_id']),
warehouseName: _readWarehouseName(json, 'warehouse_name') as String?,
vendorInvoiceNo: json['vendor_invoice_no'] as String?,
vendorInvoiceDate: _dateFromJsonNullable(json['vendor_invoice_date']),
vendorInvoiceAmount: _doubleFromJsonNullable(
json['vendor_invoice_amount'],
),
vehicleNo: json['vehicle_no'] as String?,
lrNo: json['lr_no'] as String?,
lrDate: _dateFromJsonNullable(json['lr_date']),
receivedBy: _intFromJsonNullable(json['received_by']),
qualityCheckedBy: _intFromJsonNullable(json['quality_checked_by']),
remarks: json['remarks'] as String?,
cancellationReason: json['cancellation_reason'] as String?,
createdAt: _dateFromJsonNullable(json['created_at']),
updatedAt: _dateFromJsonNullable(json['updated_at']),
items:
(json['items'] as List<dynamic>?)
?.map((e) => GrnItemModel.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
);
_$GrnModelImpl _$$GrnModelImplFromJson(
Map<String, dynamic> json,
) => _$GrnModelImpl(
id: _idFromJson(json['id']),
grnNumber: _readPoNumber(json, 'grn_number') as String?,
grnDate: _dateFromJsonNullable(json['grn_date']),
status: json['status'] as String? ?? 'POSTED',
poId: _intFromJsonNullable(json['po_id']),
poNumber: _readPoRefNumber(json, 'po_number') as String?,
vendorId: _intFromJsonNullable(json['vendor_id']),
vendorName: _readVendorName(json, 'vendor_name') as String?,
warehouseId: _intFromJsonNullable(json['warehouse_id']),
warehouseName: _readWarehouseName(json, 'warehouse_name') as String?,
vendorInvoiceNo: json['vendor_invoice_no'] as String?,
vendorInvoiceDate: _dateFromJsonNullable(json['vendor_invoice_date']),
vendorInvoiceAmount: _doubleFromJsonNullable(json['vendor_invoice_amount']),
vehicleNo: json['vehicle_no'] as String?,
lrNo: json['lr_no'] as String?,
lrDate: _dateFromJsonNullable(json['lr_date']),
receivedBy: _intFromJsonNullable(json['received_by']),
qualityCheckedBy: _intFromJsonNullable(json['quality_checked_by']),
remarks: json['remarks'] as String?,
cancellationReason: json['cancellation_reason'] as String?,
createdAt: _dateFromJsonNullable(json['created_at']),
updatedAt: _dateFromJsonNullable(json['updated_at']),
items:
(json['items'] as List<dynamic>?)
?.map((e) => GrnItemModel.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
attachments:
(json['attachments'] as List<dynamic>?)
?.map((e) => GrnAttachmentModel.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
);
Map<String, dynamic> _$$GrnModelImplToJson(_$GrnModelImpl instance) =>
<String, dynamic>{
@ -64,8 +68,33 @@ Map<String, dynamic> _$$GrnModelImplToJson(_$GrnModelImpl instance) =>
'created_at': instance.createdAt?.toIso8601String(),
'updated_at': instance.updatedAt?.toIso8601String(),
'items': instance.items,
'attachments': instance.attachments,
};
_$GrnAttachmentModelImpl _$$GrnAttachmentModelImplFromJson(
Map<String, dynamic> json,
) => _$GrnAttachmentModelImpl(
id: _idFromJson(json['id']),
grnId: _idFromJson(json['grn_id']),
fileName: json['file_name'] as String?,
fileType: json['file_type'] as String?,
fileSize: _intFromJsonNullable(json['file_size']),
uploadedByName: _readUploadedByName(json, 'uploaded_by_name') as String?,
createdAt: _dateFromJsonNullable(json['created_at']),
);
Map<String, dynamic> _$$GrnAttachmentModelImplToJson(
_$GrnAttachmentModelImpl instance,
) => <String, dynamic>{
'id': instance.id,
'grn_id': instance.grnId,
'file_name': instance.fileName,
'file_type': instance.fileType,
'file_size': instance.fileSize,
'uploaded_by_name': instance.uploadedByName,
'created_at': instance.createdAt?.toIso8601String(),
};
_$GrnItemModelImpl _$$GrnItemModelImplFromJson(Map<String, dynamic> json) =>
_$GrnItemModelImpl(
id: _idFromJson(json['id']),
@ -87,8 +116,12 @@ _$GrnItemModelImpl _$$GrnItemModelImplFromJson(Map<String, dynamic> json) =>
mfgDate: _dateFromJsonNullable(json['mfg_date']),
expiryDate: _dateFromJsonNullable(json['expiry_date']),
storageLocation: json['storage_location'] as String?,
assetCategoryId: _intFromJsonNullable(json['asset_category_id']),
assetSubcategoryId: _intFromJsonNullable(json['asset_subcategory_id']),
itemCategoryId: _intFromJsonNullable(
_readGrnItemCategoryId(json, 'item_category_id'),
),
itemSubcategoryId: _intFromJsonNullable(
_readGrnItemSubcategoryId(json, 'item_subcategory_id'),
),
uomId: _intFromJsonNullable(json['uom_id']),
uomName: _readUomName(json, 'uom_name') as String?,
remarks: json['remarks'] as String?,
@ -115,8 +148,8 @@ Map<String, dynamic> _$$GrnItemModelImplToJson(_$GrnItemModelImpl instance) =>
'mfg_date': instance.mfgDate?.toIso8601String(),
'expiry_date': instance.expiryDate?.toIso8601String(),
'storage_location': instance.storageLocation,
'asset_category_id': instance.assetCategoryId,
'asset_subcategory_id': instance.assetSubcategoryId,
'item_category_id': instance.itemCategoryId,
'item_subcategory_id': instance.itemSubcategoryId,
'uom_id': instance.uomId,
'uom_name': instance.uomName,
'remarks': instance.remarks,

View File

@ -48,7 +48,10 @@ Object? _readItemName(Map<dynamic, dynamic> json, String key) {
final flat = json['item_name'];
if (flat is String && flat.isNotEmpty) return flat;
final nested = json['item'];
if (nested is Map) return nested['name'];
if (nested is Map) {
final name = nested['item_name'] ?? nested['name'];
if (name is String && name.isNotEmpty) return name;
}
return null;
}
@ -56,7 +59,10 @@ Object? _readItemCode(Map<dynamic, dynamic> json, String key) {
final flat = json['item_code'];
if (flat is String && flat.isNotEmpty) return flat;
final nested = json['item'];
if (nested is Map) return nested['code'];
if (nested is Map) {
final code = nested['item_code'] ?? nested['code'];
if (code is String && code.isNotEmpty) return code;
}
return null;
}
@ -64,10 +70,46 @@ Object? _readUomName(Map<dynamic, dynamic> json, String key) {
final flat = json['uom_name'];
if (flat is String && flat.isNotEmpty) return flat;
final nested = json['uom'];
if (nested is Map) return nested['name'];
if (nested is Map) {
final name = nested['name'] ?? nested['uom_name'] ?? nested['code'];
if (name is String && name.isNotEmpty) return name;
}
return null;
}
Object? _readHsnCodeId(Map<dynamic, dynamic> json, String key) {
final flat = json['hsn_code_id'];
if (flat != null) return flat;
final nested = json['hsn_code'] ?? json['hsn'];
if (nested is Map) return nested['id'] ?? nested['hsn_code_id'];
return null;
}
Object? _readHsnCodeName(Map<dynamic, dynamic> json, String key) {
final flat = json['hsn_code_name'] ?? json['hsn_code'];
if (flat is String && flat.isNotEmpty) return flat;
final nested = json['hsn_code'] ?? json['hsn'];
if (nested is Map) {
final code = nested['code'] ?? nested['hsn_code'] ?? nested['name'];
if (code is String && code.isNotEmpty) return code;
}
return null;
}
Object? _readPoItemNestedInt(Map<dynamic, dynamic> json, String field) {
final flat = json[field];
if (flat != null) return flat;
final nested = json['item'];
if (nested is Map) return nested[field];
return null;
}
Object? _readPoItemCategoryId(Map<dynamic, dynamic> json, String key) =>
_readPoItemNestedInt(json, 'item_category_id');
Object? _readPoItemSubcategoryId(Map<dynamic, dynamic> json, String key) =>
_readPoItemNestedInt(json, 'item_subcategory_id');
Object? _readPoNumber(Map<dynamic, dynamic> json, String key) {
final poNumber = json['po_number'];
if (poNumber != null && poNumber.toString().trim().isNotEmpty) {
@ -176,9 +218,28 @@ class PurchaseOrderItemModel with _$PurchaseOrderItemModel {
@JsonKey(name: 'discount_amount', fromJson: _doubleFromJsonNullable)
double? discountAmount,
@JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable) int? gstRateId,
@JsonKey(name: 'hsn_code_id', fromJson: _intFromJsonNullable) int? hsnCodeId,
@JsonKey(
name: 'hsn_code_id',
readValue: _readHsnCodeId,
fromJson: _intFromJsonNullable,
)
int? hsnCodeId,
@JsonKey(name: 'hsn_code_name', readValue: _readHsnCodeName)
String? hsnCodeName,
@JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable)
double? lineAmount,
@JsonKey(
name: 'item_category_id',
readValue: _readPoItemCategoryId,
fromJson: _intFromJsonNullable,
)
int? itemCategoryId,
@JsonKey(
name: 'item_subcategory_id',
readValue: _readPoItemSubcategoryId,
fromJson: _intFromJsonNullable,
)
int? itemSubcategoryId,
String? remarks,
}) = _PurchaseOrderItemModel;

View File

@ -922,10 +922,28 @@ mixin _$PurchaseOrderItemModel {
double? get discountAmount => throw _privateConstructorUsedError;
@JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable)
int? get gstRateId => throw _privateConstructorUsedError;
@JsonKey(name: 'hsn_code_id', fromJson: _intFromJsonNullable)
@JsonKey(
name: 'hsn_code_id',
readValue: _readHsnCodeId,
fromJson: _intFromJsonNullable,
)
int? get hsnCodeId => throw _privateConstructorUsedError;
@JsonKey(name: 'hsn_code_name', readValue: _readHsnCodeName)
String? get hsnCodeName => throw _privateConstructorUsedError;
@JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable)
double? get lineAmount => throw _privateConstructorUsedError;
@JsonKey(
name: 'item_category_id',
readValue: _readPoItemCategoryId,
fromJson: _intFromJsonNullable,
)
int? get itemCategoryId => throw _privateConstructorUsedError;
@JsonKey(
name: 'item_subcategory_id',
readValue: _readPoItemSubcategoryId,
fromJson: _intFromJsonNullable,
)
int? get itemSubcategoryId => throw _privateConstructorUsedError;
String? get remarks => throw _privateConstructorUsedError;
/// Serializes this PurchaseOrderItemModel to a JSON map.
@ -965,10 +983,28 @@ abstract class $PurchaseOrderItemModelCopyWith<$Res> {
double? discountAmount,
@JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable)
int? gstRateId,
@JsonKey(name: 'hsn_code_id', fromJson: _intFromJsonNullable)
@JsonKey(
name: 'hsn_code_id',
readValue: _readHsnCodeId,
fromJson: _intFromJsonNullable,
)
int? hsnCodeId,
@JsonKey(name: 'hsn_code_name', readValue: _readHsnCodeName)
String? hsnCodeName,
@JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable)
double? lineAmount,
@JsonKey(
name: 'item_category_id',
readValue: _readPoItemCategoryId,
fromJson: _intFromJsonNullable,
)
int? itemCategoryId,
@JsonKey(
name: 'item_subcategory_id',
readValue: _readPoItemSubcategoryId,
fromJson: _intFromJsonNullable,
)
int? itemSubcategoryId,
String? remarks,
});
}
@ -1006,7 +1042,10 @@ class _$PurchaseOrderItemModelCopyWithImpl<
Object? discountAmount = freezed,
Object? gstRateId = freezed,
Object? hsnCodeId = freezed,
Object? hsnCodeName = freezed,
Object? lineAmount = freezed,
Object? itemCategoryId = freezed,
Object? itemSubcategoryId = freezed,
Object? remarks = freezed,
}) {
return _then(
@ -1071,10 +1110,22 @@ class _$PurchaseOrderItemModelCopyWithImpl<
? _value.hsnCodeId
: hsnCodeId // ignore: cast_nullable_to_non_nullable
as int?,
hsnCodeName: freezed == hsnCodeName
? _value.hsnCodeName
: hsnCodeName // ignore: cast_nullable_to_non_nullable
as String?,
lineAmount: freezed == lineAmount
? _value.lineAmount
: lineAmount // ignore: cast_nullable_to_non_nullable
as double?,
itemCategoryId: freezed == itemCategoryId
? _value.itemCategoryId
: itemCategoryId // ignore: cast_nullable_to_non_nullable
as int?,
itemSubcategoryId: freezed == itemSubcategoryId
? _value.itemSubcategoryId
: itemSubcategoryId // ignore: cast_nullable_to_non_nullable
as int?,
remarks: freezed == remarks
? _value.remarks
: remarks // ignore: cast_nullable_to_non_nullable
@ -1114,10 +1165,28 @@ abstract class _$$PurchaseOrderItemModelImplCopyWith<$Res>
double? discountAmount,
@JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable)
int? gstRateId,
@JsonKey(name: 'hsn_code_id', fromJson: _intFromJsonNullable)
@JsonKey(
name: 'hsn_code_id',
readValue: _readHsnCodeId,
fromJson: _intFromJsonNullable,
)
int? hsnCodeId,
@JsonKey(name: 'hsn_code_name', readValue: _readHsnCodeName)
String? hsnCodeName,
@JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable)
double? lineAmount,
@JsonKey(
name: 'item_category_id',
readValue: _readPoItemCategoryId,
fromJson: _intFromJsonNullable,
)
int? itemCategoryId,
@JsonKey(
name: 'item_subcategory_id',
readValue: _readPoItemSubcategoryId,
fromJson: _intFromJsonNullable,
)
int? itemSubcategoryId,
String? remarks,
});
}
@ -1152,7 +1221,10 @@ class __$$PurchaseOrderItemModelImplCopyWithImpl<$Res>
Object? discountAmount = freezed,
Object? gstRateId = freezed,
Object? hsnCodeId = freezed,
Object? hsnCodeName = freezed,
Object? lineAmount = freezed,
Object? itemCategoryId = freezed,
Object? itemSubcategoryId = freezed,
Object? remarks = freezed,
}) {
return _then(
@ -1217,10 +1289,22 @@ class __$$PurchaseOrderItemModelImplCopyWithImpl<$Res>
? _value.hsnCodeId
: hsnCodeId // ignore: cast_nullable_to_non_nullable
as int?,
hsnCodeName: freezed == hsnCodeName
? _value.hsnCodeName
: hsnCodeName // ignore: cast_nullable_to_non_nullable
as String?,
lineAmount: freezed == lineAmount
? _value.lineAmount
: lineAmount // ignore: cast_nullable_to_non_nullable
as double?,
itemCategoryId: freezed == itemCategoryId
? _value.itemCategoryId
: itemCategoryId // ignore: cast_nullable_to_non_nullable
as int?,
itemSubcategoryId: freezed == itemSubcategoryId
? _value.itemSubcategoryId
: itemSubcategoryId // ignore: cast_nullable_to_non_nullable
as int?,
remarks: freezed == remarks
? _value.remarks
: remarks // ignore: cast_nullable_to_non_nullable
@ -1253,10 +1337,28 @@ class _$PurchaseOrderItemModelImpl implements _PurchaseOrderItemModel {
this.discountAmount,
@JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable)
this.gstRateId,
@JsonKey(name: 'hsn_code_id', fromJson: _intFromJsonNullable)
@JsonKey(
name: 'hsn_code_id',
readValue: _readHsnCodeId,
fromJson: _intFromJsonNullable,
)
this.hsnCodeId,
@JsonKey(name: 'hsn_code_name', readValue: _readHsnCodeName)
this.hsnCodeName,
@JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable)
this.lineAmount,
@JsonKey(
name: 'item_category_id',
readValue: _readPoItemCategoryId,
fromJson: _intFromJsonNullable,
)
this.itemCategoryId,
@JsonKey(
name: 'item_subcategory_id',
readValue: _readPoItemSubcategoryId,
fromJson: _intFromJsonNullable,
)
this.itemSubcategoryId,
this.remarks,
});
@ -1306,17 +1408,38 @@ class _$PurchaseOrderItemModelImpl implements _PurchaseOrderItemModel {
@JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable)
final int? gstRateId;
@override
@JsonKey(name: 'hsn_code_id', fromJson: _intFromJsonNullable)
@JsonKey(
name: 'hsn_code_id',
readValue: _readHsnCodeId,
fromJson: _intFromJsonNullable,
)
final int? hsnCodeId;
@override
@JsonKey(name: 'hsn_code_name', readValue: _readHsnCodeName)
final String? hsnCodeName;
@override
@JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable)
final double? lineAmount;
@override
@JsonKey(
name: 'item_category_id',
readValue: _readPoItemCategoryId,
fromJson: _intFromJsonNullable,
)
final int? itemCategoryId;
@override
@JsonKey(
name: 'item_subcategory_id',
readValue: _readPoItemSubcategoryId,
fromJson: _intFromJsonNullable,
)
final int? itemSubcategoryId;
@override
final String? remarks;
@override
String toString() {
return 'PurchaseOrderItemModel(id: $id, poId: $poId, itemId: $itemId, itemCode: $itemCode, itemName: $itemName, lineNo: $lineNo, orderedQty: $orderedQty, receivedQty: $receivedQty, uomId: $uomId, uomName: $uomName, rate: $rate, discountPct: $discountPct, discountAmount: $discountAmount, gstRateId: $gstRateId, hsnCodeId: $hsnCodeId, lineAmount: $lineAmount, remarks: $remarks)';
return 'PurchaseOrderItemModel(id: $id, poId: $poId, itemId: $itemId, itemCode: $itemCode, itemName: $itemName, lineNo: $lineNo, orderedQty: $orderedQty, receivedQty: $receivedQty, uomId: $uomId, uomName: $uomName, rate: $rate, discountPct: $discountPct, discountAmount: $discountAmount, gstRateId: $gstRateId, hsnCodeId: $hsnCodeId, hsnCodeName: $hsnCodeName, lineAmount: $lineAmount, itemCategoryId: $itemCategoryId, itemSubcategoryId: $itemSubcategoryId, remarks: $remarks)';
}
@override
@ -1347,14 +1470,20 @@ class _$PurchaseOrderItemModelImpl implements _PurchaseOrderItemModel {
other.gstRateId == gstRateId) &&
(identical(other.hsnCodeId, hsnCodeId) ||
other.hsnCodeId == hsnCodeId) &&
(identical(other.hsnCodeName, hsnCodeName) ||
other.hsnCodeName == hsnCodeName) &&
(identical(other.lineAmount, lineAmount) ||
other.lineAmount == lineAmount) &&
(identical(other.itemCategoryId, itemCategoryId) ||
other.itemCategoryId == itemCategoryId) &&
(identical(other.itemSubcategoryId, itemSubcategoryId) ||
other.itemSubcategoryId == itemSubcategoryId) &&
(identical(other.remarks, remarks) || other.remarks == remarks));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
int get hashCode => Object.hashAll([
runtimeType,
id,
poId,
@ -1371,9 +1500,12 @@ class _$PurchaseOrderItemModelImpl implements _PurchaseOrderItemModel {
discountAmount,
gstRateId,
hsnCodeId,
hsnCodeName,
lineAmount,
itemCategoryId,
itemSubcategoryId,
remarks,
);
]);
/// Create a copy of PurchaseOrderItemModel
/// with the given fields replaced by the non-null parameter values.
@ -1416,10 +1548,28 @@ abstract class _PurchaseOrderItemModel implements PurchaseOrderItemModel {
final double? discountAmount,
@JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable)
final int? gstRateId,
@JsonKey(name: 'hsn_code_id', fromJson: _intFromJsonNullable)
@JsonKey(
name: 'hsn_code_id',
readValue: _readHsnCodeId,
fromJson: _intFromJsonNullable,
)
final int? hsnCodeId,
@JsonKey(name: 'hsn_code_name', readValue: _readHsnCodeName)
final String? hsnCodeName,
@JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable)
final double? lineAmount,
@JsonKey(
name: 'item_category_id',
readValue: _readPoItemCategoryId,
fromJson: _intFromJsonNullable,
)
final int? itemCategoryId,
@JsonKey(
name: 'item_subcategory_id',
readValue: _readPoItemSubcategoryId,
fromJson: _intFromJsonNullable,
)
final int? itemSubcategoryId,
final String? remarks,
}) = _$PurchaseOrderItemModelImpl;
@ -1469,12 +1619,33 @@ abstract class _PurchaseOrderItemModel implements PurchaseOrderItemModel {
@JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable)
int? get gstRateId;
@override
@JsonKey(name: 'hsn_code_id', fromJson: _intFromJsonNullable)
@JsonKey(
name: 'hsn_code_id',
readValue: _readHsnCodeId,
fromJson: _intFromJsonNullable,
)
int? get hsnCodeId;
@override
@JsonKey(name: 'hsn_code_name', readValue: _readHsnCodeName)
String? get hsnCodeName;
@override
@JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable)
double? get lineAmount;
@override
@JsonKey(
name: 'item_category_id',
readValue: _readPoItemCategoryId,
fromJson: _intFromJsonNullable,
)
int? get itemCategoryId;
@override
@JsonKey(
name: 'item_subcategory_id',
readValue: _readPoItemSubcategoryId,
fromJson: _intFromJsonNullable,
)
int? get itemSubcategoryId;
@override
String? get remarks;
/// Create a copy of PurchaseOrderItemModel

View File

@ -93,8 +93,15 @@ _$PurchaseOrderItemModelImpl _$$PurchaseOrderItemModelImplFromJson(
discountPct: _doubleFromJsonNullable(json['discount_pct']),
discountAmount: _doubleFromJsonNullable(json['discount_amount']),
gstRateId: _intFromJsonNullable(json['gst_rate_id']),
hsnCodeId: _intFromJsonNullable(json['hsn_code_id']),
hsnCodeId: _intFromJsonNullable(_readHsnCodeId(json, 'hsn_code_id')),
hsnCodeName: _readHsnCodeName(json, 'hsn_code_name') as String?,
lineAmount: _doubleFromJsonNullable(json['line_amount']),
itemCategoryId: _intFromJsonNullable(
_readPoItemCategoryId(json, 'item_category_id'),
),
itemSubcategoryId: _intFromJsonNullable(
_readPoItemSubcategoryId(json, 'item_subcategory_id'),
),
remarks: json['remarks'] as String?,
);
@ -116,6 +123,9 @@ Map<String, dynamic> _$$PurchaseOrderItemModelImplToJson(
'discount_amount': instance.discountAmount,
'gst_rate_id': instance.gstRateId,
'hsn_code_id': instance.hsnCodeId,
'hsn_code_name': instance.hsnCodeName,
'line_amount': instance.lineAmount,
'item_category_id': instance.itemCategoryId,
'item_subcategory_id': instance.itemSubcategoryId,
'remarks': instance.remarks,
};

View File

@ -6,7 +6,6 @@ import '../../core/config/dev_config.dart';
import '../../core/constants/route_constants.dart';
import '../../modules/dashboard/presentation/screens/dashboard_screen.dart';
import '../../modules/assets/presentation/screens/asset_alerts_screen.dart';
import '../../modules/assets/presentation/screens/asset_categories_screen.dart';
import '../../modules/assets/presentation/screens/asset_detail_screen.dart';
import '../../modules/assets/presentation/screens/asset_list_screen.dart';
import '../../modules/auth/presentation/screens/change_password_screen.dart';
@ -215,18 +214,27 @@ final routerProvider = Provider<GoRouter>((ref) {
routes: [
GoRoute(
path: 'add',
builder: (context, state) => const PurchaseOrderFormScreen(),
pageBuilder: (context, state) => shellPage(
state,
const PurchaseOrderFormScreen(),
),
),
GoRoute(
path: ':id/edit',
builder: (context, state) => PurchaseOrderFormScreen(
purchaseOrderId: state.pathParameters['id']!,
pageBuilder: (context, state) => shellPage(
state,
PurchaseOrderFormScreen(
purchaseOrderId: state.pathParameters['id']!,
),
),
),
GoRoute(
path: ':id',
builder: (context, state) => PurchaseOrderDetailScreen(
purchaseOrderId: state.pathParameters['id']!,
pageBuilder: (context, state) => shellPage(
state,
PurchaseOrderDetailScreen(
purchaseOrderId: state.pathParameters['id']!,
),
),
),
],
@ -272,10 +280,6 @@ final routerProvider = Provider<GoRouter>((ref) {
pageBuilder: (context, state) =>
shellPage(state, const AssetListScreen()),
routes: [
GoRoute(
path: 'categories',
builder: (context, state) => const AssetCategoriesScreen(),
),
GoRoute(
path: 'alerts',
builder: (context, state) => const AssetAlertsScreen(),

View File

@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
import 'app_card.dart';
/// Fixed height for every row in [AppDataTable] and themed [DataTable] widgets.
const double kAppTableRowHeight = 52;
class AppDataColumn<T> {
const AppDataColumn({
required this.label,
@ -18,6 +21,32 @@ class AppDataColumn<T> {
final Alignment alignment;
}
/// Helpers for table cell content single-line text with ellipsis and tooltip.
class AppTableCell {
AppTableCell._();
/// Renders [value] on one line; shows the full text in a tooltip when truncated.
static Widget text(
String? value, {
TextStyle? style,
String placeholder = '',
TextAlign? textAlign,
bool showTooltip = true,
}) {
final display =
(value == null || value.trim().isEmpty) ? placeholder : value.trim();
return _EllipsisTooltipText(
text: display,
style: style,
textAlign: textAlign,
showTooltip: showTooltip && display != placeholder,
);
}
/// Wraps non-text cell widgets (chips, actions) inside the row height budget.
static Widget child(Widget widget) => widget;
}
class AppDataTable<T> extends StatelessWidget {
const AppDataTable({
super.key,
@ -38,6 +67,7 @@ class AppDataTable<T> extends StatelessWidget {
final void Function(String column, bool ascending)? onSort;
final String emptyMessage;
final bool wrapInCard;
/// Set true when the table is placed inside another scrollable.
final bool shrinkWrap;
@ -64,9 +94,7 @@ class AppDataTable<T> extends StatelessWidget {
return ListView(
padding: EdgeInsets.zero,
shrinkWrap: shrinkWrap,
physics: shrinkWrap
? const NeverScrollableScrollPhysics()
: null,
physics: shrinkWrap ? const NeverScrollableScrollPhysics() : null,
children: [
_TableHeaderRow<T>(
columns: columns,
@ -111,56 +139,63 @@ class _TableHeaderRow<T> extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
return SizedBox(
height: kAppTableRowHeight,
width: double.infinity,
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
child: Row(
children: columns.map((col) {
final isSorted = col.sortKey != null && col.sortKey == sortColumn;
final label = Text(
col.label.toUpperCase(),
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
color: theme.colorScheme.onSurfaceVariant,
),
);
child: ColoredBox(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: columns.map((col) {
final isSorted = col.sortKey != null && col.sortKey == sortColumn;
final label = Text(
col.label.toUpperCase(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
color: theme.colorScheme.onSurfaceVariant,
),
);
Widget header = label;
if (col.sortKey != null && onSort != null) {
header = InkWell(
onTap: () => onSort!(
col.sortKey!,
isSorted ? !sortAscending : true,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
label,
if (isSorted) ...[
const SizedBox(width: 4),
Icon(
sortAscending
? Icons.arrow_upward
: Icons.arrow_downward,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
],
],
),
);
}
Widget header = label;
if (col.sortKey != null && onSort != null) {
header = InkWell(
onTap: () => onSort!(
col.sortKey!,
isSorted ? !sortAscending : true,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(child: label),
if (isSorted) ...[
const SizedBox(width: 4),
Icon(
sortAscending
? Icons.arrow_upward
: Icons.arrow_downward,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
],
],
),
);
}
return Expanded(
flex: col.flex,
child: Align(
alignment: col.alignment,
child: header,
),
);
}).toList(),
return Expanded(
flex: col.flex,
child: Align(
alignment: col.alignment,
child: header,
),
);
}).toList(),
),
),
),
);
}
@ -179,28 +214,125 @@ class _TableDataRow<T> extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
return SizedBox(
height: kAppTableRowHeight,
width: double.infinity,
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: theme.colorScheme.outline.withValues(alpha: 0.08),
child: DecoratedBox(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: theme.colorScheme.outline.withValues(alpha: 0.08),
),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: columns.map((col) {
return Expanded(
flex: col.flex,
child: Align(
alignment: col.alignment,
child: _TableCellSlot(
alignment: col.alignment,
child: col.cellBuilder(context, row),
),
),
);
}).toList(),
),
),
),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: columns.map((col) {
return Expanded(
flex: col.flex,
child: Align(
alignment: col.alignment,
child: col.cellBuilder(context, row),
),
);
}).toList(),
),
);
}
}
class _TableCellSlot extends StatelessWidget {
const _TableCellSlot({
required this.child,
required this.alignment,
});
final Widget child;
final Alignment alignment;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
return SizedBox(
width: constraints.maxWidth,
child: Align(
alignment: alignment,
widthFactor: 1,
child: _coerceTableCell(child, context),
),
);
},
);
}
Widget _coerceTableCell(Widget widget, BuildContext context) {
if (widget is Text) {
final text = widget.data ?? widget.textSpan?.toPlainText() ?? '';
if (text.isEmpty) return widget;
return AppTableCell.text(
text,
style: widget.style ?? DefaultTextStyle.of(context).style,
textAlign: widget.textAlign,
);
}
return widget;
}
}
class _EllipsisTooltipText extends StatelessWidget {
const _EllipsisTooltipText({
required this.text,
this.style,
this.textAlign,
this.showTooltip = true,
});
final String text;
final TextStyle? style;
final TextAlign? textAlign;
final bool showTooltip;
@override
Widget build(BuildContext context) {
final effectiveStyle = style ?? DefaultTextStyle.of(context).style;
return LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth;
final painter = TextPainter(
text: TextSpan(text: text, style: effectiveStyle),
maxLines: 1,
textDirection: Directionality.of(context),
textAlign: textAlign ?? TextAlign.start,
)..layout(maxWidth: maxWidth.isFinite ? maxWidth : double.infinity);
final overflows = maxWidth.isFinite &&
(painter.didExceedMaxLines || painter.width > maxWidth);
final textWidget = Text(
text,
style: effectiveStyle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: textAlign,
);
if (!showTooltip || !overflows) return textWidget;
return Tooltip(
message: text,
waitDuration: const Duration(milliseconds: 400),
child: textWidget,
);
},
);
}
}

View File

@ -35,10 +35,27 @@ class AppSearchableDropdown<T> extends StatefulWidget {
class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
final _layerLink = LayerLink();
final _fieldKey = GlobalKey();
final _formFieldKey = UniqueKey();
final _formFieldStateKey = GlobalKey<FormFieldState<T>>();
OverlayEntry? _overlayEntry;
bool _ignoreOutsideTap = false;
@override
void didUpdateWidget(covariant AppSearchableDropdown<T> oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.value == oldWidget.value) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final fieldState = _formFieldStateKey.currentState;
if (fieldState == null || fieldState.value == widget.value) return;
fieldState.didChange(widget.value);
if (fieldState.hasError) {
fieldState.validate();
}
});
}
@override
void dispose() {
_removeOverlay();
@ -167,7 +184,7 @@ class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
final theme = Theme.of(context);
return FormField<T>(
key: widget.key ?? _formFieldKey,
key: _formFieldStateKey,
initialValue: widget.value,
validator: widget.validator,
builder: (field) {
@ -342,3 +359,216 @@ class _SearchableDropdownPanelState<T>
);
}
}
/// Searchable lookup anchored to the field same overlay UI as
/// [AppSearchableDropdown] but without a [FormField] wrapper.
class AppSearchableLookupField<T> extends StatefulWidget {
const AppSearchableLookupField({
super.key,
required this.label,
required this.value,
required this.options,
required this.onChanged,
this.hint,
this.searchHint = 'Search...',
this.enabled = true,
this.isDense = false,
});
final String label;
final T? value;
final List<AppDropdownOption<T>> options;
final ValueChanged<T?> onChanged;
final String? hint;
final String searchHint;
final bool enabled;
final bool isDense;
@override
State<AppSearchableLookupField<T>> createState() =>
_AppSearchableLookupFieldState<T>();
}
class _AppSearchableLookupFieldState<T>
extends State<AppSearchableLookupField<T>> {
final _layerLink = LayerLink();
final _fieldKey = GlobalKey();
OverlayEntry? _overlayEntry;
bool _ignoreOutsideTap = false;
@override
void dispose() {
_removeOverlay();
super.dispose();
}
String? _labelForValue(T? selected) {
if (selected == null) return null;
for (final option in widget.options) {
if (option.value == selected) return option.label;
}
return null;
}
void _removeOverlay() {
if (_overlayEntry == null) return;
_overlayEntry!.remove();
_overlayEntry = null;
_ignoreOutsideTap = false;
if (mounted) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() {});
});
}
}
void _openPicker() {
if (!widget.enabled || widget.options.isEmpty) return;
if (_overlayEntry != null) {
_removeOverlay();
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _overlayEntry != null) return;
_showOverlay();
});
}
void _showOverlay() {
final renderBox =
_fieldKey.currentContext?.findRenderObject() as RenderBox?;
if (renderBox == null || !renderBox.hasSize) return;
final fieldSize = renderBox.size;
final fieldTopLeft = renderBox.localToGlobal(Offset.zero);
final screenSize = MediaQuery.sizeOf(context);
final viewInsets = MediaQuery.viewInsetsOf(context);
final spaceBelow = screenSize.height -
viewInsets.bottom -
fieldTopLeft.dy -
fieldSize.height;
final spaceAbove = fieldTopLeft.dy - viewInsets.top;
final showAbove = spaceBelow < 180 && spaceAbove > spaceBelow;
final availableSpace = (showAbove ? spaceAbove : spaceBelow) - 8;
final maxPanelHeight =
availableSpace.clamp(120.0, screenSize.height * 0.45);
_ignoreOutsideTap = true;
_overlayEntry = OverlayEntry(
builder: (overlayContext) {
final theme = Theme.of(overlayContext);
return Stack(
children: [
Positioned.fill(
child: GestureDetector(
onTap: _removeOverlay,
behavior: HitTestBehavior.translucent,
),
),
CompositedTransformFollower(
link: _layerLink,
showWhenUnlinked: false,
targetAnchor:
showAbove ? Alignment.topLeft : Alignment.bottomLeft,
followerAnchor:
showAbove ? Alignment.bottomLeft : Alignment.topLeft,
offset: Offset(0, showAbove ? -4 : 4),
child: TapRegion(
onTapOutside: (_) {
if (_ignoreOutsideTap) return;
_removeOverlay();
},
child: Material(
elevation: 8,
borderRadius: BorderRadius.circular(8),
clipBehavior: Clip.antiAlias,
color: theme.colorScheme.surface,
shadowColor: Colors.black45,
child: SizedBox(
width: fieldSize.width,
child: _SearchableDropdownPanel<T>(
maxHeight: maxPanelHeight,
options: widget.options,
selected: widget.value,
searchHint: widget.searchHint,
onSelected: (value) {
_removeOverlay();
widget.onChanged(value);
},
),
),
),
),
),
],
);
},
);
final overlay = Overlay.maybeOf(context, rootOverlay: true) ??
Overlay.of(context);
overlay.insert(_overlayEntry!);
setState(() {});
WidgetsBinding.instance.addPostFrameCallback((_) {
_ignoreOutsideTap = false;
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final effectiveHint =
widget.hint ?? 'Select ${widget.label.toLowerCase()}';
final canOpen = widget.enabled && widget.options.isNotEmpty;
final colors = theme.colorScheme;
final displayLabel = _labelForValue(widget.value);
return Padding(
padding: const EdgeInsets.only(top: 8),
child: CompositedTransformTarget(
link: _layerLink,
child: KeyedSubtree(
key: _fieldKey,
child: InkWell(
onTap: canOpen ? _openPicker : null,
borderRadius: BorderRadius.circular(8),
child: InputDecorator(
isFocused: _overlayEntry != null,
isEmpty: displayLabel == null,
decoration: InputDecoration(
labelText: widget.label,
hintText: displayLabel == null ? effectiveHint : null,
floatingLabelBehavior: FloatingLabelBehavior.always,
isDense: widget.isDense,
suffixIcon: Icon(
_overlayEntry != null
? Icons.arrow_drop_up
: Icons.arrow_drop_down,
color: canOpen
? colors.onSurfaceVariant
: theme.disabledColor,
),
enabled: canOpen,
),
child: Text(
displayLabel ?? '\u00A0',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyLarge?.copyWith(
color: displayLabel == null
? Colors.transparent
: colors.onSurface,
),
),
),
),
),
),
);
}
}

View File

@ -23,6 +23,10 @@ const _sidebarItemPadding = 12.0;
const _sidebarIconSize = 20.0;
const _sidebarChildIndent = 28.0;
/// Set to `true` to show the Light/Dark toggle in the sidebar again.
/// Kept hidden for now do not delete `_buildThemeToggle`.
const showSidebarThemeToggle = false;
class AppSidebar extends ConsumerStatefulWidget {
const AppSidebar({
super.key,
@ -149,7 +153,10 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(context, isNarrow: isNarrow),
if (!isNarrow) ...[
// NOTE: Light/Dark theme toggle is temporarily hidden from the
// sidebar. Do not remove `_buildThemeToggle` restore by
// setting [showSidebarThemeToggle] to true.
if (!isNarrow && showSidebarThemeToggle) ...[
const SizedBox(height: 16),
_buildThemeToggle(context, isLightActive),
],
@ -221,14 +228,15 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
}),
const SizedBox(height: 16),
if (!isNarrow) const _SectionLabel(label: 'SUPPORT'),
_SidebarNavItem(
icon: Icons.notifications_outlined,
label: 'Notifications',
selected: false,
collapsed: isNarrow,
badge: isNarrow ? null : '3',
onTap: () {},
),
if (AppConstants.showNotificationsMenu)
_SidebarNavItem(
icon: Icons.notifications_outlined,
label: 'Notifications',
selected: false,
collapsed: isNarrow,
badge: isNarrow ? null : '3',
onTap: () {},
),
if (_hasSettings)
_SidebarNavItem(
icon: Icons.settings_outlined,

View File

@ -105,10 +105,11 @@ class AppTopNav extends ConsumerWidget {
),
),
),
IconButton(
icon: const Icon(Icons.notifications_outlined, size: 22),
onPressed: () {},
),
if (AppConstants.showNotificationsMenu)
IconButton(
icon: const Icon(Icons.notifications_outlined, size: 22),
onPressed: () {},
),
if (DevConfig.screenPreviewEnabled)
IconButton(
icon: const Icon(Icons.apps_outlined, size: 22),

View File

@ -3,6 +3,8 @@ import 'dart:convert';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import '../../core/utils/media_url.dart';
/// Displays company logo in the sidebar from URL, data URI, or fallback icon.
class SidebarLogo extends StatelessWidget {
const SidebarLogo({
@ -53,7 +55,7 @@ class SidebarLogo extends StatelessWidget {
}
Widget _buildLogoContent(Widget fallback) {
final url = logoUrl?.trim();
final url = resolveMediaUrl(logoUrl);
if (url == null || url.isEmpty) {
return Center(child: fallback);
}
@ -99,9 +101,9 @@ String? resolveSidebarLogoUrl({
required String companyProfileLogo,
required String? brandingLogo,
}) {
if (companyProfileLogo.isNotEmpty) return companyProfileLogo;
if (brandingLogo != null && brandingLogo.isNotEmpty) return brandingLogo;
return null;
final fromProfile = resolveMediaUrl(companyProfileLogo);
if (fromProfile != null && fromProfile.isNotEmpty) return fromProfile;
return resolveMediaUrl(brandingLogo);
}
/// Resolves sidebar title from company name or app tagline.

View File

@ -182,4 +182,29 @@ void main() {
);
});
});
group('hsnCode', () {
test('accepts 48 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,
);
});
});
}