master screen refresh issue
This commit is contained in:
parent
ef0f33ecbb
commit
f2d5b49dac
@ -192,7 +192,7 @@ String humanizeLabel(String key) {
|
|||||||
'hsn': 'HSN',
|
'hsn': 'HSN',
|
||||||
'uom': 'UOM',
|
'uom': 'UOM',
|
||||||
'po': 'PO',
|
'po': 'PO',
|
||||||
'grn': 'GRN',
|
'grn': 'Purchase Receipt',
|
||||||
'url': 'URL',
|
'url': 'URL',
|
||||||
'api': 'API',
|
'api': 'API',
|
||||||
};
|
};
|
||||||
|
|||||||
@ -19,6 +19,8 @@ const Map<String, String> permissionModuleAliases = {
|
|||||||
'purchase_orders': 'PURCHASE_ORDER',
|
'purchase_orders': 'PURCHASE_ORDER',
|
||||||
'purchase_order': 'PURCHASE_ORDER',
|
'purchase_order': 'PURCHASE_ORDER',
|
||||||
'grn': 'GRN',
|
'grn': 'GRN',
|
||||||
|
'purchase_receipt': 'GRN',
|
||||||
|
'purchase_receipts': 'GRN',
|
||||||
'reports': 'REPORTS',
|
'reports': 'REPORTS',
|
||||||
'audit_logs': 'AUDIT_LOGS',
|
'audit_logs': 'AUDIT_LOGS',
|
||||||
'audit': 'AUDIT_LOGS',
|
'audit': 'AUDIT_LOGS',
|
||||||
|
|||||||
@ -203,7 +203,7 @@ Future<List<FilterOptionModel>> _safeGrnOptions(Ref ref) async {
|
|||||||
.map(
|
.map(
|
||||||
(grn) => FilterOptionModel(
|
(grn) => FilterOptionModel(
|
||||||
id: grn.id,
|
id: grn.id,
|
||||||
name: grn.grnNumber ?? 'GRN #${grn.id}',
|
name: grn.grnNumber ?? 'Purchase Receipt #${grn.id}',
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
|||||||
@ -17,6 +17,8 @@ import '../../../../shared/widgets/app_empty_state.dart';
|
|||||||
import '../../../../shared/widgets/app_loading_view.dart';
|
import '../../../../shared/widgets/app_loading_view.dart';
|
||||||
import '../../../../shared/widgets/app_status_chip.dart';
|
import '../../../../shared/widgets/app_status_chip.dart';
|
||||||
import '../../../../shared/widgets/can_permission.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/entity_attachments_card.dart';
|
||||||
import '../../../../shared/widgets/error_view.dart';
|
import '../../../../shared/widgets/error_view.dart';
|
||||||
import '../../../../shared/widgets/page_header.dart';
|
import '../../../../shared/widgets/page_header.dart';
|
||||||
@ -97,6 +99,10 @@ class _AssetDetailScreenState extends ConsumerState<AssetDetailScreen>
|
|||||||
onPressed: () => context.go(RouteConstants.assets),
|
onPressed: () => context.go(RouteConstants.assets),
|
||||||
),
|
),
|
||||||
title: state.asset.assetName,
|
title: state.asset.assetName,
|
||||||
|
titleTrailing: AppStatusChip(
|
||||||
|
status: state.asset.status ?? 'IN_USE',
|
||||||
|
compact: true,
|
||||||
|
),
|
||||||
subtitle: state.asset.assetCode ?? 'Asset ID: ${state.asset.id}',
|
subtitle: state.asset.assetCode ?? 'Asset ID: ${state.asset.id}',
|
||||||
actions: [
|
actions: [
|
||||||
if (canEdit)
|
if (canEdit)
|
||||||
@ -245,6 +251,9 @@ class _OverviewTab extends ConsumerWidget {
|
|||||||
currentValue: asset.resolvedCurrentValue,
|
currentValue: asset.resolvedCurrentValue,
|
||||||
purchaseCost: asset.purchaseCost,
|
purchaseCost: asset.purchaseCost,
|
||||||
);
|
);
|
||||||
|
final scheme = theme.colorScheme;
|
||||||
|
final activeColor =
|
||||||
|
asset.isActive ? const Color(0xFF16A34A) : scheme.onSurfaceVariant;
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
child: Center(
|
child: Center(
|
||||||
@ -259,52 +268,68 @@ class _OverviewTab extends ConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Row(
|
||||||
'Asset Details',
|
children: [
|
||||||
style: theme.textTheme.labelLarge?.copyWith(
|
Expanded(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
child: Text(
|
||||||
fontWeight: FontWeight.w600,
|
'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),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// 1) Top Summary
|
// 1) Identity
|
||||||
_AssetOverviewSection(
|
DetailOverviewSection(
|
||||||
title: 'Summary',
|
|
||||||
child: _AssetSummaryStrip(
|
|
||||||
status: asset.status ?? 'IN_USE',
|
|
||||||
maintenanceDue: asset.maintenance?.isDue,
|
|
||||||
currentValue: asset.resolvedCurrentValue,
|
|
||||||
isActive: asset.isActive,
|
|
||||||
daysUntilDueDisplay: daysUntilDueDisplay,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// 2) Identity
|
|
||||||
_AssetOverviewSection(
|
|
||||||
title: 'Identity',
|
title: 'Identity',
|
||||||
child: _AssetInfoGrid(
|
child: DetailInfoGrid(
|
||||||
items: [
|
items: [
|
||||||
_AssetInfo('Asset Name', asset.assetName),
|
DetailInfoItem('Asset Name', asset.assetName),
|
||||||
_AssetInfo('Asset Code', asset.assetCode ?? '—'),
|
DetailInfoItem('Asset Code', asset.assetCode ?? '—'),
|
||||||
_AssetInfo('Location', asset.locationName ?? '—'),
|
DetailInfoItem('Location', asset.locationName ?? '—'),
|
||||||
_AssetInfo(
|
DetailInfoItem(
|
||||||
'Asset Category',
|
'Asset Category',
|
||||||
asset.assetCategoryName ?? '—',
|
asset.assetCategoryName ?? '—',
|
||||||
),
|
),
|
||||||
_AssetInfo(
|
DetailInfoItem(
|
||||||
'Subcategory',
|
'Subcategory',
|
||||||
asset.assetSubcategoryName ?? '—',
|
asset.assetSubcategoryName ?? '—',
|
||||||
),
|
),
|
||||||
_AssetInfo(
|
DetailInfoItem(
|
||||||
'Manufacturer',
|
'Manufacturer',
|
||||||
asset.manufacturer ?? '—',
|
asset.manufacturer ?? '—',
|
||||||
),
|
),
|
||||||
_AssetInfo(
|
DetailInfoItem(
|
||||||
'Brand / Model',
|
'Brand / Model',
|
||||||
asset.brandModel ?? '—',
|
asset.brandModel ?? '—',
|
||||||
),
|
),
|
||||||
_AssetInfo(
|
DetailInfoItem(
|
||||||
'Serial Number',
|
'Serial Number',
|
||||||
asset.serialNumber ?? '—',
|
asset.serialNumber ?? '—',
|
||||||
),
|
),
|
||||||
@ -312,81 +337,66 @@ class _OverviewTab extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// 3) Maintenance
|
// 2) Purchase & Warranty
|
||||||
_AssetOverviewSection(
|
DetailOverviewSection(
|
||||||
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(
|
|
||||||
title: 'Purchase & Warranty',
|
title: 'Purchase & Warranty',
|
||||||
child: _AssetInfoGrid(
|
child: DetailInfoGrid(
|
||||||
items: [
|
items: [
|
||||||
_AssetInfo(
|
DetailInfoItem(
|
||||||
'Purchase Date',
|
'Purchase Date - Warranty Expiry',
|
||||||
asset.purchaseDate != null
|
_purchaseWarrantyRange(
|
||||||
? dateFormat.format(asset.purchaseDate!)
|
purchaseDate: asset.purchaseDate,
|
||||||
: '—',
|
warrantyExpiryDate: asset.warrantyExpiryDate,
|
||||||
|
dateFormat: dateFormat,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_AssetInfo(
|
DetailInfoItem(
|
||||||
'Purchase Cost',
|
'Purchase Cost',
|
||||||
asset.purchaseCost != null
|
asset.purchaseCost != null
|
||||||
? CurrencyFormatter.format(asset.purchaseCost)
|
? CurrencyFormatter.format(asset.purchaseCost)
|
||||||
: '—',
|
: '—',
|
||||||
),
|
),
|
||||||
_AssetInfo(
|
DetailInfoItem.widget(
|
||||||
'Warranty Expiry',
|
'PO Number',
|
||||||
asset.warrantyExpiryDate != null
|
_DocumentNumberLink(
|
||||||
? dateFormat.format(asset.warrantyExpiryDate!)
|
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
|
// 4) Valuation & Depreciation
|
||||||
_AssetOverviewSection(
|
DetailOverviewSection(
|
||||||
title: 'Valuation & Depreciation',
|
title: 'Valuation & Depreciation',
|
||||||
showDivider: false,
|
|
||||||
child: _ValuationDepreciationCard(
|
child: _ValuationDepreciationCard(
|
||||||
currentValue: asset.resolvedCurrentValue,
|
currentValue: asset.resolvedCurrentValue,
|
||||||
purchaseCost: asset.purchaseCost,
|
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 ||
|
if (asset.maintenanceFrequencyInDays != null ||
|
||||||
asset.maintenanceChecklistJson?.isNotEmpty == true ||
|
asset.maintenanceChecklistJson?.isNotEmpty == true ||
|
||||||
asset.maintenance != null) ...[
|
asset.maintenance != null) ...[
|
||||||
@ -461,8 +504,8 @@ class _OverviewTab extends ConsumerWidget {
|
|||||||
padding: EdgeInsets.symmetric(vertical: 20),
|
padding: EdgeInsets.symmetric(vertical: 20),
|
||||||
child: Divider(height: 1),
|
child: Divider(height: 1),
|
||||||
),
|
),
|
||||||
_AssetInfoGrid(
|
DetailInfoGrid(
|
||||||
items: [_AssetInfo('Remarks', asset.remarks!)],
|
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({
|
double? _retainedPercentage({
|
||||||
required double? currentValue,
|
required double? currentValue,
|
||||||
required double? purchaseCost,
|
required double? purchaseCost,
|
||||||
@ -509,265 +639,6 @@ double? _retainedPercentage({
|
|||||||
return (currentValue / purchaseCost * 100).clamp(0, 100);
|
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 {
|
class _ValuationDepreciationCard extends StatelessWidget {
|
||||||
const _ValuationDepreciationCard({
|
const _ValuationDepreciationCard({
|
||||||
required this.currentValue,
|
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) {
|
String _userLabel(int? userId, List<FilterOptionModel> users) {
|
||||||
if (userId == null || userId <= 0) return '—';
|
if (userId == null || userId <= 0) return '—';
|
||||||
final id = userId.toString();
|
final id = userId.toString();
|
||||||
|
|||||||
@ -636,7 +636,7 @@ class _AssetFormScreenState extends ConsumerState<AssetFormScreen> {
|
|||||||
final grnItems =
|
final grnItems =
|
||||||
ref.read(assetGrnItemsProvider(_grnId)).valueOrNull ?? const [];
|
ref.read(assetGrnItemsProvider(_grnId)).valueOrNull ?? const [];
|
||||||
if (grnItems.isNotEmpty && _grnItemId == null) {
|
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),
|
onChanged: (v) => setState(() => _poId = v),
|
||||||
),
|
),
|
||||||
_optionalLookupDropdown(
|
_optionalLookupDropdown(
|
||||||
label: 'GRN',
|
label: 'Purchase Receipt',
|
||||||
value: _grnId,
|
value: _grnId,
|
||||||
options: lookups.grns,
|
options: lookups.grns,
|
||||||
onChanged: (v) => setState(() {
|
onChanged: (v) => setState(() {
|
||||||
@ -883,7 +883,7 @@ class _AssetFormScreenState extends ConsumerState<AssetFormScreen> {
|
|||||||
grnItemsAsync.when(
|
grnItemsAsync.when(
|
||||||
loading: () => const LinearProgressIndicator(),
|
loading: () => const LinearProgressIndicator(),
|
||||||
error: (_, __) => _optionalLookupDropdown(
|
error: (_, __) => _optionalLookupDropdown(
|
||||||
label: 'GRN Item',
|
label: 'Purchase Receipt Item',
|
||||||
value: _grnItemId,
|
value: _grnItemId,
|
||||||
options: const [],
|
options: const [],
|
||||||
onChanged: (v) =>
|
onChanged: (v) =>
|
||||||
@ -891,14 +891,14 @@ class _AssetFormScreenState extends ConsumerState<AssetFormScreen> {
|
|||||||
enabled: false,
|
enabled: false,
|
||||||
),
|
),
|
||||||
data: (items) => _optionalLookupDropdown(
|
data: (items) => _optionalLookupDropdown(
|
||||||
label: 'GRN Item',
|
label: 'Purchase Receipt Item',
|
||||||
value: _grnItemId,
|
value: _grnItemId,
|
||||||
options: items,
|
options: items,
|
||||||
onChanged: (v) =>
|
onChanged: (v) =>
|
||||||
setState(() => _grnItemId = v),
|
setState(() => _grnItemId = v),
|
||||||
enabled: _grnId != null && items.isNotEmpty,
|
enabled: _grnId != null && items.isNotEmpty,
|
||||||
emptyHint: _grnId == null
|
emptyHint: _grnId == null
|
||||||
? 'Select GRN first'
|
? 'Select Purchase Receipt first'
|
||||||
: 'None',
|
: 'None',
|
||||||
required: _grnId != null && items.isNotEmpty,
|
required: _grnId != null && items.isNotEmpty,
|
||||||
),
|
),
|
||||||
|
|||||||
@ -64,10 +64,59 @@ class AuthRemoteDataSource {
|
|||||||
return _mapProfileToUser(profile, permissions: permissions, roleId: roleId);
|
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 response = await dio.get<Map<String, dynamic>>(ApiEndpoints.me);
|
||||||
final data = ApiEnvelope.data(response);
|
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 {
|
Future<void> forgotPassword(ForgotPasswordRequest request) async {
|
||||||
|
|||||||
@ -33,7 +33,9 @@ class AuthRepositoryImpl implements AuthRepository {
|
|||||||
return safeApiCall(() async {
|
return safeApiCall(() async {
|
||||||
final loginResponse = await remote.login(request);
|
final loginResponse = await remote.login(request);
|
||||||
await _persistSession(loginResponse);
|
await _persistSession(loginResponse);
|
||||||
final user = await remote.getCurrentUser();
|
final user = await remote.getCurrentUser(
|
||||||
|
accessToken: loginResponse.tokens.accessToken,
|
||||||
|
);
|
||||||
return loginResponse.copyWith(user: user);
|
return loginResponse.copyWith(user: user);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -56,7 +58,10 @@ class AuthRepositoryImpl implements AuthRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Result<UserModel>> getCurrentUser() async {
|
Future<Result<UserModel>> getCurrentUser() async {
|
||||||
return safeApiCall(() => remote.getCurrentUser());
|
return safeApiCall(() async {
|
||||||
|
final accessToken = await tokenStorage.getAccessToken();
|
||||||
|
return remote.getCurrentUser(accessToken: accessToken);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -91,7 +96,8 @@ class AuthRepositoryImpl implements AuthRepository {
|
|||||||
final updated = await remote.updateProfile(request);
|
final updated = await remote.updateProfile(request);
|
||||||
// Prefer fresh /auth/me so avatar + role/department stay in sync.
|
// Prefer fresh /auth/me so avatar + role/department stay in sync.
|
||||||
try {
|
try {
|
||||||
return await remote.getCurrentUser();
|
final accessToken = await tokenStorage.getAccessToken();
|
||||||
|
return await remote.getCurrentUser(accessToken: accessToken);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import '../../../../shared/providers/permissions_provider.dart';
|
|||||||
import '../../../../shared/utils/file_download_helper.dart';
|
import '../../../../shared/utils/file_download_helper.dart';
|
||||||
import '../../../../shared/widgets/app_text_field.dart';
|
import '../../../../shared/widgets/app_text_field.dart';
|
||||||
import '../../../../shared/widgets/app_loading_view.dart';
|
import '../../../../shared/widgets/app_loading_view.dart';
|
||||||
|
import '../../../../shared/widgets/detail_overview_widgets.dart';
|
||||||
import '../../../../shared/widgets/error_view.dart';
|
import '../../../../shared/widgets/error_view.dart';
|
||||||
import '../providers/grn_lookups_provider.dart';
|
import '../providers/grn_lookups_provider.dart';
|
||||||
import '../providers/grn_provider.dart';
|
import '../providers/grn_provider.dart';
|
||||||
@ -63,7 +64,7 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
body: detailAsync.when(
|
body: detailAsync.when(
|
||||||
loading: () => const AppLoadingView(message: 'Loading GRN...'),
|
loading: () => const AppLoadingView(message: 'Loading Purchase Receipt...'),
|
||||||
error: (e, _) => ErrorView.fromFailure(
|
error: (e, _) => ErrorView.fromFailure(
|
||||||
e is Failure ? e : Failure.unknown(message: e.toString()),
|
e is Failure ? e : Failure.unknown(message: e.toString()),
|
||||||
onRetry: () => ref.invalidate(grnDetailProvider(widget.grnId)),
|
onRetry: () => ref.invalidate(grnDetailProvider(widget.grnId)),
|
||||||
@ -150,7 +151,7 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
|
|||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Cancel GRN'),
|
title: const Text('Cancel Purchase Receipt'),
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
@ -175,7 +176,7 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
|
|||||||
if (reasonController.text.trim().isEmpty) return;
|
if (reasonController.text.trim().isEmpty) return;
|
||||||
Navigator.pop(context, true);
|
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(
|
() => ref.read(grnDetailProvider(widget.grnId).notifier).cancel(
|
||||||
cancellationReason: reasonController.text.trim(),
|
cancellationReason: reasonController.text.trim(),
|
||||||
),
|
),
|
||||||
'GRN cancelled',
|
'Purchase Receipt cancelled',
|
||||||
);
|
);
|
||||||
reasonController.dispose();
|
reasonController.dispose();
|
||||||
}
|
}
|
||||||
@ -199,7 +200,7 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
|
|||||||
.downloadPdf();
|
.downloadPdf();
|
||||||
await downloadFile(
|
await downloadFile(
|
||||||
bytes: bytes,
|
bytes: bytes,
|
||||||
fileName: '${grn.grnNumber ?? 'GRN-${grn.id}'}.pdf',
|
fileName: '${grn.grnNumber ?? 'PurchaseReceipt-${grn.id}'}.pdf',
|
||||||
);
|
);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
showAppToastFromSnackBar(context, const SnackBar(content: Text('PDF downloaded')));
|
showAppToastFromSnackBar(context, const SnackBar(content: Text('PDF downloaded')));
|
||||||
@ -300,7 +301,7 @@ class _DetailHeader extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Text(
|
child: Text(
|
||||||
grn.grnNumber ?? 'GRN #${grn.id}',
|
grn.grnNumber ?? 'Purchase Receipt #${grn.id}',
|
||||||
style: theme.textTheme.headlineSmall,
|
style: theme.textTheme.headlineSmall,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
@ -528,124 +529,113 @@ class _ReceiptDetailsCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return _SectionCard(
|
||||||
title: 'RECEIPT DETAILS',
|
title: 'RECEIPT DETAILS',
|
||||||
child: LayoutBuilder(
|
child: Column(
|
||||||
builder: (context, constraints) {
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
final cols = constraints.maxWidth < 600
|
children: [
|
||||||
? 1
|
DetailOverviewSection(
|
||||||
: constraints.maxWidth < 900
|
title: 'Summary',
|
||||||
? 2
|
child: DetailSummaryStrip(
|
||||||
: 4;
|
metrics: [
|
||||||
const spacing = 20.0;
|
DetailSummaryMetric(
|
||||||
final width = (constraints.maxWidth - spacing * (cols - 1)) / cols;
|
icon: Icons.flag_outlined,
|
||||||
final items = [
|
label: 'Status',
|
||||||
_DetailField(
|
accent: scheme.secondary,
|
||||||
label: 'GRN Date',
|
child: GrnStatusChip(status: grn.status, compact: true),
|
||||||
value: DateFormatter.displayDate(grn.grnDate),
|
),
|
||||||
|
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',
|
DetailOverviewSection(
|
||||||
value: _displayOrDash(grn.poNumber),
|
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',
|
DetailOverviewSection(
|
||||||
value: _displayOrDash(grn.vendorName),
|
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 {
|
class _LineItemsCard extends StatelessWidget {
|
||||||
const _LineItemsCard({required this.grn});
|
const _LineItemsCard({required this.grn});
|
||||||
|
|
||||||
|
|||||||
@ -289,7 +289,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
showAppToastFromSnackBar(context,
|
showAppToastFromSnackBar(context,
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(widget.isEditing ? 'GRN updated' : 'GRN created'),
|
content: Text(widget.isEditing ? 'Purchase Receipt updated' : 'Purchase Receipt created'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
@ -363,7 +363,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
|||||||
lookups: lookups,
|
lookups: lookups,
|
||||||
existing: existingAsync.valueOrNull,
|
existing: existingAsync.valueOrNull,
|
||||||
)
|
)
|
||||||
: const AppLoadingView(message: 'Loading GRN...'),
|
: const AppLoadingView(message: 'Loading Purchase Receipt...'),
|
||||||
error: (e, _) => ErrorView.fromFailure(
|
error: (e, _) => ErrorView.fromFailure(
|
||||||
e is Failure ? e : Failure.unknown(message: e.toString()),
|
e is Failure ? e : Failure.unknown(message: e.toString()),
|
||||||
onRetry: () => ref.invalidate(grnFormProvider(widget.grnId)),
|
onRetry: () => ref.invalidate(grnFormProvider(widget.grnId)),
|
||||||
@ -391,7 +391,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
|||||||
|
|
||||||
if (widget.isEditing && existing != null && !existing.canEdit) {
|
if (widget.isEditing && existing != null && !existing.canEdit) {
|
||||||
return ErrorView.fromFailure(
|
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}'),
|
onRetry: () => context.go('${RouteConstants.grn}/${existing.id}'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -425,7 +425,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
|||||||
FormRowFour(
|
FormRowFour(
|
||||||
children: [
|
children: [
|
||||||
_DateField(
|
_DateField(
|
||||||
label: 'GRN Date *',
|
label: 'Purchase Receipt Date *',
|
||||||
value: _grnDate,
|
value: _grnDate,
|
||||||
enabled: !widget.isEditing,
|
enabled: !widget.isEditing,
|
||||||
onTap: widget.isEditing
|
onTap: widget.isEditing
|
||||||
@ -641,7 +641,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
|||||||
Widget _buildHeader(GrnModel? existing) {
|
Widget _buildHeader(GrnModel? existing) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final title = widget.isEditing
|
final title = widget.isEditing
|
||||||
? 'Edit ${existing?.grnNumber ?? 'GRN'}'
|
? 'Edit ${existing?.grnNumber ?? 'Purchase Receipt'}'
|
||||||
: 'Create Purchase Receipt';
|
: 'Create Purchase Receipt';
|
||||||
final subtitle = widget.isEditing
|
final subtitle = widget.isEditing
|
||||||
? null
|
? null
|
||||||
@ -656,7 +656,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
AppButton(
|
AppButton(
|
||||||
label: widget.isEditing ? 'Update GRN' : 'Save GRN',
|
label: widget.isEditing ? 'Update Purchase Receipt' : 'Save Purchase Receipt',
|
||||||
icon: Icons.check,
|
icon: Icons.check,
|
||||||
expand: false,
|
expand: false,
|
||||||
isLoading: _isSubmitting,
|
isLoading: _isSubmitting,
|
||||||
|
|||||||
@ -66,7 +66,7 @@ class _GrnListScreenState extends ConsumerState<GrnListScreen> {
|
|||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: listAsync.when(
|
child: listAsync.when(
|
||||||
loading: () => const AppLoadingView(message: 'Loading GRNs...'),
|
loading: () => const AppLoadingView(message: 'Loading Purchase Receipts...'),
|
||||||
error: (error, _) => ErrorView.fromFailure(
|
error: (error, _) => ErrorView.fromFailure(
|
||||||
error is Failure ? error : Failure.unknown(message: error.toString()),
|
error is Failure ? error : Failure.unknown(message: error.toString()),
|
||||||
onRetry: () => ref.invalidate(grnListProvider),
|
onRetry: () => ref.invalidate(grnListProvider),
|
||||||
@ -110,7 +110,7 @@ class _GrnListScreenState extends ConsumerState<GrnListScreen> {
|
|||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: () => context.go(RouteConstants.grnAdd),
|
onPressed: () => context.go(RouteConstants.grnAdd),
|
||||||
icon: const Icon(Icons.add),
|
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,
|
totalItems: state.total,
|
||||||
pageSize: state.query.limit,
|
pageSize: state.query.limit,
|
||||||
itemsOnPage: state.grns.length,
|
itemsOnPage: state.grns.length,
|
||||||
itemLabel: 'GRNs',
|
itemLabel: 'Purchase Receipts',
|
||||||
onPageChanged: ref.read(grnListProvider.notifier).setPage,
|
onPageChanged: ref.read(grnListProvider.notifier).setPage,
|
||||||
onPageSizeChanged: ref.read(grnListProvider.notifier).setPageSize,
|
onPageSizeChanged: ref.read(grnListProvider.notifier).setPageSize,
|
||||||
),
|
),
|
||||||
@ -148,9 +148,9 @@ class _GrnListScreenState extends ConsumerState<GrnListScreen> {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
height: 240,
|
height: 240,
|
||||||
child: AppEmptyState(
|
child: AppEmptyState(
|
||||||
title: 'No GRNs found',
|
title: 'No Purchase Receipts found',
|
||||||
description:
|
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,
|
icon: Icons.inventory_2_outlined,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -216,7 +216,7 @@ class _GrnListScreenState extends ConsumerState<GrnListScreen> {
|
|||||||
void _editGrn(GrnModel grn) {
|
void _editGrn(GrnModel grn) {
|
||||||
if (!grn.canEdit) {
|
if (!grn.canEdit) {
|
||||||
showAppToastFromSnackBar(context,
|
showAppToastFromSnackBar(context,
|
||||||
const SnackBar(content: Text('Only posted GRNs can be edited')),
|
const SnackBar(content: Text('Only posted Purchase Receipts can be edited')),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -250,7 +250,7 @@ class _FiltersBar extends StatelessWidget {
|
|||||||
onChanged: onSearch,
|
onChanged: onSearch,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Search',
|
labelText: 'Search',
|
||||||
hintText: 'Search GRN number, PO, vendor...',
|
hintText: 'Search receipt number, PO, vendor...',
|
||||||
prefixIcon: Icon(Icons.search, size: 20),
|
prefixIcon: Icon(Icons.search, size: 20),
|
||||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||||
isDense: true,
|
isDense: true,
|
||||||
@ -310,7 +310,7 @@ class _GrnDataTable extends ConsumerWidget {
|
|||||||
static List<AppTableColumnOption> get columnOptions => const [
|
static List<AppTableColumnOption> get columnOptions => const [
|
||||||
AppTableColumnOption(
|
AppTableColumnOption(
|
||||||
id: 'grn_number',
|
id: 'grn_number',
|
||||||
label: 'GRN Number',
|
label: 'Purchase Receipt Number',
|
||||||
required: true,
|
required: true,
|
||||||
),
|
),
|
||||||
AppTableColumnOption(id: 'date', label: 'Date'),
|
AppTableColumnOption(id: 'date', label: 'Date'),
|
||||||
@ -330,7 +330,7 @@ class _GrnDataTable extends ConsumerWidget {
|
|||||||
return [
|
return [
|
||||||
AppDataColumn(
|
AppDataColumn(
|
||||||
id: 'grn_number',
|
id: 'grn_number',
|
||||||
label: 'GRN Number',
|
label: 'Purchase Receipt Number',
|
||||||
sortKey: 'grn_number',
|
sortKey: 'grn_number',
|
||||||
locked: true,
|
locked: true,
|
||||||
flex: 2,
|
flex: 2,
|
||||||
@ -461,7 +461,7 @@ class _GrnCardList extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: AppTableCell.link(
|
child: AppTableCell.link(
|
||||||
grn.grnNumber ?? 'GRN #${grn.id}',
|
grn.grnNumber ?? 'Purchase Receipt #${grn.id}',
|
||||||
onTap: () => onView(grn),
|
onTap: () => onView(grn),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -206,8 +206,8 @@ class _GrnAttachmentsCardState extends ConsumerState<GrnAttachmentsCard> {
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
showUpload
|
showUpload
|
||||||
? 'PDF, JPEG, PNG, or WebP · upload/delete only while GRN is Posted'
|
? 'PDF, JPEG, PNG, or WebP · upload/delete only while Purchase Receipt is Posted'
|
||||||
: 'Supporting documents for this GRN',
|
: 'Supporting documents for this Purchase Receipt',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@ -256,7 +256,7 @@ class _GrnAttachmentsCardState extends ConsumerState<GrnAttachmentsCard> {
|
|||||||
attachments.isNotEmpty) ...[
|
attachments.isNotEmpty) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
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(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
|
|||||||
@ -334,7 +334,7 @@ class _GrnLineItemCardState extends State<_GrnLineItemCard> {
|
|||||||
columnCount: 12,
|
columnCount: 12,
|
||||||
spans: const [2, 2, 2, 3, 3],
|
spans: const [2, 2, 2, 3, 3],
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
stackBelowWidth: 1100,
|
stackBelowWidth: 0,
|
||||||
children: [
|
children: [
|
||||||
AppTextField(
|
AppTextField(
|
||||||
key: ValueKey('$lineKey-accepted'),
|
key: ValueKey('$lineKey-accepted'),
|
||||||
@ -431,7 +431,7 @@ class _GrnLineItemCardState extends State<_GrnLineItemCard> {
|
|||||||
columnCount: 12,
|
columnCount: 12,
|
||||||
spans: const [3, 3, 3, 3],
|
spans: const [3, 3, 3, 3],
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
stackBelowWidth: 1100,
|
stackBelowWidth: 0,
|
||||||
children: [
|
children: [
|
||||||
_GrnLineDateField(
|
_GrnLineDateField(
|
||||||
key: ValueKey('$lineKey-mfg'),
|
key: ValueKey('$lineKey-mfg'),
|
||||||
|
|||||||
@ -725,7 +725,7 @@ String masterFieldHintLabel(String label) {
|
|||||||
'gst': 'GST',
|
'gst': 'GST',
|
||||||
'sac': 'SAC',
|
'sac': 'SAC',
|
||||||
'po': 'PO',
|
'po': 'PO',
|
||||||
'grn': 'GRN',
|
'grn': 'Purchase Receipt',
|
||||||
'amc': 'AMC',
|
'amc': 'AMC',
|
||||||
};
|
};
|
||||||
return label
|
return label
|
||||||
|
|||||||
@ -4,8 +4,10 @@ import 'package:go_router/go_router.dart';
|
|||||||
|
|
||||||
import '../../../../core/constants/enums.dart';
|
import '../../../../core/constants/enums.dart';
|
||||||
import '../../../../core/constants/route_constants.dart';
|
import '../../../../core/constants/route_constants.dart';
|
||||||
|
import '../../../../shared/providers/auth_provider.dart';
|
||||||
import '../../../../shared/providers/permissions_provider.dart';
|
import '../../../../shared/providers/permissions_provider.dart';
|
||||||
import '../../../../shared/widgets/app_hover_effect.dart';
|
import '../../../../shared/widgets/app_hover_effect.dart';
|
||||||
|
import '../../../../shared/widgets/app_loading_view.dart';
|
||||||
import '../../domain/entities/master_definition.dart';
|
import '../../domain/entities/master_definition.dart';
|
||||||
|
|
||||||
class MastersHubScreen extends ConsumerWidget {
|
class MastersHubScreen extends ConsumerWidget {
|
||||||
@ -16,8 +18,15 @@ class MastersHubScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final authState = ref.watch(authStateProvider);
|
||||||
final categories = masterCategories;
|
final categories = masterCategories;
|
||||||
final canViewMasters = ref.can('masters', PermissionAction.read);
|
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(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
@ -28,64 +37,74 @@ class MastersHubScreen extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Master Data',
|
'Master Data',
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
style: theme.textTheme.headlineSmall?.copyWith(
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Browse and manage all master records by category.',
|
'Browse and manage all master records by category.',
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
...categories.map((category) {
|
if (!canViewMasters)
|
||||||
if (!canViewMasters) return const SizedBox.shrink();
|
Padding(
|
||||||
final items = masterDefinitions
|
padding: const EdgeInsets.only(top: 48),
|
||||||
.where((def) => def.category == category)
|
child: Center(
|
||||||
.toList();
|
child: Text(
|
||||||
return Column(
|
'You do not have permission to view master data.',
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
children: [
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
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_confirmation_dialog.dart';
|
||||||
import '../../../../shared/widgets/app_loading_view.dart';
|
import '../../../../shared/widgets/app_loading_view.dart';
|
||||||
import '../../../../shared/widgets/app_text_field.dart';
|
import '../../../../shared/widgets/app_text_field.dart';
|
||||||
|
import '../../../../shared/widgets/detail_overview_widgets.dart';
|
||||||
import '../../../../shared/widgets/entity_attachments_card.dart';
|
import '../../../../shared/widgets/entity_attachments_card.dart';
|
||||||
import '../../../../shared/widgets/error_view.dart';
|
import '../../../../shared/widgets/error_view.dart';
|
||||||
import '../providers/purchase_order_lookups_provider.dart';
|
import '../providers/purchase_order_lookups_provider.dart';
|
||||||
@ -864,6 +865,8 @@ class _OrderDetailsCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final scheme = theme.colorScheme;
|
||||||
final paymentTerm =
|
final paymentTerm =
|
||||||
_lookupName(lookups?.paymentTerms, order.paymentTermId);
|
_lookupName(lookups?.paymentTerms, order.paymentTermId);
|
||||||
final deliveryTerm =
|
final deliveryTerm =
|
||||||
@ -871,91 +874,89 @@ class _OrderDetailsCard extends StatelessWidget {
|
|||||||
|
|
||||||
return _SectionCard(
|
return _SectionCard(
|
||||||
title: 'ORDER DETAILS',
|
title: 'ORDER DETAILS',
|
||||||
child: LayoutBuilder(
|
child: Column(
|
||||||
builder: (context, constraints) {
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
final cols = constraints.maxWidth < 600
|
children: [
|
||||||
? 1
|
DetailOverviewSection(
|
||||||
: constraints.maxWidth < 900
|
title: 'Summary',
|
||||||
? 2
|
child: DetailSummaryStrip(
|
||||||
: 4;
|
metrics: [
|
||||||
const spacing = 20.0;
|
DetailSummaryMetric(
|
||||||
final width = (constraints.maxWidth - spacing * (cols - 1)) / cols;
|
icon: Icons.flag_outlined,
|
||||||
final items = [
|
label: 'Status',
|
||||||
_DetailField(
|
accent: scheme.secondary,
|
||||||
label: 'PO Date',
|
child: PoStatusChip(status: order.status, compact: true),
|
||||||
value: DateFormatter.displayDate(order.poDate),
|
),
|
||||||
|
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',
|
DetailOverviewSection(
|
||||||
value: DateFormatter.displayDate(order.expectedDeliveryDate),
|
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',
|
DetailOverviewSection(
|
||||||
value: _displayOrDash(order.vendorName),
|
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 {
|
class _PoAttachmentsSection extends ConsumerWidget {
|
||||||
const _PoAttachmentsSection({
|
const _PoAttachmentsSection({
|
||||||
required this.poId,
|
required this.poId,
|
||||||
|
|||||||
@ -593,7 +593,7 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
|
|||||||
final itemOptions = _itemOptionsWithSelected();
|
final itemOptions = _itemOptionsWithSelected();
|
||||||
final uomOptions = _uomOptionsWithSelected();
|
final uomOptions = _uomOptionsWithSelected();
|
||||||
final gstOptions = [
|
final gstOptions = [
|
||||||
const AppDropdownOption<int?>(value: null, label: 'Select GST Rate'),
|
const AppDropdownOption<int?>(value: null, label: 'Select'),
|
||||||
...widget.gstRates.map((e) {
|
...widget.gstRates.map((e) {
|
||||||
final id = _parseId(e.id);
|
final id = _parseId(e.id);
|
||||||
if (id == null) return null;
|
if (id == null) return null;
|
||||||
@ -601,9 +601,24 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
|
|||||||
final label = pct != null
|
final label = pct != null
|
||||||
? (pct % 1 == 0 ? '${pct.toInt()}%' : '$pct%')
|
? (pct % 1 == 0 ? '${pct.toInt()}%' : '$pct%')
|
||||||
: e.name.split(' — ').first.trim();
|
: e.name.split(' — ').first.trim();
|
||||||
|
if (label.isEmpty) return null;
|
||||||
return AppDropdownOption<int?>(value: id, label: label);
|
return AppDropdownOption<int?>(value: id, label: label);
|
||||||
}),
|
}),
|
||||||
].whereType<AppDropdownOption<int?>>().toList();
|
].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;
|
const spacing = 8.0;
|
||||||
|
|
||||||
@ -629,6 +644,7 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
|
|||||||
controller: line.qtyController,
|
controller: line.qtyController,
|
||||||
label: 'Qty *',
|
label: 'Qty *',
|
||||||
hint: '0',
|
hint: '0',
|
||||||
|
isDense: true,
|
||||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
inputFormatters: [
|
inputFormatters: [
|
||||||
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')),
|
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')),
|
||||||
@ -678,6 +694,7 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
|
|||||||
controller: line.discountController,
|
controller: line.discountController,
|
||||||
label: 'Disc %',
|
label: 'Disc %',
|
||||||
hint: '0',
|
hint: '0',
|
||||||
|
isDense: true,
|
||||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
inputFormatters: [
|
inputFormatters: [
|
||||||
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')),
|
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')),
|
||||||
@ -699,6 +716,7 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
|
|||||||
hint: 'Select',
|
hint: 'Select',
|
||||||
searchHint: 'Search GST %...',
|
searchHint: 'Search GST %...',
|
||||||
options: gstOptions,
|
options: gstOptions,
|
||||||
|
isDense: true,
|
||||||
openInSidePanel: true,
|
openInSidePanel: true,
|
||||||
refreshLookups: () async {
|
refreshLookups: () async {
|
||||||
ref.invalidate(purchaseOrderLookupsProvider);
|
ref.invalidate(purchaseOrderLookupsProvider);
|
||||||
@ -707,114 +725,64 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
|
|||||||
parseCreatedId: int.tryParse,
|
parseCreatedId: int.tryParse,
|
||||||
onChanged: (v) => _updateLine(() => line.gstRateId = v),
|
onChanged: (v) => _updateLine(() => line.gstRateId = v),
|
||||||
);
|
);
|
||||||
final amountField = _AmountWithRemove(
|
final amountField = _AmountDisplay(
|
||||||
amount: CurrencyFormatter.format(calc.lineAmount),
|
label: 'Amount',
|
||||||
|
value: CurrencyFormatter.format(calc.lineAmount),
|
||||||
backgroundColor: amountBg,
|
backgroundColor: amountBg,
|
||||||
onRemove: widget.onRemove,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(color: borderColor),
|
border: Border.all(color: borderColor),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
child: QuickAddInlineHost(
|
child: QuickAddInlineHost(
|
||||||
child: QuickAddBlockable(
|
child: QuickAddBlockable(
|
||||||
child: LayoutBuilder(
|
child: SizedBox(
|
||||||
builder: (context, constraints) {
|
width: double.infinity,
|
||||||
final width = constraints.maxWidth;
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
// 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,
|
|
||||||
children: [
|
children: [
|
||||||
itemField,
|
Expanded(flex: 3, child: itemField),
|
||||||
qtyField,
|
const SizedBox(width: spacing),
|
||||||
uomField,
|
// Wider than flex 1 so floating labels ("Qty *", "Disc %") aren't clipped.
|
||||||
rateField,
|
Expanded(flex: 2, child: qtyField),
|
||||||
discField,
|
const SizedBox(width: spacing),
|
||||||
gstField,
|
Expanded(flex: 2, child: uomField),
|
||||||
amountField,
|
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),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: FittedBox(
|
||||||
value,
|
fit: BoxFit.scaleDown,
|
||||||
style: theme.textTheme.titleSmall?.copyWith(
|
alignment: Alignment.centerLeft,
|
||||||
fontWeight: FontWeight.w700,
|
child: Text(
|
||||||
color: theme.colorScheme.onSurface,
|
value,
|
||||||
|
maxLines: 1,
|
||||||
|
softWrap: false,
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: theme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -62,7 +62,7 @@ const rbacModules = [
|
|||||||
),
|
),
|
||||||
RbacModule(
|
RbacModule(
|
||||||
key: 'grn',
|
key: 'grn',
|
||||||
label: 'GRN / PO Receipt',
|
label: 'Purchase Receipt',
|
||||||
icon: Icons.inventory_outlined,
|
icon: Icons.inventory_outlined,
|
||||||
color: Color(0xFF0891B2),
|
color: Color(0xFF0891B2),
|
||||||
),
|
),
|
||||||
@ -288,7 +288,7 @@ List<ManagedRole> defaultRoles = [
|
|||||||
ManagedRole(
|
ManagedRole(
|
||||||
id: 'store_manager',
|
id: 'store_manager',
|
||||||
name: 'Store Manager',
|
name: 'Store Manager',
|
||||||
description: 'GRN, warehouse receipts, stock view',
|
description: 'Purchase Receipt, warehouse receipts, stock view',
|
||||||
icon: Icons.warehouse_outlined,
|
icon: Icons.warehouse_outlined,
|
||||||
color: const Color(0xFF0891B2),
|
color: const Color(0xFF0891B2),
|
||||||
userCount: 12,
|
userCount: 12,
|
||||||
@ -297,7 +297,7 @@ List<ManagedRole> defaultRoles = [
|
|||||||
ManagedRole(
|
ManagedRole(
|
||||||
id: 'accounts',
|
id: 'accounts',
|
||||||
name: 'Accounts',
|
name: 'Accounts',
|
||||||
description: 'View PO, GRN, vendor financial details',
|
description: 'View PO, Purchase Receipt, vendor financial details',
|
||||||
icon: Icons.account_balance_wallet_outlined,
|
icon: Icons.account_balance_wallet_outlined,
|
||||||
color: const Color(0xFF7C3AED),
|
color: const Color(0xFF7C3AED),
|
||||||
userCount: 8,
|
userCount: 8,
|
||||||
|
|||||||
@ -1017,7 +1017,7 @@ class _RolesTab extends ConsumerWidget {
|
|||||||
key: ValueKey('$themeMode-$brightness-${rolesState.page}'),
|
key: ValueKey('$themeMode-$brightness-${rolesState.page}'),
|
||||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||||
maxCrossAxisExtent: 320,
|
maxCrossAxisExtent: 320,
|
||||||
mainAxisExtent: 168,
|
mainAxisExtent: 176,
|
||||||
crossAxisSpacing: 16,
|
crossAxisSpacing: 16,
|
||||||
mainAxisSpacing: 16,
|
mainAxisSpacing: 16,
|
||||||
),
|
),
|
||||||
@ -1106,6 +1106,11 @@ class _RolesTab extends ConsumerWidget {
|
|||||||
icon: const Icon(Icons.edit_outlined, size: 18),
|
icon: const Icon(Icons.edit_outlined, size: 18),
|
||||||
onPressed: () => onEditRole(role),
|
onPressed: () => onEditRole(role),
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(
|
||||||
|
minWidth: 32,
|
||||||
|
minHeight: 32,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (canDeleteRole && !isProtectedRole(role))
|
if (canDeleteRole && !isProtectedRole(role))
|
||||||
IconButton(
|
IconButton(
|
||||||
@ -1117,6 +1122,11 @@ class _RolesTab extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
onPressed: () => onDeleteRole(role),
|
onPressed: () => onDeleteRole(role),
|
||||||
visualDensity: VisualDensity.compact,
|
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),
|
const SizedBox(height: 4),
|
||||||
Tooltip(
|
Expanded(
|
||||||
message: displayDescription == '—' ? '' : displayDescription,
|
child: Tooltip(
|
||||||
waitDuration: const Duration(milliseconds: 300),
|
message: displayDescription == '—'
|
||||||
child: Text(
|
? ''
|
||||||
displayDescription,
|
: displayDescription,
|
||||||
maxLines: 2,
|
waitDuration: const Duration(milliseconds: 300),
|
||||||
overflow: TextOverflow.ellipsis,
|
child: Align(
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
alignment: Alignment.topLeft,
|
||||||
color: Theme.of(context)
|
child: Text(
|
||||||
.colorScheme
|
displayDescription,
|
||||||
.onSurfaceVariant,
|
maxLines: 2,
|
||||||
height: 1.35,
|
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(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Tooltip(
|
Tooltip(
|
||||||
|
|||||||
@ -5,13 +5,14 @@ import 'package:go_router/go_router.dart';
|
|||||||
import '../../../../core/utils/formatters.dart';
|
import '../../../../core/utils/formatters.dart';
|
||||||
|
|
||||||
import '../../../../core/errors/failure.dart';
|
import '../../../../core/errors/failure.dart';
|
||||||
|
import '../../../../shared/models/user_management_models.dart';
|
||||||
import '../../../../shared/widgets/app_button.dart';
|
import '../../../../shared/widgets/app_button.dart';
|
||||||
import '../../../../shared/widgets/app_confirmation_dialog.dart';
|
import '../../../../shared/widgets/app_confirmation_dialog.dart';
|
||||||
import '../../../../shared/widgets/app_loading_view.dart';
|
import '../../../../shared/widgets/app_loading_view.dart';
|
||||||
import '../../../../shared/widgets/app_status_chip.dart';
|
import '../../../../shared/widgets/app_status_chip.dart';
|
||||||
|
import '../../../../shared/widgets/detail_overview_widgets.dart';
|
||||||
import '../../../../shared/widgets/error_view.dart';
|
import '../../../../shared/widgets/error_view.dart';
|
||||||
import '../../../../shared/widgets/page_header.dart';
|
import '../../../../shared/widgets/page_header.dart';
|
||||||
import '../../../../shared/models/user_management_models.dart';
|
|
||||||
import '../providers/users_provider.dart';
|
import '../providers/users_provider.dart';
|
||||||
import '../../../../shared/widgets/app_toast.dart';
|
import '../../../../shared/widgets/app_toast.dart';
|
||||||
|
|
||||||
@ -81,27 +82,87 @@ class _UserDetailScreenState extends ConsumerState<UserDetailScreen> {
|
|||||||
AppCard(
|
AppCard(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Column(
|
child: DetailOverviewCard(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
title: 'User Details',
|
||||||
children: [
|
children: [
|
||||||
_DetailRow(label: 'Email', value: user.email),
|
DetailOverviewSection(
|
||||||
_DetailRow(label: 'Mobile', value: user.mobile),
|
title: 'Summary',
|
||||||
_DetailRow(label: 'Role', value: user.roleLabel),
|
child: DetailSummaryStrip(
|
||||||
_DetailRow(label: 'Department', value: user.departmentLabel),
|
metrics: [
|
||||||
_DetailRow(
|
DetailSummaryMetric(
|
||||||
label: 'Status',
|
icon: Icons.flag_outlined,
|
||||||
valueWidget: AppStatusChip(status: user.status),
|
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)
|
DetailOverviewSection(
|
||||||
_DetailRow(
|
title: 'Contact',
|
||||||
label: 'Created',
|
child: DetailInfoGrid(
|
||||||
value: DateFormatter.displayDateTime(user.createdAt),
|
items: [
|
||||||
|
DetailInfoItem('Email', user.email),
|
||||||
|
DetailInfoItem('Mobile', user.mobile),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
if (user.updatedAt != null)
|
),
|
||||||
_DetailRow(
|
DetailOverviewSection(
|
||||||
label: 'Updated',
|
title: 'Account',
|
||||||
value: DateFormatter.displayDateTime(user.updatedAt),
|
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();
|
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_form_panel.dart';
|
||||||
import '../widgets/vendor_sub_resource_panels.dart';
|
import '../widgets/vendor_sub_resource_panels.dart';
|
||||||
import '../../../../shared/widgets/api_feedback.dart';
|
import '../../../../shared/widgets/api_feedback.dart';
|
||||||
|
import '../../../../shared/widgets/app_status_chip.dart';
|
||||||
import '../../../../shared/widgets/app_toast.dart';
|
import '../../../../shared/widgets/app_toast.dart';
|
||||||
|
import '../../../../shared/widgets/detail_overview_widgets.dart';
|
||||||
|
|
||||||
class VendorDetailScreen extends ConsumerStatefulWidget {
|
class VendorDetailScreen extends ConsumerStatefulWidget {
|
||||||
const VendorDetailScreen({super.key, required this.vendorId});
|
const VendorDetailScreen({super.key, required this.vendorId});
|
||||||
@ -250,7 +252,11 @@ class _OverviewTab extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final scheme = theme.colorScheme;
|
||||||
final hasRemarks = vendor.remarks?.trim().isNotEmpty == true;
|
final hasRemarks = vendor.remarks?.trim().isNotEmpty == true;
|
||||||
|
final status = vendor.status ?? 'active';
|
||||||
|
final activeColor =
|
||||||
|
vendor.isActive ? const Color(0xFF16A34A) : scheme.onSurfaceVariant;
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
child: Center(
|
child: Center(
|
||||||
@ -262,50 +268,143 @@ class _OverviewTab extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
DetailOverviewCard(
|
||||||
'Vendor Details',
|
title: 'Vendor Details',
|
||||||
style: theme.textTheme.labelLarge?.copyWith(
|
children: [
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
DetailOverviewSection(
|
||||||
fontWeight: FontWeight.w600,
|
title: 'Summary',
|
||||||
),
|
child: DetailSummaryStrip(
|
||||||
),
|
metrics: [
|
||||||
const SizedBox(height: 12),
|
DetailSummaryMetric(
|
||||||
_VendorInfoGrid(
|
icon: Icons.flag_outlined,
|
||||||
items: [
|
label: 'Status',
|
||||||
_VendorInfo('Vendor Code', vendor.vendorCode ?? '—'),
|
accent: scheme.secondary,
|
||||||
_VendorInfo('Vendor Name', vendor.vendorName),
|
child: AppStatusChip(
|
||||||
_VendorInfo('Type', vendorTypeLabel(vendor.vendorType)),
|
status: status,
|
||||||
_VendorInfo(
|
compact: true,
|
||||||
'GST Treatment',
|
),
|
||||||
gstTreatmentLabel(vendor.gstTreatment),
|
),
|
||||||
|
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(
|
DetailOverviewSection(
|
||||||
'Source Of Supply',
|
title: 'Identity',
|
||||||
vendor.sourceOfSupply ?? '—',
|
child: DetailInfoGrid(
|
||||||
|
items: [
|
||||||
|
DetailInfoItem(
|
||||||
|
'Vendor Code',
|
||||||
|
vendor.vendorCode ?? '—',
|
||||||
|
),
|
||||||
|
DetailInfoItem('Vendor Name', vendor.vendorName),
|
||||||
|
DetailInfoItem(
|
||||||
|
'Type',
|
||||||
|
vendorTypeLabel(vendor.vendorType),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_VendorInfo('GSTIN', vendor.gstin ?? '—'),
|
DetailOverviewSection(
|
||||||
_VendorInfo('PAN', vendor.pan ?? '—'),
|
title: 'Tax & Compliance',
|
||||||
_VendorInfo('Payment Term', vendor.paymentTermName ?? '—'),
|
child: DetailInfoGrid(
|
||||||
_VendorInfo(
|
items: [
|
||||||
'Credit Period',
|
DetailInfoItem(
|
||||||
vendor.creditPeriodDays != null
|
'GST Treatment',
|
||||||
? '${vendor.creditPeriodDays} days'
|
gstTreatmentLabel(vendor.gstTreatment),
|
||||||
: '—',
|
),
|
||||||
|
DetailInfoItem(
|
||||||
|
'Source Of Supply',
|
||||||
|
vendor.sourceOfSupply ?? '—',
|
||||||
|
),
|
||||||
|
DetailInfoItem('GSTIN', vendor.gstin ?? '—'),
|
||||||
|
DetailInfoItem('PAN', vendor.pan ?? '—'),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_VendorInfo('Status', vendorStatusLabel(vendor.status)),
|
DetailOverviewSection(
|
||||||
_VendorInfo('Active', vendor.isActive ? 'Yes' : 'No'),
|
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 {
|
class _AddressesTab extends ConsumerWidget {
|
||||||
const _AddressesTab({
|
const _AddressesTab({
|
||||||
required this.vendorId,
|
required this.vendorId,
|
||||||
|
|||||||
@ -116,6 +116,28 @@ Object? _readVendorNameFromNested(Map<dynamic, dynamic> json, String key) {
|
|||||||
return null;
|
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
|
@freezed
|
||||||
class AssetCategoryModel with _$AssetCategoryModel {
|
class AssetCategoryModel with _$AssetCategoryModel {
|
||||||
const factory AssetCategoryModel({
|
const factory AssetCategoryModel({
|
||||||
@ -191,7 +213,10 @@ class AssetModel with _$AssetModel {
|
|||||||
@JsonKey(name: 'vendor_id', fromJson: _intFromJsonNullable) int? vendorId,
|
@JsonKey(name: 'vendor_id', fromJson: _intFromJsonNullable) int? vendorId,
|
||||||
@JsonKey(name: 'vendor_name', readValue: _readVendorNameFromNested) String? vendorName,
|
@JsonKey(name: 'vendor_name', readValue: _readVendorNameFromNested) String? vendorName,
|
||||||
@JsonKey(name: 'po_id', fromJson: _intFromJsonNullable) int? poId,
|
@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_id', fromJson: _intFromJsonNullable) int? grnId,
|
||||||
|
@JsonKey(name: 'grn_number', readValue: _readGrnNumberFromNested)
|
||||||
|
String? grnNumber,
|
||||||
@JsonKey(name: 'grn_item_id', fromJson: _intFromJsonNullable) int? grnItemId,
|
@JsonKey(name: 'grn_item_id', fromJson: _intFromJsonNullable) int? grnItemId,
|
||||||
@JsonKey(name: 'purchase_date', fromJson: _dateFromJsonNullable) DateTime? purchaseDate,
|
@JsonKey(name: 'purchase_date', fromJson: _dateFromJsonNullable) DateTime? purchaseDate,
|
||||||
@JsonKey(name: 'purchase_cost', fromJson: _doubleFromJsonNullable) double? purchaseCost,
|
@JsonKey(name: 'purchase_cost', fromJson: _doubleFromJsonNullable) double? purchaseCost,
|
||||||
|
|||||||
@ -482,8 +482,12 @@ mixin _$AssetModel {
|
|||||||
String? get vendorName => throw _privateConstructorUsedError;
|
String? get vendorName => throw _privateConstructorUsedError;
|
||||||
@JsonKey(name: 'po_id', fromJson: _intFromJsonNullable)
|
@JsonKey(name: 'po_id', fromJson: _intFromJsonNullable)
|
||||||
int? get poId => throw _privateConstructorUsedError;
|
int? get poId => throw _privateConstructorUsedError;
|
||||||
|
@JsonKey(name: 'po_number', readValue: _readPoNumberFromNested)
|
||||||
|
String? get poNumber => throw _privateConstructorUsedError;
|
||||||
@JsonKey(name: 'grn_id', fromJson: _intFromJsonNullable)
|
@JsonKey(name: 'grn_id', fromJson: _intFromJsonNullable)
|
||||||
int? get grnId => throw _privateConstructorUsedError;
|
int? get grnId => throw _privateConstructorUsedError;
|
||||||
|
@JsonKey(name: 'grn_number', readValue: _readGrnNumberFromNested)
|
||||||
|
String? get grnNumber => throw _privateConstructorUsedError;
|
||||||
@JsonKey(name: 'grn_item_id', fromJson: _intFromJsonNullable)
|
@JsonKey(name: 'grn_item_id', fromJson: _intFromJsonNullable)
|
||||||
int? get grnItemId => throw _privateConstructorUsedError;
|
int? get grnItemId => throw _privateConstructorUsedError;
|
||||||
@JsonKey(name: 'purchase_date', fromJson: _dateFromJsonNullable)
|
@JsonKey(name: 'purchase_date', fromJson: _dateFromJsonNullable)
|
||||||
@ -614,7 +618,11 @@ abstract class $AssetModelCopyWith<$Res> {
|
|||||||
@JsonKey(name: 'vendor_name', readValue: _readVendorNameFromNested)
|
@JsonKey(name: 'vendor_name', readValue: _readVendorNameFromNested)
|
||||||
String? vendorName,
|
String? vendorName,
|
||||||
@JsonKey(name: 'po_id', fromJson: _intFromJsonNullable) int? poId,
|
@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_id', fromJson: _intFromJsonNullable) int? grnId,
|
||||||
|
@JsonKey(name: 'grn_number', readValue: _readGrnNumberFromNested)
|
||||||
|
String? grnNumber,
|
||||||
@JsonKey(name: 'grn_item_id', fromJson: _intFromJsonNullable)
|
@JsonKey(name: 'grn_item_id', fromJson: _intFromJsonNullable)
|
||||||
int? grnItemId,
|
int? grnItemId,
|
||||||
@JsonKey(name: 'purchase_date', fromJson: _dateFromJsonNullable)
|
@JsonKey(name: 'purchase_date', fromJson: _dateFromJsonNullable)
|
||||||
@ -702,7 +710,9 @@ class _$AssetModelCopyWithImpl<$Res, $Val extends AssetModel>
|
|||||||
Object? vendorId = freezed,
|
Object? vendorId = freezed,
|
||||||
Object? vendorName = freezed,
|
Object? vendorName = freezed,
|
||||||
Object? poId = freezed,
|
Object? poId = freezed,
|
||||||
|
Object? poNumber = freezed,
|
||||||
Object? grnId = freezed,
|
Object? grnId = freezed,
|
||||||
|
Object? grnNumber = freezed,
|
||||||
Object? grnItemId = freezed,
|
Object? grnItemId = freezed,
|
||||||
Object? purchaseDate = freezed,
|
Object? purchaseDate = freezed,
|
||||||
Object? purchaseCost = freezed,
|
Object? purchaseCost = freezed,
|
||||||
@ -824,10 +834,18 @@ class _$AssetModelCopyWithImpl<$Res, $Val extends AssetModel>
|
|||||||
? _value.poId
|
? _value.poId
|
||||||
: poId // ignore: cast_nullable_to_non_nullable
|
: poId // ignore: cast_nullable_to_non_nullable
|
||||||
as int?,
|
as int?,
|
||||||
|
poNumber: freezed == poNumber
|
||||||
|
? _value.poNumber
|
||||||
|
: poNumber // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
grnId: freezed == grnId
|
grnId: freezed == grnId
|
||||||
? _value.grnId
|
? _value.grnId
|
||||||
: grnId // ignore: cast_nullable_to_non_nullable
|
: grnId // ignore: cast_nullable_to_non_nullable
|
||||||
as int?,
|
as int?,
|
||||||
|
grnNumber: freezed == grnNumber
|
||||||
|
? _value.grnNumber
|
||||||
|
: grnNumber // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
grnItemId: freezed == grnItemId
|
grnItemId: freezed == grnItemId
|
||||||
? _value.grnItemId
|
? _value.grnItemId
|
||||||
: grnItemId // ignore: cast_nullable_to_non_nullable
|
: grnItemId // ignore: cast_nullable_to_non_nullable
|
||||||
@ -992,7 +1010,11 @@ abstract class _$$AssetModelImplCopyWith<$Res>
|
|||||||
@JsonKey(name: 'vendor_name', readValue: _readVendorNameFromNested)
|
@JsonKey(name: 'vendor_name', readValue: _readVendorNameFromNested)
|
||||||
String? vendorName,
|
String? vendorName,
|
||||||
@JsonKey(name: 'po_id', fromJson: _intFromJsonNullable) int? poId,
|
@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_id', fromJson: _intFromJsonNullable) int? grnId,
|
||||||
|
@JsonKey(name: 'grn_number', readValue: _readGrnNumberFromNested)
|
||||||
|
String? grnNumber,
|
||||||
@JsonKey(name: 'grn_item_id', fromJson: _intFromJsonNullable)
|
@JsonKey(name: 'grn_item_id', fromJson: _intFromJsonNullable)
|
||||||
int? grnItemId,
|
int? grnItemId,
|
||||||
@JsonKey(name: 'purchase_date', fromJson: _dateFromJsonNullable)
|
@JsonKey(name: 'purchase_date', fromJson: _dateFromJsonNullable)
|
||||||
@ -1079,7 +1101,9 @@ class __$$AssetModelImplCopyWithImpl<$Res>
|
|||||||
Object? vendorId = freezed,
|
Object? vendorId = freezed,
|
||||||
Object? vendorName = freezed,
|
Object? vendorName = freezed,
|
||||||
Object? poId = freezed,
|
Object? poId = freezed,
|
||||||
|
Object? poNumber = freezed,
|
||||||
Object? grnId = freezed,
|
Object? grnId = freezed,
|
||||||
|
Object? grnNumber = freezed,
|
||||||
Object? grnItemId = freezed,
|
Object? grnItemId = freezed,
|
||||||
Object? purchaseDate = freezed,
|
Object? purchaseDate = freezed,
|
||||||
Object? purchaseCost = freezed,
|
Object? purchaseCost = freezed,
|
||||||
@ -1201,10 +1225,18 @@ class __$$AssetModelImplCopyWithImpl<$Res>
|
|||||||
? _value.poId
|
? _value.poId
|
||||||
: poId // ignore: cast_nullable_to_non_nullable
|
: poId // ignore: cast_nullable_to_non_nullable
|
||||||
as int?,
|
as int?,
|
||||||
|
poNumber: freezed == poNumber
|
||||||
|
? _value.poNumber
|
||||||
|
: poNumber // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
grnId: freezed == grnId
|
grnId: freezed == grnId
|
||||||
? _value.grnId
|
? _value.grnId
|
||||||
: grnId // ignore: cast_nullable_to_non_nullable
|
: grnId // ignore: cast_nullable_to_non_nullable
|
||||||
as int?,
|
as int?,
|
||||||
|
grnNumber: freezed == grnNumber
|
||||||
|
? _value.grnNumber
|
||||||
|
: grnNumber // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
grnItemId: freezed == grnItemId
|
grnItemId: freezed == grnItemId
|
||||||
? _value.grnItemId
|
? _value.grnItemId
|
||||||
: grnItemId // ignore: cast_nullable_to_non_nullable
|
: grnItemId // ignore: cast_nullable_to_non_nullable
|
||||||
@ -1362,7 +1394,11 @@ class _$AssetModelImpl implements _AssetModel {
|
|||||||
@JsonKey(name: 'vendor_name', readValue: _readVendorNameFromNested)
|
@JsonKey(name: 'vendor_name', readValue: _readVendorNameFromNested)
|
||||||
this.vendorName,
|
this.vendorName,
|
||||||
@JsonKey(name: 'po_id', fromJson: _intFromJsonNullable) this.poId,
|
@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_id', fromJson: _intFromJsonNullable) this.grnId,
|
||||||
|
@JsonKey(name: 'grn_number', readValue: _readGrnNumberFromNested)
|
||||||
|
this.grnNumber,
|
||||||
@JsonKey(name: 'grn_item_id', fromJson: _intFromJsonNullable)
|
@JsonKey(name: 'grn_item_id', fromJson: _intFromJsonNullable)
|
||||||
this.grnItemId,
|
this.grnItemId,
|
||||||
@JsonKey(name: 'purchase_date', fromJson: _dateFromJsonNullable)
|
@JsonKey(name: 'purchase_date', fromJson: _dateFromJsonNullable)
|
||||||
@ -1513,9 +1549,15 @@ class _$AssetModelImpl implements _AssetModel {
|
|||||||
@JsonKey(name: 'po_id', fromJson: _intFromJsonNullable)
|
@JsonKey(name: 'po_id', fromJson: _intFromJsonNullable)
|
||||||
final int? poId;
|
final int? poId;
|
||||||
@override
|
@override
|
||||||
|
@JsonKey(name: 'po_number', readValue: _readPoNumberFromNested)
|
||||||
|
final String? poNumber;
|
||||||
|
@override
|
||||||
@JsonKey(name: 'grn_id', fromJson: _intFromJsonNullable)
|
@JsonKey(name: 'grn_id', fromJson: _intFromJsonNullable)
|
||||||
final int? grnId;
|
final int? grnId;
|
||||||
@override
|
@override
|
||||||
|
@JsonKey(name: 'grn_number', readValue: _readGrnNumberFromNested)
|
||||||
|
final String? grnNumber;
|
||||||
|
@override
|
||||||
@JsonKey(name: 'grn_item_id', fromJson: _intFromJsonNullable)
|
@JsonKey(name: 'grn_item_id', fromJson: _intFromJsonNullable)
|
||||||
final int? grnItemId;
|
final int? grnItemId;
|
||||||
@override
|
@override
|
||||||
@ -1589,7 +1631,7 @@ class _$AssetModelImpl implements _AssetModel {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
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
|
@override
|
||||||
@ -1652,7 +1694,11 @@ class _$AssetModelImpl implements _AssetModel {
|
|||||||
(identical(other.vendorName, vendorName) ||
|
(identical(other.vendorName, vendorName) ||
|
||||||
other.vendorName == vendorName) &&
|
other.vendorName == vendorName) &&
|
||||||
(identical(other.poId, poId) || other.poId == poId) &&
|
(identical(other.poId, poId) || other.poId == poId) &&
|
||||||
|
(identical(other.poNumber, poNumber) ||
|
||||||
|
other.poNumber == poNumber) &&
|
||||||
(identical(other.grnId, grnId) || other.grnId == grnId) &&
|
(identical(other.grnId, grnId) || other.grnId == grnId) &&
|
||||||
|
(identical(other.grnNumber, grnNumber) ||
|
||||||
|
other.grnNumber == grnNumber) &&
|
||||||
(identical(other.grnItemId, grnItemId) ||
|
(identical(other.grnItemId, grnItemId) ||
|
||||||
other.grnItemId == grnItemId) &&
|
other.grnItemId == grnItemId) &&
|
||||||
(identical(other.purchaseDate, purchaseDate) ||
|
(identical(other.purchaseDate, purchaseDate) ||
|
||||||
@ -1725,7 +1771,9 @@ class _$AssetModelImpl implements _AssetModel {
|
|||||||
vendorId,
|
vendorId,
|
||||||
vendorName,
|
vendorName,
|
||||||
poId,
|
poId,
|
||||||
|
poNumber,
|
||||||
grnId,
|
grnId,
|
||||||
|
grnNumber,
|
||||||
grnItemId,
|
grnItemId,
|
||||||
purchaseDate,
|
purchaseDate,
|
||||||
purchaseCost,
|
purchaseCost,
|
||||||
@ -1827,7 +1875,11 @@ abstract class _AssetModel implements AssetModel {
|
|||||||
@JsonKey(name: 'vendor_name', readValue: _readVendorNameFromNested)
|
@JsonKey(name: 'vendor_name', readValue: _readVendorNameFromNested)
|
||||||
final String? vendorName,
|
final String? vendorName,
|
||||||
@JsonKey(name: 'po_id', fromJson: _intFromJsonNullable) final int? poId,
|
@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_id', fromJson: _intFromJsonNullable) final int? grnId,
|
||||||
|
@JsonKey(name: 'grn_number', readValue: _readGrnNumberFromNested)
|
||||||
|
final String? grnNumber,
|
||||||
@JsonKey(name: 'grn_item_id', fromJson: _intFromJsonNullable)
|
@JsonKey(name: 'grn_item_id', fromJson: _intFromJsonNullable)
|
||||||
final int? grnItemId,
|
final int? grnItemId,
|
||||||
@JsonKey(name: 'purchase_date', fromJson: _dateFromJsonNullable)
|
@JsonKey(name: 'purchase_date', fromJson: _dateFromJsonNullable)
|
||||||
@ -1969,9 +2021,15 @@ abstract class _AssetModel implements AssetModel {
|
|||||||
@JsonKey(name: 'po_id', fromJson: _intFromJsonNullable)
|
@JsonKey(name: 'po_id', fromJson: _intFromJsonNullable)
|
||||||
int? get poId;
|
int? get poId;
|
||||||
@override
|
@override
|
||||||
|
@JsonKey(name: 'po_number', readValue: _readPoNumberFromNested)
|
||||||
|
String? get poNumber;
|
||||||
|
@override
|
||||||
@JsonKey(name: 'grn_id', fromJson: _intFromJsonNullable)
|
@JsonKey(name: 'grn_id', fromJson: _intFromJsonNullable)
|
||||||
int? get grnId;
|
int? get grnId;
|
||||||
@override
|
@override
|
||||||
|
@JsonKey(name: 'grn_number', readValue: _readGrnNumberFromNested)
|
||||||
|
String? get grnNumber;
|
||||||
|
@override
|
||||||
@JsonKey(name: 'grn_item_id', fromJson: _intFromJsonNullable)
|
@JsonKey(name: 'grn_item_id', fromJson: _intFromJsonNullable)
|
||||||
int? get grnItemId;
|
int? get grnItemId;
|
||||||
@override
|
@override
|
||||||
|
|||||||
@ -81,7 +81,9 @@ _$AssetModelImpl _$$AssetModelImplFromJson(
|
|||||||
vendorId: _intFromJsonNullable(json['vendor_id']),
|
vendorId: _intFromJsonNullable(json['vendor_id']),
|
||||||
vendorName: _readVendorNameFromNested(json, 'vendor_name') as String?,
|
vendorName: _readVendorNameFromNested(json, 'vendor_name') as String?,
|
||||||
poId: _intFromJsonNullable(json['po_id']),
|
poId: _intFromJsonNullable(json['po_id']),
|
||||||
|
poNumber: _readPoNumberFromNested(json, 'po_number') as String?,
|
||||||
grnId: _intFromJsonNullable(json['grn_id']),
|
grnId: _intFromJsonNullable(json['grn_id']),
|
||||||
|
grnNumber: _readGrnNumberFromNested(json, 'grn_number') as String?,
|
||||||
grnItemId: _intFromJsonNullable(json['grn_item_id']),
|
grnItemId: _intFromJsonNullable(json['grn_item_id']),
|
||||||
purchaseDate: _dateFromJsonNullable(json['purchase_date']),
|
purchaseDate: _dateFromJsonNullable(json['purchase_date']),
|
||||||
purchaseCost: _doubleFromJsonNullable(json['purchase_cost']),
|
purchaseCost: _doubleFromJsonNullable(json['purchase_cost']),
|
||||||
@ -135,7 +137,9 @@ Map<String, dynamic> _$$AssetModelImplToJson(
|
|||||||
'vendor_id': instance.vendorId,
|
'vendor_id': instance.vendorId,
|
||||||
'vendor_name': instance.vendorName,
|
'vendor_name': instance.vendorName,
|
||||||
'po_id': instance.poId,
|
'po_id': instance.poId,
|
||||||
|
'po_number': instance.poNumber,
|
||||||
'grn_id': instance.grnId,
|
'grn_id': instance.grnId,
|
||||||
|
'grn_number': instance.grnNumber,
|
||||||
'grn_item_id': instance.grnItemId,
|
'grn_item_id': instance.grnItemId,
|
||||||
'purchase_date': instance.purchaseDate?.toIso8601String(),
|
'purchase_date': instance.purchaseDate?.toIso8601String(),
|
||||||
'purchase_cost': instance.purchaseCost,
|
'purchase_cost': instance.purchaseCost,
|
||||||
|
|||||||
@ -267,6 +267,7 @@ class RolePermissionMatrix {
|
|||||||
'MASTERS' => styles[2],
|
'MASTERS' => styles[2],
|
||||||
'VENDOR' => styles[3],
|
'VENDOR' => styles[3],
|
||||||
'PURCHASE_ORDERS' || 'PURCHASE_ORDER' || 'PO' => styles[4],
|
'PURCHASE_ORDERS' || 'PURCHASE_ORDER' || 'PO' => styles[4],
|
||||||
|
'Purchase Receipt' => styles[5],
|
||||||
'GRN' => styles[5],
|
'GRN' => styles[5],
|
||||||
'ASSETS' || 'ASSET' || 'ASSET_MANAGEMENT' => styles[6],
|
'ASSETS' || 'ASSET' || 'ASSET_MANAGEMENT' => styles[6],
|
||||||
_ => styles[index % styles.length],
|
_ => styles[index % styles.length],
|
||||||
|
|||||||
@ -56,10 +56,7 @@ class UserModel with _$UserModel {
|
|||||||
companyId: json['company_id']?.toString(),
|
companyId: json['company_id']?.toString(),
|
||||||
branchId: json['branch_id']?.toString(),
|
branchId: json['branch_id']?.toString(),
|
||||||
avatarUrl: json['avatar_url'] as String? ?? json['avatarUrl'] as String?,
|
avatarUrl: json['avatar_url'] as String? ?? json['avatarUrl'] as String?,
|
||||||
permissions: (json['permissions'] as List<dynamic>?)
|
permissions: _permissionsFromJson(json),
|
||||||
?.map((e) => e.toString())
|
|
||||||
.toList() ??
|
|
||||||
const [],
|
|
||||||
createdAt: json['created_at'] != null
|
createdAt: json['created_at'] != null
|
||||||
? DateTime.tryParse(json['created_at'].toString())
|
? DateTime.tryParse(json['created_at'].toString())
|
||||||
: null,
|
: 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) {
|
Object? _firstRoleFromList(Object? roles) {
|
||||||
if (roles is! List || roles.isEmpty) return null;
|
if (roles is! List || roles.isEmpty) return null;
|
||||||
return roles.first;
|
return roles.first;
|
||||||
|
|||||||
@ -11,7 +11,7 @@ final userPermissionsProvider = Provider<List<String>>((ref) {
|
|||||||
extension PermissionCheck on WidgetRef {
|
extension PermissionCheck on WidgetRef {
|
||||||
bool can(String module, PermissionAction action) {
|
bool can(String module, PermissionAction action) {
|
||||||
return hasPermission(
|
return hasPermission(
|
||||||
userPermissions: read(userPermissionsProvider),
|
userPermissions: watch(userPermissionsProvider),
|
||||||
module: module,
|
module: module,
|
||||||
action: action,
|
action: action,
|
||||||
);
|
);
|
||||||
@ -21,7 +21,7 @@ extension PermissionCheck on WidgetRef {
|
|||||||
extension PermissionCheckReader on Ref {
|
extension PermissionCheckReader on Ref {
|
||||||
bool can(String module, PermissionAction action) {
|
bool can(String module, PermissionAction action) {
|
||||||
return hasPermission(
|
return hasPermission(
|
||||||
userPermissions: read(userPermissionsProvider),
|
userPermissions: watch(userPermissionsProvider),
|
||||||
module: module,
|
module: module,
|
||||||
action: action,
|
action: action,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -95,7 +95,7 @@ const List<MenuItem> appMenuItems = [
|
|||||||
),
|
),
|
||||||
MenuItem(
|
MenuItem(
|
||||||
label: 'Purchase Receipt',
|
label: 'Purchase Receipt',
|
||||||
icon: Icons.inventory_2_outlined,
|
icon: Icons.move_to_inbox_outlined,
|
||||||
route: RouteConstants.grn,
|
route: RouteConstants.grn,
|
||||||
module: 'grn',
|
module: 'grn',
|
||||||
),
|
),
|
||||||
|
|||||||
@ -240,15 +240,15 @@ class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
|
|||||||
_overlayEntry != null
|
_overlayEntry != null
|
||||||
? Icons.arrow_drop_up
|
? Icons.arrow_drop_up
|
||||||
: Icons.arrow_drop_down,
|
: Icons.arrow_drop_down,
|
||||||
size: 24,
|
size: widget.isDense ? 20 : 24,
|
||||||
color: canOpen
|
color: canOpen
|
||||||
? colors.onSurfaceVariant
|
? colors.onSurfaceVariant
|
||||||
: theme.disabledColor,
|
: theme.disabledColor,
|
||||||
),
|
),
|
||||||
// Match AppTextField height (default icon box is 48px).
|
// Match AppTextField height (default icon box is 48px).
|
||||||
suffixIconConstraints: const BoxConstraints(
|
suffixIconConstraints: BoxConstraints(
|
||||||
minWidth: 40,
|
minWidth: widget.isDense ? 28 : 40,
|
||||||
minHeight: 40,
|
minHeight: widget.isDense ? 32 : 40,
|
||||||
),
|
),
|
||||||
enabled: canOpen,
|
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.subtitle,
|
||||||
this.actions,
|
this.actions,
|
||||||
this.leading,
|
this.leading,
|
||||||
|
this.titleTrailing,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String title;
|
final String title;
|
||||||
final String? subtitle;
|
final String? subtitle;
|
||||||
final List<Widget>? actions;
|
final List<Widget>? actions;
|
||||||
final Widget? leading;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -30,12 +49,7 @@ class PageHeader extends StatelessWidget {
|
|||||||
leading!,
|
leading!,
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
],
|
],
|
||||||
Expanded(
|
Expanded(child: _titleRow(context)),
|
||||||
child: Text(
|
|
||||||
title,
|
|
||||||
style: Theme.of(context).textTheme.headlineSmall,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (subtitle != null) ...[
|
if (subtitle != null) ...[
|
||||||
@ -63,7 +77,7 @@ class PageHeader extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(title, style: Theme.of(context).textTheme.headlineSmall),
|
_titleRow(context),
|
||||||
if (subtitle != null) ...[
|
if (subtitle != null) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user