master screen refresh issue
This commit is contained in:
parent
ef0f33ecbb
commit
f2d5b49dac
@ -192,7 +192,7 @@ String humanizeLabel(String key) {
|
||||
'hsn': 'HSN',
|
||||
'uom': 'UOM',
|
||||
'po': 'PO',
|
||||
'grn': 'GRN',
|
||||
'grn': 'Purchase Receipt',
|
||||
'url': 'URL',
|
||||
'api': 'API',
|
||||
};
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
),
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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});
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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,
|
||||
),
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -725,7 +725,7 @@ String masterFieldHintLabel(String label) {
|
||||
'gst': 'GST',
|
||||
'sac': 'SAC',
|
||||
'po': 'PO',
|
||||
'grn': 'GRN',
|
||||
'grn': 'Purchase Receipt',
|
||||
'amc': 'AMC',
|
||||
};
|
||||
return label
|
||||
|
||||
@ -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),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 (2–3 columns)
|
||||
return ResponsiveFormGrid(
|
||||
spacing: spacing,
|
||||
xsColumns: 1,
|
||||
smallColumns: 1,
|
||||
mediumColumns: 2,
|
||||
largeColumns: 3,
|
||||
smallBreakpoint: 520,
|
||||
mediumBreakpoint: 520,
|
||||
largeBreakpoint: 800,
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
itemField,
|
||||
qtyField,
|
||||
uomField,
|
||||
rateField,
|
||||
discField,
|
||||
gstField,
|
||||
amountField,
|
||||
Expanded(flex: 3, child: itemField),
|
||||
const SizedBox(width: spacing),
|
||||
// Wider than flex 1 so floating labels ("Qty *", "Disc %") aren't clipped.
|
||||
Expanded(flex: 2, child: qtyField),
|
||||
const SizedBox(width: spacing),
|
||||
Expanded(flex: 2, child: uomField),
|
||||
const SizedBox(width: spacing),
|
||||
// Rate and Amount share the same flex so widths match.
|
||||
Expanded(flex: 2, child: rateField),
|
||||
const SizedBox(width: spacing),
|
||||
Expanded(flex: 2, child: discField),
|
||||
const SizedBox(width: spacing),
|
||||
Expanded(flex: 2, child: gstField),
|
||||
const SizedBox(width: spacing),
|
||||
Expanded(flex: 2, child: amountField),
|
||||
if (widget.onRemove != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 20),
|
||||
child: IconButton(
|
||||
tooltip: 'Remove line',
|
||||
onPressed: widget.onRemove,
|
||||
icon: const Icon(Icons.delete_outline, size: 20),
|
||||
color: theme.colorScheme.error,
|
||||
visualDensity: VisualDensity.compact,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 36,
|
||||
minHeight: 36,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AmountWithRemove extends StatelessWidget {
|
||||
const _AmountWithRemove({
|
||||
required this.amount,
|
||||
required this.backgroundColor,
|
||||
this.onRemove,
|
||||
});
|
||||
|
||||
final String amount;
|
||||
final Color backgroundColor;
|
||||
final VoidCallback? onRemove;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _AmountDisplay(
|
||||
label: 'Amount',
|
||||
value: amount,
|
||||
backgroundColor: backgroundColor,
|
||||
),
|
||||
),
|
||||
if (onRemove != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 20),
|
||||
child: IconButton(
|
||||
tooltip: 'Remove line',
|
||||
onPressed: onRemove,
|
||||
icon: const Icon(Icons.delete_outline, size: 20),
|
||||
color: theme.colorScheme.error,
|
||||
visualDensity: VisualDensity.compact,
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -851,11 +819,17 @@ class _AmountDisplay extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
value,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: theme.colorScheme.onSurface,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
value,
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 ?? '—'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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',
|
||||
),
|
||||
|
||||
@ -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,
|
||||
),
|
||||
|
||||
334
lib/shared/widgets/detail_overview_widgets.dart
Normal file
334
lib/shared/widgets/detail_overview_widgets.dart
Normal 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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
609
lib/shared/widgets/document_preview_dialog.dart
Normal file
609
lib/shared/widgets/document_preview_dialog.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user