From f2d5b49dac240aa8c72565967daeccb30a4b09c2 Mon Sep 17 00:00:00 2001 From: SurendarSuri30 Date: Tue, 4 Aug 2026 15:01:34 +0530 Subject: [PATCH] master screen refresh issue --- lib/core/utils/formatters.dart | 2 +- lib/core/utils/permission_utils.dart | 2 + .../asset_form_lookups_provider.dart | 2 +- .../screens/asset_detail_screen.dart | 665 ++++++------------ .../screens/asset_form_screen.dart | 10 +- .../datasources/auth_remote_data_source.dart | 53 +- .../repositories/auth_repository_impl.dart | 12 +- .../screens/grn_detail_screen.dart | 218 +++--- .../presentation/screens/grn_form_screen.dart | 12 +- .../presentation/screens/grn_list_screen.dart | 20 +- .../widgets/grn_attachments_card.dart | 6 +- .../widgets/grn_line_items_editor.dart | 4 +- .../domain/entities/master_definition.dart | 2 +- .../screens/masters_hub_screen.dart | 119 ++-- .../screens/purchase_order_detail_screen.dart | 155 ++-- .../purchase_order_line_items_editor.dart | 174 ++--- .../rbac/domain/entities/rbac_entities.dart | 6 +- .../screens/users_role_management_screen.dart | 50 +- .../screens/user_detail_screen.dart | 133 ++-- .../screens/vendor_detail_screen.dart | 256 +++---- lib/shared/models/asset_model.dart | 25 + lib/shared/models/asset_model.freezed.dart | 60 +- lib/shared/models/asset_model.g.dart | 4 + .../models/permission_matrix_models.dart | 1 + lib/shared/models/user_model.dart | 32 +- .../providers/permissions_provider.dart | 4 +- lib/shared/routes/menu_config.dart | 2 +- .../widgets/app_searchable_dropdown.dart | 8 +- .../widgets/detail_overview_widgets.dart | 334 +++++++++ .../widgets/document_preview_dialog.dart | 609 ++++++++++++++++ lib/shared/widgets/page_header.dart | 28 +- 31 files changed, 1981 insertions(+), 1027 deletions(-) create mode 100644 lib/shared/widgets/detail_overview_widgets.dart create mode 100644 lib/shared/widgets/document_preview_dialog.dart diff --git a/lib/core/utils/formatters.dart b/lib/core/utils/formatters.dart index b24990a..78df9ef 100644 --- a/lib/core/utils/formatters.dart +++ b/lib/core/utils/formatters.dart @@ -192,7 +192,7 @@ String humanizeLabel(String key) { 'hsn': 'HSN', 'uom': 'UOM', 'po': 'PO', - 'grn': 'GRN', + 'grn': 'Purchase Receipt', 'url': 'URL', 'api': 'API', }; diff --git a/lib/core/utils/permission_utils.dart b/lib/core/utils/permission_utils.dart index 755d80d..b0dccf7 100644 --- a/lib/core/utils/permission_utils.dart +++ b/lib/core/utils/permission_utils.dart @@ -19,6 +19,8 @@ const Map permissionModuleAliases = { 'purchase_orders': 'PURCHASE_ORDER', 'purchase_order': 'PURCHASE_ORDER', 'grn': 'GRN', + 'purchase_receipt': 'GRN', + 'purchase_receipts': 'GRN', 'reports': 'REPORTS', 'audit_logs': 'AUDIT_LOGS', 'audit': 'AUDIT_LOGS', diff --git a/lib/modules/assets/presentation/providers/asset_form_lookups_provider.dart b/lib/modules/assets/presentation/providers/asset_form_lookups_provider.dart index 86ec7d8..2a6fd14 100644 --- a/lib/modules/assets/presentation/providers/asset_form_lookups_provider.dart +++ b/lib/modules/assets/presentation/providers/asset_form_lookups_provider.dart @@ -203,7 +203,7 @@ Future> _safeGrnOptions(Ref ref) async { .map( (grn) => FilterOptionModel( id: grn.id, - name: grn.grnNumber ?? 'GRN #${grn.id}', + name: grn.grnNumber ?? 'Purchase Receipt #${grn.id}', ), ) .toList(); diff --git a/lib/modules/assets/presentation/screens/asset_detail_screen.dart b/lib/modules/assets/presentation/screens/asset_detail_screen.dart index a988b6b..d4d01c3 100644 --- a/lib/modules/assets/presentation/screens/asset_detail_screen.dart +++ b/lib/modules/assets/presentation/screens/asset_detail_screen.dart @@ -17,6 +17,8 @@ import '../../../../shared/widgets/app_empty_state.dart'; import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_status_chip.dart'; import '../../../../shared/widgets/can_permission.dart'; +import '../../../../shared/widgets/detail_overview_widgets.dart'; +import '../../../../shared/widgets/document_preview_dialog.dart'; import '../../../../shared/widgets/entity_attachments_card.dart'; import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/page_header.dart'; @@ -97,6 +99,10 @@ class _AssetDetailScreenState extends ConsumerState onPressed: () => context.go(RouteConstants.assets), ), title: state.asset.assetName, + titleTrailing: AppStatusChip( + status: state.asset.status ?? 'IN_USE', + compact: true, + ), subtitle: state.asset.assetCode ?? 'Asset ID: ${state.asset.id}', actions: [ if (canEdit) @@ -245,6 +251,9 @@ class _OverviewTab extends ConsumerWidget { currentValue: asset.resolvedCurrentValue, purchaseCost: asset.purchaseCost, ); + final scheme = theme.colorScheme; + final activeColor = + asset.isActive ? const Color(0xFF16A34A) : scheme.onSurfaceVariant; return SingleChildScrollView( child: Center( @@ -259,52 +268,68 @@ class _OverviewTab extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - 'Asset Details', - style: theme.textTheme.labelLarge?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), + Row( + children: [ + Expanded( + child: Text( + 'Asset Details', + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ), + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: activeColor, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: activeColor.withValues(alpha: 0.35), + blurRadius: 6, + ), + ], + ), + ), + const SizedBox(width: 8), + Text( + asset.isActive ? 'Active' : 'Inactive', + style: theme.textTheme.labelMedium?.copyWith( + color: activeColor, + fontWeight: FontWeight.w700, + ), + ), + ], ), const SizedBox(height: 16), - // 1) Top Summary - _AssetOverviewSection( - title: 'Summary', - child: _AssetSummaryStrip( - status: asset.status ?? 'IN_USE', - maintenanceDue: asset.maintenance?.isDue, - currentValue: asset.resolvedCurrentValue, - isActive: asset.isActive, - daysUntilDueDisplay: daysUntilDueDisplay, - ), - ), - - // 2) Identity - _AssetOverviewSection( + // 1) Identity + DetailOverviewSection( title: 'Identity', - child: _AssetInfoGrid( + child: DetailInfoGrid( items: [ - _AssetInfo('Asset Name', asset.assetName), - _AssetInfo('Asset Code', asset.assetCode ?? '—'), - _AssetInfo('Location', asset.locationName ?? '—'), - _AssetInfo( + DetailInfoItem('Asset Name', asset.assetName), + DetailInfoItem('Asset Code', asset.assetCode ?? '—'), + DetailInfoItem('Location', asset.locationName ?? '—'), + DetailInfoItem( 'Asset Category', asset.assetCategoryName ?? '—', ), - _AssetInfo( + DetailInfoItem( 'Subcategory', asset.assetSubcategoryName ?? '—', ), - _AssetInfo( + DetailInfoItem( 'Manufacturer', asset.manufacturer ?? '—', ), - _AssetInfo( + DetailInfoItem( 'Brand / Model', asset.brandModel ?? '—', ), - _AssetInfo( + DetailInfoItem( 'Serial Number', asset.serialNumber ?? '—', ), @@ -312,81 +337,66 @@ class _OverviewTab extends ConsumerWidget { ), ), - // 3) Maintenance - _AssetOverviewSection( - title: 'Maintenance', - child: _AssetInfoGrid( - items: [ - _AssetInfo( - 'Maintenance Incharge', - maintenanceInchargeLabel, - ), - _AssetInfo( - 'Maintenance Frequency', - asset.maintenanceFrequencyInDays != null - ? '${asset.maintenanceFrequencyInDays} days' - : '—', - ), - _AssetInfo( - 'Next Due Date', - asset.maintenance?.nextDueDate != null - ? dateFormat.format( - asset.maintenance!.nextDueDate!, - ) - : '—', - ), - if (daysUntilDueDisplay == null) - const _AssetInfo('Days Until Due', '—') - else - _AssetInfo.widget( - 'Days Until Due', - Text( - daysUntilDueDisplay.label, - style: TextStyle( - color: daysUntilDueDisplay.color, - fontWeight: FontWeight.w600, - ), - ), - ), - _AssetInfo( - 'Condition', - assetConditionLabel(asset.condition), - ), - ], - ), - ), - - // 4) Purchase & Warranty - _AssetOverviewSection( + // 2) Purchase & Warranty + DetailOverviewSection( title: 'Purchase & Warranty', - child: _AssetInfoGrid( + child: DetailInfoGrid( items: [ - _AssetInfo( - 'Purchase Date', - asset.purchaseDate != null - ? dateFormat.format(asset.purchaseDate!) - : '—', + DetailInfoItem( + 'Purchase Date - Warranty Expiry', + _purchaseWarrantyRange( + purchaseDate: asset.purchaseDate, + warrantyExpiryDate: asset.warrantyExpiryDate, + dateFormat: dateFormat, + ), ), - _AssetInfo( + DetailInfoItem( 'Purchase Cost', asset.purchaseCost != null ? CurrencyFormatter.format(asset.purchaseCost) : '—', ), - _AssetInfo( - 'Warranty Expiry', - asset.warrantyExpiryDate != null - ? dateFormat.format(asset.warrantyExpiryDate!) - : '—', + DetailInfoItem.widget( + 'PO Number', + _DocumentNumberLink( + label: asset.poNumber, + enabled: asset.poId != null && + (asset.poNumber?.trim().isNotEmpty ?? + false), + onTap: () { + final poId = asset.poId; + if (poId == null) return; + showPurchaseOrderPreviewDialog( + context, + purchaseOrderId: poId.toString(), + ); + }, + ), + ), + DetailInfoItem.widget( + 'Purchase Receipt Number', + _DocumentNumberLink( + label: asset.grnNumber, + enabled: asset.grnId != null && + (asset.grnNumber?.trim().isNotEmpty ?? + false), + onTap: () { + final grnId = asset.grnId; + if (grnId == null) return; + showGrnPreviewDialog( + context, + grnId: grnId.toString(), + ); + }, + ), ), ], ), ), - // 5) Valuation & Depreciation - _AssetOverviewSection( + // 4) Valuation & Depreciation + DetailOverviewSection( title: 'Valuation & Depreciation', - showDivider: false, child: _ValuationDepreciationCard( currentValue: asset.resolvedCurrentValue, purchaseCost: asset.purchaseCost, @@ -404,6 +414,39 @@ class _OverviewTab extends ConsumerWidget { ), ), + // 5) Maintenance + DetailOverviewSection( + title: 'Maintenance', + showDivider: false, + child: DetailInfoGrid( + items: [ + DetailInfoItem( + 'Maintenance Incharge', + maintenanceInchargeLabel, + ), + DetailInfoItem( + 'Maintenance Frequency', + asset.maintenanceFrequencyInDays != null + ? '${asset.maintenanceFrequencyInDays} days' + : '—', + ), + DetailInfoItem.widget( + 'Next Due Date', + _nextDueDateValue( + theme: theme, + nextDueDate: asset.maintenance?.nextDueDate, + dateFormat: dateFormat, + daysUntilDueDisplay: daysUntilDueDisplay, + ), + ), + DetailInfoItem( + 'Condition', + assetConditionLabel(asset.condition), + ), + ], + ), + ), + if (asset.maintenanceFrequencyInDays != null || asset.maintenanceChecklistJson?.isNotEmpty == true || asset.maintenance != null) ...[ @@ -461,8 +504,8 @@ class _OverviewTab extends ConsumerWidget { padding: EdgeInsets.symmetric(vertical: 20), child: Divider(height: 1), ), - _AssetInfoGrid( - items: [_AssetInfo('Remarks', asset.remarks!)], + DetailInfoGrid( + items: [DetailInfoItem('Remarks', asset.remarks!)], ), ], ], @@ -499,6 +542,93 @@ class _OverviewTab extends ConsumerWidget { } } +String _purchaseWarrantyRange({ + required DateTime? purchaseDate, + required DateTime? warrantyExpiryDate, + required DateFormat dateFormat, +}) { + final purchase = + purchaseDate != null ? dateFormat.format(purchaseDate) : '—'; + final warranty = + warrantyExpiryDate != null ? dateFormat.format(warrantyExpiryDate) : '—'; + return '$purchase - $warranty'; +} + +class _DocumentNumberLink extends StatelessWidget { + const _DocumentNumberLink({ + required this.label, + required this.enabled, + required this.onTap, + }); + + final String? label; + final bool enabled; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final text = label?.trim().isNotEmpty == true ? label!.trim() : '—'; + if (!enabled) { + return Text( + text, + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + ); + } + + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(4), + child: Text( + text, + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w700, + decoration: TextDecoration.underline, + decorationColor: theme.colorScheme.primary.withValues(alpha: 0.45), + ), + ), + ); + } +} + +Widget _nextDueDateValue({ + required ThemeData theme, + required DateTime? nextDueDate, + required DateFormat dateFormat, + required MaintenanceDueDisplay? daysUntilDueDisplay, +}) { + final dateLabel = + nextDueDate != null ? dateFormat.format(nextDueDate) : '—'; + final valueStyle = theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.onSurface, + fontWeight: FontWeight.w600, + ); + + if (daysUntilDueDisplay == null) { + return Text(dateLabel, style: valueStyle); + } + + return Text.rich( + TextSpan( + style: valueStyle, + children: [ + TextSpan(text: dateLabel), + TextSpan( + text: ' (${daysUntilDueDisplay.label})', + style: valueStyle?.copyWith( + color: daysUntilDueDisplay.color, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); +} + double? _retainedPercentage({ required double? currentValue, required double? purchaseCost, @@ -509,265 +639,6 @@ double? _retainedPercentage({ return (currentValue / purchaseCost * 100).clamp(0, 100); } -class _AssetOverviewSection extends StatelessWidget { - const _AssetOverviewSection({ - required this.title, - required this.child, - this.showDivider = true, - }); - - final String title; - final Widget child; - final bool showDivider; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - title.toUpperCase(), - style: theme.textTheme.labelMedium?.copyWith( - color: theme.colorScheme.primary, - fontWeight: FontWeight.w700, - letterSpacing: 0.4, - ), - ), - const SizedBox(height: 12), - child, - if (showDivider) - const Padding( - padding: EdgeInsets.symmetric(vertical: 20), - child: Divider(height: 1), - ) - else - const SizedBox(height: 8), - ], - ); - } -} - -/// Stylish KPI strip for Overview → Summary (theme primary / secondary accents). -class _AssetSummaryStrip extends StatelessWidget { - const _AssetSummaryStrip({ - required this.status, - required this.maintenanceDue, - required this.currentValue, - required this.isActive, - required this.daysUntilDueDisplay, - }); - - final String status; - final bool? maintenanceDue; - final double? currentValue; - final bool isActive; - final MaintenanceDueDisplay? daysUntilDueDisplay; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final scheme = theme.colorScheme; - final dueColor = maintenanceDue == null - ? scheme.onSurfaceVariant - : maintenanceDue! - ? scheme.error - : (daysUntilDueDisplay?.color ?? const Color(0xFF16A34A)); - final dueLabel = maintenanceDue == null - ? '—' - : maintenanceDue! - ? 'Yes' - : 'No'; - final activeColor = - isActive ? const Color(0xFF16A34A) : scheme.onSurfaceVariant; - - return Container( - width: double.infinity, - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - scheme.primary.withValues(alpha: 0.07), - scheme.secondary.withValues(alpha: 0.06), - scheme.surfaceContainerHighest.withValues(alpha: 0.35), - ], - ), - border: Border.all( - color: scheme.primary.withValues(alpha: 0.14), - ), - ), - child: LayoutBuilder( - builder: (context, constraints) { - final cols = constraints.maxWidth < 560 - ? 1 - : constraints.maxWidth < 820 - ? 2 - : 4; - const gap = 12.0; - final tileWidth = - (constraints.maxWidth - gap * (cols - 1)) / cols; - - final tiles = [ - _SummaryMetricTile( - width: tileWidth, - icon: Icons.flag_outlined, - label: 'Status', - accent: scheme.secondary, - child: AppStatusChip(status: status, compact: true), - ), - _SummaryMetricTile( - width: tileWidth, - icon: Icons.build_circle_outlined, - label: 'Maintenance Due', - accent: dueColor, - child: Text( - dueLabel, - style: theme.textTheme.titleMedium?.copyWith( - color: dueColor, - fontWeight: FontWeight.w700, - ), - ), - ), - _SummaryMetricTile( - width: tileWidth, - icon: Icons.payments_outlined, - label: 'Current Value', - accent: scheme.primary, - child: Text( - currentValue != null - ? CurrencyFormatter.format(currentValue) - : '—', - style: theme.textTheme.titleMedium?.copyWith( - color: scheme.onSurface, - fontWeight: FontWeight.w800, - ), - ), - ), - _SummaryMetricTile( - width: tileWidth, - icon: isActive - ? Icons.check_circle_outline - : Icons.pause_circle_outline, - label: 'Active', - accent: activeColor, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: activeColor, - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: activeColor.withValues(alpha: 0.35), - blurRadius: 6, - ), - ], - ), - ), - const SizedBox(width: 8), - Text( - isActive ? 'Active' : 'Inactive', - style: theme.textTheme.titleMedium?.copyWith( - color: activeColor, - fontWeight: FontWeight.w700, - ), - ), - ], - ), - ), - ]; - - return Wrap( - spacing: gap, - runSpacing: gap, - children: tiles, - ); - }, - ), - ); - } -} - -class _SummaryMetricTile extends StatelessWidget { - const _SummaryMetricTile({ - required this.width, - required this.icon, - required this.label, - required this.accent, - required this.child, - }); - - final double width; - final IconData icon; - final String label; - final Color accent; - final Widget child; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final scheme = theme.colorScheme; - - return SizedBox( - width: width, - child: Container( - padding: const EdgeInsets.fromLTRB(14, 12, 14, 14), - decoration: BoxDecoration( - color: scheme.surface.withValues(alpha: 0.92), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: accent.withValues(alpha: 0.18)), - boxShadow: [ - BoxShadow( - color: scheme.shadow.withValues(alpha: 0.04), - blurRadius: 10, - offset: const Offset(0, 3), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 30, - height: 30, - decoration: BoxDecoration( - color: accent.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(8), - ), - child: Icon(icon, size: 16, color: accent), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - label.toUpperCase(), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.labelSmall?.copyWith( - color: scheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - letterSpacing: 0.5, - ), - ), - ), - ], - ), - const SizedBox(height: 12), - child, - ], - ), - ), - ); - } -} - class _ValuationDepreciationCard extends StatelessWidget { const _ValuationDepreciationCard({ required this.currentValue, @@ -1019,98 +890,6 @@ class _ValuationDepreciationCard extends StatelessWidget { } } -class _AssetInfoGrid extends StatelessWidget { - const _AssetInfoGrid({required this.items}); - - final List<_AssetInfo> items; - static const int _columns = 4; - - @override - Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - final maxWidth = constraints.maxWidth; - final cols = maxWidth < 560 - ? 1 - : maxWidth < 780 - ? 2 - : maxWidth < 1000 - ? 3 - : _columns; - const spacing = 16.0; - final colWidth = (maxWidth - spacing * (cols - 1)) / cols; - - return Wrap( - spacing: spacing, - runSpacing: 18, - children: items - .map( - (item) => SizedBox( - width: colWidth, - child: _AssetDetailTile( - label: item.label, - value: item.value, - valueWidget: item.valueWidget, - ), - ), - ) - .toList(), - ); - }, - ); - } -} - -class _AssetDetailTile extends StatelessWidget { - const _AssetDetailTile({ - required this.label, - this.value, - this.valueWidget, - }); - - final String label; - final String? value; - final Widget? valueWidget; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label.toUpperCase(), - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.85), - fontWeight: FontWeight.w500, - letterSpacing: 0.45, - ), - ), - const SizedBox(height: 6), - valueWidget ?? - Text( - value ?? '—', - style: theme.textTheme.titleSmall?.copyWith( - color: theme.colorScheme.onSurface, - fontWeight: FontWeight.w600, - ), - ), - ], - ); - } -} - -class _AssetInfo { - const _AssetInfo(this.label, this.value) : valueWidget = null; - - const _AssetInfo.widget(this.label, this.valueWidget) : value = null; - - final String label; - final String? value; - final Widget? valueWidget; -} - String _userLabel(int? userId, List users) { if (userId == null || userId <= 0) return '—'; final id = userId.toString(); diff --git a/lib/modules/assets/presentation/screens/asset_form_screen.dart b/lib/modules/assets/presentation/screens/asset_form_screen.dart index 2718ab3..6c78d42 100644 --- a/lib/modules/assets/presentation/screens/asset_form_screen.dart +++ b/lib/modules/assets/presentation/screens/asset_form_screen.dart @@ -636,7 +636,7 @@ class _AssetFormScreenState extends ConsumerState { final grnItems = ref.read(assetGrnItemsProvider(_grnId)).valueOrNull ?? const []; if (grnItems.isNotEmpty && _grnItemId == null) { - return 'Please select a GRN item when a GRN is linked'; + return 'Please select a Purchase Receipt item when a Purchase Receipt is linked'; } } @@ -872,7 +872,7 @@ class _AssetFormScreenState extends ConsumerState { onChanged: (v) => setState(() => _poId = v), ), _optionalLookupDropdown( - label: 'GRN', + label: 'Purchase Receipt', value: _grnId, options: lookups.grns, onChanged: (v) => setState(() { @@ -883,7 +883,7 @@ class _AssetFormScreenState extends ConsumerState { grnItemsAsync.when( loading: () => const LinearProgressIndicator(), error: (_, __) => _optionalLookupDropdown( - label: 'GRN Item', + label: 'Purchase Receipt Item', value: _grnItemId, options: const [], onChanged: (v) => @@ -891,14 +891,14 @@ class _AssetFormScreenState extends ConsumerState { enabled: false, ), data: (items) => _optionalLookupDropdown( - label: 'GRN Item', + label: 'Purchase Receipt Item', value: _grnItemId, options: items, onChanged: (v) => setState(() => _grnItemId = v), enabled: _grnId != null && items.isNotEmpty, emptyHint: _grnId == null - ? 'Select GRN first' + ? 'Select Purchase Receipt first' : 'None', required: _grnId != null && items.isNotEmpty, ), diff --git a/lib/modules/auth/data/datasources/auth_remote_data_source.dart b/lib/modules/auth/data/datasources/auth_remote_data_source.dart index c885ca6..95d1b56 100644 --- a/lib/modules/auth/data/datasources/auth_remote_data_source.dart +++ b/lib/modules/auth/data/datasources/auth_remote_data_source.dart @@ -64,10 +64,59 @@ class AuthRemoteDataSource { return _mapProfileToUser(profile, permissions: permissions, roleId: roleId); } - Future getCurrentUser() async { + Future getCurrentUser({String? accessToken}) async { final response = await dio.get>(ApiEndpoints.me); final data = ApiEnvelope.data(response); - return UserModel.fromLoginJson(data); + final user = UserModel.fromLoginJson(data); + return withRolePermissionsIfMissing( + user, + accessToken: accessToken, + mePayload: data, + ); + } + + /// When `/auth/me` omits permissions, load them from the role matrix. + Future withRolePermissionsIfMissing( + UserModel user, { + required String? accessToken, + Map? mePayload, + }) async { + if (user.permissions.isNotEmpty) return user; + + final roleId = (mePayload != null ? _roleIdFromMe(mePayload) : null) ?? + (accessToken == null || accessToken.isEmpty + ? null + : JwtUtils.roleId(accessToken)); + if (roleId == null || roleId.isEmpty) return user; + + final permissions = await _fetchRolePermissions(roleId); + if (permissions.isEmpty) return user; + return user.copyWith(permissions: permissions); + } + + String? _roleIdFromMe(Map data) { + final direct = data['role_id'] ?? data['roleId']; + if (direct != null && direct.toString().trim().isNotEmpty) { + return direct.toString().trim(); + } + final role = data['role']; + if (role is Map) { + final id = role['id'] ?? role['role_id']; + if (id != null && id.toString().trim().isNotEmpty) { + return id.toString().trim(); + } + } + final roles = data['roles']; + if (roles is List && roles.isNotEmpty) { + final first = roles.first; + if (first is Map) { + final id = first['id'] ?? first['role_id']; + if (id != null && id.toString().trim().isNotEmpty) { + return id.toString().trim(); + } + } + } + return null; } Future forgotPassword(ForgotPasswordRequest request) async { diff --git a/lib/modules/auth/data/repositories/auth_repository_impl.dart b/lib/modules/auth/data/repositories/auth_repository_impl.dart index b52b7be..28c48a6 100644 --- a/lib/modules/auth/data/repositories/auth_repository_impl.dart +++ b/lib/modules/auth/data/repositories/auth_repository_impl.dart @@ -33,7 +33,9 @@ class AuthRepositoryImpl implements AuthRepository { return safeApiCall(() async { final loginResponse = await remote.login(request); await _persistSession(loginResponse); - final user = await remote.getCurrentUser(); + final user = await remote.getCurrentUser( + accessToken: loginResponse.tokens.accessToken, + ); return loginResponse.copyWith(user: user); }); } @@ -56,7 +58,10 @@ class AuthRepositoryImpl implements AuthRepository { @override Future> getCurrentUser() async { - return safeApiCall(() => remote.getCurrentUser()); + return safeApiCall(() async { + final accessToken = await tokenStorage.getAccessToken(); + return remote.getCurrentUser(accessToken: accessToken); + }); } @override @@ -91,7 +96,8 @@ class AuthRepositoryImpl implements AuthRepository { final updated = await remote.updateProfile(request); // Prefer fresh /auth/me so avatar + role/department stay in sync. try { - return await remote.getCurrentUser(); + final accessToken = await tokenStorage.getAccessToken(); + return await remote.getCurrentUser(accessToken: accessToken); } catch (_) { return updated; } diff --git a/lib/modules/grn/presentation/screens/grn_detail_screen.dart b/lib/modules/grn/presentation/screens/grn_detail_screen.dart index 237d98a..f6d78a5 100644 --- a/lib/modules/grn/presentation/screens/grn_detail_screen.dart +++ b/lib/modules/grn/presentation/screens/grn_detail_screen.dart @@ -12,6 +12,7 @@ import '../../../../shared/providers/permissions_provider.dart'; import '../../../../shared/utils/file_download_helper.dart'; import '../../../../shared/widgets/app_text_field.dart'; import '../../../../shared/widgets/app_loading_view.dart'; +import '../../../../shared/widgets/detail_overview_widgets.dart'; import '../../../../shared/widgets/error_view.dart'; import '../providers/grn_lookups_provider.dart'; import '../providers/grn_provider.dart'; @@ -63,7 +64,7 @@ class _GrnDetailScreenState extends ConsumerState { return Scaffold( backgroundColor: Theme.of(context).colorScheme.surface, body: detailAsync.when( - loading: () => const AppLoadingView(message: 'Loading GRN...'), + loading: () => const AppLoadingView(message: 'Loading Purchase Receipt...'), error: (e, _) => ErrorView.fromFailure( e is Failure ? e : Failure.unknown(message: e.toString()), onRetry: () => ref.invalidate(grnDetailProvider(widget.grnId)), @@ -150,7 +151,7 @@ class _GrnDetailScreenState extends ConsumerState { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('Cancel GRN'), + title: const Text('Cancel Purchase Receipt'), content: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -175,7 +176,7 @@ class _GrnDetailScreenState extends ConsumerState { if (reasonController.text.trim().isEmpty) return; Navigator.pop(context, true); }, - child: const Text('Cancel GRN'), + child: const Text('Cancel Purchase Receipt'), ), ], ), @@ -186,7 +187,7 @@ class _GrnDetailScreenState extends ConsumerState { () => ref.read(grnDetailProvider(widget.grnId).notifier).cancel( cancellationReason: reasonController.text.trim(), ), - 'GRN cancelled', + 'Purchase Receipt cancelled', ); reasonController.dispose(); } @@ -199,7 +200,7 @@ class _GrnDetailScreenState extends ConsumerState { .downloadPdf(); await downloadFile( bytes: bytes, - fileName: '${grn.grnNumber ?? 'GRN-${grn.id}'}.pdf', + fileName: '${grn.grnNumber ?? 'PurchaseReceipt-${grn.id}'}.pdf', ); if (mounted) { showAppToastFromSnackBar(context, const SnackBar(content: Text('PDF downloaded'))); @@ -300,7 +301,7 @@ class _DetailHeader extends StatelessWidget { children: [ Flexible( child: Text( - grn.grnNumber ?? 'GRN #${grn.id}', + grn.grnNumber ?? 'Purchase Receipt #${grn.id}', style: theme.textTheme.headlineSmall, overflow: TextOverflow.ellipsis, ), @@ -528,124 +529,113 @@ class _ReceiptDetailsCard extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final locationDisplay = _displayOrDash( + [ + if (grn.locationName?.trim().isNotEmpty == true) + grn.locationName!.trim(), + if (grn.locationType?.trim().isNotEmpty == true) + '(${grn.locationType!.trim()})', + ].join(' ').trim(), + ); + 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), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DetailOverviewSection( + title: 'Summary', + child: DetailSummaryStrip( + metrics: [ + DetailSummaryMetric( + icon: Icons.flag_outlined, + label: 'Status', + accent: scheme.secondary, + child: GrnStatusChip(status: grn.status, compact: true), + ), + DetailSummaryMetric( + icon: Icons.calendar_today_outlined, + label: 'Purchase Receipt Date', + child: Text( + DateFormatter.displayDate(grn.grnDate), + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + DetailSummaryMetric( + icon: Icons.receipt_long_outlined, + label: 'PO Number', + child: Text( + _displayOrDash(grn.poNumber), + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + DetailSummaryMetric( + icon: Icons.payments_outlined, + label: 'Invoice Amount', + accent: scheme.primary, + child: Text( + grn.vendorInvoiceAmount != null + ? CurrencyFormatter.format(grn.vendorInvoiceAmount!) + : '—', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + ), + ], ), - _DetailField( - label: 'PO Number', - value: _displayOrDash(grn.poNumber), + ), + DetailOverviewSection( + title: 'Receipt', + child: DetailInfoGrid( + items: [ + DetailInfoItem('Vendor', _displayOrDash(grn.vendorName)), + DetailInfoItem('Location', locationDisplay), + DetailInfoItem( + 'Vendor Invoice No', + _displayOrDash(grn.vendorInvoiceNo), + ), + DetailInfoItem( + 'Vendor Invoice Date', + DateFormatter.displayDate(grn.vendorInvoiceDate), + ), + DetailInfoItem( + 'Received By', + _userLabel(lookups?.users, grn.receivedBy), + ), + DetailInfoItem( + 'Quality Checked By', + _userLabel(lookups?.users, grn.qualityCheckedBy), + ), + ], ), - _DetailField( - label: 'Vendor', - value: _displayOrDash(grn.vendorName), + ), + DetailOverviewSection( + title: 'Transport', + showDivider: false, + child: DetailInfoGrid( + items: [ + DetailInfoItem('Vehicle No', _displayOrDash(grn.vehicleNo)), + DetailInfoItem('LR No', _displayOrDash(grn.lrNo)), + DetailInfoItem( + 'LR Date', + DateFormatter.displayDate(grn.lrDate), + ), + ], ), - _DetailField( - label: 'Location', - value: _displayOrDash( - [ - if (grn.locationName?.trim().isNotEmpty == true) - grn.locationName!.trim(), - if (grn.locationType?.trim().isNotEmpty == true) - '(${grn.locationType!.trim()})', - ].join(' ').trim(), - ), - ), - _DetailField( - label: 'Vendor Invoice No', - value: _displayOrDash(grn.vendorInvoiceNo), - ), - _DetailField( - label: 'Vendor Invoice Date', - value: DateFormatter.displayDate(grn.vendorInvoiceDate), - ), - _DetailField( - label: 'Vendor Invoice Amount', - value: grn.vendorInvoiceAmount != null - ? CurrencyFormatter.format(grn.vendorInvoiceAmount!) - : '—', - ), - _DetailField( - label: 'Vehicle No', - value: _displayOrDash(grn.vehicleNo), - ), - _DetailField( - label: 'LR No', - value: _displayOrDash(grn.lrNo), - ), - _DetailField( - label: 'LR Date', - value: DateFormatter.displayDate(grn.lrDate), - ), - _DetailField( - label: 'Received By', - value: _userLabel(lookups?.users, grn.receivedBy), - ), - _DetailField( - label: 'Quality Checked By', - value: _userLabel(lookups?.users, grn.qualityCheckedBy), - ), - ]; - - return Wrap( - spacing: spacing, - runSpacing: 16, - children: items - .map((item) => SizedBox(width: width, child: item)) - .toList(), - ); - }, + ), + ], ), ); } } -class _DetailField extends StatelessWidget { - const _DetailField({ - required this.label, - required this.value, - }); - - final String label; - final String value; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 4), - Text( - value, - style: theme.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - ], - ); - } -} - class _LineItemsCard extends StatelessWidget { const _LineItemsCard({required this.grn}); diff --git a/lib/modules/grn/presentation/screens/grn_form_screen.dart b/lib/modules/grn/presentation/screens/grn_form_screen.dart index f85716c..2623a00 100644 --- a/lib/modules/grn/presentation/screens/grn_form_screen.dart +++ b/lib/modules/grn/presentation/screens/grn_form_screen.dart @@ -289,7 +289,7 @@ class _GrnFormScreenState extends ConsumerState { if (!mounted) return; showAppToastFromSnackBar(context, SnackBar( - content: Text(widget.isEditing ? 'GRN updated' : 'GRN created'), + content: Text(widget.isEditing ? 'Purchase Receipt updated' : 'Purchase Receipt created'), ), ); WidgetsBinding.instance.addPostFrameCallback((_) { @@ -363,7 +363,7 @@ class _GrnFormScreenState extends ConsumerState { lookups: lookups, existing: existingAsync.valueOrNull, ) - : const AppLoadingView(message: 'Loading GRN...'), + : const AppLoadingView(message: 'Loading Purchase Receipt...'), error: (e, _) => ErrorView.fromFailure( e is Failure ? e : Failure.unknown(message: e.toString()), onRetry: () => ref.invalidate(grnFormProvider(widget.grnId)), @@ -391,7 +391,7 @@ class _GrnFormScreenState extends ConsumerState { if (widget.isEditing && existing != null && !existing.canEdit) { return ErrorView.fromFailure( - const Failure.validation(message: 'This GRN cannot be edited'), + const Failure.validation(message: 'This Purchase Receipt cannot be edited'), onRetry: () => context.go('${RouteConstants.grn}/${existing.id}'), ); } @@ -425,7 +425,7 @@ class _GrnFormScreenState extends ConsumerState { FormRowFour( children: [ _DateField( - label: 'GRN Date *', + label: 'Purchase Receipt Date *', value: _grnDate, enabled: !widget.isEditing, onTap: widget.isEditing @@ -641,7 +641,7 @@ class _GrnFormScreenState extends ConsumerState { Widget _buildHeader(GrnModel? existing) { final theme = Theme.of(context); final title = widget.isEditing - ? 'Edit ${existing?.grnNumber ?? 'GRN'}' + ? 'Edit ${existing?.grnNumber ?? 'Purchase Receipt'}' : 'Create Purchase Receipt'; final subtitle = widget.isEditing ? null @@ -656,7 +656,7 @@ class _GrnFormScreenState extends ConsumerState { ), const SizedBox(width: 12), AppButton( - label: widget.isEditing ? 'Update GRN' : 'Save GRN', + label: widget.isEditing ? 'Update Purchase Receipt' : 'Save Purchase Receipt', icon: Icons.check, expand: false, isLoading: _isSubmitting, diff --git a/lib/modules/grn/presentation/screens/grn_list_screen.dart b/lib/modules/grn/presentation/screens/grn_list_screen.dart index c73b44f..c3a0263 100644 --- a/lib/modules/grn/presentation/screens/grn_list_screen.dart +++ b/lib/modules/grn/presentation/screens/grn_list_screen.dart @@ -66,7 +66,7 @@ class _GrnListScreenState extends ConsumerState { return Padding( padding: const EdgeInsets.all(24), child: listAsync.when( - loading: () => const AppLoadingView(message: 'Loading GRNs...'), + loading: () => const AppLoadingView(message: 'Loading Purchase Receipts...'), error: (error, _) => ErrorView.fromFailure( error is Failure ? error : Failure.unknown(message: error.toString()), onRetry: () => ref.invalidate(grnListProvider), @@ -110,7 +110,7 @@ class _GrnListScreenState extends ConsumerState { child: ElevatedButton.icon( onPressed: () => context.go(RouteConstants.grnAdd), icon: const Icon(Icons.add), - label: const Text('Create GRN'), + label: const Text('Create Purchase Receipt'), ), ), ], @@ -134,7 +134,7 @@ class _GrnListScreenState extends ConsumerState { totalItems: state.total, pageSize: state.query.limit, itemsOnPage: state.grns.length, - itemLabel: 'GRNs', + itemLabel: 'Purchase Receipts', onPageChanged: ref.read(grnListProvider.notifier).setPage, onPageSizeChanged: ref.read(grnListProvider.notifier).setPageSize, ), @@ -148,9 +148,9 @@ class _GrnListScreenState extends ConsumerState { SizedBox( height: 240, child: AppEmptyState( - title: 'No GRNs found', + title: 'No Purchase Receipts found', description: - 'Try adjusting filters or create a new goods received note.', + 'Try adjusting filters or create a new purchase receipt.', icon: Icons.inventory_2_outlined, ), ), @@ -216,7 +216,7 @@ class _GrnListScreenState extends ConsumerState { void _editGrn(GrnModel grn) { if (!grn.canEdit) { showAppToastFromSnackBar(context, - const SnackBar(content: Text('Only posted GRNs can be edited')), + const SnackBar(content: Text('Only posted Purchase Receipts can be edited')), ); return; } @@ -250,7 +250,7 @@ class _FiltersBar extends StatelessWidget { onChanged: onSearch, decoration: const InputDecoration( labelText: 'Search', - hintText: 'Search GRN number, PO, vendor...', + hintText: 'Search receipt number, PO, vendor...', prefixIcon: Icon(Icons.search, size: 20), floatingLabelBehavior: FloatingLabelBehavior.always, isDense: true, @@ -310,7 +310,7 @@ class _GrnDataTable extends ConsumerWidget { static List get columnOptions => const [ AppTableColumnOption( id: 'grn_number', - label: 'GRN Number', + label: 'Purchase Receipt Number', required: true, ), AppTableColumnOption(id: 'date', label: 'Date'), @@ -330,7 +330,7 @@ class _GrnDataTable extends ConsumerWidget { return [ AppDataColumn( id: 'grn_number', - label: 'GRN Number', + label: 'Purchase Receipt Number', sortKey: 'grn_number', locked: true, flex: 2, @@ -461,7 +461,7 @@ class _GrnCardList extends StatelessWidget { children: [ Expanded( child: AppTableCell.link( - grn.grnNumber ?? 'GRN #${grn.id}', + grn.grnNumber ?? 'Purchase Receipt #${grn.id}', onTap: () => onView(grn), ), ), diff --git a/lib/modules/grn/presentation/widgets/grn_attachments_card.dart b/lib/modules/grn/presentation/widgets/grn_attachments_card.dart index 8d24927..70871be 100644 --- a/lib/modules/grn/presentation/widgets/grn_attachments_card.dart +++ b/lib/modules/grn/presentation/widgets/grn_attachments_card.dart @@ -206,8 +206,8 @@ class _GrnAttachmentsCardState extends ConsumerState { const SizedBox(height: 4), Text( showUpload - ? 'PDF, JPEG, PNG, or WebP · upload/delete only while GRN is Posted' - : 'Supporting documents for this GRN', + ? 'PDF, JPEG, PNG, or WebP · upload/delete only while Purchase Receipt is Posted' + : 'Supporting documents for this Purchase Receipt', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), @@ -256,7 +256,7 @@ class _GrnAttachmentsCardState extends ConsumerState { attachments.isNotEmpty) ...[ const SizedBox(height: 8), Text( - 'This GRN is cancelled — attachments are view/download only.', + 'This Purchase Receipt is cancelled — attachments are view/download only.', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), diff --git a/lib/modules/grn/presentation/widgets/grn_line_items_editor.dart b/lib/modules/grn/presentation/widgets/grn_line_items_editor.dart index a8b86c3..82c5b34 100644 --- a/lib/modules/grn/presentation/widgets/grn_line_items_editor.dart +++ b/lib/modules/grn/presentation/widgets/grn_line_items_editor.dart @@ -334,7 +334,7 @@ class _GrnLineItemCardState extends State<_GrnLineItemCard> { columnCount: 12, spans: const [2, 2, 2, 3, 3], spacing: 8, - stackBelowWidth: 1100, + stackBelowWidth: 0, children: [ AppTextField( key: ValueKey('$lineKey-accepted'), @@ -431,7 +431,7 @@ class _GrnLineItemCardState extends State<_GrnLineItemCard> { columnCount: 12, spans: const [3, 3, 3, 3], spacing: 8, - stackBelowWidth: 1100, + stackBelowWidth: 0, children: [ _GrnLineDateField( key: ValueKey('$lineKey-mfg'), diff --git a/lib/modules/master_data/domain/entities/master_definition.dart b/lib/modules/master_data/domain/entities/master_definition.dart index 52e9726..6fd9b70 100644 --- a/lib/modules/master_data/domain/entities/master_definition.dart +++ b/lib/modules/master_data/domain/entities/master_definition.dart @@ -725,7 +725,7 @@ String masterFieldHintLabel(String label) { 'gst': 'GST', 'sac': 'SAC', 'po': 'PO', - 'grn': 'GRN', + 'grn': 'Purchase Receipt', 'amc': 'AMC', }; return label diff --git a/lib/modules/master_data/presentation/screens/masters_hub_screen.dart b/lib/modules/master_data/presentation/screens/masters_hub_screen.dart index a7e4020..7607e77 100644 --- a/lib/modules/master_data/presentation/screens/masters_hub_screen.dart +++ b/lib/modules/master_data/presentation/screens/masters_hub_screen.dart @@ -4,8 +4,10 @@ import 'package:go_router/go_router.dart'; import '../../../../core/constants/enums.dart'; import '../../../../core/constants/route_constants.dart'; +import '../../../../shared/providers/auth_provider.dart'; import '../../../../shared/providers/permissions_provider.dart'; import '../../../../shared/widgets/app_hover_effect.dart'; +import '../../../../shared/widgets/app_loading_view.dart'; import '../../domain/entities/master_definition.dart'; class MastersHubScreen extends ConsumerWidget { @@ -16,8 +18,15 @@ class MastersHubScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final authState = ref.watch(authStateProvider); final categories = masterCategories; final canViewMasters = ref.can('masters', PermissionAction.read); + final theme = Theme.of(context); + + if (authState.status == AuthStatus.initial || + authState.status == AuthStatus.loading) { + return const AppLoadingView(message: 'Loading master data...'); + } return LayoutBuilder( builder: (context, constraints) { @@ -28,64 +37,74 @@ class MastersHubScreen extends ConsumerWidget { children: [ Text( 'Master Data', - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.w700, - ), + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + ), ), const SizedBox(height: 8), Text( 'Browse and manage all master records by category.', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), ), const SizedBox(height: 24), - ...categories.map((category) { - if (!canViewMasters) return const SizedBox.shrink(); - final items = masterDefinitions - .where((def) => def.category == category) - .toList(); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - category.toUpperCase(), - style: Theme.of(context).textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.w700, - letterSpacing: 0.8, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), - ), - const SizedBox(height: 16), - GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: - SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: _tileMaxWidth, - mainAxisSpacing: 10, - crossAxisSpacing: 10, - mainAxisExtent: _tileHeight, + if (!canViewMasters) + Padding( + padding: const EdgeInsets.only(top: 48), + child: Center( + child: Text( + 'You do not have permission to view master data.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, ), - itemCount: items.length, - itemBuilder: (context, index) { - final def = items[index]; - return _MasterAppTile( - title: def.title, - icon: def.icon, - color: _masterIconColor(index), - onTap: () => context.push( - RouteConstants.masterList(def.routeKey), - ), - ); - }, ), - const SizedBox(height: 28), - ], - ); - }), + ), + ) + else + ...categories.map((category) { + final items = masterDefinitions + .where((def) => def.category == category) + .toList(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + category.toUpperCase(), + style: theme.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: 0.8, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: + const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: _tileMaxWidth, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + mainAxisExtent: _tileHeight, + ), + itemCount: items.length, + itemBuilder: (context, index) { + final def = items[index]; + return _MasterAppTile( + title: def.title, + icon: def.icon, + color: _masterIconColor(index), + onTap: () => context.push( + RouteConstants.masterList(def.routeKey), + ), + ); + }, + ), + const SizedBox(height: 28), + ], + ); + }), ], ), ); diff --git a/lib/modules/purchase_orders/presentation/screens/purchase_order_detail_screen.dart b/lib/modules/purchase_orders/presentation/screens/purchase_order_detail_screen.dart index 3f9e189..58480f8 100644 --- a/lib/modules/purchase_orders/presentation/screens/purchase_order_detail_screen.dart +++ b/lib/modules/purchase_orders/presentation/screens/purchase_order_detail_screen.dart @@ -15,6 +15,7 @@ import '../../../../shared/utils/file_download_helper.dart'; import '../../../../shared/widgets/app_confirmation_dialog.dart'; import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_text_field.dart'; +import '../../../../shared/widgets/detail_overview_widgets.dart'; import '../../../../shared/widgets/entity_attachments_card.dart'; import '../../../../shared/widgets/error_view.dart'; import '../providers/purchase_order_lookups_provider.dart'; @@ -864,6 +865,8 @@ class _OrderDetailsCard extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; final paymentTerm = _lookupName(lookups?.paymentTerms, order.paymentTermId); final deliveryTerm = @@ -871,91 +874,89 @@ class _OrderDetailsCard extends StatelessWidget { return _SectionCard( title: 'ORDER 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: 'PO Date', - value: DateFormatter.displayDate(order.poDate), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DetailOverviewSection( + title: 'Summary', + child: DetailSummaryStrip( + metrics: [ + DetailSummaryMetric( + icon: Icons.flag_outlined, + label: 'Status', + accent: scheme.secondary, + child: PoStatusChip(status: order.status, compact: true), + ), + DetailSummaryMetric( + icon: Icons.payments_outlined, + label: 'Total Amount', + accent: scheme.primary, + child: Text( + order.totalAmount != null + ? CurrencyFormatter.format(order.totalAmount) + : '—', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + ), + DetailSummaryMetric( + icon: Icons.calendar_today_outlined, + label: 'PO Date', + child: Text( + DateFormatter.displayDate(order.poDate), + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + DetailSummaryMetric( + icon: Icons.inventory_2_outlined, + label: 'Line Items', + child: Text( + '${order.items.length}', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + ], ), - _DetailField( - label: 'Expected Delivery', - value: DateFormatter.displayDate(order.expectedDeliveryDate), + ), + DetailOverviewSection( + title: 'Vendor & Delivery', + child: DetailInfoGrid( + items: [ + DetailInfoItem('Vendor', _displayOrDash(order.vendorName)), + DetailInfoItem( + 'Vendor Type', + vendorTypeLabel(order.vendorType), + ), + DetailInfoItem( + 'Expected Delivery', + DateFormatter.displayDate(order.expectedDeliveryDate), + ), + DetailInfoItem('Billing', _displayOrDash(order.billingName)), + DetailInfoItem('Shipping', _displayOrDash(order.shippingName)), + ], ), - _DetailField( - label: 'Vendor', - value: _displayOrDash(order.vendorName), + ), + DetailOverviewSection( + title: 'Terms', + showDivider: false, + child: DetailInfoGrid( + items: [ + DetailInfoItem('Payment Term', paymentTerm), + DetailInfoItem('Delivery Term', deliveryTerm), + ], ), - _DetailField( - label: 'Vendor Type', - value: vendorTypeLabel(order.vendorType), - ), - _DetailField( - label: 'Billing', - value: _displayOrDash(order.billingName), - ), - _DetailField( - label: 'Shipping', - value: _displayOrDash(order.shippingName), - ), - _DetailField(label: 'Payment Term', value: paymentTerm), - _DetailField(label: 'Delivery Term', value: deliveryTerm), - ]; - - return Wrap( - spacing: spacing, - runSpacing: 16, - children: items - .map((item) => SizedBox(width: width, child: item)) - .toList(), - ); - }, + ), + ], ), ); } } -class _DetailField extends StatelessWidget { - const _DetailField({ - required this.label, - required this.value, - }); - - final String label; - final String value; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 4), - Text( - value, - style: theme.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - ], - ); - } -} - class _PoAttachmentsSection extends ConsumerWidget { const _PoAttachmentsSection({ required this.poId, diff --git a/lib/modules/purchase_orders/presentation/widgets/purchase_order_line_items_editor.dart b/lib/modules/purchase_orders/presentation/widgets/purchase_order_line_items_editor.dart index 07541d6..b6aa811 100644 --- a/lib/modules/purchase_orders/presentation/widgets/purchase_order_line_items_editor.dart +++ b/lib/modules/purchase_orders/presentation/widgets/purchase_order_line_items_editor.dart @@ -593,7 +593,7 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> { final itemOptions = _itemOptionsWithSelected(); final uomOptions = _uomOptionsWithSelected(); final gstOptions = [ - const AppDropdownOption(value: null, label: 'Select GST Rate'), + const AppDropdownOption(value: null, label: 'Select'), ...widget.gstRates.map((e) { final id = _parseId(e.id); if (id == null) return null; @@ -601,9 +601,24 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> { final label = pct != null ? (pct % 1 == 0 ? '${pct.toInt()}%' : '$pct%') : e.name.split(' — ').first.trim(); + if (label.isEmpty) return null; return AppDropdownOption(value: id, label: label); }), ].whereType>().toList(); + final selectedGstId = line.gstRateId; + if (selectedGstId != null && + !gstOptions.any((o) => o.value == selectedGstId)) { + final pct = widget.gstRatePctById[selectedGstId.toString()]; + gstOptions.insert( + 1, + AppDropdownOption( + value: selectedGstId, + label: pct != null + ? (pct % 1 == 0 ? '${pct.toInt()}%' : '$pct%') + : 'GST #$selectedGstId', + ), + ); + } const spacing = 8.0; @@ -629,6 +644,7 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> { controller: line.qtyController, label: 'Qty *', hint: '0', + isDense: true, keyboardType: const TextInputType.numberWithOptions(decimal: true), inputFormatters: [ FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')), @@ -678,6 +694,7 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> { controller: line.discountController, label: 'Disc %', hint: '0', + isDense: true, keyboardType: const TextInputType.numberWithOptions(decimal: true), inputFormatters: [ FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')), @@ -699,6 +716,7 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> { hint: 'Select', searchHint: 'Search GST %...', options: gstOptions, + isDense: true, openInSidePanel: true, refreshLookups: () async { ref.invalidate(purchaseOrderLookupsProvider); @@ -707,114 +725,64 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> { parseCreatedId: int.tryParse, onChanged: (v) => _updateLine(() => line.gstRateId = v), ); - final amountField = _AmountWithRemove( - amount: CurrencyFormatter.format(calc.lineAmount), + final amountField = _AmountDisplay( + label: 'Amount', + value: CurrencyFormatter.format(calc.lineAmount), backgroundColor: amountBg, - onRemove: widget.onRemove, ); return Container( - padding: const EdgeInsets.all(16), + width: double.infinity, + padding: const EdgeInsets.all(12), decoration: BoxDecoration( border: Border.all(color: borderColor), borderRadius: BorderRadius.circular(10), ), child: QuickAddInlineHost( child: QuickAddBlockable( - child: LayoutBuilder( - builder: (context, constraints) { - final width = constraints.maxWidth; - - // Wide: single flex row - if (width >= 1100) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded(flex: 3, child: itemField), - const SizedBox(width: spacing), - Expanded(flex: 1, child: qtyField), - const SizedBox(width: spacing), - Expanded(flex: 2, child: uomField), - const SizedBox(width: spacing), - Expanded(flex: 1, child: rateField), - const SizedBox(width: spacing), - Expanded(flex: 1, child: discField), - const SizedBox(width: spacing), - Expanded(flex: 2, child: gstField), - const SizedBox(width: spacing), - Expanded(flex: 2, child: amountField), - ], - ); - } - - // Medium / narrow: wrapping grid (2–3 columns) - return ResponsiveFormGrid( - spacing: spacing, - xsColumns: 1, - smallColumns: 1, - mediumColumns: 2, - largeColumns: 3, - smallBreakpoint: 520, - mediumBreakpoint: 520, - largeBreakpoint: 800, + child: SizedBox( + width: double.infinity, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - itemField, - qtyField, - uomField, - rateField, - discField, - gstField, - amountField, + Expanded(flex: 3, child: itemField), + const SizedBox(width: spacing), + // Wider than flex 1 so floating labels ("Qty *", "Disc %") aren't clipped. + Expanded(flex: 2, child: qtyField), + const SizedBox(width: spacing), + Expanded(flex: 2, child: uomField), + const SizedBox(width: spacing), + // Rate and Amount share the same flex so widths match. + Expanded(flex: 2, child: rateField), + const SizedBox(width: spacing), + Expanded(flex: 2, child: discField), + const SizedBox(width: spacing), + Expanded(flex: 2, child: gstField), + const SizedBox(width: spacing), + Expanded(flex: 2, child: amountField), + if (widget.onRemove != null) ...[ + const SizedBox(width: 4), + Padding( + padding: const EdgeInsets.only(top: 20), + child: IconButton( + tooltip: 'Remove line', + onPressed: widget.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 _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, ), ), - ], - ], + ), + ), ); } } @@ -851,11 +819,17 @@ class _AmountDisplay extends StatelessWidget { borderRadius: BorderRadius.circular(8), ), ), - child: Text( - value, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w700, - color: theme.colorScheme.onSurface, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + value, + maxLines: 1, + softWrap: false, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + color: theme.colorScheme.onSurface, + ), ), ), ), diff --git a/lib/modules/rbac/domain/entities/rbac_entities.dart b/lib/modules/rbac/domain/entities/rbac_entities.dart index 5be44bf..773a434 100644 --- a/lib/modules/rbac/domain/entities/rbac_entities.dart +++ b/lib/modules/rbac/domain/entities/rbac_entities.dart @@ -62,7 +62,7 @@ const rbacModules = [ ), RbacModule( key: 'grn', - label: 'GRN / PO Receipt', + label: 'Purchase Receipt', icon: Icons.inventory_outlined, color: Color(0xFF0891B2), ), @@ -288,7 +288,7 @@ List defaultRoles = [ ManagedRole( id: 'store_manager', name: 'Store Manager', - description: 'GRN, warehouse receipts, stock view', + description: 'Purchase Receipt, warehouse receipts, stock view', icon: Icons.warehouse_outlined, color: const Color(0xFF0891B2), userCount: 12, @@ -297,7 +297,7 @@ List defaultRoles = [ ManagedRole( id: 'accounts', name: 'Accounts', - description: 'View PO, GRN, vendor financial details', + description: 'View PO, Purchase Receipt, vendor financial details', icon: Icons.account_balance_wallet_outlined, color: const Color(0xFF7C3AED), userCount: 8, diff --git a/lib/modules/rbac/presentation/screens/users_role_management_screen.dart b/lib/modules/rbac/presentation/screens/users_role_management_screen.dart index e4ecdb1..9535dda 100644 --- a/lib/modules/rbac/presentation/screens/users_role_management_screen.dart +++ b/lib/modules/rbac/presentation/screens/users_role_management_screen.dart @@ -1017,7 +1017,7 @@ class _RolesTab extends ConsumerWidget { key: ValueKey('$themeMode-$brightness-${rolesState.page}'), gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: 320, - mainAxisExtent: 168, + mainAxisExtent: 176, crossAxisSpacing: 16, mainAxisSpacing: 16, ), @@ -1106,6 +1106,11 @@ class _RolesTab extends ConsumerWidget { icon: const Icon(Icons.edit_outlined, size: 18), onPressed: () => onEditRole(role), visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), ), if (canDeleteRole && !isProtectedRole(role)) IconButton( @@ -1117,6 +1122,11 @@ class _RolesTab extends ConsumerWidget { ), onPressed: () => onDeleteRole(role), visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), ), ], ), @@ -1131,22 +1141,32 @@ class _RolesTab extends ConsumerWidget { ), ), const SizedBox(height: 4), - Tooltip( - message: displayDescription == '—' ? '' : displayDescription, - waitDuration: const Duration(milliseconds: 300), - child: Text( - displayDescription, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - height: 1.35, - ), + Expanded( + child: Tooltip( + message: displayDescription == '—' + ? '' + : displayDescription, + waitDuration: const Duration(milliseconds: 300), + child: Align( + alignment: Alignment.topLeft, + child: Text( + displayDescription, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + height: 1.35, + ), + ), + ), ), ), - const SizedBox(height: 10), + const SizedBox(height: 8), Row( children: [ Tooltip( diff --git a/lib/modules/users/presentation/screens/user_detail_screen.dart b/lib/modules/users/presentation/screens/user_detail_screen.dart index 419cb50..3e4e824 100644 --- a/lib/modules/users/presentation/screens/user_detail_screen.dart +++ b/lib/modules/users/presentation/screens/user_detail_screen.dart @@ -5,13 +5,14 @@ import 'package:go_router/go_router.dart'; import '../../../../core/utils/formatters.dart'; import '../../../../core/errors/failure.dart'; +import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/widgets/app_button.dart'; import '../../../../shared/widgets/app_confirmation_dialog.dart'; import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_status_chip.dart'; +import '../../../../shared/widgets/detail_overview_widgets.dart'; import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/page_header.dart'; -import '../../../../shared/models/user_management_models.dart'; import '../providers/users_provider.dart'; import '../../../../shared/widgets/app_toast.dart'; @@ -81,27 +82,87 @@ class _UserDetailScreenState extends ConsumerState { AppCard( child: Padding( padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: DetailOverviewCard( + title: 'User Details', children: [ - _DetailRow(label: 'Email', value: user.email), - _DetailRow(label: 'Mobile', value: user.mobile), - _DetailRow(label: 'Role', value: user.roleLabel), - _DetailRow(label: 'Department', value: user.departmentLabel), - _DetailRow( - label: 'Status', - valueWidget: AppStatusChip(status: user.status), + DetailOverviewSection( + title: 'Summary', + child: DetailSummaryStrip( + metrics: [ + DetailSummaryMetric( + icon: Icons.flag_outlined, + label: 'Status', + accent: Theme.of(context).colorScheme.secondary, + child: AppStatusChip( + status: user.status, + compact: true, + ), + ), + DetailSummaryMetric( + icon: Icons.badge_outlined, + label: 'Employee Code', + child: Text( + user.employeeCode, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + ), + DetailSummaryMetric( + icon: Icons.admin_panel_settings_outlined, + label: 'Role', + child: Text( + user.roleLabel, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + ), + DetailSummaryMetric( + icon: Icons.apartment_outlined, + label: 'Department', + child: Text( + user.departmentLabel, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + ), + ], + ), ), - if (user.createdAt != null) - _DetailRow( - label: 'Created', - value: DateFormatter.displayDateTime(user.createdAt), + DetailOverviewSection( + title: 'Contact', + child: DetailInfoGrid( + items: [ + DetailInfoItem('Email', user.email), + DetailInfoItem('Mobile', user.mobile), + ], ), - if (user.updatedAt != null) - _DetailRow( - label: 'Updated', - value: DateFormatter.displayDateTime(user.updatedAt), + ), + DetailOverviewSection( + title: 'Account', + showDivider: false, + child: DetailInfoGrid( + items: [ + DetailInfoItem('Role', user.roleLabel), + DetailInfoItem('Department', user.departmentLabel), + if (user.createdAt != null) + DetailInfoItem( + 'Created', + DateFormatter.displayDateTime(user.createdAt), + ), + if (user.updatedAt != null) + DetailInfoItem( + 'Updated', + DateFormatter.displayDateTime(user.updatedAt), + ), + ], ), + ), ], ), ), @@ -135,39 +196,3 @@ class _UserDetailScreenState extends ConsumerState { if (success) context.pop(); } } - -class _DetailRow extends StatelessWidget { - const _DetailRow({ - required this.label, - this.value, - this.valueWidget, - }); - - final String label; - final String? value; - final Widget? valueWidget; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 140, - child: Text( - label, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: valueWidget ?? Text(value ?? '—'), - ), - ], - ), - ); - } -} diff --git a/lib/modules/vendors/presentation/screens/vendor_detail_screen.dart b/lib/modules/vendors/presentation/screens/vendor_detail_screen.dart index 53eb986..6177be8 100644 --- a/lib/modules/vendors/presentation/screens/vendor_detail_screen.dart +++ b/lib/modules/vendors/presentation/screens/vendor_detail_screen.dart @@ -19,7 +19,9 @@ import '../providers/vendors_provider.dart'; import '../widgets/vendor_form_panel.dart'; import '../widgets/vendor_sub_resource_panels.dart'; import '../../../../shared/widgets/api_feedback.dart'; +import '../../../../shared/widgets/app_status_chip.dart'; import '../../../../shared/widgets/app_toast.dart'; +import '../../../../shared/widgets/detail_overview_widgets.dart'; class VendorDetailScreen extends ConsumerStatefulWidget { const VendorDetailScreen({super.key, required this.vendorId}); @@ -250,7 +252,11 @@ class _OverviewTab extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final scheme = theme.colorScheme; final hasRemarks = vendor.remarks?.trim().isNotEmpty == true; + final status = vendor.status ?? 'active'; + final activeColor = + vendor.isActive ? const Color(0xFF16A34A) : scheme.onSurfaceVariant; return SingleChildScrollView( child: Center( @@ -262,50 +268,143 @@ class _OverviewTab extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - 'Vendor Details', - style: theme.textTheme.labelLarge?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 12), - _VendorInfoGrid( - items: [ - _VendorInfo('Vendor Code', vendor.vendorCode ?? '—'), - _VendorInfo('Vendor Name', vendor.vendorName), - _VendorInfo('Type', vendorTypeLabel(vendor.vendorType)), - _VendorInfo( - 'GST Treatment', - gstTreatmentLabel(vendor.gstTreatment), + DetailOverviewCard( + title: 'Vendor Details', + children: [ + DetailOverviewSection( + title: 'Summary', + child: DetailSummaryStrip( + metrics: [ + DetailSummaryMetric( + icon: Icons.flag_outlined, + label: 'Status', + accent: scheme.secondary, + child: AppStatusChip( + status: status, + compact: true, + ), + ), + DetailSummaryMetric( + icon: vendor.isActive + ? Icons.check_circle_outline + : Icons.pause_circle_outline, + label: 'Active', + accent: activeColor, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: activeColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Text( + vendor.isActive ? 'Active' : 'Inactive', + style: theme.textTheme.titleMedium + ?.copyWith( + color: activeColor, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + DetailSummaryMetric( + icon: Icons.category_outlined, + label: 'Type', + accent: scheme.primary, + child: Text( + vendorTypeLabel(vendor.vendorType), + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + DetailSummaryMetric( + icon: Icons.schedule_outlined, + label: 'Credit Period', + accent: scheme.primary, + child: Text( + vendor.creditPeriodDays != null + ? '${vendor.creditPeriodDays} days' + : '—', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), ), - _VendorInfo( - 'Source Of Supply', - vendor.sourceOfSupply ?? '—', + DetailOverviewSection( + title: 'Identity', + child: DetailInfoGrid( + items: [ + DetailInfoItem( + 'Vendor Code', + vendor.vendorCode ?? '—', + ), + DetailInfoItem('Vendor Name', vendor.vendorName), + DetailInfoItem( + 'Type', + vendorTypeLabel(vendor.vendorType), + ), + ], + ), ), - _VendorInfo('GSTIN', vendor.gstin ?? '—'), - _VendorInfo('PAN', vendor.pan ?? '—'), - _VendorInfo('Payment Term', vendor.paymentTermName ?? '—'), - _VendorInfo( - 'Credit Period', - vendor.creditPeriodDays != null - ? '${vendor.creditPeriodDays} days' - : '—', + DetailOverviewSection( + title: 'Tax & Compliance', + child: DetailInfoGrid( + items: [ + DetailInfoItem( + 'GST Treatment', + gstTreatmentLabel(vendor.gstTreatment), + ), + DetailInfoItem( + 'Source Of Supply', + vendor.sourceOfSupply ?? '—', + ), + DetailInfoItem('GSTIN', vendor.gstin ?? '—'), + DetailInfoItem('PAN', vendor.pan ?? '—'), + ], + ), ), - _VendorInfo('Status', vendorStatusLabel(vendor.status)), - _VendorInfo('Active', vendor.isActive ? 'Yes' : 'No'), + DetailOverviewSection( + title: 'Commercial', + showDivider: !hasRemarks, + child: DetailInfoGrid( + items: [ + DetailInfoItem( + 'Payment Term', + vendor.paymentTermName ?? '—', + ), + DetailInfoItem( + 'Credit Period', + vendor.creditPeriodDays != null + ? '${vendor.creditPeriodDays} days' + : '—', + ), + DetailInfoItem( + 'Status', + vendorStatusLabel(vendor.status), + ), + ], + ), + ), + if (hasRemarks) + DetailOverviewSection( + title: 'Remarks', + showDivider: false, + child: DetailInfoGrid( + items: [DetailInfoItem('Remarks', vendor.remarks!)], + ), + ), ], ), - if (hasRemarks) ...[ - const Padding( - padding: EdgeInsets.symmetric(vertical: 20), - child: Divider(height: 1), - ), - _VendorInfoGrid( - columns: 1, - items: [_VendorInfo('Remarks', vendor.remarks!)], - ), - ], ], ), ), @@ -316,85 +415,6 @@ class _OverviewTab extends StatelessWidget { } } -class _VendorInfoGrid extends StatelessWidget { - const _VendorInfoGrid({ - required this.items, - this.columns = 3, - }); - - final List<_VendorInfo> items; - final int columns; - - @override - Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - final maxWidth = constraints.maxWidth; - final cols = maxWidth < 600 - ? 1 - : maxWidth < 900 - ? 2 - : columns; - const spacing = 16.0; - final colWidth = (maxWidth - spacing * (cols - 1)) / cols; - - return Wrap( - spacing: spacing, - runSpacing: 16, - children: items - .map( - (item) => SizedBox( - width: colWidth, - child: _VendorDetailTile( - label: item.label, - value: item.value, - ), - ), - ) - .toList(), - ); - }, - ); - } -} - -class _VendorDetailTile extends StatelessWidget { - const _VendorDetailTile({ - required this.label, - required this.value, - }); - - final String label; - final String value; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 4), - Text(value, style: theme.textTheme.bodyLarge), - ], - ); - } -} - -class _VendorInfo { - const _VendorInfo(this.label, this.value); - - final String label; - final String value; -} - class _AddressesTab extends ConsumerWidget { const _AddressesTab({ required this.vendorId, diff --git a/lib/shared/models/asset_model.dart b/lib/shared/models/asset_model.dart index cf30c8d..1cd487d 100644 --- a/lib/shared/models/asset_model.dart +++ b/lib/shared/models/asset_model.dart @@ -116,6 +116,28 @@ Object? _readVendorNameFromNested(Map json, String key) { return null; } +Object? _readPoNumberFromNested(Map json, String key) { + final flat = json['po_number']; + if (flat is String && flat.trim().isNotEmpty) return flat.trim(); + final nested = json['purchase_order']; + if (nested is Map) { + final number = nested['po_number']?.toString().trim(); + if (number != null && number.isNotEmpty) return number; + } + return null; +} + +Object? _readGrnNumberFromNested(Map json, String key) { + final flat = json['grn_number']; + if (flat is String && flat.trim().isNotEmpty) return flat.trim(); + final nested = json['grn']; + if (nested is Map) { + final number = nested['grn_number']?.toString().trim(); + if (number != null && number.isNotEmpty) return number; + } + return null; +} + @freezed class AssetCategoryModel with _$AssetCategoryModel { const factory AssetCategoryModel({ @@ -191,7 +213,10 @@ class AssetModel with _$AssetModel { @JsonKey(name: 'vendor_id', fromJson: _intFromJsonNullable) int? vendorId, @JsonKey(name: 'vendor_name', readValue: _readVendorNameFromNested) String? vendorName, @JsonKey(name: 'po_id', fromJson: _intFromJsonNullable) int? poId, + @JsonKey(name: 'po_number', readValue: _readPoNumberFromNested) String? poNumber, @JsonKey(name: 'grn_id', fromJson: _intFromJsonNullable) int? grnId, + @JsonKey(name: 'grn_number', readValue: _readGrnNumberFromNested) + String? grnNumber, @JsonKey(name: 'grn_item_id', fromJson: _intFromJsonNullable) int? grnItemId, @JsonKey(name: 'purchase_date', fromJson: _dateFromJsonNullable) DateTime? purchaseDate, @JsonKey(name: 'purchase_cost', fromJson: _doubleFromJsonNullable) double? purchaseCost, diff --git a/lib/shared/models/asset_model.freezed.dart b/lib/shared/models/asset_model.freezed.dart index 77b527d..f9dc4da 100644 --- a/lib/shared/models/asset_model.freezed.dart +++ b/lib/shared/models/asset_model.freezed.dart @@ -482,8 +482,12 @@ mixin _$AssetModel { String? get vendorName => throw _privateConstructorUsedError; @JsonKey(name: 'po_id', fromJson: _intFromJsonNullable) int? get poId => throw _privateConstructorUsedError; + @JsonKey(name: 'po_number', readValue: _readPoNumberFromNested) + String? get poNumber => throw _privateConstructorUsedError; @JsonKey(name: 'grn_id', fromJson: _intFromJsonNullable) int? get grnId => throw _privateConstructorUsedError; + @JsonKey(name: 'grn_number', readValue: _readGrnNumberFromNested) + String? get grnNumber => throw _privateConstructorUsedError; @JsonKey(name: 'grn_item_id', fromJson: _intFromJsonNullable) int? get grnItemId => throw _privateConstructorUsedError; @JsonKey(name: 'purchase_date', fromJson: _dateFromJsonNullable) @@ -614,7 +618,11 @@ abstract class $AssetModelCopyWith<$Res> { @JsonKey(name: 'vendor_name', readValue: _readVendorNameFromNested) String? vendorName, @JsonKey(name: 'po_id', fromJson: _intFromJsonNullable) int? poId, + @JsonKey(name: 'po_number', readValue: _readPoNumberFromNested) + String? poNumber, @JsonKey(name: 'grn_id', fromJson: _intFromJsonNullable) int? grnId, + @JsonKey(name: 'grn_number', readValue: _readGrnNumberFromNested) + String? grnNumber, @JsonKey(name: 'grn_item_id', fromJson: _intFromJsonNullable) int? grnItemId, @JsonKey(name: 'purchase_date', fromJson: _dateFromJsonNullable) @@ -702,7 +710,9 @@ class _$AssetModelCopyWithImpl<$Res, $Val extends AssetModel> Object? vendorId = freezed, Object? vendorName = freezed, Object? poId = freezed, + Object? poNumber = freezed, Object? grnId = freezed, + Object? grnNumber = freezed, Object? grnItemId = freezed, Object? purchaseDate = freezed, Object? purchaseCost = freezed, @@ -824,10 +834,18 @@ class _$AssetModelCopyWithImpl<$Res, $Val extends AssetModel> ? _value.poId : poId // ignore: cast_nullable_to_non_nullable as int?, + poNumber: freezed == poNumber + ? _value.poNumber + : poNumber // ignore: cast_nullable_to_non_nullable + as String?, grnId: freezed == grnId ? _value.grnId : grnId // ignore: cast_nullable_to_non_nullable as int?, + grnNumber: freezed == grnNumber + ? _value.grnNumber + : grnNumber // ignore: cast_nullable_to_non_nullable + as String?, grnItemId: freezed == grnItemId ? _value.grnItemId : grnItemId // ignore: cast_nullable_to_non_nullable @@ -992,7 +1010,11 @@ abstract class _$$AssetModelImplCopyWith<$Res> @JsonKey(name: 'vendor_name', readValue: _readVendorNameFromNested) String? vendorName, @JsonKey(name: 'po_id', fromJson: _intFromJsonNullable) int? poId, + @JsonKey(name: 'po_number', readValue: _readPoNumberFromNested) + String? poNumber, @JsonKey(name: 'grn_id', fromJson: _intFromJsonNullable) int? grnId, + @JsonKey(name: 'grn_number', readValue: _readGrnNumberFromNested) + String? grnNumber, @JsonKey(name: 'grn_item_id', fromJson: _intFromJsonNullable) int? grnItemId, @JsonKey(name: 'purchase_date', fromJson: _dateFromJsonNullable) @@ -1079,7 +1101,9 @@ class __$$AssetModelImplCopyWithImpl<$Res> Object? vendorId = freezed, Object? vendorName = freezed, Object? poId = freezed, + Object? poNumber = freezed, Object? grnId = freezed, + Object? grnNumber = freezed, Object? grnItemId = freezed, Object? purchaseDate = freezed, Object? purchaseCost = freezed, @@ -1201,10 +1225,18 @@ class __$$AssetModelImplCopyWithImpl<$Res> ? _value.poId : poId // ignore: cast_nullable_to_non_nullable as int?, + poNumber: freezed == poNumber + ? _value.poNumber + : poNumber // ignore: cast_nullable_to_non_nullable + as String?, grnId: freezed == grnId ? _value.grnId : grnId // ignore: cast_nullable_to_non_nullable as int?, + grnNumber: freezed == grnNumber + ? _value.grnNumber + : grnNumber // ignore: cast_nullable_to_non_nullable + as String?, grnItemId: freezed == grnItemId ? _value.grnItemId : grnItemId // ignore: cast_nullable_to_non_nullable @@ -1362,7 +1394,11 @@ class _$AssetModelImpl implements _AssetModel { @JsonKey(name: 'vendor_name', readValue: _readVendorNameFromNested) this.vendorName, @JsonKey(name: 'po_id', fromJson: _intFromJsonNullable) this.poId, + @JsonKey(name: 'po_number', readValue: _readPoNumberFromNested) + this.poNumber, @JsonKey(name: 'grn_id', fromJson: _intFromJsonNullable) this.grnId, + @JsonKey(name: 'grn_number', readValue: _readGrnNumberFromNested) + this.grnNumber, @JsonKey(name: 'grn_item_id', fromJson: _intFromJsonNullable) this.grnItemId, @JsonKey(name: 'purchase_date', fromJson: _dateFromJsonNullable) @@ -1513,9 +1549,15 @@ class _$AssetModelImpl implements _AssetModel { @JsonKey(name: 'po_id', fromJson: _intFromJsonNullable) final int? poId; @override + @JsonKey(name: 'po_number', readValue: _readPoNumberFromNested) + final String? poNumber; + @override @JsonKey(name: 'grn_id', fromJson: _intFromJsonNullable) final int? grnId; @override + @JsonKey(name: 'grn_number', readValue: _readGrnNumberFromNested) + final String? grnNumber; + @override @JsonKey(name: 'grn_item_id', fromJson: _intFromJsonNullable) final int? grnItemId; @override @@ -1589,7 +1631,7 @@ class _$AssetModelImpl implements _AssetModel { @override String toString() { - return 'AssetModel(id: $id, assetName: $assetName, assetCode: $assetCode, assetCategoryId: $assetCategoryId, assetCategoryName: $assetCategoryName, assetSubcategoryId: $assetSubcategoryId, assetSubcategoryName: $assetSubcategoryName, locationId: $locationId, locationName: $locationName, brandModel: $brandModel, manufacturer: $manufacturer, serialNumber: $serialNumber, partNumber: $partNumber, departmentId: $departmentId, departmentName: $departmentName, locationDetail: $locationDetail, assignedToUserId: $assignedToUserId, maintenanceInchargeUserId: $maintenanceInchargeUserId, maintenanceFrequencyInDays: $maintenanceFrequencyInDays, maintenanceChecklistJson: $maintenanceChecklistJson, commencementDate: $commencementDate, vendorId: $vendorId, vendorName: $vendorName, poId: $poId, grnId: $grnId, grnItemId: $grnItemId, purchaseDate: $purchaseDate, purchaseCost: $purchaseCost, usefulLifeYears: $usefulLifeYears, depreciationMethod: $depreciationMethod, depreciationRate: $depreciationRate, salvageValue: $salvageValue, salvagePercentage: $salvagePercentage, currentValue: $currentValue, depreciation: $depreciation, warrantyExpiryDate: $warrantyExpiryDate, condition: $condition, status: $status, qrCodeValue: $qrCodeValue, disposalDate: $disposalDate, disposalReason: $disposalReason, disposalValue: $disposalValue, remarks: $remarks, isActive: $isActive, createdAt: $createdAt, updatedAt: $updatedAt, maintenance: $maintenance)'; + return 'AssetModel(id: $id, assetName: $assetName, assetCode: $assetCode, assetCategoryId: $assetCategoryId, assetCategoryName: $assetCategoryName, assetSubcategoryId: $assetSubcategoryId, assetSubcategoryName: $assetSubcategoryName, locationId: $locationId, locationName: $locationName, brandModel: $brandModel, manufacturer: $manufacturer, serialNumber: $serialNumber, partNumber: $partNumber, departmentId: $departmentId, departmentName: $departmentName, locationDetail: $locationDetail, assignedToUserId: $assignedToUserId, maintenanceInchargeUserId: $maintenanceInchargeUserId, maintenanceFrequencyInDays: $maintenanceFrequencyInDays, maintenanceChecklistJson: $maintenanceChecklistJson, commencementDate: $commencementDate, vendorId: $vendorId, vendorName: $vendorName, poId: $poId, poNumber: $poNumber, grnId: $grnId, grnNumber: $grnNumber, grnItemId: $grnItemId, purchaseDate: $purchaseDate, purchaseCost: $purchaseCost, usefulLifeYears: $usefulLifeYears, depreciationMethod: $depreciationMethod, depreciationRate: $depreciationRate, salvageValue: $salvageValue, salvagePercentage: $salvagePercentage, currentValue: $currentValue, depreciation: $depreciation, warrantyExpiryDate: $warrantyExpiryDate, condition: $condition, status: $status, qrCodeValue: $qrCodeValue, disposalDate: $disposalDate, disposalReason: $disposalReason, disposalValue: $disposalValue, remarks: $remarks, isActive: $isActive, createdAt: $createdAt, updatedAt: $updatedAt, maintenance: $maintenance)'; } @override @@ -1652,7 +1694,11 @@ class _$AssetModelImpl implements _AssetModel { (identical(other.vendorName, vendorName) || other.vendorName == vendorName) && (identical(other.poId, poId) || other.poId == poId) && + (identical(other.poNumber, poNumber) || + other.poNumber == poNumber) && (identical(other.grnId, grnId) || other.grnId == grnId) && + (identical(other.grnNumber, grnNumber) || + other.grnNumber == grnNumber) && (identical(other.grnItemId, grnItemId) || other.grnItemId == grnItemId) && (identical(other.purchaseDate, purchaseDate) || @@ -1725,7 +1771,9 @@ class _$AssetModelImpl implements _AssetModel { vendorId, vendorName, poId, + poNumber, grnId, + grnNumber, grnItemId, purchaseDate, purchaseCost, @@ -1827,7 +1875,11 @@ abstract class _AssetModel implements AssetModel { @JsonKey(name: 'vendor_name', readValue: _readVendorNameFromNested) final String? vendorName, @JsonKey(name: 'po_id', fromJson: _intFromJsonNullable) final int? poId, + @JsonKey(name: 'po_number', readValue: _readPoNumberFromNested) + final String? poNumber, @JsonKey(name: 'grn_id', fromJson: _intFromJsonNullable) final int? grnId, + @JsonKey(name: 'grn_number', readValue: _readGrnNumberFromNested) + final String? grnNumber, @JsonKey(name: 'grn_item_id', fromJson: _intFromJsonNullable) final int? grnItemId, @JsonKey(name: 'purchase_date', fromJson: _dateFromJsonNullable) @@ -1969,9 +2021,15 @@ abstract class _AssetModel implements AssetModel { @JsonKey(name: 'po_id', fromJson: _intFromJsonNullable) int? get poId; @override + @JsonKey(name: 'po_number', readValue: _readPoNumberFromNested) + String? get poNumber; + @override @JsonKey(name: 'grn_id', fromJson: _intFromJsonNullable) int? get grnId; @override + @JsonKey(name: 'grn_number', readValue: _readGrnNumberFromNested) + String? get grnNumber; + @override @JsonKey(name: 'grn_item_id', fromJson: _intFromJsonNullable) int? get grnItemId; @override diff --git a/lib/shared/models/asset_model.g.dart b/lib/shared/models/asset_model.g.dart index badfcb7..0aaba4a 100644 --- a/lib/shared/models/asset_model.g.dart +++ b/lib/shared/models/asset_model.g.dart @@ -81,7 +81,9 @@ _$AssetModelImpl _$$AssetModelImplFromJson( vendorId: _intFromJsonNullable(json['vendor_id']), vendorName: _readVendorNameFromNested(json, 'vendor_name') as String?, poId: _intFromJsonNullable(json['po_id']), + poNumber: _readPoNumberFromNested(json, 'po_number') as String?, grnId: _intFromJsonNullable(json['grn_id']), + grnNumber: _readGrnNumberFromNested(json, 'grn_number') as String?, grnItemId: _intFromJsonNullable(json['grn_item_id']), purchaseDate: _dateFromJsonNullable(json['purchase_date']), purchaseCost: _doubleFromJsonNullable(json['purchase_cost']), @@ -135,7 +137,9 @@ Map _$$AssetModelImplToJson( 'vendor_id': instance.vendorId, 'vendor_name': instance.vendorName, 'po_id': instance.poId, + 'po_number': instance.poNumber, 'grn_id': instance.grnId, + 'grn_number': instance.grnNumber, 'grn_item_id': instance.grnItemId, 'purchase_date': instance.purchaseDate?.toIso8601String(), 'purchase_cost': instance.purchaseCost, diff --git a/lib/shared/models/permission_matrix_models.dart b/lib/shared/models/permission_matrix_models.dart index ac65182..55ab516 100644 --- a/lib/shared/models/permission_matrix_models.dart +++ b/lib/shared/models/permission_matrix_models.dart @@ -267,6 +267,7 @@ class RolePermissionMatrix { 'MASTERS' => styles[2], 'VENDOR' => styles[3], 'PURCHASE_ORDERS' || 'PURCHASE_ORDER' || 'PO' => styles[4], + 'Purchase Receipt' => styles[5], 'GRN' => styles[5], 'ASSETS' || 'ASSET' || 'ASSET_MANAGEMENT' => styles[6], _ => styles[index % styles.length], diff --git a/lib/shared/models/user_model.dart b/lib/shared/models/user_model.dart index 00a1b0b..27b151b 100644 --- a/lib/shared/models/user_model.dart +++ b/lib/shared/models/user_model.dart @@ -56,10 +56,7 @@ class UserModel with _$UserModel { companyId: json['company_id']?.toString(), branchId: json['branch_id']?.toString(), avatarUrl: json['avatar_url'] as String? ?? json['avatarUrl'] as String?, - permissions: (json['permissions'] as List?) - ?.map((e) => e.toString()) - .toList() ?? - const [], + permissions: _permissionsFromJson(json), createdAt: json['created_at'] != null ? DateTime.tryParse(json['created_at'].toString()) : null, @@ -70,6 +67,33 @@ class UserModel with _$UserModel { } } +List _permissionsFromJson(Map json) { + final raw = json['permissions'] ?? json['permission_keys'] ?? json['perms']; + if (raw is! List) return const []; + + final permissions = []; + for (final entry in raw) { + if (entry is String) { + final value = entry.trim(); + if (value.isNotEmpty) permissions.add(value); + continue; + } + if (entry is Map) { + final key = entry['key'] ?? entry['permission'] ?? entry['code']; + if (key != null && key.toString().trim().isNotEmpty) { + permissions.add(key.toString().trim()); + continue; + } + final module = entry['module'] ?? entry['module_code'] ?? entry['resource']; + final action = entry['action'] ?? entry['permission_action']; + if (module != null && action != null) { + permissions.add('${module.toString().trim()}.${action.toString().trim()}'); + } + } + } + return permissions; +} + Object? _firstRoleFromList(Object? roles) { if (roles is! List || roles.isEmpty) return null; return roles.first; diff --git a/lib/shared/providers/permissions_provider.dart b/lib/shared/providers/permissions_provider.dart index 47f45e5..eace363 100644 --- a/lib/shared/providers/permissions_provider.dart +++ b/lib/shared/providers/permissions_provider.dart @@ -11,7 +11,7 @@ final userPermissionsProvider = Provider>((ref) { extension PermissionCheck on WidgetRef { bool can(String module, PermissionAction action) { return hasPermission( - userPermissions: read(userPermissionsProvider), + userPermissions: watch(userPermissionsProvider), module: module, action: action, ); @@ -21,7 +21,7 @@ extension PermissionCheck on WidgetRef { extension PermissionCheckReader on Ref { bool can(String module, PermissionAction action) { return hasPermission( - userPermissions: read(userPermissionsProvider), + userPermissions: watch(userPermissionsProvider), module: module, action: action, ); diff --git a/lib/shared/routes/menu_config.dart b/lib/shared/routes/menu_config.dart index ad299e7..8e46a03 100644 --- a/lib/shared/routes/menu_config.dart +++ b/lib/shared/routes/menu_config.dart @@ -95,7 +95,7 @@ const List appMenuItems = [ ), MenuItem( label: 'Purchase Receipt', - icon: Icons.inventory_2_outlined, + icon: Icons.move_to_inbox_outlined, route: RouteConstants.grn, module: 'grn', ), diff --git a/lib/shared/widgets/app_searchable_dropdown.dart b/lib/shared/widgets/app_searchable_dropdown.dart index c55beb6..ffe379a 100644 --- a/lib/shared/widgets/app_searchable_dropdown.dart +++ b/lib/shared/widgets/app_searchable_dropdown.dart @@ -240,15 +240,15 @@ class _AppSearchableDropdownState extends State> { _overlayEntry != null ? Icons.arrow_drop_up : Icons.arrow_drop_down, - size: 24, + size: widget.isDense ? 20 : 24, color: canOpen ? colors.onSurfaceVariant : theme.disabledColor, ), // Match AppTextField height (default icon box is 48px). - suffixIconConstraints: const BoxConstraints( - minWidth: 40, - minHeight: 40, + suffixIconConstraints: BoxConstraints( + minWidth: widget.isDense ? 28 : 40, + minHeight: widget.isDense ? 32 : 40, ), enabled: canOpen, ), diff --git a/lib/shared/widgets/detail_overview_widgets.dart b/lib/shared/widgets/detail_overview_widgets.dart new file mode 100644 index 0000000..ba31d54 --- /dev/null +++ b/lib/shared/widgets/detail_overview_widgets.dart @@ -0,0 +1,334 @@ +import 'package:flutter/material.dart'; + +/// Section header used in entity detail overview tabs (Asset, Vendor, User, etc.). +class DetailOverviewSection extends StatelessWidget { + const DetailOverviewSection({ + super.key, + required this.title, + this.child, + this.showDivider = true, + this.trailing, + }); + + final String title; + final Widget? child; + final bool showDivider; + /// Shown on the right of the section heading (e.g. Active indicator). + final Widget? trailing; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final titleText = Text( + title.toUpperCase(), + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w700, + letterSpacing: 0.4, + ), + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (trailing == null) + titleText + else + Row( + children: [ + Expanded(child: titleText), + trailing!, + ], + ), + if (child != null) ...[ + const SizedBox(height: 12), + child!, + ], + if (showDivider) + const Padding( + padding: EdgeInsets.symmetric(vertical: 20), + child: Divider(height: 1), + ) + else if (child != null) + const SizedBox(height: 8), + ], + ); + } +} + +class DetailInfoItem { + const DetailInfoItem(this.label, this.value) : valueWidget = null; + + const DetailInfoItem.widget(this.label, this.valueWidget) : value = null; + + final String label; + final String? value; + final Widget? valueWidget; +} + +/// Responsive label/value grid — 4 columns on wide screens (matches Asset overview). +class DetailInfoGrid extends StatelessWidget { + const DetailInfoGrid({ + super.key, + required this.items, + this.columns = 4, + }); + + final List items; + final int columns; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final maxWidth = constraints.maxWidth; + final cols = maxWidth < 560 + ? 1 + : maxWidth < 780 + ? 2 + : maxWidth < 1000 + ? 3 + : columns; + const spacing = 16.0; + final colWidth = (maxWidth - spacing * (cols - 1)) / cols; + + return Wrap( + spacing: spacing, + runSpacing: 18, + children: items + .map( + (item) => SizedBox( + width: colWidth, + child: _DetailInfoTile( + label: item.label, + value: item.value, + valueWidget: item.valueWidget, + ), + ), + ) + .toList(), + ); + }, + ); + } +} + +class _DetailInfoTile extends StatelessWidget { + const _DetailInfoTile({ + required this.label, + this.value, + this.valueWidget, + }); + + final String label; + final String? value; + final Widget? valueWidget; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label.toUpperCase(), + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.85), + fontWeight: FontWeight.w500, + letterSpacing: 0.45, + ), + ), + const SizedBox(height: 6), + valueWidget ?? + Text( + value ?? '—', + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } +} + +class DetailSummaryMetric { + const DetailSummaryMetric({ + required this.icon, + required this.label, + required this.child, + this.accent, + }); + + final IconData icon; + final String label; + final Widget child; + final Color? accent; +} + +/// Gradient KPI strip for detail overview summaries. +class DetailSummaryStrip extends StatelessWidget { + const DetailSummaryStrip({super.key, required this.metrics}); + + final List metrics; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + scheme.primary.withValues(alpha: 0.07), + scheme.secondary.withValues(alpha: 0.06), + scheme.surfaceContainerHighest.withValues(alpha: 0.35), + ], + ), + border: Border.all( + color: scheme.primary.withValues(alpha: 0.14), + ), + ), + child: LayoutBuilder( + builder: (context, constraints) { + final count = metrics.length; + final cols = constraints.maxWidth < 560 + ? 1 + : constraints.maxWidth < 820 + ? 2 + : count.clamp(1, 4); + const gap = 12.0; + final tileWidth = + (constraints.maxWidth - gap * (cols - 1)) / cols; + + return Wrap( + spacing: gap, + runSpacing: gap, + children: [ + for (final metric in metrics) + DetailSummaryMetricTile( + width: tileWidth, + icon: metric.icon, + label: metric.label, + accent: metric.accent ?? scheme.primary, + child: metric.child, + ), + ], + ); + }, + ), + ); + } +} + +class DetailSummaryMetricTile extends StatelessWidget { + const DetailSummaryMetricTile({ + super.key, + required this.width, + required this.icon, + required this.label, + required this.accent, + required this.child, + }); + + final double width; + final IconData icon; + final String label; + final Color accent; + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + + return SizedBox( + width: width, + child: Container( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 14), + decoration: BoxDecoration( + color: scheme.surface.withValues(alpha: 0.92), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: accent.withValues(alpha: 0.18)), + boxShadow: [ + BoxShadow( + color: scheme.shadow.withValues(alpha: 0.04), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, size: 16, color: accent), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + label.toUpperCase(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelSmall?.copyWith( + color: scheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + child, + ], + ), + ), + ); + } +} + +/// Card wrapper for detail overview content (title + sections). +class DetailOverviewCard extends StatelessWidget { + const DetailOverviewCard({ + super.key, + required this.title, + required this.children, + }); + + final String title; + final List children; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + title, + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + ...children, + ], + ); + } +} diff --git a/lib/shared/widgets/document_preview_dialog.dart b/lib/shared/widgets/document_preview_dialog.dart new file mode 100644 index 0000000..e9c5ec7 --- /dev/null +++ b/lib/shared/widgets/document_preview_dialog.dart @@ -0,0 +1,609 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; + +import '../../core/constants/route_constants.dart'; +import '../../core/errors/failure.dart'; +import '../../core/theme/app_typography.dart'; +import '../../modules/grn/presentation/providers/grn_lookups_provider.dart'; +import '../../modules/grn/presentation/providers/grn_provider.dart'; +import '../../modules/purchase_orders/presentation/providers/purchase_orders_provider.dart'; +import '../models/grn_model.dart'; +import '../models/purchase_order_model.dart'; +import '../models/user_management_models.dart'; +import 'error_view.dart'; + +Future showPurchaseOrderPreviewDialog( + BuildContext context, { + required String purchaseOrderId, +}) { + return showDialog( + context: context, + barrierColor: Colors.black.withValues(alpha: 0.45), + builder: (context) => DocumentPreviewDialog( + child: _PurchaseOrderPreviewBody( + purchaseOrderId: purchaseOrderId, + ), + ), + ); +} + +Future showGrnPreviewDialog( + BuildContext context, { + required String grnId, +}) { + return showDialog( + context: context, + barrierColor: Colors.black.withValues(alpha: 0.45), + builder: (context) => DocumentPreviewDialog( + child: _GrnPreviewBody(grnId: grnId), + ), + ); +} + +/// Shared shell matching the document preview mock (theme fonts + colors). +class DocumentPreviewDialog extends StatelessWidget { + const DocumentPreviewDialog({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Dialog( + insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24), + backgroundColor: Colors.transparent, + elevation: 0, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520, maxHeight: 720), + child: Material( + color: scheme.surface, + borderRadius: BorderRadius.circular(16), + clipBehavior: Clip.antiAlias, + child: child, + ), + ), + ); + } +} + +class _PreviewHeader extends StatelessWidget { + const _PreviewHeader({ + required this.eyebrow, + required this.title, + }); + + final String eyebrow; + final String title; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(20, 18, 12, 20), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + scheme.primary, + Color.lerp(scheme.primary, scheme.secondary, 0.45)!, + ], + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + eyebrow.toUpperCase(), + style: AppTypography.label3( + weight: AppTypography.semiBold, + color: scheme.onPrimary.withValues(alpha: 0.75), + ).copyWith(letterSpacing: 0.8), + ), + const SizedBox(height: 6), + Text( + title, + style: AppTypography.heading6( + weight: AppTypography.bold, + color: scheme.onPrimary, + ), + ), + ], + ), + ), + IconButton( + tooltip: 'Close', + onPressed: () => Navigator.of(context).pop(), + style: IconButton.styleFrom( + foregroundColor: scheme.onPrimary, + backgroundColor: scheme.onPrimary.withValues(alpha: 0.12), + ), + icon: const Icon(Icons.close, size: 18), + ), + ], + ), + ); + } +} + +class _StatusPill extends StatelessWidget { + const _StatusPill({required this.label, required this.color}); + + final String label; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: color.withValues(alpha: 0.25)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 7, + height: 7, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 8), + Text( + label, + style: AppTypography.label3( + weight: AppTypography.semiBold, + color: color, + ), + ), + ], + ), + ); + } +} + +class _PreviewField extends StatelessWidget { + const _PreviewField({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label.toUpperCase(), + style: AppTypography.caption1( + weight: AppTypography.medium, + color: scheme.onSurfaceVariant.withValues(alpha: 0.85), + ).copyWith(letterSpacing: 0.45), + ), + const SizedBox(height: 4), + Text( + value, + style: AppTypography.body3( + weight: AppTypography.semiBold, + color: scheme.onSurface, + ), + ), + ], + ); + } +} + +class _PreviewFooter extends StatelessWidget { + const _PreviewFooter({ + required this.primaryLabel, + required this.onPrimary, + }); + + final String primaryLabel; + final VoidCallback onPrimary; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), + child: Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.of(context).pop(), + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(44), + foregroundColor: scheme.onSurface, + side: BorderSide(color: scheme.outline.withValues(alpha: 0.35)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: const Text('Close'), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 2, + child: FilledButton( + onPressed: onPrimary, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(44), + backgroundColor: scheme.primary, + foregroundColor: scheme.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: Text( + primaryLabel, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.visible, + textAlign: TextAlign.center, + ), + ), + ), + ], + ), + ); + } +} + +class _PurchaseOrderPreviewBody extends ConsumerWidget { + const _PurchaseOrderPreviewBody({ + required this.purchaseOrderId, + }); + + final String purchaseOrderId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final orderAsync = ref.watch(purchaseOrderDetailProvider(purchaseOrderId)); + final scheme = Theme.of(context).colorScheme; + final dateFormat = DateFormat('dd MMM yyyy'); + + return orderAsync.when( + loading: () => const SizedBox( + height: 280, + child: Center(child: CircularProgressIndicator(strokeWidth: 2)), + ), + error: (e, _) => Padding( + padding: const EdgeInsets.all(24), + child: ErrorView.fromFailure( + e is Failure ? e : Failure.unknown(message: e.toString()), + onRetry: () => + ref.invalidate(purchaseOrderDetailProvider(purchaseOrderId)), + ), + ), + data: (order) { + final statusColor = _poStatusColor(order.status, scheme); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _PreviewHeader( + eyebrow: 'Purchase Order', + title: order.poNo?.trim().isNotEmpty == true + ? order.poNo!.trim() + : 'PO #$purchaseOrderId', + ), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 460), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _StatusPill( + label: poStatusLabel(order.status), + color: statusColor, + ), + const SizedBox(height: 18), + _PreviewField( + label: 'Vendor', + value: _dash(order.vendorName), + ), + const SizedBox(height: 14), + _TwoColumnFields( + left: [ + _PreviewField( + label: 'Order Date', + value: order.poDate != null + ? dateFormat.format(order.poDate!) + : '—', + ), + ], + right: [ + _PreviewField( + label: 'Delivery Date', + value: order.expectedDeliveryDate != null + ? dateFormat.format(order.expectedDeliveryDate!) + : '—', + ), + ], + ), + const SizedBox(height: 14), + _TwoColumnFields( + left: [ + _PreviewField( + label: 'Billing', + value: _dash(order.billingName), + ), + ], + right: [ + _PreviewField( + label: 'Shipping', + value: _dash(order.shippingName), + ), + ], + ), + ], + ), + ), + ), + _PreviewFooter( + primaryLabel: 'View full order', + onPrimary: () { + Navigator.of(context).pop(); + context.push( + '${RouteConstants.purchaseOrders}/$purchaseOrderId', + ); + }, + ), + ], + ); + }, + ); + } +} + +class _GrnPreviewBody extends ConsumerWidget { + const _GrnPreviewBody({required this.grnId}); + + final String grnId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final grnAsync = ref.watch(grnDetailProvider(grnId)); + final lookups = ref.watch(grnLookupsProvider).valueOrNull; + final scheme = Theme.of(context).colorScheme; + final dateFormat = DateFormat('dd MMM yyyy'); + + return grnAsync.when( + loading: () => const SizedBox( + height: 280, + child: Center(child: CircularProgressIndicator(strokeWidth: 2)), + ), + error: (e, _) => Padding( + padding: const EdgeInsets.all(24), + child: ErrorView.fromFailure( + e is Failure ? e : Failure.unknown(message: e.toString()), + onRetry: () => ref.invalidate(grnDetailProvider(grnId)), + ), + ), + data: (grn) { + final receivedBy = _userLabel(lookups?.users, grn.receivedBy); + final locationParts = [ + if (grn.locationName?.trim().isNotEmpty == true) + grn.locationName!.trim(), + if (grn.locationType?.trim().isNotEmpty == true) + grn.locationType!.trim(), + ]; + final receivedByDisplay = [ + if (receivedBy != '—') receivedBy, + if (locationParts.isNotEmpty) locationParts.join(' · '), + ].join(' · '); + final qtyOrdered = grn.items.fold( + 0, + (sum, item) => sum + (item.currentQty ?? 0), + ); + final qtyReceived = grn.items.fold( + 0, + (sum, item) => sum + (item.acceptedQty ?? 0), + ); + final damaged = grn.items.fold( + 0, + (sum, item) => sum + (item.damagedQty ?? 0), + ); + final condition = damaged > 0 + ? 'Damaged items present' + : (grn.items.isEmpty ? '—' : 'Accepted, no damage'); + final statusColor = grn.status.toUpperCase() == 'CANCELLED' + ? scheme.error + : const Color(0xFF16A34A); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _PreviewHeader( + eyebrow: 'Purchase Receipt', + title: grn.grnNumber?.trim().isNotEmpty == true + ? grn.grnNumber!.trim() + : 'Purchase Receipt #$grnId', + ), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 460), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _StatusPill( + label: grnStatusLabel(grn.status), + color: statusColor, + ), + const SizedBox(height: 18), + _TwoColumnFields( + left: [ + _PreviewField( + label: 'Received Date', + value: grn.grnDate != null + ? dateFormat.format(grn.grnDate!) + : '—', + ), + _PreviewField( + label: 'Against PO', + value: _dash(grn.poNumber), + ), + _PreviewField( + label: 'Quantity Ordered', + value: grn.items.isEmpty + ? '—' + : _qtyLabel(qtyOrdered), + ), + ], + right: [ + _PreviewField( + label: 'Received By', + value: receivedByDisplay.isEmpty + ? '—' + : receivedByDisplay, + ), + _PreviewField( + label: 'Condition on Receipt', + value: condition, + ), + _PreviewField( + label: 'Quantity Received', + value: grn.items.isEmpty + ? '—' + : _qtyLabel(qtyReceived), + ), + ], + ), + ], + ), + ), + ), + _PreviewFooter( + primaryLabel: 'View full Purchase Receipt', + onPrimary: () { + Navigator.of(context).pop(); + context.push('${RouteConstants.grn}/$grnId'); + }, + ), + ], + ); + }, + ); + } +} + +class _TwoColumnFields extends StatelessWidget { + const _TwoColumnFields({ + required this.left, + required this.right, + }); + + final List left; + final List right; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final rowCount = + left.length > right.length ? left.length : right.length; + if (constraints.maxWidth < 360) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < rowCount; i++) ...[ + if (i > 0) const SizedBox(height: 14), + if (i < left.length) left[i], + if (i < left.length && i < right.length) + const SizedBox(height: 14), + if (i < right.length) right[i], + ], + ], + ); + } + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < left.length; i++) ...[ + if (i > 0) const SizedBox(height: 14), + left[i], + ], + ], + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < right.length; i++) ...[ + if (i > 0) const SizedBox(height: 14), + right[i], + ], + ], + ), + ), + ], + ); + }, + ); + } +} + +String _dash(String? value) { + final trimmed = value?.trim(); + if (trimmed == null || trimmed.isEmpty) return '—'; + return trimmed; +} + +String _userLabel(List? users, int? userId) { + if (userId == null || users == null) return '—'; + for (final user in users) { + if (int.tryParse(user.id) == userId) return user.name; + } + return 'User #$userId'; +} + +String _fmtQty(double? value) { + if (value == null) return '—'; + if (value == value.roundToDouble()) return value.toInt().toString(); + return value.toStringAsFixed(2); +} + +String _qtyLabel(double value) { + final qty = _fmtQty(value); + return value == 1 ? '$qty unit' : '$qty units'; +} + +Color _poStatusColor(String status, ColorScheme scheme) { + switch (status.toUpperCase()) { + case 'APPROVED': + case 'FULLY_RECEIVED': + return const Color(0xFF16A34A); + case 'PARTIALLY_RECEIVED': + return const Color(0xFFD97706); + case 'REJECTED': + case 'CANCELLED': + return scheme.error; + case 'PENDING_APPROVAL': + case 'SUBMITTED': + case 'PENDING': + return scheme.secondary; + default: + return scheme.onSurfaceVariant; + } +} diff --git a/lib/shared/widgets/page_header.dart b/lib/shared/widgets/page_header.dart index 79766de..6e233f6 100644 --- a/lib/shared/widgets/page_header.dart +++ b/lib/shared/widgets/page_header.dart @@ -9,12 +9,31 @@ class PageHeader extends StatelessWidget { this.subtitle, this.actions, this.leading, + this.titleTrailing, }); final String title; final String? subtitle; final List? actions; final Widget? leading; + /// Shown immediately after the title (e.g. status chip). + final Widget? titleTrailing; + + Widget _titleRow(BuildContext context) { + final titleText = Text( + title, + style: Theme.of(context).textTheme.headlineSmall, + ); + if (titleTrailing == null) return titleText; + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Flexible(child: titleText), + const SizedBox(width: 10), + titleTrailing!, + ], + ); + } @override Widget build(BuildContext context) { @@ -30,12 +49,7 @@ class PageHeader extends StatelessWidget { leading!, const SizedBox(width: 8), ], - Expanded( - child: Text( - title, - style: Theme.of(context).textTheme.headlineSmall, - ), - ), + Expanded(child: _titleRow(context)), ], ), if (subtitle != null) ...[ @@ -63,7 +77,7 @@ class PageHeader extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(title, style: Theme.of(context).textTheme.headlineSmall), + _titleRow(context), if (subtitle != null) ...[ const SizedBox(height: 4), Text(