master screen refresh issue

This commit is contained in:
Surendiran 2026-08-04 15:01:34 +05:30
parent ef0f33ecbb
commit f2d5b49dac
31 changed files with 1981 additions and 1027 deletions

View File

@ -192,7 +192,7 @@ String humanizeLabel(String key) {
'hsn': 'HSN',
'uom': 'UOM',
'po': 'PO',
'grn': 'GRN',
'grn': 'Purchase Receipt',
'url': 'URL',
'api': 'API',
};

View File

@ -19,6 +19,8 @@ const Map<String, String> 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',

View File

@ -203,7 +203,7 @@ Future<List<FilterOptionModel>> _safeGrnOptions(Ref ref) async {
.map(
(grn) => FilterOptionModel(
id: grn.id,
name: grn.grnNumber ?? 'GRN #${grn.id}',
name: grn.grnNumber ?? 'Purchase Receipt #${grn.id}',
),
)
.toList();

View File

@ -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<AssetDetailScreen>
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 = <Widget>[
_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<FilterOptionModel> users) {
if (userId == null || userId <= 0) return '';
final id = userId.toString();

View File

@ -636,7 +636,7 @@ class _AssetFormScreenState extends ConsumerState<AssetFormScreen> {
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<AssetFormScreen> {
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<AssetFormScreen> {
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<AssetFormScreen> {
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,
),

View File

@ -64,10 +64,59 @@ class AuthRemoteDataSource {
return _mapProfileToUser(profile, permissions: permissions, roleId: roleId);
}
Future<UserModel> getCurrentUser() async {
Future<UserModel> getCurrentUser({String? accessToken}) async {
final response = await dio.get<Map<String, dynamic>>(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<UserModel> withRolePermissionsIfMissing(
UserModel user, {
required String? accessToken,
Map<String, dynamic>? 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<String, dynamic> 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<void> forgotPassword(ForgotPasswordRequest request) async {

View File

@ -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<Result<UserModel>> 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;
}

View File

@ -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<GrnDetailScreen> {
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<GrnDetailScreen> {
final confirmed = await showDialog<bool>(
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<GrnDetailScreen> {
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<GrnDetailScreen> {
() => 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<GrnDetailScreen> {
.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});

View File

@ -289,7 +289,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
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<GrnFormScreen> {
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<GrnFormScreen> {
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<GrnFormScreen> {
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<GrnFormScreen> {
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<GrnFormScreen> {
),
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,

View File

@ -66,7 +66,7 @@ class _GrnListScreenState extends ConsumerState<GrnListScreen> {
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<GrnListScreen> {
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<GrnListScreen> {
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<GrnListScreen> {
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<GrnListScreen> {
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<AppTableColumnOption> 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),
),
),

View File

@ -206,8 +206,8 @@ class _GrnAttachmentsCardState extends ConsumerState<GrnAttachmentsCard> {
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<GrnAttachmentsCard> {
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,
),

View File

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

View File

@ -725,7 +725,7 @@ String masterFieldHintLabel(String label) {
'gst': 'GST',
'sac': 'SAC',
'po': 'PO',
'grn': 'GRN',
'grn': 'Purchase Receipt',
'amc': 'AMC',
};
return label

View File

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

View File

@ -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,

View File

@ -593,7 +593,7 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
final itemOptions = _itemOptionsWithSelected();
final uomOptions = _uomOptionsWithSelected();
final gstOptions = [
const AppDropdownOption<int?>(value: null, label: 'Select GST Rate'),
const AppDropdownOption<int?>(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<int?>(value: id, label: label);
}),
].whereType<AppDropdownOption<int?>>().toList();
final selectedGstId = line.gstRateId;
if (selectedGstId != null &&
!gstOptions.any((o) => o.value == selectedGstId)) {
final pct = widget.gstRatePctById[selectedGstId.toString()];
gstOptions.insert(
1,
AppDropdownOption<int?>(
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 (23 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,
),
),
),
),

View File

@ -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<ManagedRole> 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<ManagedRole> 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,

View File

@ -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(

View File

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

View File

@ -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,

View File

@ -116,6 +116,28 @@ Object? _readVendorNameFromNested(Map<dynamic, dynamic> json, String key) {
return null;
}
Object? _readPoNumberFromNested(Map<dynamic, dynamic> 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<dynamic, dynamic> 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,

View File

@ -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

View File

@ -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<String, dynamic> _$$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,

View File

@ -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],

View File

@ -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<dynamic>?)
?.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<String> _permissionsFromJson(Map<String, dynamic> json) {
final raw = json['permissions'] ?? json['permission_keys'] ?? json['perms'];
if (raw is! List) return const [];
final permissions = <String>[];
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;

View File

@ -11,7 +11,7 @@ final userPermissionsProvider = Provider<List<String>>((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,
);

View File

@ -95,7 +95,7 @@ const List<MenuItem> appMenuItems = [
),
MenuItem(
label: 'Purchase Receipt',
icon: Icons.inventory_2_outlined,
icon: Icons.move_to_inbox_outlined,
route: RouteConstants.grn,
module: 'grn',
),

View File

@ -240,15 +240,15 @@ class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
_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,
),

View File

@ -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<DetailInfoItem> 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<DetailSummaryMetric> 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<Widget> 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,
],
);
}
}

View File

@ -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<void> showPurchaseOrderPreviewDialog(
BuildContext context, {
required String purchaseOrderId,
}) {
return showDialog<void>(
context: context,
barrierColor: Colors.black.withValues(alpha: 0.45),
builder: (context) => DocumentPreviewDialog(
child: _PurchaseOrderPreviewBody(
purchaseOrderId: purchaseOrderId,
),
),
);
}
Future<void> showGrnPreviewDialog(
BuildContext context, {
required String grnId,
}) {
return showDialog<void>(
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<double>(
0,
(sum, item) => sum + (item.currentQty ?? 0),
);
final qtyReceived = grn.items.fold<double>(
0,
(sum, item) => sum + (item.acceptedQty ?? 0),
);
final damaged = grn.items.fold<double>(
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<Widget> left;
final List<Widget> 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<FilterOptionModel>? users, int? userId) {
if (userId == null || users == null) return '';
for (final user in users) {
if (int.tryParse(user.id) == userId) return user.name;
}
return 'User #$userId';
}
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;
}
}

View File

@ -9,12 +9,31 @@ class PageHeader extends StatelessWidget {
this.subtitle,
this.actions,
this.leading,
this.titleTrailing,
});
final String title;
final String? subtitle;
final List<Widget>? 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(