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

View File

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

View File

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

View File

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

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'; 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 - _ / /// Required master code allowed: A-Z a-z 0-9 - _ /
static String? masterCode(String? value, {String fieldName = 'Code'}) { static String? masterCode(String? value, {String fieldName = 'Code'}) {
final requiredError = required(value, fieldName: fieldName); final requiredError = required(value, fieldName: fieldName);

View File

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

View File

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

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

View File

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

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

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 // Assets
_GalleryEntry(title: 'Asset List', route: RouteConstants.assets, group: 'Assets'), _GalleryEntry(title: 'Asset List', route: RouteConstants.assets, group: 'Assets'),
_GalleryEntry(title: 'Asset Detail', route: '/assets/demo-asset', group: 'Assets'), _GalleryEntry(title: 'Asset Detail', route: '/assets/demo-asset', group: 'Assets'),
_GalleryEntry(title: 'Asset Categories', route: RouteConstants.assetCategories, group: 'Assets'),
_GalleryEntry(title: 'Categories', route: RouteConstants.assetCategories, group: 'Assets'),
_GalleryEntry(title: 'Alerts', route: RouteConstants.assetAlerts, group: 'Assets'), _GalleryEntry(title: 'Alerts', route: RouteConstants.assetAlerts, group: 'Assets'),
// Master data // Master data
_GalleryEntry(title: 'Master Data Hub', route: RouteConstants.masterData, group: 'Master Data'), _GalleryEntry(title: 'Master Data Hub', route: RouteConstants.masterData, group: 'Master Data'),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,13 +14,13 @@ import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_dropdown.dart'; import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/app_text_field.dart'; import '../../../../shared/widgets/app_text_field.dart';
import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/page_header.dart';
import '../providers/grn_lookups_provider.dart'; import '../providers/grn_lookups_provider.dart';
import '../providers/grn_provider.dart'; import '../providers/grn_provider.dart';
import '../widgets/grn_line_items_editor.dart'; import '../widgets/grn_line_items_editor.dart';
import '../widgets/grn_status_chip.dart';
class GrnFormScreen extends ConsumerStatefulWidget { class GrnFormScreen extends ConsumerStatefulWidget {
const GrnFormScreen({super.key, this.grnId}); const GrnFormScreen({super.key, this.grnId});
@ -58,6 +58,9 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
super.initState(); super.initState();
if (!widget.isEditing) { if (!widget.isEditing) {
_grnDate = DateTime.now(); _grnDate = DateTime.now();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) ref.invalidate(grnLookupsProvider);
});
} }
} }
@ -90,8 +93,8 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
_vehicleNoController.text = grn.vehicleNo ?? ''; _vehicleNoController.text = grn.vehicleNo ?? '';
_lrNoController.text = grn.lrNo ?? ''; _lrNoController.text = grn.lrNo ?? '';
_lrDate = grn.lrDate; _lrDate = grn.lrDate;
_receivedById = grn.receivedBy; _receivedById = _normalizeUserId(grn.receivedBy);
_qualityCheckedById = grn.qualityCheckedBy; _qualityCheckedById = _normalizeUserId(grn.qualityCheckedBy);
_remarksController.text = grn.remarks ?? ''; _remarksController.text = grn.remarks ?? '';
}); });
} }
@ -112,11 +115,18 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
int? _parseId(String value) => int.tryParse(value.trim()); int? _parseId(String value) => int.tryParse(value.trim());
int? _normalizeUserId(int? id) => id != null && id > 0 ? id : null;
int? _dropdownValue(int? selected, Iterable<int> validIds) { int? _dropdownValue(int? selected, Iterable<int> validIds) {
if (selected == null) return null; if (selected == null) return null;
return validIds.contains(selected) ? selected : null; return validIds.contains(selected) ? selected : null;
} }
void _putOptionalUserId(Map<String, dynamic> payload, String key, int? id) {
final normalized = _normalizeUserId(id);
if (normalized != null) payload[key] = normalized;
}
List<AppDropdownOption<int>> _intOptions(List<FilterOptionModel> options) { List<AppDropdownOption<int>> _intOptions(List<FilterOptionModel> options) {
return options return options
.map((e) { .map((e) {
@ -130,7 +140,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
Map<String, dynamic> _buildCreatePayload() { Map<String, dynamic> _buildCreatePayload() {
final poId = int.tryParse(_selectedPoId ?? ''); final poId = int.tryParse(_selectedPoId ?? '');
return { final payload = <String, dynamic>{
'grn_date': DateFormatter.toApiDate(_grnDate ?? DateTime.now()), 'grn_date': DateFormatter.toApiDate(_grnDate ?? DateTime.now()),
'po_id': poId, 'po_id': poId,
'warehouse_id': _warehouseId, 'warehouse_id': _warehouseId,
@ -143,17 +153,19 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
double.tryParse(_vendorInvoiceAmountController.text.trim()), double.tryParse(_vendorInvoiceAmountController.text.trim()),
if (_vehicleNoController.text.trim().isNotEmpty) if (_vehicleNoController.text.trim().isNotEmpty)
'vehicle_no': _vehicleNoController.text.trim(), 'vehicle_no': _vehicleNoController.text.trim(),
if (_lrNoController.text.trim().isNotEmpty) 'lr_no': _lrNoController.text.trim(), if (_lrNoController.text.trim().isNotEmpty)
'lr_no': _lrNoController.text.trim(),
if (_lrDate != null) 'lr_date': DateFormatter.toApiDate(_lrDate!), if (_lrDate != null) 'lr_date': DateFormatter.toApiDate(_lrDate!),
if (_receivedById != null) 'received_by': _receivedById,
if (_qualityCheckedById != null) 'quality_checked_by': _qualityCheckedById,
'remarks': _remarksController.text.trim(),
'items': _lines.map((line) => line.toPayload()).toList(),
}; };
_putOptionalUserId(payload, 'received_by', _receivedById);
_putOptionalUserId(payload, 'quality_checked_by', _qualityCheckedById);
payload['remarks'] = _remarksController.text.trim();
payload['items'] = _lines.map((line) => line.toPayload()).toList();
return payload;
} }
Map<String, dynamic> _buildUpdatePayload() { Map<String, dynamic> _buildUpdatePayload() {
return { final payload = <String, dynamic>{
if (_vendorInvoiceNoController.text.trim().isNotEmpty) if (_vendorInvoiceNoController.text.trim().isNotEmpty)
'vendor_invoice_no': _vendorInvoiceNoController.text.trim(), 'vendor_invoice_no': _vendorInvoiceNoController.text.trim(),
if (_vendorInvoiceDate != null) if (_vendorInvoiceDate != null)
@ -163,12 +175,14 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
double.tryParse(_vendorInvoiceAmountController.text.trim()), double.tryParse(_vendorInvoiceAmountController.text.trim()),
if (_vehicleNoController.text.trim().isNotEmpty) if (_vehicleNoController.text.trim().isNotEmpty)
'vehicle_no': _vehicleNoController.text.trim(), 'vehicle_no': _vehicleNoController.text.trim(),
if (_lrNoController.text.trim().isNotEmpty) 'lr_no': _lrNoController.text.trim(), if (_lrNoController.text.trim().isNotEmpty)
'lr_no': _lrNoController.text.trim(),
if (_lrDate != null) 'lr_date': DateFormatter.toApiDate(_lrDate!), if (_lrDate != null) 'lr_date': DateFormatter.toApiDate(_lrDate!),
if (_receivedById != null) 'received_by': _receivedById,
if (_qualityCheckedById != null) 'quality_checked_by': _qualityCheckedById,
'remarks': _remarksController.text.trim(),
}; };
_putOptionalUserId(payload, 'received_by', _receivedById);
_putOptionalUserId(payload, 'quality_checked_by', _qualityCheckedById);
payload['remarks'] = _remarksController.text.trim();
return payload;
} }
String? _lineItemsError() { String? _lineItemsError() {
@ -180,6 +194,10 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
if (line.acceptedQty < 0) { if (line.acceptedQty < 0) {
return 'Accepted quantity must be zero or more for line ${line.lineNo}'; return 'Accepted quantity must be zero or more for line ${line.lineNo}';
} }
if (line.rejectedQty > 0 &&
line.rejectionReasonController.text.trim().isEmpty) {
return 'Rejection reason is required for line ${line.lineNo}';
}
} }
return null; return null;
} }
@ -206,7 +224,8 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
} }
final lineError = _lineItemsError(); final lineError = _lineItemsError();
if (lineError != null) { if (lineError != null) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(lineError))); ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(lineError)));
return; return;
} }
} }
@ -241,7 +260,8 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
if (!mounted) return; if (!mounted) return;
final message = final message =
e is Failure ? validationErrorMessage(e) : e.toString(); e is Failure ? validationErrorMessage(e) : e.toString();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
} finally { } finally {
if (mounted) setState(() => _isSubmitting = false); if (mounted) setState(() => _isSubmitting = false);
} }
@ -260,6 +280,17 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
if (picked != null) onPicked(picked); if (picked != null) onPicked(picked);
} }
void _goBack(GrnModel? existing) {
if (_isSubmitting) return;
if (widget.isEditing && existing != null) {
context.go('${RouteConstants.grn}/${existing.id}');
} else if (widget.isEditing) {
context.go('${RouteConstants.grn}/${widget.grnId}');
} else {
context.go(RouteConstants.grn);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final lookupsAsync = ref.watch(grnLookupsProvider); final lookupsAsync = ref.watch(grnLookupsProvider);
@ -268,33 +299,33 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
: const AsyncData<GrnModel?>(null); : const AsyncData<GrnModel?>(null);
return Scaffold( return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).scaffoldBackgroundColor,
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go(
widget.isEditing
? '${RouteConstants.grn}/${widget.grnId}'
: RouteConstants.grn,
),
),
title: Text(widget.isEditing ? 'Edit GRN' : 'Create GRN'),
),
body: lookupsAsync.when( body: lookupsAsync.when(
loading: () => const AppLoadingView(message: 'Loading form...'), skipLoadingOnReload: true,
loading: () => lookupsAsync.hasValue
? _buildFormBody(
lookups: lookupsAsync.value!,
existing: existingAsync.valueOrNull,
)
: const AppLoadingView(message: 'Loading form...'),
error: (e, _) => ErrorView.fromFailure( error: (e, _) => ErrorView.fromFailure(
e is Failure ? e : Failure.unknown(message: e.toString()), e is Failure ? e : Failure.unknown(message: e.toString()),
onRetry: () => ref.invalidate(grnLookupsProvider), onRetry: () => ref.invalidate(grnLookupsProvider),
), ),
data: (lookups) => existingAsync.when( data: (lookups) => existingAsync.when(
loading: () => const AppLoadingView(message: 'Loading GRN...'), skipLoadingOnReload: true,
loading: () => existingAsync.hasValue
? _buildFormBody(
lookups: lookups,
existing: existingAsync.valueOrNull,
)
: const AppLoadingView(message: 'Loading GRN...'),
error: (e, _) => ErrorView.fromFailure( error: (e, _) => ErrorView.fromFailure(
e is Failure ? e : Failure.unknown(message: e.toString()), e is Failure ? e : Failure.unknown(message: e.toString()),
onRetry: () => ref.invalidate(grnFormProvider(widget.grnId)), onRetry: () => ref.invalidate(grnFormProvider(widget.grnId)),
), ),
data: (existing) => _buildFormBody(lookups: lookups, existing: existing), data: (existing) =>
_buildFormBody(lookups: lookups, existing: existing),
), ),
), ),
); );
@ -323,6 +354,8 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
final warehouseIds = final warehouseIds =
lookups.warehouses.map((e) => _parseId(e.id)).whereType<int>(); lookups.warehouses.map((e) => _parseId(e.id)).whereType<int>();
final userIds =
lookups.users.map((e) => _parseId(e.id)).whereType<int>();
final poOptions = lookups.receivablePurchaseOrders final poOptions = lookups.receivablePurchaseOrders
.map( .map(
(po) => AppDropdownOption( (po) => AppDropdownOption(
@ -331,220 +364,401 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
), ),
) )
.toList(); .toList();
final theme = Theme.of(context);
return SingleChildScrollView( return SingleChildScrollView(
controller: _scrollController, controller: _scrollController,
padding: const EdgeInsets.all(24), padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
child: Align( child: Align(
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1200), constraints: const BoxConstraints(maxWidth: 1200),
child: Column( child: Form(
crossAxisAlignment: CrossAxisAlignment.stretch, key: _formKey,
children: [ child: Column(
if (!widget.isEditing) crossAxisAlignment: CrossAxisAlignment.stretch,
const PageHeader( children: [
title: 'Create Goods Received Note', _buildHeader(existing),
subtitle: 'Receive items against an approved purchase order', const SizedBox(height: 16),
) _SectionCard(
else if (existing?.grnNumber != null) title: 'RECEIPT DETAILS',
Padding( child: Column(
padding: const EdgeInsets.only(bottom: 16), children: [
child: Text( FormRowFour(
existing!.grnNumber!, children: [
style: Theme.of(context).textTheme.titleMedium?.copyWith( _DateField(
color: Theme.of(context).colorScheme.onSurfaceVariant, label: 'GRN date *',
), value: _grnDate,
), enabled: !widget.isEditing,
), onTap: widget.isEditing
Form( ? null
key: _formKey, : () => _pickDate(
child: Column( current: _grnDate,
crossAxisAlignment: CrossAxisAlignment.stretch, onPicked: (d) =>
children: [ setState(() => _grnDate = d),
ResponsiveFormGrid( ),
children: [ ),
_DateField( if (!widget.isEditing)
label: 'GRN Date *', AppSearchableDropdown<String>(
value: _grnDate, label: 'Purchase order *',
enabled: !widget.isEditing, value: _selectedPoId,
onTap: widget.isEditing hint: 'Select PO',
? null searchHint: 'Search PO...',
: () => _pickDate( options: poOptions,
current: _grnDate, onChanged: (v) async {
onPicked: (d) => setState(() => _grnDate = d), setState(() => _selectedPoId = v);
), if (v == null) {
), for (final line in _lines) {
if (!widget.isEditing) line.dispose();
AppSearchableDropdown<String>( }
label: 'Purchase Order *', setState(() => _lines.clear());
value: _selectedPoId, return;
searchHint: 'Search PO...',
isDense: true,
options: poOptions,
onChanged: (v) async {
setState(() => _selectedPoId = v);
if (v == null) {
for (final line in _lines) {
line.dispose();
} }
setState(() => _lines.clear()); try {
return; final po = await ref.read(
} grnPurchaseOrderProvider(v).future,
try { );
final po = await ref.read( if (mounted && po != null) {
grnPurchaseOrderProvider(v).future, _loadLinesFromPo(po);
); }
if (mounted && po != null) _loadLinesFromPo(po); } catch (e) {
} catch (e) { if (!mounted) return;
if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(
ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(e.toString())),
SnackBar(content: Text(e.toString())), );
); }
} },
}, validator: (v) => v == null
validator: (v) => ? 'Purchase order is required'
v == null ? 'Purchase order is required' : null, : null,
) )
else else
Padding( _ReadOnlyField(
padding: const EdgeInsets.only(top: 8), label: 'Purchase order',
child: InputDecorator( value: existing?.poNumber ?? '',
decoration: const InputDecoration(
labelText: 'Purchase Order',
floatingLabelBehavior: FloatingLabelBehavior.always,
isDense: true,
),
child: Text(existing?.poNumber ?? ''),
), ),
AppSearchableDropdown<int>(
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>( AppTextField(
label: 'Warehouse *', label: 'Vendor invoice no',
value: _dropdownValue(_warehouseId, warehouseIds), controller: _vendorInvoiceNoController,
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 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), FormRowFour(
GrnLineItemsEditor( children: [
items: _lines, _DateField(
onChanged: () => setState(() {}), label: 'Vendor invoice date',
), value: _vendorInvoiceDate,
] else ...[ onTap: () => _pickDate(
const SizedBox(height: 16), current: _vendorInvoiceDate,
Text( onPicked: (d) =>
'Line items cannot be changed after posting.', setState(() => _vendorInvoiceDate = d),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
),
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: [ children: [
Expanded( Text(
child: AppButton( 'Line items cannot be changed after posting.',
label: widget.isEditing ? 'Save Changes' : 'Create GRN', style: theme.textTheme.bodyMedium?.copyWith(
onPressed: _isSubmitting ? null : _submit, color: theme.colorScheme.onSurfaceVariant,
isLoading: _isSubmitting,
), ),
), ),
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 { class _DateField extends StatelessWidget {
@ -571,16 +785,15 @@ class _DateField extends StatelessWidget {
decoration: InputDecoration( decoration: InputDecoration(
labelText: label, labelText: label,
floatingLabelBehavior: FloatingLabelBehavior.always, floatingLabelBehavior: FloatingLabelBehavior.always,
isDense: true,
suffixIcon: const Icon(Icons.calendar_today_outlined, size: 18), suffixIcon: const Icon(Icons.calendar_today_outlined, size: 18),
enabled: enabled, enabled: enabled,
), ),
child: Text( child: Text(
value != null ? DateFormatter.displayDate(value) : 'Select date', value != null ? DateFormatter.displayDate(value) : 'Select date',
style: Theme.of(context).textTheme.bodyLarge?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: value != null color: value == null
? null ? Theme.of(context).colorScheme.onSurfaceVariant
: Theme.of(context).hintColor, : null,
), ),
), ),
), ),

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,29 +15,29 @@ class PoStatusChip extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final (color, label) = _resolveStatus(status); final (color, label) = _resolveStatus(status);
return _PoChip(label: label, color: color, compact: compact); return _PoBadge(label: label, color: color, compact: compact);
} }
(Color, String) _resolveStatus(String raw) { (Color, String) _resolveStatus(String raw) {
switch (raw.toUpperCase()) { switch (raw.toUpperCase()) {
case 'DRAFT': case 'DRAFT':
return (Colors.blueGrey.shade700, poStatusLabel(raw)); return (const Color(0xFF546E7A), poStatusLabel(raw));
case 'SUBMITTED': case 'SUBMITTED':
case 'PENDING_APPROVAL': case 'PENDING_APPROVAL':
case 'PENDING': case 'PENDING':
return (Colors.orange.shade800, poStatusLabel(raw)); return (const Color(0xFFE65100), poStatusLabel(raw));
case 'APPROVED': case 'APPROVED':
return (Colors.green.shade700, poStatusLabel(raw)); return (const Color(0xFF2E7D32), poStatusLabel(raw));
case 'REJECTED': case 'REJECTED':
return (Colors.red.shade700, poStatusLabel(raw)); return (const Color(0xFFC62828), poStatusLabel(raw));
case 'CANCELLED': case 'CANCELLED':
return (Colors.grey.shade700, poStatusLabel(raw)); return (const Color(0xFF616161), poStatusLabel(raw));
case 'PARTIALLY_RECEIVED': case 'PARTIALLY_RECEIVED':
return (Colors.teal.shade700, poStatusLabel(raw)); return (const Color(0xFF00695C), poStatusLabel(raw));
case 'FULLY_RECEIVED': case 'FULLY_RECEIVED':
return (Colors.indigo.shade700, poStatusLabel(raw)); return (const Color(0xFF283593), poStatusLabel(raw));
default: default:
return (Colors.blueGrey, poStatusLabel(raw)); return (const Color(0xFF546E7A), poStatusLabel(raw));
} }
} }
} }
@ -54,17 +54,16 @@ class PoRevisionChip extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final color = Theme.of(context).colorScheme.primary; return _PoBadge(
return _PoChip(
label: 'Revision $revisionNo', label: 'Revision $revisionNo',
color: color, color: const Color(0xFF607D8B),
compact: compact, compact: compact,
); );
} }
} }
class _PoChip extends StatelessWidget { class _PoBadge extends StatelessWidget {
const _PoChip({ const _PoBadge({
required this.label, required this.label,
required this.color, required this.color,
this.compact = false, this.compact = false,
@ -76,26 +75,29 @@ class _PoChip extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Chip( // Avoid Container.alignment it expands to max width inside Wrap/Row
label: Text( // and turns the chip into a full-width bar under the PO number.
label, return Container(
style: TextStyle( height: compact ? 22 : 26,
color: color, padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 10),
fontSize: compact ? 11 : 12, decoration: BoxDecoration(
fontWeight: FontWeight.w600, color: color.withValues(alpha: 0.12),
height: 1.2, 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 'package:flutter/material.dart';
import '../../../../core/utils/formatters.dart';
import '../../../../shared/models/purchase_order_model.dart'; import '../../../../shared/models/purchase_order_model.dart';
import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/widgets/app_dropdown.dart'; import '../../../../shared/widgets/app_dropdown.dart';
@ -7,6 +8,92 @@ import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_side_panel.dart'; import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/app_text_field.dart'; import '../../../../shared/widgets/app_text_field.dart';
/// Per-line amount breakdown for PO calculations.
class PoLineCalculation {
const PoLineCalculation({
required this.baseAmount,
required this.discountAmount,
required this.lineAmount,
required this.gstAmount,
});
final double baseAmount;
final double discountAmount;
final double lineAmount;
final double gstAmount;
static const zero = PoLineCalculation(
baseAmount: 0,
discountAmount: 0,
lineAmount: 0,
gstAmount: 0,
);
/// 3.1 Base = Qty × Rate
/// 3.2 Discount = Base × Disc% ÷ 100
/// 3.3 Line Amount = Base Discount
/// 3.4 GST Amount = Line Amount × GST% ÷ 100
factory PoLineCalculation.compute({
required double qty,
required double rate,
required double discPct,
required double gstPct,
}) {
final baseAmount = qty * rate;
final discountAmount = baseAmount * discPct / 100;
final lineAmount = baseAmount - discountAmount;
final gstAmount = lineAmount * gstPct / 100;
return PoLineCalculation(
baseAmount: baseAmount,
discountAmount: discountAmount,
lineAmount: lineAmount,
gstAmount: gstAmount,
);
}
}
/// Order-level totals derived from line calculations + header charges.
class PoOrderTotals {
const PoOrderTotals({
required this.taxableAmount,
required this.taxAmount,
required this.grandTotal,
});
final double taxableAmount;
final double taxAmount;
final double grandTotal;
static const zero = PoOrderTotals(
taxableAmount: 0,
taxAmount: 0,
grandTotal: 0,
);
/// 3.5 Taxable = sum of Line Amounts
/// 3.6 Tax = sum of GST Amounts
/// 3.7 Grand Total = Taxable + Tax + Freight + Other Discount
factory PoOrderTotals.compute({
required Iterable<PoLineCalculation> lines,
required double freight,
required double otherCharges,
required double discountAmount,
}) {
var taxable = 0.0;
var tax = 0.0;
for (final line in lines) {
taxable += line.lineAmount;
tax += line.gstAmount;
}
final grandTotal = taxable + tax + freight + otherCharges - discountAmount;
return PoOrderTotals(
taxableAmount: taxable,
taxAmount: tax,
grandTotal: grandTotal,
);
}
}
class PoLineItemDraft { class PoLineItemDraft {
PoLineItemDraft({ PoLineItemDraft({
this.itemId, this.itemId,
@ -16,11 +103,11 @@ class PoLineItemDraft {
TextEditingController? rateController, TextEditingController? rateController,
TextEditingController? discountController, TextEditingController? discountController,
this.gstRateId, this.gstRateId,
TextEditingController? remarksController, this.hsnCodeId,
}) : qtyController = qtyController ?? TextEditingController(), }) : qtyController = qtyController ?? TextEditingController(),
rateController = rateController ?? TextEditingController(), rateController = rateController ?? TextEditingController(),
discountController = discountController ?? TextEditingController(text: '0'), discountController =
remarksController = remarksController ?? TextEditingController(); discountController ?? TextEditingController(text: '0');
int? itemId; int? itemId;
int lineNo; int lineNo;
@ -29,19 +116,20 @@ class PoLineItemDraft {
final TextEditingController rateController; final TextEditingController rateController;
final TextEditingController discountController; final TextEditingController discountController;
int? gstRateId; int? gstRateId;
final TextEditingController remarksController; int? hsnCodeId;
factory PoLineItemDraft.fromModel(PurchaseOrderItemModel item) { factory PoLineItemDraft.fromModel(PurchaseOrderItemModel item) {
return PoLineItemDraft( return PoLineItemDraft(
itemId: item.itemId, itemId: item.itemId,
lineNo: item.lineNo ?? 1, lineNo: item.lineNo ?? 1,
qtyController: TextEditingController(text: item.orderedQty?.toString() ?? ''), qtyController:
TextEditingController(text: item.orderedQty?.toString() ?? ''),
uomId: item.uomId, uomId: item.uomId,
rateController: TextEditingController(text: item.rate?.toString() ?? ''), rateController: TextEditingController(text: item.rate?.toString() ?? ''),
discountController: discountController:
TextEditingController(text: item.discountPct?.toString() ?? '0'), TextEditingController(text: item.discountPct?.toString() ?? '0'),
gstRateId: item.gstRateId, gstRateId: item.gstRateId,
remarksController: TextEditingController(text: item.remarks ?? ''), hsnCodeId: item.hsnCodeId,
); );
} }
@ -49,7 +137,21 @@ class PoLineItemDraft {
qtyController.dispose(); qtyController.dispose();
rateController.dispose(); rateController.dispose();
discountController.dispose(); discountController.dispose();
remarksController.dispose(); }
PoLineCalculation calculate(Map<String, double> gstRatePctById) {
final qty = double.tryParse(qtyController.text.trim()) ?? 0;
final rate = double.tryParse(rateController.text.trim()) ?? 0;
final discPct = double.tryParse(discountController.text.trim()) ?? 0;
final gstPct = gstRateId == null
? 0.0
: (gstRatePctById[gstRateId.toString()] ?? 0.0);
return PoLineCalculation.compute(
qty: qty,
rate: rate,
discPct: discPct,
gstPct: gstPct,
);
} }
Map<String, dynamic> toPayload() { Map<String, dynamic> toPayload() {
@ -66,8 +168,7 @@ class PoLineItemDraft {
'rate': rate, 'rate': rate,
'discount_pct': double.tryParse(discountController.text.trim()) ?? 0, 'discount_pct': double.tryParse(discountController.text.trim()) ?? 0,
if (gstRateId != null) 'gst_rate_id': gstRateId, if (gstRateId != null) 'gst_rate_id': gstRateId,
if (remarksController.text.trim().isNotEmpty) if (hsnCodeId != null) 'hsn_code_id': hsnCodeId,
'remarks': remarksController.text.trim(),
}; };
} }
} }
@ -77,79 +178,134 @@ class PurchaseOrderLineItemsEditor extends StatefulWidget {
super.key, super.key,
required this.lines, required this.lines,
required this.items, required this.items,
required this.itemHsnById,
required this.itemUomById,
required this.itemGstRateById,
required this.uom, required this.uom,
required this.gstRates, required this.gstRates,
required this.gstRatePctById,
required this.onAddLine, required this.onAddLine,
required this.onRemoveLine, required this.onRemoveLine,
this.onChanged,
}); });
final List<PoLineItemDraft> lines; final List<PoLineItemDraft> lines;
final List<FilterOptionModel> items; final List<FilterOptionModel> items;
final Map<String, int?> itemHsnById;
final Map<String, int?> itemUomById;
final Map<String, int?> itemGstRateById;
final List<FilterOptionModel> uom; final List<FilterOptionModel> uom;
final List<FilterOptionModel> gstRates; final List<FilterOptionModel> gstRates;
final Map<String, double> gstRatePctById;
final VoidCallback onAddLine; final VoidCallback onAddLine;
final ValueChanged<int> onRemoveLine; final ValueChanged<int> onRemoveLine;
final VoidCallback? onChanged;
@override @override
State<PurchaseOrderLineItemsEditor> createState() => State<PurchaseOrderLineItemsEditor> createState() =>
_PurchaseOrderLineItemsEditorState(); _PurchaseOrderLineItemsEditorState();
} }
class _PurchaseOrderLineItemsEditorState extends State<PurchaseOrderLineItemsEditor> { class _PurchaseOrderLineItemsEditorState
extends State<PurchaseOrderLineItemsEditor> {
void _notifyChanged() {
widget.onChanged?.call();
setState(() {});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final cardColor = isDark
? theme.colorScheme.surfaceContainerHighest
: theme.colorScheme.surface;
final borderColor = theme.colorScheme.outline.withValues(
alpha: isDark ? 0.35 : 0.2,
);
return Column( return Container(
crossAxisAlignment: CrossAxisAlignment.start, width: double.infinity,
children: [ padding: const EdgeInsets.all(20),
Row( decoration: BoxDecoration(
children: [ color: cardColor,
Text('Line Items', style: theme.textTheme.titleMedium), borderRadius: BorderRadius.circular(12),
const Spacer(), border: Border.all(color: borderColor),
TextButton.icon( ),
onPressed: widget.onAddLine, child: Column(
icon: const Icon(Icons.add), crossAxisAlignment: CrossAxisAlignment.stretch,
label: const Text('Add line'), children: [
), Row(
], children: [
), Text(
const SizedBox(height: 8), 'LINE ITEMS',
if (widget.lines.isEmpty) style: theme.textTheme.labelMedium?.copyWith(
Container( color: theme.colorScheme.onSurfaceVariant,
width: double.infinity, fontWeight: FontWeight.w600,
padding: const EdgeInsets.all(24), letterSpacing: 0.8,
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,
), ),
textAlign: TextAlign.center, const Spacer(),
), TextButton.icon(
) onPressed: () {
else widget.onAddLine();
...widget.lines.asMap().entries.map((entry) { widget.onChanged?.call();
final index = entry.key; },
final line = entry.value; icon: const Icon(Icons.add, size: 18),
return Padding( label: const Text('Add line'),
padding: const EdgeInsets.only(bottom: 12), style: TextButton.styleFrom(
child: _LineItemCard( foregroundColor: theme.colorScheme.primary,
key: ObjectKey(line), ),
line: line,
items: widget.items,
uom: widget.uom,
gstRates: widget.gstRates,
onRemove: widget.lines.length > 1
? () => widget.onRemoveLine(index)
: null,
), ),
); ],
}), ),
], 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, super.key,
required this.line, required this.line,
required this.items, required this.items,
required this.itemHsnById,
required this.itemUomById,
required this.itemGstRateById,
required this.uom, required this.uom,
required this.gstRates, required this.gstRates,
required this.gstRatePctById,
required this.onChanged,
this.onRemove, this.onRemove,
}); });
final PoLineItemDraft line; final PoLineItemDraft line;
final List<FilterOptionModel> items; final List<FilterOptionModel> items;
final Map<String, int?> itemHsnById;
final Map<String, int?> itemUomById;
final Map<String, int?> itemGstRateById;
final List<FilterOptionModel> uom; final List<FilterOptionModel> uom;
final List<FilterOptionModel> gstRates; final List<FilterOptionModel> gstRates;
final Map<String, double> gstRatePctById;
final VoidCallback onChanged;
final VoidCallback? onRemove; final VoidCallback? onRemove;
@override @override
@ -175,18 +341,69 @@ class _LineItemCard extends StatefulWidget {
} }
class _LineItemCardState extends State<_LineItemCard> { class _LineItemCardState extends State<_LineItemCard> {
@override
void initState() {
super.initState();
widget.line.qtyController.addListener(_onFieldChanged);
widget.line.rateController.addListener(_onFieldChanged);
widget.line.discountController.addListener(_onFieldChanged);
}
@override
void dispose() {
widget.line.qtyController.removeListener(_onFieldChanged);
widget.line.rateController.removeListener(_onFieldChanged);
widget.line.discountController.removeListener(_onFieldChanged);
super.dispose();
}
void _onFieldChanged() {
widget.onChanged();
setState(() {});
}
int? _parseId(String value) => int.tryParse(value.trim()); int? _parseId(String value) => int.tryParse(value.trim());
void _updateLine(void Function() mutate) { void _updateLine(void Function() mutate) {
mutate(); mutate();
widget.onChanged();
setState(() {}); setState(() {});
} }
void _onItemChanged(int? itemId) {
_updateLine(() {
widget.line.itemId = itemId;
if (itemId == null) return;
final key = itemId.toString();
final defaultUom = widget.itemUomById[key];
if (defaultUom != null) {
widget.line.uomId = defaultUom;
}
final defaultGst = widget.itemGstRateById[key];
if (defaultGst != null) {
widget.line.gstRateId = defaultGst;
}
final defaultHsn = widget.itemHsnById[key];
if (defaultHsn != null) {
widget.line.hsnCodeId = defaultHsn;
}
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final line = widget.line; final line = widget.line;
final lineKey = 'po-line-${line.lineNo}'; final lineKey = 'po-line-${line.lineNo}';
final calc = line.calculate(widget.gstRatePctById);
final isDark = theme.brightness == Brightness.dark;
final borderColor = theme.colorScheme.outline.withValues(
alpha: isDark ? 0.35 : 0.18,
);
final amountBg = theme.colorScheme.primary.withValues(
alpha: isDark ? 0.18 : 0.08,
);
final itemOptions = widget.items final itemOptions = widget.items
.map((e) { .map((e) {
final id = _parseId(e.id); final id = _parseId(e.id);
@ -204,7 +421,7 @@ class _LineItemCardState extends State<_LineItemCard> {
.whereType<AppDropdownOption<int>>() .whereType<AppDropdownOption<int>>()
.toList(); .toList();
final gstOptions = [ final gstOptions = [
const AppDropdownOption<int?>(value: null, label: 'No GST'), const AppDropdownOption<int?>(value: null, label: 'Select GST rate'),
...widget.gstRates.map((e) { ...widget.gstRates.map((e) {
final id = _parseId(e.id); final id = _parseId(e.id);
if (id == null) return null; if (id == null) return null;
@ -213,116 +430,179 @@ class _LineItemCardState extends State<_LineItemCard> {
].whereType<AppDropdownOption<int?>>().toList(); ].whereType<AppDropdownOption<int?>>().toList();
return Container( return Container(
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all(color: theme.dividerColor), border: Border.all(color: borderColor),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(10),
), ),
child: Column( child: FormRow(
crossAxisAlignment: CrossAxisAlignment.stretch, columnCount: 12,
spans: const [3, 1, 2, 1, 1, 2, 2],
spacing: 8,
stackBelowWidth: 1100,
children: [ children: [
Padding( AppSearchableDropdown<int>(
padding: const EdgeInsets.symmetric(horizontal: 16), key: ValueKey('$lineKey-item'),
child: Row( label: 'Item *',
children: [ value: line.itemId,
Text('Line ${line.lineNo}', style: theme.textTheme.titleSmall), hint: 'Select item',
const Spacer(), searchHint: 'Search item...',
if (widget.onRemove != null) options: itemOptions,
IconButton( onChanged: _onItemChanged,
tooltip: 'Remove line', validator: (v) => v == null ? 'Item is required' : null,
icon: const Icon(Icons.delete_outline),
onPressed: widget.onRemove,
),
],
),
), ),
const SizedBox(height: 12), AppTextField(
FormRow( key: ValueKey('$lineKey-qty'),
columnCount: 6, controller: line.qtyController,
horizontalPadding: 16, label: 'Qty *',
spacing: 8, hint: '0',
stackBelowWidth: 992, keyboardType:
children: [ const TextInputType.numberWithOptions(decimal: true),
AppSearchableDropdown<int>( validator: (v) {
key: ValueKey('$lineKey-item'), if (v == null || v.trim().isEmpty) {
label: 'Item *', return 'Required';
value: line.itemId, }
searchHint: 'Search item...', final qty = double.tryParse(v);
options: itemOptions, if (qty == null || qty <= 0) return 'Invalid';
onChanged: (v) => _updateLine(() => line.itemId = v), return null;
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),
),
],
), ),
FormRow( AppSearchableDropdown<int>(
columnCount: 6, key: ValueKey('$lineKey-uom'),
spans: const [6], label: 'UOM *',
horizontalPadding: 16, value: line.uomId,
spacing: 8, hint: 'Select UOM',
stackBelowWidth: 992, searchHint: 'Search UOM...',
children: [ options: uomOptions,
AppTextField( onChanged: (v) => _updateLine(() => line.uomId = v),
key: ValueKey('$lineKey-remarks'), validator: (v) => v == null ? 'Required' : null,
controller: line.remarksController, ),
label: 'Remarks', AppTextField(
maxLines: 2, 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/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../../shared/widgets/app_data_table.dart';
import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/widgets/app_card.dart'; import '../../../../shared/widgets/app_card.dart';
@ -221,12 +223,13 @@ class EmployeeCodeBadge extends StatelessWidget {
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.7), color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: Text( child: AppTableCell.text(
code, code,
style: theme.textTheme.labelSmall?.copyWith( style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
showTooltip: true,
), ),
); );
} }
@ -246,24 +249,24 @@ class UserTableUserCell extends StatelessWidget {
UserAvatarChip( UserAvatarChip(
name: user.fullName, name: user.fullName,
initials: user.initialsDisplay, initials: user.initialsDisplay,
radius: 16,
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( AppTableCell.text(
user.fullName, user.fullName,
style: const TextStyle(fontWeight: FontWeight.w600), style: const TextStyle(fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis,
), ),
Text( AppTableCell.text(
user.email, user.email,
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
overflow: TextOverflow.ellipsis,
), ),
], ],
), ),
@ -274,10 +277,16 @@ class UserTableUserCell extends StatelessWidget {
} }
class UserAvatarChip extends StatelessWidget { class UserAvatarChip extends StatelessWidget {
const UserAvatarChip({super.key, required this.name, this.initials}); const UserAvatarChip({
super.key,
required this.name,
this.initials,
this.radius = 20,
});
final String name; final String name;
final String? initials; final String? initials;
final double radius;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -293,14 +302,14 @@ class UserAvatarChip extends StatelessWidget {
(name.isNotEmpty ? name.trim()[0].toUpperCase() : 'U'); (name.isNotEmpty ? name.trim()[0].toUpperCase() : 'U');
return CircleAvatar( return CircleAvatar(
radius: 20, radius: radius,
backgroundColor: color.withValues(alpha: 0.12), backgroundColor: color.withValues(alpha: 0.12),
child: Text( child: Text(
display.length > 2 ? display.substring(0, 2) : display, display.length > 2 ? display.substring(0, 2) : display,
style: TextStyle( style: TextStyle(
color: color, color: color,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
fontSize: 12, fontSize: radius * 0.6,
), ),
), ),
); );

View File

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

View File

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@ import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/network/api_handler.dart';
import '../../../../core/theme/theme_provider.dart'; import '../../../../core/theme/theme_provider.dart';
import '../../../../core/utils/favicon_store.dart'; import '../../../../core/utils/favicon_store.dart';
import '../../../../core/utils/favicon_updater.dart'; import '../../../../core/utils/favicon_updater.dart';
@ -41,15 +42,16 @@ class _CompanyProfileSettingsScreenState
late final TextEditingController _logoUrlController; late final TextEditingController _logoUrlController;
late final TextEditingController _faviconUrlController; late final TextEditingController _faviconUrlController;
bool _loading = true;
bool _saving = false;
bool _uploadingLogo = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final profile = ref.read(appSettingsProvider).companyProfile; final profile = ref.read(appSettingsProvider).companyProfile;
final faviconFromPrefs = final faviconFromPrefs =
FaviconStore(ref.read(sharedPreferencesProvider)).read(); FaviconStore(ref.read(sharedPreferencesProvider)).read();
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(appSettingsProvider.notifier).refreshCompanyProfile();
});
_nameController = TextEditingController(text: profile.companyName); _nameController = TextEditingController(text: profile.companyName);
_codeController = TextEditingController(text: profile.companyCode); _codeController = TextEditingController(text: profile.companyCode);
_registrationController = _registrationController =
@ -68,6 +70,43 @@ class _CompanyProfileSettingsScreenState
? profile.faviconUrl ? profile.faviconUrl
: faviconFromPrefs, : faviconFromPrefs,
); );
WidgetsBinding.instance.addPostFrameCallback((_) => _loadFromApi());
}
Future<void> _loadFromApi() async {
setState(() => _loading = true);
final failure =
await ref.read(appSettingsProvider.notifier).refreshCompanyProfile();
if (!mounted) return;
_applyProfile(ref.read(appSettingsProvider).companyProfile);
setState(() => _loading = false);
if (failure != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(failure.message),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
}
void _applyProfile(CompanyProfileSettings profile) {
_nameController.text = profile.companyName;
_codeController.text = profile.companyCode;
_registrationController.text = profile.registrationNumber;
_gstController.text = profile.gstNumber;
_addressController.text = profile.address;
_cityController.text = profile.city;
_stateController.text = profile.state;
_pincodeController.text = profile.pincode;
_emailController.text = profile.email;
_phoneController.text = profile.phone;
_websiteController.text = profile.website;
_logoUrlController.text = profile.logoUrl;
if (profile.faviconUrl.isNotEmpty) {
_faviconUrlController.text = profile.faviconUrl;
}
setState(() {});
} }
@override @override
@ -95,23 +134,37 @@ class _CompanyProfileSettingsScreenState
final faviconUrl = _faviconUrlController.text.trim(); final faviconUrl = _faviconUrlController.text.trim();
final companyName = _nameController.text.trim(); final companyName = _nameController.text.trim();
await ref.read(appSettingsProvider.notifier).updateCompanyProfile( setState(() => _saving = true);
CompanyProfileSettings( final failure =
companyName: companyName, await ref.read(appSettingsProvider.notifier).updateCompanyProfile(
companyCode: _codeController.text.trim(), CompanyProfileSettings(
registrationNumber: _registrationController.text.trim(), companyName: companyName,
gstNumber: _gstController.text.trim(), companyCode: _codeController.text.trim(),
address: _addressController.text.trim(), registrationNumber: _registrationController.text.trim(),
city: _cityController.text.trim(), gstNumber: _gstController.text.trim(),
state: _stateController.text.trim(), address: _addressController.text.trim(),
pincode: _pincodeController.text.trim(), city: _cityController.text.trim(),
email: _emailController.text.trim(), state: _stateController.text.trim(),
phone: _phoneController.text.trim(), pincode: _pincodeController.text.trim(),
website: _websiteController.text.trim(), email: _emailController.text.trim(),
logoUrl: logoUrl, phone: _phoneController.text.trim(),
faviconUrl: faviconUrl, 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( await ref.read(brandingProvider.notifier).updateBranding(
ref.read(brandingProvider).copyWith( ref.read(brandingProvider).copyWith(
@ -165,14 +218,19 @@ class _CompanyProfileSettingsScreenState
final bytes = file.bytes; final bytes = file.bytes;
if (bytes == null) return; if (bytes == null) return;
final uploadedUrl = await ref.read(appSettingsProvider.notifier).uploadCompanyLogo( setState(() => _uploadingLogo = true);
bytes, final uploadResult =
file.name, await ref.read(appSettingsProvider.notifier).uploadCompanyLogo(
); bytes,
file.name,
);
if (!mounted) return; if (!mounted) return;
setState(() => _uploadingLogo = false);
if (uploadedUrl != null && uploadedUrl.isNotEmpty) { final uploadedUrl = uploadResult.data;
if (uploadResult.failure == null &&
uploadedUrl != null &&
uploadedUrl.isNotEmpty) {
setState(() => _logoUrlController.text = uploadedUrl); setState(() => _logoUrlController.text = uploadedUrl);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Logo uploaded')), const SnackBar(content: Text('Logo uploaded')),
@ -180,9 +238,21 @@ class _CompanyProfileSettingsScreenState
return; return;
} }
if (uploadResult.failure != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(uploadResult.failure!.message),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
// Local preview fallback when upload is unavailable still apply as main logo.
final ext = (file.extension ?? 'png').toLowerCase(); final ext = (file.extension ?? 'png').toLowerCase();
final mime = ext == 'jpg' ? 'jpeg' : ext; final mime = ext == 'jpg' ? 'jpeg' : ext;
final dataUri = 'data:image/$mime;base64,${base64Encode(bytes)}'; final dataUri = 'data:image/$mime;base64,${base64Encode(bytes)}';
await ref.read(appSettingsProvider.notifier).applyLocalCompanyLogo(dataUri);
if (!mounted) return;
setState(() => _logoUrlController.text = dataUri); setState(() => _logoUrlController.text = dataUri);
} }
@ -195,147 +265,165 @@ class _CompanyProfileSettingsScreenState
return SettingsPageLayout( return SettingsPageLayout(
title: 'Company Profile', title: 'Company Profile',
subtitle: 'Company information and branding assets', subtitle: 'Company information and branding assets',
child: Form( child: _loading
key: _formKey, ? const Padding(
child: Column( padding: EdgeInsets.symmetric(vertical: 48),
children: [ child: Center(child: CircularProgressIndicator()),
SettingsFormCard( )
title: 'Company Information', : Form(
children: [ key: _formKey,
AppTextField( child: Column(
controller: _nameController, children: [
label: 'Company Name', SettingsFormCard(
validator: (v) => Validators.required(v, fieldName: 'Company name'), title: 'Company Information',
), children: [
const SizedBox(height: 16), AppTextField(
AppTextField( controller: _nameController,
controller: _codeController, label: 'Company Name',
label: 'Company Code', validator: (v) =>
validator: (v) => Validators.required(v, fieldName: 'Company code'), Validators.required(v, fieldName: 'Company name'),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
AppTextField( AppTextField(
controller: _registrationController, controller: _codeController,
label: 'Registration Number', label: 'Company Code',
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
AppTextField( AppTextField(
controller: _gstController, controller: _registrationController,
label: 'GST/VAT Number', label: 'Registration Number',
validator: Validators.optionalGstin, ),
inputFormatters: Validators.gstinInput, const SizedBox(height: 16),
), AppTextField(
const SizedBox(height: 16), controller: _gstController,
AppTextField( label: 'GST/VAT Number',
controller: _addressController, validator: Validators.optionalGstin,
label: 'Address', inputFormatters: Validators.gstinInput,
maxLines: 2, ),
), const SizedBox(height: 16),
const SizedBox(height: 16), AppTextField(
SidePanelFormRow( controller: _addressController,
left: AppTextField( label: 'Address',
controller: _cityController, maxLines: 2,
label: 'City', ),
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( const SizedBox(height: 16),
controller: _stateController, SettingsFormCard(
label: 'State', 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),
const SizedBox(height: 16), SettingsFormCard(
AppTextField( title: 'Favicon Upload',
controller: _pincodeController, subtitle:
label: 'Pincode', 'Upload an image or provide a favicon URL for the browser tab',
keyboardType: TextInputType.number, children: [
), Center(
const SizedBox(height: 16), child: SidebarLogo(
AppTextField( logoUrl: _faviconUrlController.text.trim().isEmpty
controller: _emailController, ? null
label: 'Email', : _faviconUrlController.text.trim(),
keyboardType: TextInputType.emailAddress, width: 64,
validator: Validators.email, height: 64,
), fit: BoxFit.contain,
const SizedBox(height: 16), ),
AppTextField( ),
controller: _phoneController, const SizedBox(height: 16),
label: 'Phone', AppTextField(
keyboardType: TextInputType.phone, controller: _faviconUrlController,
validator: Validators.optionalMobile, label: 'Favicon URL',
inputFormatters: Validators.mobileInput, hint: 'https://example.com/favicon.ico',
), onChanged: (_) => setState(() {}),
const SizedBox(height: 16), ),
AppTextField( const SizedBox(height: 12),
controller: _websiteController, OutlinedButton.icon(
label: 'Website', onPressed: _pickFavicon,
keyboardType: TextInputType.url, 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/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/network/api_handler.dart';
import '../../../../core/utils/validators.dart'; import '../../../../core/utils/validators.dart';
import '../../../../shared/widgets/app_button.dart'; import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_text_field.dart'; import '../../../../shared/widgets/app_text_field.dart';
@ -25,31 +26,49 @@ class _EmailConfigurationScreenState
late final TextEditingController _passwordController; late final TextEditingController _passwordController;
late final TextEditingController _senderEmailController; late final TextEditingController _senderEmailController;
late final TextEditingController _senderNameController; late final TextEditingController _senderNameController;
late final TextEditingController _allocationTemplateController;
late final TextEditingController _returnTemplateController; bool _loading = true;
late final TextEditingController _maintenanceTemplateController; bool _saving = false;
late final TextEditingController _warrantyTemplateController;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final email = ref.read(appSettingsProvider).email; final email = ref.read(appSettingsProvider).email;
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(appSettingsProvider.notifier).refreshEmailSettings();
});
_hostController = TextEditingController(text: email.smtpHost); _hostController = TextEditingController(text: email.smtpHost);
_portController = TextEditingController(text: '${email.smtpPort}'); _portController = TextEditingController(text: '${email.smtpPort}');
_usernameController = TextEditingController(text: email.smtpUsername); _usernameController = TextEditingController(text: email.smtpUsername);
_passwordController = TextEditingController(text: email.smtpPassword); _passwordController = TextEditingController(text: email.smtpPassword);
_senderEmailController = TextEditingController(text: email.senderEmail); _senderEmailController = TextEditingController(text: email.senderEmail);
_senderNameController = TextEditingController(text: email.senderName); _senderNameController = TextEditingController(text: email.senderName);
_allocationTemplateController = WidgetsBinding.instance.addPostFrameCallback((_) => _loadFromApi());
TextEditingController(text: email.allocationTemplate); }
_returnTemplateController = TextEditingController(text: email.returnTemplate);
_maintenanceTemplateController = Future<void> _loadFromApi() async {
TextEditingController(text: email.maintenanceTemplate); setState(() => _loading = true);
_warrantyTemplateController = final failure =
TextEditingController(text: email.warrantyTemplate); await ref.read(appSettingsProvider.notifier).refreshEmailSettings();
if (!mounted) return;
_applyEmail(ref.read(appSettingsProvider).email);
setState(() => _loading = false);
if (failure != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(failure.message),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
}
void _applyEmail(EmailConfigurationSettings email) {
_hostController.text = email.smtpHost;
_portController.text = '${email.smtpPort}';
_usernameController.text = email.smtpUsername;
if (email.smtpPassword.isNotEmpty) {
_passwordController.text = email.smtpPassword;
}
_senderEmailController.text = email.senderEmail;
_senderNameController.text = email.senderName;
} }
@override @override
@ -60,121 +79,117 @@ class _EmailConfigurationScreenState
_passwordController.dispose(); _passwordController.dispose();
_senderEmailController.dispose(); _senderEmailController.dispose();
_senderNameController.dispose(); _senderNameController.dispose();
_allocationTemplateController.dispose();
_returnTemplateController.dispose();
_maintenanceTemplateController.dispose();
_warrantyTemplateController.dispose();
super.dispose(); super.dispose();
} }
Future<void> _save() async { Future<void> _save() async {
if (!_formKey.currentState!.validate()) return; if (!_formKey.currentState!.validate()) return;
await ref.read(appSettingsProvider.notifier).updateEmail( final port = int.tryParse(_portController.text.trim());
EmailConfigurationSettings( if (port == null || port <= 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Enter a valid SMTP port')),
);
return;
}
final current = ref.read(appSettingsProvider).email;
setState(() => _saving = true);
final failure = await ref.read(appSettingsProvider.notifier).updateEmail(
current.copyWith(
smtpHost: _hostController.text.trim(), smtpHost: _hostController.text.trim(),
smtpPort: int.parse(_portController.text.trim()), smtpPort: port,
smtpUsername: _usernameController.text.trim(), smtpUsername: _usernameController.text.trim(),
smtpPassword: _passwordController.text.trim(), smtpPassword: _passwordController.text.trim(),
senderEmail: _senderEmailController.text.trim(), senderEmail: _senderEmailController.text.trim(),
senderName: _senderNameController.text.trim(), senderName: _senderNameController.text.trim(),
allocationTemplate: _allocationTemplateController.text.trim(),
returnTemplate: _returnTemplateController.text.trim(),
maintenanceTemplate: _maintenanceTemplateController.text.trim(),
warrantyTemplate: _warrantyTemplateController.text.trim(),
), ),
); );
if (!mounted) return;
setState(() => _saving = false);
if (mounted) { if (failure != null) {
ScaffoldMessenger.of(context).showSnackBar( 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SettingsPageLayout( return SettingsPageLayout(
title: 'Email Configuration', title: 'Email Configuration',
subtitle: 'SMTP server settings and email templates', subtitle: 'SMTP server settings',
child: Form( child: _loading
key: _formKey, ? const Padding(
child: Column( padding: EdgeInsets.symmetric(vertical: 48),
children: [ child: Center(child: CircularProgressIndicator()),
SettingsFormCard( )
title: 'SMTP Settings', : Form(
children: [ key: _formKey,
AppTextField( child: Column(
controller: _hostController, children: [
label: 'SMTP Host', SettingsFormCard(
hint: 'smtp.gmail.com', title: 'SMTP Settings',
), children: [
const SizedBox(height: 16), AppTextField(
AppTextField( controller: _hostController,
controller: _portController, label: 'SMTP Host',
label: 'SMTP Port', hint: 'smtp.gmail.com',
keyboardType: TextInputType.number, validator: (v) =>
), Validators.required(v, fieldName: 'SMTP host'),
const SizedBox(height: 16), ),
AppTextField( const SizedBox(height: 16),
controller: _usernameController, AppTextField(
label: 'Username', controller: _portController,
), label: 'SMTP Port',
const SizedBox(height: 16), keyboardType: TextInputType.number,
AppTextField( validator: (v) =>
controller: _passwordController, Validators.required(v, fieldName: 'SMTP port'),
label: 'Password', ),
obscureText: true, const SizedBox(height: 16),
), AppTextField(
const SizedBox(height: 16), controller: _usernameController,
AppTextField( label: 'Username',
controller: _senderEmailController, ),
label: 'Sender Email', const SizedBox(height: 16),
keyboardType: TextInputType.emailAddress, AppTextField(
validator: Validators.email, controller: _passwordController,
), label: 'Password',
const SizedBox(height: 16), obscureText: true,
AppTextField( hint: 'Leave blank to keep existing password',
controller: _senderNameController, ),
label: 'Sender Name', 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final visiblePhase1 = visiblePhase1SettingsSections;
final visiblePhase2 = visiblePhase2SettingsSections;
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Center( child: Center(
@ -21,35 +24,31 @@ class SettingsScreen extends StatelessWidget {
children: [ children: [
const PageHeader( const PageHeader(
title: 'Settings', title: 'Settings',
subtitle: 'Configure company, security, assets, and system behavior', subtitle:
), 'Configure company, security, assets, and system behavior',
Text(
'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: (_) {},
), ),
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; return null;
} }
Object? _readAssetCategoryName(Map<dynamic, dynamic> json, String key) { Object? _readItemCategoryName(Map<dynamic, dynamic> json, String key) {
final flat = json['asset_category_name']; for (final flatKey in ['item_category_name', 'asset_category_name']) {
if (flat is String && flat.isNotEmpty) return flat; final flat = json[flatKey];
return _readNestedName(json, 'asset_category'); 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) { Object? _readPlantName(Map<dynamic, dynamic> json, String key) {
@ -48,10 +51,12 @@ Object? _readPlantName(Map<dynamic, dynamic> json, String key) {
return _readNestedName(json, 'plant'); return _readNestedName(json, 'plant');
} }
Object? _readAssetCategoryId(Map<dynamic, dynamic> json, String key) { Object? _readItemCategoryId(Map<dynamic, dynamic> json, String key) {
final flat = json['asset_category_id']; for (final flatKey in ['item_category_id', 'asset_category_id']) {
if (flat != null) return flat; final flat = json[flatKey];
final nested = json['asset_category']; if (flat != null) return flat;
}
final nested = json['item_category'] ?? json['asset_category'];
if (nested is Map) return nested['id']; if (nested is Map) return nested['id'];
return null; return null;
} }
@ -64,16 +69,21 @@ Object? _readPlantId(Map<dynamic, dynamic> json, String key) {
return null; return null;
} }
Object? _readAssetSubcategoryName(Map<dynamic, dynamic> json, String key) { Object? _readItemSubcategoryName(Map<dynamic, dynamic> json, String key) {
final flat = json['asset_subcategory_name']; for (final flatKey in ['item_subcategory_name', 'asset_subcategory_name']) {
if (flat is String && flat.isNotEmpty) return flat; final flat = json[flatKey];
return _readNestedName(json, 'asset_subcategory'); 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) { Object? _readItemSubcategoryId(Map<dynamic, dynamic> json, String key) {
final flat = json['asset_subcategory_id']; for (final flatKey in ['item_subcategory_id', 'asset_subcategory_id']) {
if (flat != null) return flat; final flat = json[flatKey];
final nested = json['asset_subcategory']; if (flat != null) return flat;
}
final nested = json['item_subcategory'] ?? json['asset_subcategory'];
if (nested is Map) return nested['id']; if (nested is Map) return nested['id'];
return null; return null;
} }
@ -127,20 +137,20 @@ class AssetModel with _$AssetModel {
@JsonKey(name: 'asset_name') required String assetName, @JsonKey(name: 'asset_name') required String assetName,
@JsonKey(name: 'asset_code') String? assetCode, @JsonKey(name: 'asset_code') String? assetCode,
@JsonKey( @JsonKey(
name: 'asset_category_id', name: 'item_category_id',
readValue: _readAssetCategoryId, readValue: _readItemCategoryId,
fromJson: _intFromJsonNullable, fromJson: _intFromJsonNullable,
) )
int? assetCategoryId, int? assetCategoryId,
@JsonKey(name: 'asset_category_name', readValue: _readAssetCategoryName) @JsonKey(name: 'item_category_name', readValue: _readItemCategoryName)
String? assetCategoryName, String? assetCategoryName,
@JsonKey( @JsonKey(
name: 'asset_subcategory_id', name: 'item_subcategory_id',
readValue: _readAssetSubcategoryId, readValue: _readItemSubcategoryId,
fromJson: _intFromJsonNullable, fromJson: _intFromJsonNullable,
) )
int? assetSubcategoryId, int? assetSubcategoryId,
@JsonKey(name: 'asset_subcategory_name', readValue: _readAssetSubcategoryName) @JsonKey(name: 'item_subcategory_name', readValue: _readItemSubcategoryName)
String? assetSubcategoryName, String? assetSubcategoryName,
@JsonKey(name: 'plant_id', readValue: _readPlantId, fromJson: _intFromJsonNullable) @JsonKey(name: 'plant_id', readValue: _readPlantId, fromJson: _intFromJsonNullable)
int? plantId, int? plantId,

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
import 'app_card.dart'; import 'app_card.dart';
/// Fixed height for every row in [AppDataTable] and themed [DataTable] widgets.
const double kAppTableRowHeight = 52;
class AppDataColumn<T> { class AppDataColumn<T> {
const AppDataColumn({ const AppDataColumn({
required this.label, required this.label,
@ -18,6 +21,32 @@ class AppDataColumn<T> {
final Alignment alignment; final Alignment alignment;
} }
/// Helpers for table cell content single-line text with ellipsis and tooltip.
class AppTableCell {
AppTableCell._();
/// Renders [value] on one line; shows the full text in a tooltip when truncated.
static Widget text(
String? value, {
TextStyle? style,
String placeholder = '',
TextAlign? textAlign,
bool showTooltip = true,
}) {
final display =
(value == null || value.trim().isEmpty) ? placeholder : value.trim();
return _EllipsisTooltipText(
text: display,
style: style,
textAlign: textAlign,
showTooltip: showTooltip && display != placeholder,
);
}
/// Wraps non-text cell widgets (chips, actions) inside the row height budget.
static Widget child(Widget widget) => widget;
}
class AppDataTable<T> extends StatelessWidget { class AppDataTable<T> extends StatelessWidget {
const AppDataTable({ const AppDataTable({
super.key, super.key,
@ -38,6 +67,7 @@ class AppDataTable<T> extends StatelessWidget {
final void Function(String column, bool ascending)? onSort; final void Function(String column, bool ascending)? onSort;
final String emptyMessage; final String emptyMessage;
final bool wrapInCard; final bool wrapInCard;
/// Set true when the table is placed inside another scrollable. /// Set true when the table is placed inside another scrollable.
final bool shrinkWrap; final bool shrinkWrap;
@ -64,9 +94,7 @@ class AppDataTable<T> extends StatelessWidget {
return ListView( return ListView(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
shrinkWrap: shrinkWrap, shrinkWrap: shrinkWrap,
physics: shrinkWrap physics: shrinkWrap ? const NeverScrollableScrollPhysics() : null,
? const NeverScrollableScrollPhysics()
: null,
children: [ children: [
_TableHeaderRow<T>( _TableHeaderRow<T>(
columns: columns, columns: columns,
@ -111,56 +139,63 @@ class _TableHeaderRow<T> extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Container( return SizedBox(
height: kAppTableRowHeight,
width: double.infinity, width: double.infinity,
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4), child: ColoredBox(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
child: Row( child: Padding(
children: columns.map((col) { padding: const EdgeInsets.symmetric(horizontal: 20),
final isSorted = col.sortKey != null && col.sortKey == sortColumn; child: Row(
final label = Text( children: columns.map((col) {
col.label.toUpperCase(), final isSorted = col.sortKey != null && col.sortKey == sortColumn;
style: theme.textTheme.labelSmall?.copyWith( final label = Text(
fontWeight: FontWeight.w700, col.label.toUpperCase(),
letterSpacing: 0.6, maxLines: 1,
color: theme.colorScheme.onSurfaceVariant, overflow: TextOverflow.ellipsis,
), style: theme.textTheme.labelSmall?.copyWith(
); fontWeight: FontWeight.w700,
letterSpacing: 0.6,
color: theme.colorScheme.onSurfaceVariant,
),
);
Widget header = label; Widget header = label;
if (col.sortKey != null && onSort != null) { if (col.sortKey != null && onSort != null) {
header = InkWell( header = InkWell(
onTap: () => onSort!( onTap: () => onSort!(
col.sortKey!, col.sortKey!,
isSorted ? !sortAscending : true, isSorted ? !sortAscending : true,
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
label, Flexible(child: label),
if (isSorted) ...[ if (isSorted) ...[
const SizedBox(width: 4), const SizedBox(width: 4),
Icon( Icon(
sortAscending sortAscending
? Icons.arrow_upward ? Icons.arrow_upward
: Icons.arrow_downward, : Icons.arrow_downward,
size: 14, size: 14,
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
], ],
], ],
), ),
); );
} }
return Expanded( return Expanded(
flex: col.flex, flex: col.flex,
child: Align( child: Align(
alignment: col.alignment, alignment: col.alignment,
child: header, child: header,
), ),
); );
}).toList(), }).toList(),
),
),
), ),
); );
} }
@ -179,28 +214,125 @@ class _TableDataRow<T> extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Container( return SizedBox(
height: kAppTableRowHeight,
width: double.infinity, width: double.infinity,
decoration: BoxDecoration( child: DecoratedBox(
border: Border( decoration: BoxDecoration(
bottom: BorderSide( border: Border(
color: theme.colorScheme.outline.withValues(alpha: 0.08), 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>> { class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
final _layerLink = LayerLink(); final _layerLink = LayerLink();
final _fieldKey = GlobalKey(); final _fieldKey = GlobalKey();
final _formFieldKey = UniqueKey(); final _formFieldStateKey = GlobalKey<FormFieldState<T>>();
OverlayEntry? _overlayEntry; OverlayEntry? _overlayEntry;
bool _ignoreOutsideTap = false; bool _ignoreOutsideTap = false;
@override
void didUpdateWidget(covariant AppSearchableDropdown<T> oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.value == oldWidget.value) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final fieldState = _formFieldStateKey.currentState;
if (fieldState == null || fieldState.value == widget.value) return;
fieldState.didChange(widget.value);
if (fieldState.hasError) {
fieldState.validate();
}
});
}
@override @override
void dispose() { void dispose() {
_removeOverlay(); _removeOverlay();
@ -167,7 +184,7 @@ class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
final theme = Theme.of(context); final theme = Theme.of(context);
return FormField<T>( return FormField<T>(
key: widget.key ?? _formFieldKey, key: _formFieldStateKey,
initialValue: widget.value, initialValue: widget.value,
validator: widget.validator, validator: widget.validator,
builder: (field) { builder: (field) {
@ -342,3 +359,216 @@ class _SearchableDropdownPanelState<T>
); );
} }
} }
/// Searchable lookup anchored to the field same overlay UI as
/// [AppSearchableDropdown] but without a [FormField] wrapper.
class AppSearchableLookupField<T> extends StatefulWidget {
const AppSearchableLookupField({
super.key,
required this.label,
required this.value,
required this.options,
required this.onChanged,
this.hint,
this.searchHint = 'Search...',
this.enabled = true,
this.isDense = false,
});
final String label;
final T? value;
final List<AppDropdownOption<T>> options;
final ValueChanged<T?> onChanged;
final String? hint;
final String searchHint;
final bool enabled;
final bool isDense;
@override
State<AppSearchableLookupField<T>> createState() =>
_AppSearchableLookupFieldState<T>();
}
class _AppSearchableLookupFieldState<T>
extends State<AppSearchableLookupField<T>> {
final _layerLink = LayerLink();
final _fieldKey = GlobalKey();
OverlayEntry? _overlayEntry;
bool _ignoreOutsideTap = false;
@override
void dispose() {
_removeOverlay();
super.dispose();
}
String? _labelForValue(T? selected) {
if (selected == null) return null;
for (final option in widget.options) {
if (option.value == selected) return option.label;
}
return null;
}
void _removeOverlay() {
if (_overlayEntry == null) return;
_overlayEntry!.remove();
_overlayEntry = null;
_ignoreOutsideTap = false;
if (mounted) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() {});
});
}
}
void _openPicker() {
if (!widget.enabled || widget.options.isEmpty) return;
if (_overlayEntry != null) {
_removeOverlay();
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _overlayEntry != null) return;
_showOverlay();
});
}
void _showOverlay() {
final renderBox =
_fieldKey.currentContext?.findRenderObject() as RenderBox?;
if (renderBox == null || !renderBox.hasSize) return;
final fieldSize = renderBox.size;
final fieldTopLeft = renderBox.localToGlobal(Offset.zero);
final screenSize = MediaQuery.sizeOf(context);
final viewInsets = MediaQuery.viewInsetsOf(context);
final spaceBelow = screenSize.height -
viewInsets.bottom -
fieldTopLeft.dy -
fieldSize.height;
final spaceAbove = fieldTopLeft.dy - viewInsets.top;
final showAbove = spaceBelow < 180 && spaceAbove > spaceBelow;
final availableSpace = (showAbove ? spaceAbove : spaceBelow) - 8;
final maxPanelHeight =
availableSpace.clamp(120.0, screenSize.height * 0.45);
_ignoreOutsideTap = true;
_overlayEntry = OverlayEntry(
builder: (overlayContext) {
final theme = Theme.of(overlayContext);
return Stack(
children: [
Positioned.fill(
child: GestureDetector(
onTap: _removeOverlay,
behavior: HitTestBehavior.translucent,
),
),
CompositedTransformFollower(
link: _layerLink,
showWhenUnlinked: false,
targetAnchor:
showAbove ? Alignment.topLeft : Alignment.bottomLeft,
followerAnchor:
showAbove ? Alignment.bottomLeft : Alignment.topLeft,
offset: Offset(0, showAbove ? -4 : 4),
child: TapRegion(
onTapOutside: (_) {
if (_ignoreOutsideTap) return;
_removeOverlay();
},
child: Material(
elevation: 8,
borderRadius: BorderRadius.circular(8),
clipBehavior: Clip.antiAlias,
color: theme.colorScheme.surface,
shadowColor: Colors.black45,
child: SizedBox(
width: fieldSize.width,
child: _SearchableDropdownPanel<T>(
maxHeight: maxPanelHeight,
options: widget.options,
selected: widget.value,
searchHint: widget.searchHint,
onSelected: (value) {
_removeOverlay();
widget.onChanged(value);
},
),
),
),
),
),
],
);
},
);
final overlay = Overlay.maybeOf(context, rootOverlay: true) ??
Overlay.of(context);
overlay.insert(_overlayEntry!);
setState(() {});
WidgetsBinding.instance.addPostFrameCallback((_) {
_ignoreOutsideTap = false;
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final effectiveHint =
widget.hint ?? 'Select ${widget.label.toLowerCase()}';
final canOpen = widget.enabled && widget.options.isNotEmpty;
final colors = theme.colorScheme;
final displayLabel = _labelForValue(widget.value);
return Padding(
padding: const EdgeInsets.only(top: 8),
child: CompositedTransformTarget(
link: _layerLink,
child: KeyedSubtree(
key: _fieldKey,
child: InkWell(
onTap: canOpen ? _openPicker : null,
borderRadius: BorderRadius.circular(8),
child: InputDecorator(
isFocused: _overlayEntry != null,
isEmpty: displayLabel == null,
decoration: InputDecoration(
labelText: widget.label,
hintText: displayLabel == null ? effectiveHint : null,
floatingLabelBehavior: FloatingLabelBehavior.always,
isDense: widget.isDense,
suffixIcon: Icon(
_overlayEntry != null
? Icons.arrow_drop_up
: Icons.arrow_drop_down,
color: canOpen
? colors.onSurfaceVariant
: theme.disabledColor,
),
enabled: canOpen,
),
child: Text(
displayLabel ?? '\u00A0',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyLarge?.copyWith(
color: displayLabel == null
? Colors.transparent
: colors.onSurface,
),
),
),
),
),
),
);
}
}

View File

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

View File

@ -105,10 +105,11 @@ class AppTopNav extends ConsumerWidget {
), ),
), ),
), ),
IconButton( if (AppConstants.showNotificationsMenu)
icon: const Icon(Icons.notifications_outlined, size: 22), IconButton(
onPressed: () {}, icon: const Icon(Icons.notifications_outlined, size: 22),
), onPressed: () {},
),
if (DevConfig.screenPreviewEnabled) if (DevConfig.screenPreviewEnabled)
IconButton( IconButton(
icon: const Icon(Icons.apps_outlined, size: 22), 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:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/utils/media_url.dart';
/// Displays company logo in the sidebar from URL, data URI, or fallback icon. /// Displays company logo in the sidebar from URL, data URI, or fallback icon.
class SidebarLogo extends StatelessWidget { class SidebarLogo extends StatelessWidget {
const SidebarLogo({ const SidebarLogo({
@ -53,7 +55,7 @@ class SidebarLogo extends StatelessWidget {
} }
Widget _buildLogoContent(Widget fallback) { Widget _buildLogoContent(Widget fallback) {
final url = logoUrl?.trim(); final url = resolveMediaUrl(logoUrl);
if (url == null || url.isEmpty) { if (url == null || url.isEmpty) {
return Center(child: fallback); return Center(child: fallback);
} }
@ -99,9 +101,9 @@ String? resolveSidebarLogoUrl({
required String companyProfileLogo, required String companyProfileLogo,
required String? brandingLogo, required String? brandingLogo,
}) { }) {
if (companyProfileLogo.isNotEmpty) return companyProfileLogo; final fromProfile = resolveMediaUrl(companyProfileLogo);
if (brandingLogo != null && brandingLogo.isNotEmpty) return brandingLogo; if (fromProfile != null && fromProfile.isNotEmpty) return fromProfile;
return null; return resolveMediaUrl(brandingLogo);
} }
/// Resolves sidebar title from company name or app tagline. /// Resolves sidebar title from company name or app tagline.

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