bharat_erp/lib/modules/grn/presentation/screens/grn_detail_screen.dart

678 lines
20 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.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/grn_model.dart';
import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/providers/permissions_provider.dart';
import '../../../../shared/utils/file_download_helper.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/detail_overview_widgets.dart';
import '../../../../shared/widgets/error_view.dart';
import '../providers/grn_lookups_provider.dart';
import '../providers/grn_provider.dart';
import '../widgets/grn_attachments_card.dart';
import '../widgets/grn_line_items_editor.dart';
import '../widgets/grn_status_chip.dart';
import '../../../../shared/widgets/api_feedback.dart';
import '../../../../shared/widgets/app_toast.dart';
class GrnDetailScreen extends ConsumerStatefulWidget {
const GrnDetailScreen({super.key, required this.grnId});
final String grnId;
@override
ConsumerState<GrnDetailScreen> createState() => _GrnDetailScreenState();
}
class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
bool _isWorking = false;
bool _isDownloadingPdf = false;
bool _requestedFreshLoad = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_requestedFreshLoad) return;
_requestedFreshLoad = true;
// Always hit GET /grn/{id} when opening view.
ref.invalidate(grnDetailProvider(widget.grnId));
}
@override
void didUpdateWidget(covariant GrnDetailScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.grnId != widget.grnId) {
ref.invalidate(grnDetailProvider(widget.grnId));
}
}
@override
Widget build(BuildContext context) {
final detailAsync = ref.watch(grnDetailProvider(widget.grnId));
final lookupsAsync = ref.watch(grnLookupsProvider);
final canEdit = ref.can('grn', PermissionAction.update);
final canDelete = ref.can('grn', PermissionAction.delete);
final canExport = ref.can('grn', PermissionAction.export);
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: detailAsync.when(
loading: () => const AppLoadingView(message: 'Loading Purchase Receipt...'),
error: (e, _) => ErrorView.fromFailure(
e is Failure ? e : Failure.unknown(message: e.toString()),
onRetry: () => ref.invalidate(grnDetailProvider(widget.grnId)),
),
data: (grn) {
final lookups = lookupsAsync.asData?.value;
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_DetailHeader(
grn: grn,
isWorking: _isWorking,
isDownloadingPdf: _isDownloadingPdf,
canEdit: canEdit,
canExport: canExport,
onBack: () => context.go(RouteConstants.grn),
onPdf: () => _downloadPdf(grn),
onEdit: () => context.push(
'${RouteConstants.grn}/${grn.id}/edit',
),
onCancel: () => _cancel(grn),
),
if (grn.cancellationReason != null &&
grn.cancellationReason!.isNotEmpty) ...[
const SizedBox(height: 12),
_CancellationBanner(reason: grn.cancellationReason!),
],
const SizedBox(height: 16),
_ReceiptDetailsCard(grn: grn, lookups: lookups),
const SizedBox(height: 16),
_LineItemsCard(grn: grn),
const SizedBox(height: 16),
GrnAttachmentsCard(
grn: grn,
canUpload: canEdit,
canDelete: canDelete,
),
if (grn.remarks?.trim().isNotEmpty == true) ...[
const SizedBox(height: 16),
_SectionCard(
title: 'REMARKS',
child: Text(
grn.remarks!.trim(),
style: Theme.of(context).textTheme.bodyLarge,
),
),
],
const SizedBox(height: 20),
_DetailFooter(grn: grn),
],
),
);
},
),
);
}
Future<void> _runWorkflow(
Future<void> Function() action,
String success,
) async {
setState(() => _isWorking = true);
try {
await action();
if (mounted) {
showAppToastFromSnackBar(context, SnackBar(content: Text(success)));
}
} catch (e) {
if (mounted) {
showAppToastFromSnackBar(
context,
SnackBar(content: Text(errorDisplayMessage(e))),
);
}
} finally {
if (mounted) setState(() => _isWorking = false);
}
}
Future<void> _cancel(GrnModel grn) async {
final reasonController = TextEditingController();
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Cancel Purchase Receipt'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Cancel ${grn.grnNumber ?? grn.id}? This will reverse PO receipts.',
),
const SizedBox(height: 16),
AppTextField(
label: 'Cancellation Reason *',
controller: reasonController,
maxLines: 3,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Close'),
),
FilledButton(
onPressed: () {
if (reasonController.text.trim().isEmpty) return;
Navigator.pop(context, true);
},
child: const Text('Cancel Purchase Receipt'),
),
],
),
);
if (confirmed != true || !mounted) return;
await _runWorkflow(
() => ref.read(grnDetailProvider(widget.grnId).notifier).cancel(
cancellationReason: reasonController.text.trim(),
),
'Purchase Receipt cancelled',
);
reasonController.dispose();
}
Future<void> _downloadPdf(GrnModel grn) async {
setState(() => _isDownloadingPdf = true);
try {
final bytes = await ref
.read(grnDetailProvider(widget.grnId).notifier)
.downloadPdf();
await downloadFile(
bytes: bytes,
fileName: '${grn.grnNumber ?? 'PurchaseReceipt-${grn.id}'}.pdf',
);
if (mounted) {
showAppToastFromSnackBar(context, const SnackBar(content: Text('PDF downloaded')));
}
} catch (e) {
if (mounted) {
showAppToastFromSnackBar(
context,
SnackBar(content: Text(errorDisplayMessage(e))),
);
}
} finally {
if (mounted) setState(() => _isDownloadingPdf = false);
}
}
}
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 _displayOrDash(String? value) {
final trimmed = value?.trim();
if (trimmed == null || trimmed.isEmpty) return '';
return trimmed;
}
class _DetailHeader extends StatelessWidget {
const _DetailHeader({
required this.grn,
required this.isWorking,
required this.isDownloadingPdf,
required this.canEdit,
required this.canExport,
required this.onBack,
required this.onPdf,
required this.onEdit,
required this.onCancel,
});
final GrnModel grn;
final bool isWorking;
final bool isDownloadingPdf;
final bool canEdit;
final bool canExport;
final VoidCallback onBack;
final VoidCallback onPdf;
final VoidCallback onEdit;
final VoidCallback onCancel;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final subtitleParts = [
if (grn.poNumber?.trim().isNotEmpty == true) 'PO ${grn.poNumber!.trim()}',
if (grn.vendorName?.trim().isNotEmpty == true) grn.vendorName!.trim(),
if (grn.locationName?.trim().isNotEmpty == true)
grn.locationName!.trim(),
];
final actions = Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.end,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
if (canExport)
_HeaderActionButton(
label: 'PDF',
icon: Icons.description_outlined,
isLoading: isDownloadingPdf,
onPressed: (isWorking || isDownloadingPdf) ? null : onPdf,
),
if (canEdit && grn.canEdit)
_HeaderActionButton(
label: 'Edit',
icon: Icons.edit_outlined,
onPressed: isWorking ? null : onEdit,
),
if (canEdit && grn.canCancel)
_HeaderActionButton(
label: 'Cancel',
icon: Icons.block_outlined,
destructive: true,
onPressed: isWorking ? null : onCancel,
),
],
);
final titleBlock = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
grn.grnNumber ?? 'Purchase Receipt #${grn.id}',
style: theme.textTheme.headlineSmall,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 10),
GrnStatusChip(status: grn.status, compact: true),
],
),
if (subtitleParts.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
subtitleParts.join(' · '),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
],
);
return LayoutBuilder(
builder: (context, constraints) {
final stack = constraints.maxWidth < 800;
if (stack) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
IconButton(
tooltip: 'Back',
onPressed: onBack,
icon: const Icon(Icons.arrow_back),
),
const SizedBox(width: 4),
Expanded(child: titleBlock),
],
),
const SizedBox(height: 12),
actions,
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
IconButton(
tooltip: 'Back',
onPressed: onBack,
icon: const Icon(Icons.arrow_back),
),
const SizedBox(width: 4),
Expanded(child: titleBlock),
const SizedBox(width: 12),
actions,
],
);
},
);
}
}
class _HeaderActionButton extends StatelessWidget {
const _HeaderActionButton({
required this.label,
required this.icon,
required this.onPressed,
this.isLoading = false,
this.destructive = false,
});
final String label;
final IconData icon;
final VoidCallback? onPressed;
final bool isLoading;
final bool destructive;
static const double _height = 40;
static const double _radius = 8;
static const EdgeInsets _padding =
EdgeInsets.symmetric(horizontal: 14, vertical: 0);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final error = theme.colorScheme.error;
final style = ButtonStyle(
minimumSize: const WidgetStatePropertyAll(Size(0, _height)),
fixedSize: const WidgetStatePropertyAll(Size.fromHeight(_height)),
padding: const WidgetStatePropertyAll(_padding),
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(_radius)),
),
visualDensity: VisualDensity.standard,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
);
final child = Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isLoading)
SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: destructive ? error : null,
),
)
else
Icon(icon, size: 18),
const SizedBox(width: 8),
Text(label),
],
);
if (destructive) {
return OutlinedButton(
onPressed: onPressed,
style: style.copyWith(
foregroundColor: WidgetStatePropertyAll(error),
side: WidgetStatePropertyAll(BorderSide(color: error)),
),
child: child,
);
}
return OutlinedButton(
onPressed: onPressed,
style: style,
child: child,
);
}
}
class _CancellationBanner extends StatelessWidget {
const _CancellationBanner({required this.reason});
final String reason;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.error.withValues(alpha: 0.35),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.info_outline, color: theme.colorScheme.error, size: 20),
const SizedBox(width: 10),
Expanded(
child: Text(
'Cancellation reason: $reason',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.error,
),
),
),
],
),
);
}
}
class _SectionCard extends StatelessWidget {
const _SectionCard({
required this.title,
required this.child,
});
final String title;
final Widget child;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: theme.colorScheme.outline.withValues(alpha: 0.2),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
title,
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
letterSpacing: 0.8,
),
),
const SizedBox(height: 16),
child,
],
),
);
}
}
class _ReceiptDetailsCard extends StatelessWidget {
const _ReceiptDetailsCard({
required this.grn,
required this.lookups,
});
final GrnModel grn;
final GrnLookups? lookups;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final locationDisplay = _displayOrDash(
[
if (grn.locationName?.trim().isNotEmpty == true)
grn.locationName!.trim(),
if (grn.locationType?.trim().isNotEmpty == true)
'(${grn.locationType!.trim()})',
].join(' ').trim(),
);
return _SectionCard(
title: 'RECEIPT DETAILS',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DetailOverviewSection(
title: 'Summary',
child: DetailSummaryStrip(
metrics: [
DetailSummaryMetric(
icon: Icons.flag_outlined,
label: 'Status',
accent: scheme.secondary,
child: GrnStatusChip(status: grn.status, compact: true),
),
DetailSummaryMetric(
icon: Icons.calendar_today_outlined,
label: 'Purchase Receipt Date',
child: Text(
DateFormatter.displayDate(grn.grnDate),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
DetailSummaryMetric(
icon: Icons.receipt_long_outlined,
label: 'PO Number',
child: Text(
_displayOrDash(grn.poNumber),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
DetailSummaryMetric(
icon: Icons.payments_outlined,
label: 'Invoice Amount',
accent: scheme.primary,
child: Text(
grn.vendorInvoiceAmount != null
? CurrencyFormatter.format(grn.vendorInvoiceAmount!)
: '',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
),
],
),
),
DetailOverviewSection(
title: 'Receipt',
child: DetailInfoGrid(
items: [
DetailInfoItem('Vendor', _displayOrDash(grn.vendorName)),
DetailInfoItem('Location', locationDisplay),
DetailInfoItem(
'Vendor Invoice No',
_displayOrDash(grn.vendorInvoiceNo),
),
DetailInfoItem(
'Vendor Invoice Date',
DateFormatter.displayDate(grn.vendorInvoiceDate),
),
DetailInfoItem(
'Received By',
_userLabel(lookups?.users, grn.receivedBy),
),
DetailInfoItem(
'Quality Checked By',
_userLabel(lookups?.users, grn.qualityCheckedBy),
),
],
),
),
DetailOverviewSection(
title: 'Transport',
showDivider: false,
child: DetailInfoGrid(
items: [
DetailInfoItem('Vehicle No', _displayOrDash(grn.vehicleNo)),
DetailInfoItem('LR No', _displayOrDash(grn.lrNo)),
DetailInfoItem(
'LR Date',
DateFormatter.displayDate(grn.lrDate),
),
],
),
),
],
),
);
}
}
class _LineItemsCard extends StatelessWidget {
const _LineItemsCard({required this.grn});
final GrnModel grn;
@override
Widget build(BuildContext context) {
return _SectionCard(
title: 'LINE ITEMS · ${grn.items.length}',
child: GrnItemsTable(items: grn.items),
);
}
}
class _DetailFooter extends StatelessWidget {
const _DetailFooter({required this.grn});
final GrnModel grn;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final parts = <String>[
if (grn.createdAt != null)
'Created ${DateFormatter.displayDateTime(grn.createdAt)}',
if (grn.updatedAt != null)
'Updated ${DateFormatter.displayDateTime(grn.updatedAt)}',
];
if (parts.isEmpty) return const SizedBox.shrink();
return Text(
parts.join(' · '),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
);
}
}