bharat_erp/lib/modules/assets/presentation/screens/asset_detail_screen.dart

1647 lines
56 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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/enums.dart';
import '../../../../core/constants/route_constants.dart';
import '../../../../core/errors/failure.dart';
import '../../../../core/utils/formatters.dart';
import '../../../../shared/models/asset_model.dart';
import '../../../../shared/models/entity_attachment_model.dart';
import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/providers/permissions_provider.dart';
import '../../../../shared/widgets/app_card.dart';
import '../../../../shared/widgets/app_confirmation_dialog.dart';
import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_status_chip.dart';
import '../../../../shared/widgets/can_permission.dart';
import '../../../../shared/widgets/detail_overview_widgets.dart';
import '../../../../shared/widgets/document_preview_dialog.dart';
import '../../../../shared/widgets/entity_attachments_card.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/app_segmented_tab_bar.dart';
import '../providers/assets_provider.dart';
import '../providers/asset_form_lookups_provider.dart';
import '../utils/maintenance_due_display.dart';
import 'asset_form_screen.dart';
import '../widgets/asset_maintenance_panel.dart';
import '../widgets/asset_side_panels.dart';
import '../../../../shared/widgets/app_toast.dart';
class AssetDetailScreen extends ConsumerStatefulWidget {
const AssetDetailScreen({super.key, required this.assetId});
final String assetId;
@override
ConsumerState<AssetDetailScreen> createState() => _AssetDetailScreenState();
}
class _AssetDetailScreenState extends ConsumerState<AssetDetailScreen>
with SingleTickerProviderStateMixin {
late final TabController _tabController;
bool _requestedFreshLoad = false;
@override
void initState() {
super.initState();
_tabController = TabController(length: 4, vsync: this);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_requestedFreshLoad) return;
_requestedFreshLoad = true;
// Always hit GET /assets/{id} (+ related) when opening view.
ref.invalidate(assetDetailProvider(widget.assetId));
}
@override
void didUpdateWidget(covariant AssetDetailScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.assetId != widget.assetId) {
ref.invalidate(assetDetailProvider(widget.assetId));
}
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final detailAsync = ref.watch(assetDetailProvider(widget.assetId));
final canEdit = ref.can('assets', PermissionAction.update);
final canDelete = ref.can('assets', PermissionAction.delete);
return detailAsync.when(
loading: () => const AppLoadingView(message: 'Loading asset details...'),
error: (e, _) => ErrorView.fromFailure(
e is Failure ? e : Failure.unknown(message: e.toString()),
onRetry: () => ref.invalidate(assetDetailProvider(widget.assetId)),
),
data: (state) => Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageHeader(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: 'Back to Asset Master',
onPressed: () => context.go(RouteConstants.assets),
),
title: state.asset.assetName,
titleTrailing: AppStatusChip(
status: state.asset.status ?? 'IN_USE',
compact: true,
),
subtitle: state.asset.assetCode ?? 'Asset ID: ${state.asset.id}',
actions: [
if (canEdit)
OutlinedButton.icon(
onPressed: () =>
openAssetForm(context, ref, assetId: widget.assetId),
icon: const Icon(Icons.edit_outlined),
label: const Text('Edit'),
),
if (canEdit) ...[
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: _showTransferDialog,
icon: const Icon(Icons.swap_horiz),
label: const Text('Transfer'),
),
],
if (canDelete) ...[
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: _deleteAsset,
icon: const Icon(Icons.delete_outline),
label: const Text('Delete'),
),
],
],
),
const SizedBox(height: 16),
AppSegmentedTabBar(
controller: _tabController,
tabs: const [
AppSegmentedTab(
label: 'Overview',
icon: Icons.dashboard_outlined,
),
AppSegmentedTab(
label: 'AMC',
icon: Icons.handshake_outlined,
),
AppSegmentedTab(
label: 'Service Visits',
icon: Icons.build_outlined,
),
AppSegmentedTab(
label: 'Insurance',
icon: Icons.health_and_safety_outlined,
),
],
),
const SizedBox(height: 16),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_OverviewTab(
assetId: widget.assetId,
asset: state.asset,
attachments: state.attachments,
canUpload: canEdit,
canDelete: canDelete,
onOpenTransferHistory: _openTransferHistoryPanel,
),
_AmcTab(assetId: widget.assetId, contracts: state.amcContracts),
_ServiceVisitsTab(
assetId: widget.assetId,
visits: state.serviceVisits,
amcContracts: state.amcContracts,
),
_InsuranceTab(assetId: widget.assetId, policies: state.insurancePolicies),
],
),
),
],
),
),
);
}
Future<void> _deleteAsset() async {
final confirmed = await showAppConfirmationDialog(
context: context,
title: 'Delete Asset',
message: 'Do you want to delete this asset?',
confirmLabel: 'Delete',
isDestructive: true,
);
if (confirmed != true || !mounted) return;
final deleted = await ref.read(assetDetailProvider(widget.assetId).notifier).deleteAsset();
if (deleted && mounted) context.go(RouteConstants.assets);
}
Future<void> _showTransferDialog() async {
final transferred = await showSidePanel<bool>(
context,
TransferAssetPanel(assetId: widget.assetId),
width: 520,
);
if (transferred == true && mounted) {
showAppToastFromSnackBar(context,
const SnackBar(content: Text('Asset transferred successfully')),
);
}
}
Future<void> _openTransferHistoryPanel() async {
await showSidePanel<void>(
context,
TransferHistoryPanel(assetId: widget.assetId),
width: 520,
);
}
}
class _OverviewTab extends ConsumerWidget {
const _OverviewTab({
required this.assetId,
required this.asset,
required this.attachments,
required this.canUpload,
required this.canDelete,
required this.onOpenTransferHistory,
});
final String assetId;
final AssetModel asset;
final List<EntityAttachmentModel> attachments;
final bool canUpload;
final bool canDelete;
final VoidCallback onOpenTransferHistory;
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final dateFormat = DateFormat('dd MMM yyyy');
final users =
ref.watch(assetFormLookupsProvider).valueOrNull?.users ?? const [];
final maintenanceInchargeLabel = _userLabel(
asset.maintenanceInchargeUserId,
users,
);
final daysUntilDueDisplay = MaintenanceDueDisplay.fromDaysUntilDue(
asset.maintenance?.daysUntilDue,
);
final retainedPct = _retainedPercentage(
currentValue: asset.resolvedCurrentValue,
purchaseCost: asset.purchaseCost,
);
final scheme = theme.colorScheme;
final activeColor =
asset.isActive ? const Color(0xFF16A34A) : scheme.onSurfaceVariant;
return SingleChildScrollView(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1200),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AppCard(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Text(
'Asset Details',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
),
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: activeColor,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: activeColor.withValues(alpha: 0.35),
blurRadius: 6,
),
],
),
),
const SizedBox(width: 8),
Text(
asset.isActive ? 'Active' : 'Inactive',
style: theme.textTheme.labelMedium?.copyWith(
color: activeColor,
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: 16),
// 1) Identity
DetailOverviewSection(
title: 'Identity',
child: DetailInfoGrid(
items: [
DetailInfoItem('Asset Name', asset.assetName),
DetailInfoItem('Asset Code', asset.assetCode ?? ''),
DetailInfoItem('Location', asset.locationName ?? ''),
DetailInfoItem(
'Asset Category',
asset.assetCategoryName ?? '',
),
DetailInfoItem(
'Subcategory',
asset.assetSubcategoryName ?? '',
),
DetailInfoItem(
'Manufacturer',
asset.manufacturer ?? '',
),
DetailInfoItem(
'Brand / Model',
asset.brandModel ?? '',
),
DetailInfoItem(
'Serial Number',
asset.serialNumber ?? '',
),
],
),
),
// 2) Purchase & Warranty
DetailOverviewSection(
title: 'Purchase & Warranty',
child: DetailInfoGrid(
items: [
DetailInfoItem(
'Purchase Date - Warranty Expiry',
_purchaseWarrantyRange(
purchaseDate: asset.purchaseDate,
warrantyExpiryDate: asset.warrantyExpiryDate,
dateFormat: dateFormat,
),
),
DetailInfoItem(
'Purchase Cost',
asset.purchaseCost != null
? CurrencyFormatter.format(asset.purchaseCost)
: '',
),
DetailInfoItem.widget(
'PO Number',
_DocumentNumberLink(
label: asset.poNumber,
enabled: asset.poId != null &&
(asset.poNumber?.trim().isNotEmpty ??
false),
onTap: () {
final poId = asset.poId;
if (poId == null) return;
showPurchaseOrderPreviewDialog(
context,
purchaseOrderId: poId.toString(),
);
},
),
),
DetailInfoItem.widget(
'Purchase Receipt Number',
_DocumentNumberLink(
label: asset.grnNumber,
enabled: asset.grnId != null &&
(asset.grnNumber?.trim().isNotEmpty ??
false),
onTap: () {
final grnId = asset.grnId;
if (grnId == null) return;
showGrnPreviewDialog(
context,
grnId: grnId.toString(),
);
},
),
),
],
),
),
// 4) Valuation & Depreciation
DetailOverviewSection(
title: 'Valuation & Depreciation',
child: _ValuationDepreciationCard(
currentValue: asset.resolvedCurrentValue,
purchaseCost: asset.purchaseCost,
retainedPct: retainedPct,
commencementDate: asset.commencementDate,
usefulLifeYears: asset.usefulLifeYears,
depreciationMethod: asset.depreciationMethod,
depreciationRate: asset.depreciationRate,
annualDepreciation:
asset.depreciation?.annualDepreciation,
depreciatedAmount: asset.depreciatedAmount,
salvageValue: asset.salvageValue,
salvagePercentage: asset.salvagePercentage,
dateFormat: dateFormat,
),
),
// 5) Maintenance
DetailOverviewSection(
title: 'Maintenance',
showDivider: false,
child: DetailInfoGrid(
items: [
DetailInfoItem(
'Maintenance Incharge',
maintenanceInchargeLabel,
),
DetailInfoItem(
'Maintenance Frequency',
asset.maintenanceFrequencyInDays != null
? '${asset.maintenanceFrequencyInDays} days'
: '',
),
DetailInfoItem.widget(
'Next Due Date',
_nextDueDateValue(
theme: theme,
nextDueDate: asset.maintenance?.nextDueDate,
dateFormat: dateFormat,
daysUntilDueDisplay: daysUntilDueDisplay,
),
),
DetailInfoItem(
'Condition',
assetConditionLabel(asset.condition),
),
],
),
),
if (asset.maintenanceFrequencyInDays != null ||
asset.maintenanceChecklistJson?.isNotEmpty == true ||
asset.maintenance != null) ...[
const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Divider(height: 1),
),
AssetRecentMaintenanceLogsSection(
assetId: assetId,
leadingActions: [
OutlinedButton.icon(
onPressed: () async {
final saved = await openSubmitMaintenancePanel(
context,
ref,
asset: asset,
);
if (saved == true && context.mounted) {
ref.invalidate(myMaintenanceProvider);
ref.invalidate(assetDetailProvider(assetId));
showAppToastFromSnackBar(
context,
const SnackBar(
content:
Text('Maintenance log submitted'),
),
);
}
},
icon: const Icon(Icons.checklist_outlined),
label: const Text('Log Maintenance'),
),
],
trailingActions: [
OutlinedButton.icon(
onPressed: onOpenTransferHistory,
icon: const Icon(Icons.history, size: 18),
label: const Text('Transfer History'),
),
],
),
] else ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton.icon(
onPressed: onOpenTransferHistory,
icon: const Icon(Icons.history, size: 18),
label: const Text('Transfer History'),
),
),
],
if (asset.remarks?.trim().isNotEmpty == true) ...[
const Padding(
padding: EdgeInsets.symmetric(vertical: 20),
child: Divider(height: 1),
),
DetailInfoGrid(
items: [DetailInfoItem('Remarks', asset.remarks!)],
),
],
],
),
),
),
const SizedBox(height: 16),
EntityAttachmentsCard(
attachments: attachments,
canUpload: canUpload,
canDelete: canDelete,
subtitleWhenEditable:
'PDF, JPEG, PNG, or WebP · invoices, warranty cards, photos',
subtitleWhenReadonly: 'Supporting documents for this asset',
emptyUploadHint:
'No attachments yet. Upload invoices, warranty cards, or photos.',
onUpload: ({required bytes, required filename}) async {
await ref
.read(assetDetailProvider(assetId).notifier)
.uploadAttachment(bytes: bytes, filename: filename);
},
onDownload: (id) => ref
.read(assetDetailProvider(assetId).notifier)
.downloadAttachment(id),
onDelete: (id) => ref
.read(assetDetailProvider(assetId).notifier)
.deleteAttachment(id),
),
],
),
),
),
);
}
}
String _purchaseWarrantyRange({
required DateTime? purchaseDate,
required DateTime? warrantyExpiryDate,
required DateFormat dateFormat,
}) {
final purchase =
purchaseDate != null ? dateFormat.format(purchaseDate) : '';
final warranty =
warrantyExpiryDate != null ? dateFormat.format(warrantyExpiryDate) : '';
return '$purchase - $warranty';
}
class _DocumentNumberLink extends StatelessWidget {
const _DocumentNumberLink({
required this.label,
required this.enabled,
required this.onTap,
});
final String? label;
final bool enabled;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final text = label?.trim().isNotEmpty == true ? label!.trim() : '';
if (!enabled) {
return Text(
text,
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
);
}
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(4),
child: Text(
text,
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w700,
decoration: TextDecoration.underline,
decorationColor: theme.colorScheme.primary.withValues(alpha: 0.45),
),
),
);
}
}
Widget _nextDueDateValue({
required ThemeData theme,
required DateTime? nextDueDate,
required DateFormat dateFormat,
required MaintenanceDueDisplay? daysUntilDueDisplay,
}) {
final dateLabel =
nextDueDate != null ? dateFormat.format(nextDueDate) : '';
final valueStyle = theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.onSurface,
fontWeight: FontWeight.w600,
);
if (daysUntilDueDisplay == null) {
return Text(dateLabel, style: valueStyle);
}
return Text.rich(
TextSpan(
style: valueStyle,
children: [
TextSpan(text: dateLabel),
TextSpan(
text: ' (${daysUntilDueDisplay.label})',
style: valueStyle?.copyWith(
color: daysUntilDueDisplay.color,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
double? _retainedPercentage({
required double? currentValue,
required double? purchaseCost,
}) {
if (currentValue == null || purchaseCost == null || purchaseCost <= 0) {
return null;
}
return (currentValue / purchaseCost * 100).clamp(0, 100);
}
class _ValuationDepreciationCard extends StatelessWidget {
const _ValuationDepreciationCard({
required this.currentValue,
required this.purchaseCost,
required this.retainedPct,
required this.commencementDate,
required this.usefulLifeYears,
required this.depreciationMethod,
required this.depreciationRate,
required this.annualDepreciation,
required this.depreciatedAmount,
required this.salvageValue,
required this.salvagePercentage,
required this.dateFormat,
});
final double? currentValue;
final double? purchaseCost;
final double? retainedPct;
final DateTime? commencementDate;
final int? usefulLifeYears;
final String? depreciationMethod;
final double? depreciationRate;
final double? annualDepreciation;
final double? depreciatedAmount;
final double? salvageValue;
final double? salvagePercentage;
final DateFormat dateFormat;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final onCard = scheme.onPrimary;
final muted = onCard.withValues(alpha: 0.72);
final progress = retainedPct == null ? 0.0 : retainedPct! / 100;
final subtitleParts = <String>[];
if (purchaseCost != null) {
subtitleParts.add(
'of ${CurrencyFormatter.format(purchaseCost)} purchase cost',
);
}
if (commencementDate != null) {
subtitleParts.add('commenced ${dateFormat.format(commencementDate!)}');
}
final methodLabel = depreciationMethod == null
? ''
: '$depreciationMethod'
'${depreciationRate != null ? ' ($depreciationRate%)' : ''}';
final detailItems = <(String, String)>[
(
'Commencement Date',
commencementDate != null ? dateFormat.format(commencementDate!) : '',
),
(
'Useful Life',
usefulLifeYears != null ? '$usefulLifeYears years' : '',
),
('Depreciation Method', methodLabel),
(
'Annual Depreciation',
annualDepreciation != null
? CurrencyFormatter.format(annualDepreciation)
: '',
),
(
'Depreciated Amount',
depreciatedAmount != null
? CurrencyFormatter.format(depreciatedAmount)
: '',
),
(
'Salvage Value',
salvageValue != null ? CurrencyFormatter.format(salvageValue) : '',
),
(
'Salvage Percentage',
salvagePercentage != null ? '$salvagePercentage%' : '',
),
];
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
scheme.primary,
Color.lerp(scheme.primary, scheme.secondary, 0.55)!,
scheme.secondary,
],
stops: const [0, 0.55, 1],
),
boxShadow: [
BoxShadow(
color: scheme.primary.withValues(alpha: 0.28),
blurRadius: 18,
offset: const Offset(0, 8),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: 88,
height: 88,
child: Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: 88,
height: 88,
child: CircularProgressIndicator(
value: progress,
strokeWidth: 7,
backgroundColor: onCard.withValues(alpha: 0.18),
color: Color.lerp(scheme.secondary, Colors.white, 0.45)!,
strokeCap: StrokeCap.round,
),
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
retainedPct == null
? ''
: '${retainedPct!.toStringAsFixed(2)}%',
style: theme.textTheme.titleMedium?.copyWith(
color: onCard,
fontWeight: FontWeight.w800,
),
),
Text(
'RETAINED',
style: theme.textTheme.labelSmall?.copyWith(
color: muted,
fontWeight: FontWeight.w600,
letterSpacing: 0.6,
fontSize: 9,
),
),
],
),
],
),
),
const SizedBox(width: 18),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'CURRENT VALUE',
style: theme.textTheme.labelSmall?.copyWith(
color: muted,
fontWeight: FontWeight.w600,
letterSpacing: 0.8,
),
),
const SizedBox(height: 4),
Text(
currentValue != null
? CurrencyFormatter.format(currentValue)
: '',
style: theme.textTheme.headlineSmall?.copyWith(
color: onCard,
fontWeight: FontWeight.w800,
),
),
if (subtitleParts.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
subtitleParts.join(' · '),
style: theme.textTheme.bodySmall?.copyWith(
color: muted,
),
),
],
],
),
),
],
),
const SizedBox(height: 18),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: onCard.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: onCard.withValues(alpha: 0.12)),
),
child: LayoutBuilder(
builder: (context, constraints) {
final cols = constraints.maxWidth < 560
? 2
: constraints.maxWidth < 820
? 3
: 4;
const spacing = 16.0;
final colWidth = (constraints.maxWidth - spacing * (cols - 1)) /
cols;
return Wrap(
spacing: spacing,
runSpacing: 14,
children: [
for (final item in detailItems)
SizedBox(
width: colWidth,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.$1,
style: theme.textTheme.labelSmall?.copyWith(
color: muted,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
item.$2,
style: theme.textTheme.bodyMedium?.copyWith(
color: onCard,
fontWeight: FontWeight.w700,
),
),
],
),
),
],
);
},
),
),
],
),
);
}
}
String _userLabel(int? userId, List<FilterOptionModel> users) {
if (userId == null || userId <= 0) return '';
final id = userId.toString();
for (final user in users) {
if (user.id == id && user.name.trim().isNotEmpty) {
return user.name.trim();
}
}
return id;
}
class _AmcTab extends ConsumerWidget {
const _AmcTab({required this.assetId, required this.contracts});
final String assetId;
final List<AmcContractModel> contracts;
@override
Widget build(BuildContext context, WidgetRef ref) {
final vendors = ref.watch(assetFormLookupsProvider).valueOrNull?.vendors ??
const <FilterOptionModel>[];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CanPermission(
module: 'assets',
action: PermissionAction.create,
child: Align(
alignment: Alignment.centerRight,
child: ElevatedButton.icon(
onPressed: () => _openAddAmcPanel(context, ref),
icon: const Icon(Icons.add),
label: const Text('Add AMC'),
),
),
),
const SizedBox(height: 12),
Expanded(
child: contracts.isEmpty
? const AppEmptyState(
title: 'No AMC contracts',
description: 'Add annual maintenance contracts for this asset.',
icon: Icons.handyman_outlined,
)
: GridView.builder(
padding: EdgeInsets.zero,
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 320,
mainAxisExtent: 175,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: contracts.length,
itemBuilder: (context, index) {
return _AssetAmcCard(
contract: contracts[index],
vendors: vendors,
onEdit: () => _openEditAmcPanel(
context,
ref,
contracts[index].id,
),
);
},
),
),
],
);
}
Future<void> _openAddAmcPanel(BuildContext context, WidgetRef ref) async {
final saved = await showSidePanel<bool>(
context,
AddAmcPanel(assetId: assetId),
width: 520,
);
if (saved == true && context.mounted) {
showAppToastFromSnackBar(context,
const SnackBar(content: Text('AMC contract created')),
);
}
}
Future<void> _openEditAmcPanel(
BuildContext context,
WidgetRef ref,
String contractId,
) async {
final saved = await showSidePanel<bool>(
context,
AddAmcPanel(assetId: assetId, contractId: contractId),
width: 520,
);
if (saved == true && context.mounted) {
showAppToastFromSnackBar(context,
const SnackBar(content: Text('AMC contract updated')),
);
}
}
}
class _ServiceVisitsTab extends ConsumerWidget {
const _ServiceVisitsTab({
required this.assetId,
required this.visits,
required this.amcContracts,
});
final String assetId;
final List<ServiceVisitModel> visits;
final List<AmcContractModel> amcContracts;
@override
Widget build(BuildContext context, WidgetRef ref) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CanPermission(
module: 'assets',
action: PermissionAction.create,
child: Align(
alignment: Alignment.centerRight,
child: ElevatedButton.icon(
onPressed: () => _openLogVisitPanel(context, ref),
icon: const Icon(Icons.add),
label: const Text('Log Visit'),
),
),
),
const SizedBox(height: 12),
Expanded(
child: visits.isEmpty
? const AppEmptyState(
title: 'No service visits',
description: 'Log service visits and maintenance work for this asset.',
icon: Icons.build_outlined,
)
: GridView.builder(
padding: EdgeInsets.zero,
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 320,
mainAxisExtent: 175,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: visits.length,
itemBuilder: (context, index) {
return _AssetServiceVisitCard(
visit: visits[index],
onEdit: () => _openEditVisitPanel(
context,
ref,
visits[index].id,
),
);
},
),
),
],
);
}
Future<void> _openLogVisitPanel(BuildContext context, WidgetRef ref) async {
final saved = await showSidePanel<bool>(
context,
LogServiceVisitPanel(
assetId: assetId,
amcContracts: amcContracts,
),
width: 560,
);
if (saved == true && context.mounted) {
showAppToastFromSnackBar(context,
const SnackBar(content: Text('Service visit logged')),
);
}
}
Future<void> _openEditVisitPanel(
BuildContext context,
WidgetRef ref,
String visitId,
) async {
final saved = await showSidePanel<bool>(
context,
LogServiceVisitPanel(
assetId: assetId,
visitId: visitId,
amcContracts: amcContracts,
),
width: 560,
);
if (saved == true && context.mounted) {
showAppToastFromSnackBar(context,
const SnackBar(content: Text('Service visit updated')),
);
}
}
}
class _InsuranceTab extends ConsumerWidget {
const _InsuranceTab({required this.assetId, required this.policies});
final String assetId;
final List<InsurancePolicyModel> policies;
@override
Widget build(BuildContext context, WidgetRef ref) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CanPermission(
module: 'assets',
action: PermissionAction.create,
child: Align(
alignment: Alignment.centerRight,
child: ElevatedButton.icon(
onPressed: () => _openAddInsurancePanel(context, ref),
icon: const Icon(Icons.add),
label: const Text('Add Policy'),
),
),
),
const SizedBox(height: 12),
Expanded(
child: policies.isEmpty
? const AppEmptyState(
title: 'No insurance policies',
description: 'Add insurance policies for this asset.',
icon: Icons.shield_outlined,
)
: GridView.builder(
padding: EdgeInsets.zero,
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 320,
mainAxisExtent: 175,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: policies.length,
itemBuilder: (context, index) {
return _AssetInsuranceCard(
policy: policies[index],
onEdit: () => _openEditInsurancePanel(
context,
ref,
policies[index].id,
),
);
},
),
),
],
);
}
Future<void> _openAddInsurancePanel(BuildContext context, WidgetRef ref) async {
final saved = await showSidePanel<bool>(
context,
AddInsurancePanel(assetId: assetId),
width: 520,
);
if (saved == true && context.mounted) {
showAppToastFromSnackBar(context,
const SnackBar(content: Text('Insurance policy created')),
);
}
}
Future<void> _openEditInsurancePanel(
BuildContext context,
WidgetRef ref,
String policyId,
) async {
final saved = await showSidePanel<bool>(
context,
AddInsurancePanel(assetId: assetId, policyId: policyId),
width: 520,
);
if (saved == true && context.mounted) {
showAppToastFromSnackBar(context,
const SnackBar(content: Text('Insurance policy updated')),
);
}
}
}
class _AssetAmcCard extends StatelessWidget {
const _AssetAmcCard({
required this.contract,
required this.vendors,
this.onEdit,
});
final AmcContractModel contract;
final List<FilterOptionModel> vendors;
final VoidCallback? onEdit;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final dateFormat = DateFormat('dd MMM yyyy');
final statusColor = _assetStatusColor(contract.status, theme);
final vendor = _contractVendorLabel(contract, vendors);
return AppCard(
elevation: 0,
enableHover: true,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: const Color(0xFFCA8A04).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.handyman_outlined,
color: Color(0xFFCA8A04),
size: 20,
),
),
const Spacer(),
if (onEdit != null)
CanPermission(
module: 'assets',
action: PermissionAction.update,
child: IconButton(
icon: const Icon(Icons.edit_outlined, size: 18),
tooltip: 'Edit',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
onPressed: onEdit,
),
),
],
),
const SizedBox(height: 8),
Text(
contract.contractNo?.trim().isNotEmpty == true
? contract.contractNo!
: 'AMC #${contract.id}',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
vendor,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Row(
children: [
Icon(
Icons.calendar_today_outlined,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Expanded(
child: Text(
'${dateFormat.format(contract.startDate)} '
'${dateFormat.format(contract.endDate)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
const Spacer(),
Row(
children: [
if (contract.annualCost != null)
Text(
'${contract.annualCost}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: statusColor,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
Text(
_assetStatusLabel(contract.status),
style: theme.textTheme.bodySmall?.copyWith(
color: statusColor,
fontWeight: FontWeight.w600,
),
),
],
),
],
),
),
);
}
}
class _AssetServiceVisitCard extends StatelessWidget {
const _AssetServiceVisitCard({
required this.visit,
this.onEdit,
});
final ServiceVisitModel visit;
final VoidCallback? onEdit;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final dateFormat = DateFormat('dd MMM yyyy');
final statusColor = _assetStatusColor(visit.status, theme);
return AppCard(
elevation: 0,
enableHover: true,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: const Color(0xFF2563EB).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.build_outlined,
color: Color(0xFF2563EB),
size: 20,
),
),
const Spacer(),
if (onEdit != null)
CanPermission(
module: 'assets',
action: PermissionAction.update,
child: IconButton(
icon: const Icon(Icons.edit_outlined, size: 18),
tooltip: 'Edit',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
onPressed: onEdit,
),
),
],
),
const SizedBox(height: 8),
Text(
visit.visitType,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
if (visit.workDone?.trim().isNotEmpty == true) ...[
const SizedBox(height: 4),
Text(
visit.workDone!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
const SizedBox(height: 8),
Row(
children: [
Icon(
Icons.event_outlined,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Expanded(
child: Text(
dateFormat.format(visit.visitDate),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
const Spacer(),
Row(
children: [
if (visit.nextServiceDate != null)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.schedule_outlined,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
dateFormat.format(visit.nextServiceDate!),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
const Spacer(),
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: statusColor,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
Text(
_assetStatusLabel(visit.status),
style: theme.textTheme.bodySmall?.copyWith(
color: statusColor,
fontWeight: FontWeight.w600,
),
),
],
),
],
),
),
);
}
}
class _AssetInsuranceCard extends StatelessWidget {
const _AssetInsuranceCard({
required this.policy,
this.onEdit,
});
final InsurancePolicyModel policy;
final VoidCallback? onEdit;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final dateFormat = DateFormat('dd MMM yyyy');
final statusColor = _assetStatusColor(policy.status, theme);
return AppCard(
elevation: 0,
enableHover: true,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: const Color(0xFF16A34A).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.shield_outlined,
color: Color(0xFF16A34A),
size: 20,
),
),
const Spacer(),
if (onEdit != null)
CanPermission(
module: 'assets',
action: PermissionAction.update,
child: IconButton(
icon: const Icon(Icons.edit_outlined, size: 18),
tooltip: 'Edit',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
onPressed: onEdit,
),
),
],
),
const SizedBox(height: 8),
Text(
policy.policyNo,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
policy.insurerName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Row(
children: [
Icon(
Icons.calendar_today_outlined,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Expanded(
child: Text(
'${dateFormat.format(policy.policyStartDate)} '
'${dateFormat.format(policy.policyEndDate)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
const Spacer(),
Row(
children: [
if (policy.sumInsured != null)
Text(
'${policy.sumInsured}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: statusColor,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
Text(
_assetStatusLabel(policy.status),
style: theme.textTheme.bodySmall?.copyWith(
color: statusColor,
fontWeight: FontWeight.w600,
),
),
],
),
],
),
),
);
}
}
Color _assetStatusColor(String status, ThemeData theme) {
switch (status.toLowerCase()) {
case 'active':
case 'completed':
return const Color(0xFF16A34A);
case 'pending':
case 'scheduled':
return const Color(0xFFCA8A04);
case 'cancelled':
case 'inactive':
case 'expired':
return theme.colorScheme.onSurfaceVariant;
default:
return const Color(0xFF2563EB);
}
}
String _contractVendorLabel(
AmcContractModel contract,
List<FilterOptionModel> vendors,
) {
final direct = contract.vendorName?.trim();
if (direct != null && direct.isNotEmpty) return direct;
final vendorId = contract.vendorId;
if (vendorId != null) {
final id = vendorId.toString();
for (final vendor in vendors) {
if (vendor.id == id && vendor.name.trim().isNotEmpty) {
return vendor.name.trim();
}
}
}
return '';
}
String _assetStatusLabel(String status) {
if (status.isEmpty) return '';
final normalized = status.replaceAll('_', ' ').toLowerCase();
return normalized.split(' ').map((word) {
if (word.isEmpty) return word;
return '${word[0].toUpperCase()}${word.substring(1)}';
}).join(' ');
}