This commit is contained in:
Surendiran 2026-07-06 09:43:09 +05:30
parent bea7e37090
commit b23a74442f
17 changed files with 2200 additions and 590 deletions

View File

@ -207,6 +207,115 @@ class Validators {
}
static final RegExp _roleNamePattern = RegExp(r'^[a-zA-Z0-9 ]+$');
static final RegExp _masterNamePattern =
RegExp(r'^[A-Za-z0-9 _/&.()\-]+$');
static final RegExp _masterNameCharPattern =
RegExp(r'[A-Za-z0-9 _/&.()\-]');
static bool isMasterNameFieldKey(String key) {
final normalizedKey = key.trim().toLowerCase();
return normalizedKey == 'name' || normalizedKey == 'item_name';
}
/// Required master name allowed: A-Z a-z 0-9 space - _ / & . ( )
static String? masterName(String? value, {String fieldName = 'Name'}) {
final requiredError = required(value, fieldName: fieldName);
if (requiredError != null) return requiredError;
return _validateMasterNameChars(value!.trim(), fieldName: fieldName);
}
static String? _validateMasterNameChars(
String raw, {
String fieldName = 'Name',
}) {
if (!_masterNamePattern.hasMatch(raw)) {
return '$fieldName can only contain letters, numbers, spaces, and - _ / & . ( )';
}
return null;
}
/// Validates master name format and uniqueness against existing records.
static String? uniqueMasterName(
String? value, {
required String nameKey,
required Iterable<Map<String, dynamic>> existingRecords,
String? currentRecordId,
String fieldName = 'Name',
}) {
final formatError = masterName(value, fieldName: fieldName);
if (formatError != null) return formatError;
final normalized = value!.trim().toLowerCase();
for (final record in existingRecords) {
final recordId = record['id']?.toString();
if (currentRecordId != null && recordId == currentRecordId) continue;
final existingName = record[nameKey]?.toString().trim().toLowerCase();
if (existingName != null && existingName.isNotEmpty && existingName == normalized) {
return '$fieldName must be unique';
}
}
return null;
}
static List<TextInputFormatter> get masterNameInput => [
FilteringTextInputFormatter.allow(_masterNameCharPattern),
];
static final RegExp _masterCodePattern = RegExp(r'^[A-Za-z0-9\-_/]+$');
static final RegExp _masterCodeCharPattern = RegExp(r'[A-Za-z0-9\-_/]');
static bool isMasterCodeFieldKey(String key) {
final normalizedKey = key.trim().toLowerCase();
return normalizedKey == 'code' || normalizedKey == 'item_code';
}
/// Required master code allowed: A-Z a-z 0-9 - _ /
static String? masterCode(String? value, {String fieldName = 'Code'}) {
final requiredError = required(value, fieldName: fieldName);
if (requiredError != null) return requiredError;
return _validateMasterCodeChars(value!.trim(), fieldName: fieldName);
}
static String? _validateMasterCodeChars(
String raw, {
String fieldName = 'Code',
}) {
if (!_masterCodePattern.hasMatch(raw)) {
return '$fieldName can only contain letters, numbers, and - _ /';
}
return null;
}
/// Validates master code format and uniqueness against existing records.
static String? uniqueMasterCode(
String? value, {
required String codeKey,
required Iterable<Map<String, dynamic>> existingRecords,
String? currentRecordId,
String fieldName = 'Code',
}) {
final formatError = masterCode(value, fieldName: fieldName);
if (formatError != null) return formatError;
final normalized = value!.trim().toLowerCase();
for (final record in existingRecords) {
final recordId = record['id']?.toString();
if (currentRecordId != null && recordId == currentRecordId) continue;
final existingCode = record[codeKey]?.toString().trim().toLowerCase();
if (existingCode != null && existingCode.isNotEmpty && existingCode == normalized) {
return '$fieldName must be unique';
}
}
return null;
}
static List<TextInputFormatter> get masterCodeInput => [
FilteringTextInputFormatter.allow(_masterCodeCharPattern),
];
/// Required role name letters, numbers, and spaces only.
static String? roleName(String? value) {
@ -258,6 +367,24 @@ class Validators {
if (normalizedKey == 'account_number' || normalizedKey == 'account_no') {
return required ? accountNumber(value) : optionalAccountNumber(value);
}
if (isMasterNameFieldKey(normalizedKey)) {
if (required) {
return masterName(value, fieldName: fieldName);
}
if (value != null && value.trim().isNotEmpty) {
return _validateMasterNameChars(value.trim(), fieldName: fieldName);
}
return null;
}
if (isMasterCodeFieldKey(normalizedKey)) {
if (required) {
return masterCode(value, fieldName: fieldName);
}
if (value != null && value.trim().isNotEmpty) {
return _validateMasterCodeChars(value.trim(), fieldName: fieldName);
}
return null;
}
if (required) {
return Validators.required(value, fieldName: fieldName);
@ -290,6 +417,12 @@ class Validators {
if (normalizedKey == 'pan') {
return panInput;
}
if (isMasterNameFieldKey(normalizedKey)) {
return masterNameInput;
}
if (isMasterCodeFieldKey(normalizedKey)) {
return masterCodeInput;
}
return const [];
}

View File

@ -10,6 +10,7 @@ import '../../../../shared/models/asset_model.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';
@ -182,33 +183,56 @@ class _OverviewTab extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final dateFormat = DateFormat('dd MMM yyyy');
return SingleChildScrollView(
child: AppCard(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
_DetailRow(label: 'Asset Name', value: asset.assetName),
_DetailRow(label: 'Asset Code', value: asset.assetCode ?? ''),
_DetailRow(label: 'Category', value: asset.assetCategoryName ?? ''),
_DetailRow(label: 'Plant', value: asset.plantName ?? ''),
_DetailRow(
label: 'Warranty Expiry',
value: asset.warrantyExpiryDate != null
? dateFormat.format(asset.warrantyExpiryDate!)
: '',
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1200),
child: AppCard(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Asset Details',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 12),
_AssetInfoGrid(
items: [
_AssetInfo('Asset Name', asset.assetName),
_AssetInfo('Asset Code', asset.assetCode ?? ''),
_AssetInfo('Category', asset.assetCategoryName ?? ''),
_AssetInfo('Plant', asset.plantName ?? ''),
_AssetInfo(
'Warranty Expiry',
asset.warrantyExpiryDate != null
? dateFormat.format(asset.warrantyExpiryDate!)
: '',
),
_AssetInfo(
'Purchase Cost',
asset.purchaseCost != null ? '${asset.purchaseCost}' : '',
),
_AssetInfo.status(
'Status',
AppStatusChip(status: asset.status ?? 'active'),
),
_AssetInfo(
'Active',
asset.isActive ? 'Yes' : 'No',
),
],
),
],
),
_DetailRow(
label: 'Purchase Cost',
value: asset.purchaseCost != null ? '${asset.purchaseCost}' : '',
),
_DetailRow(
label: 'Status',
valueWidget: AppStatusChip(status: asset.status ?? 'active'),
),
],
),
),
),
),
@ -216,6 +240,88 @@ class _OverviewTab extends StatelessWidget {
}
}
class _AssetInfoGrid extends StatelessWidget {
const _AssetInfoGrid({required this.items});
final List<_AssetInfo> items;
static const int _columns = 3;
@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: _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,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
valueWidget ?? Text(value ?? '', style: theme.textTheme.bodyLarge),
],
);
}
}
class _AssetInfo {
const _AssetInfo(this.label, this.value) : valueWidget = null;
const _AssetInfo.status(this.label, this.valueWidget) : value = null;
final String label;
final String? value;
final Widget? valueWidget;
}
class _AmcTab extends ConsumerWidget {
const _AmcTab({required this.assetId, required this.contracts});
@ -242,23 +348,22 @@ class _AmcTab extends ConsumerWidget {
const SizedBox(height: 12),
Expanded(
child: contracts.isEmpty
? const Center(child: Text('No AMC contracts'))
: ListView.separated(
? 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,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final c = contracts[index];
return AppCard(
child: ListTile(
title: Text(c.contractNo ?? 'AMC #${c.id}'),
subtitle: Text(
'${c.vendorName ?? 'Vendor ${c.vendorId ?? ''}'} · '
'${DateFormat('dd MMM yyyy').format(c.startDate)} '
'${DateFormat('dd MMM yyyy').format(c.endDate)}',
),
trailing: c.annualCost != null ? Text('${c.annualCost}') : null,
),
);
return _AssetAmcCard(contract: contracts[index]);
},
),
),
@ -306,22 +411,22 @@ class _ServiceVisitsTab extends ConsumerWidget {
const SizedBox(height: 12),
Expanded(
child: visits.isEmpty
? const Center(child: Text('No service visits'))
: ListView.separated(
? 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,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final v = visits[index];
return AppCard(
child: ListTile(
title: Text(v.visitType),
subtitle: Text(
'${DateFormat('dd MMM yyyy').format(v.visitDate)}'
'${v.workDone != null ? ' · ${v.workDone}' : ''}',
),
trailing: AppStatusChip(status: v.status, compact: true),
),
);
return _AssetServiceVisitCard(visit: visits[index]);
},
),
),
@ -369,23 +474,22 @@ class _InsuranceTab extends ConsumerWidget {
const SizedBox(height: 12),
Expanded(
child: policies.isEmpty
? const Center(child: Text('No insurance policies'))
: ListView.separated(
? 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,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final p = policies[index];
return AppCard(
child: ListTile(
title: Text(p.policyNo),
subtitle: Text(
'${p.insurerName} · '
'${DateFormat('dd MMM yyyy').format(p.policyStartDate)} '
'${DateFormat('dd MMM yyyy').format(p.policyEndDate)}',
),
trailing: p.sumInsured != null ? Text('${p.sumInsured}') : null,
),
);
return _AssetInsuranceCard(policy: policies[index]);
},
),
),
@ -407,37 +511,373 @@ class _InsuranceTab extends ConsumerWidget {
}
}
class _DetailRow extends StatelessWidget {
const _DetailRow({
required this.label,
this.value,
this.valueWidget,
});
class _AssetAmcCard extends StatelessWidget {
const _AssetAmcCard({required this.contract});
final String label;
final String? value;
final Widget? valueWidget;
final AmcContractModel contract;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
SizedBox(
width: 160,
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
final theme = Theme.of(context);
final dateFormat = DateFormat('dd MMM yyyy');
final statusColor = _assetStatusColor(contract.status, theme);
final vendor = contract.vendorName?.trim().isNotEmpty == true
? contract.vendorName!
: contract.vendorId != null
? 'Vendor ${contract.vendorId}'
: '';
return AppCard(
elevation: 0,
enableHover: true,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
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,
),
),
),
Expanded(
child: valueWidget ?? Text(value ?? '', style: Theme.of(context).textTheme.bodyLarge),
),
],
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});
final ServiceVisitModel visit;
@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: [
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 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});
final InsurancePolicyModel policy;
@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: [
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 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 _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(' ');
}

View File

@ -58,73 +58,79 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
),
data: (grn) => SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageHeader(
title: grn.grnNumber ?? 'GRN #${grn.id}',
subtitle:
'PO ${grn.poNumber ?? ''} · ${grn.vendorName ?? ''}',
actions: [
if (canExport)
OutlinedButton.icon(
onPressed: _isWorking ? null : () => _downloadPdf(grn),
icon: const Icon(Icons.picture_as_pdf_outlined),
label: const Text('PDF'),
),
if (canEdit && grn.canEdit) ...[
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: _isWorking
? null
: () => context.push(
'${RouteConstants.grn}/${grn.id}/edit',
),
icon: const Icon(Icons.edit_outlined),
label: const Text('Edit'),
),
],
if (canEdit && grn.canCancel) ...[
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: _isWorking ? null : () => _cancel(grn),
icon: const Icon(Icons.block_outlined),
label: const Text('Cancel'),
),
],
],
),
const SizedBox(height: 16),
GrnStatusChip(status: grn.status),
if (grn.cancellationReason != null &&
grn.cancellationReason!.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'Cancellation reason: ${grn.cancellationReason}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
],
const SizedBox(height: 16),
_OverviewCard(grn: grn),
const SizedBox(height: 16),
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1200),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageHeader(
title: grn.grnNumber ?? 'GRN #${grn.id}',
subtitle:
'PO ${grn.poNumber ?? ''} · ${grn.vendorName ?? ''}',
actions: [
if (canExport)
OutlinedButton.icon(
onPressed: _isWorking ? null : () => _downloadPdf(grn),
icon: const Icon(Icons.picture_as_pdf_outlined),
label: const Text('PDF'),
),
if (canEdit && grn.canEdit) ...[
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: _isWorking
? null
: () => context.push(
'${RouteConstants.grn}/${grn.id}/edit',
),
icon: const Icon(Icons.edit_outlined),
label: const Text('Edit'),
),
],
if (canEdit && grn.canCancel) ...[
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: _isWorking ? null : () => _cancel(grn),
icon: const Icon(Icons.block_outlined),
label: const Text('Cancel'),
),
],
],
),
const SizedBox(height: 16),
GrnStatusChip(status: grn.status),
if (grn.cancellationReason != null &&
grn.cancellationReason!.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'Line Items',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
'Cancellation reason: ${grn.cancellationReason}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
const SizedBox(height: 12),
GrnItemsTable(items: grn.items),
],
),
const SizedBox(height: 16),
_OverviewCard(grn: grn),
const SizedBox(height: 16),
AppCard(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Line Items',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
GrnItemsTable(items: grn.items),
],
),
),
),
],
),
],
),
),
),
),
@ -210,67 +216,138 @@ class _OverviewCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final hasRemarks = grn.remarks?.trim().isNotEmpty == true;
return AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Overview',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Overview',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 12),
_GrnInfoGrid(
columns: 4,
items: [
_GrnInfo('GRN Date', DateFormatter.displayDate(grn.grnDate)),
_GrnInfo('PO Number', grn.poNumber ?? ''),
_GrnInfo('Vendor', grn.vendorName ?? ''),
_GrnInfo('Warehouse', grn.warehouseName ?? ''),
_GrnInfo('Vendor Invoice No', grn.vendorInvoiceNo ?? ''),
_GrnInfo(
'Vendor Invoice Date',
DateFormatter.displayDate(grn.vendorInvoiceDate),
),
),
const SizedBox(height: 12),
_DetailRow(label: 'GRN Date', value: DateFormatter.displayDate(grn.grnDate)),
_DetailRow(label: 'PO Number', value: grn.poNumber ?? ''),
_DetailRow(label: 'Vendor', value: grn.vendorName ?? ''),
_DetailRow(label: 'Warehouse', value: grn.warehouseName ?? ''),
_DetailRow(label: 'Vendor Invoice No', value: grn.vendorInvoiceNo ?? ''),
_DetailRow(
label: 'Vendor Invoice Date',
value: DateFormatter.displayDate(grn.vendorInvoiceDate),
),
_DetailRow(
label: 'Vendor Invoice Amount',
value: grn.vendorInvoiceAmount != null
? CurrencyFormatter.format(grn.vendorInvoiceAmount!)
: '',
),
_DetailRow(label: 'Vehicle No', value: grn.vehicleNo ?? ''),
_DetailRow(label: 'LR No', value: grn.lrNo ?? ''),
_DetailRow(label: 'LR Date', value: DateFormatter.displayDate(grn.lrDate)),
_DetailRow(label: 'Remarks', value: grn.remarks ?? ''),
],
_GrnInfo(
'Vendor Invoice Amount',
grn.vendorInvoiceAmount != null
? CurrencyFormatter.format(grn.vendorInvoiceAmount!)
: '',
),
_GrnInfo('Vehicle No', grn.vehicleNo ?? ''),
_GrnInfo('LR No', grn.lrNo ?? ''),
_GrnInfo('LR Date', DateFormatter.displayDate(grn.lrDate)),
],
),
if (hasRemarks) ...[
const Padding(
padding: EdgeInsets.symmetric(vertical: 20),
child: Divider(height: 1),
),
_GrnInfoGrid(
columns: 1,
items: [_GrnInfo('Remarks', grn.remarks!)],
),
],
],
),
),
);
}
}
class _DetailRow extends StatelessWidget {
const _DetailRow({required this.label, required this.value});
class _GrnInfoGrid extends StatelessWidget {
const _GrnInfoGrid({
required this.items,
this.columns = 4,
});
final List<_GrnInfo> 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: _GrnDetailTile(
label: item.label,
value: item.value,
),
),
)
.toList(),
);
},
);
}
}
class _GrnDetailTile extends StatelessWidget {
const _GrnDetailTile({
required this.label,
required this.value,
});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 180,
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
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,
),
Expanded(child: Text(value)),
],
),
),
const SizedBox(height: 4),
Text(value, style: theme.textTheme.bodyLarge),
],
);
}
}
class _GrnInfo {
const _GrnInfo(this.label, this.value);
final String label;
final String value;
}

View File

@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../shared/models/grn_model.dart';
import '../../../../shared/models/purchase_order_model.dart';
import '../../../../shared/widgets/app_data_table.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_text_field.dart';
@ -322,37 +323,48 @@ class GrnItemsTable extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (items.isEmpty) {
return const SizedBox.shrink();
}
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
headingRowColor: WidgetStateProperty.all(AppColors.lightSurface),
columns: const [
DataColumn(label: Text('Line')),
DataColumn(label: Text('Item')),
DataColumn(label: Text('Accepted')),
DataColumn(label: Text('Damaged')),
DataColumn(label: Text('Short')),
DataColumn(label: Text('Excess')),
DataColumn(label: Text('Batch')),
],
rows: items.map((item) {
return DataRow(
cells: [
DataCell(Text('${item.lineNo}')),
DataCell(Text(item.itemName ?? '')),
DataCell(Text(_formatQty(item.acceptedQty ?? 0))),
DataCell(Text(_formatQty(item.damagedQty ?? 0))),
DataCell(Text(_formatQty(item.shortQty ?? 0))),
DataCell(Text(_formatQty(item.excessQty ?? 0))),
DataCell(Text(item.batchNo ?? '')),
],
);
}).toList(),
),
return AppDataTable<GrnItemModel>(
wrapInCard: false,
shrinkWrap: true,
emptyMessage: 'No line items',
columns: [
AppDataColumn(
label: '#',
flex: 1,
cellBuilder: (_, item) => Text('${item.lineNo ?? ''}'),
),
AppDataColumn(
label: 'Item',
flex: 3,
cellBuilder: (_, item) => Text(item.itemName ?? item.itemCode ?? ''),
),
AppDataColumn(
label: 'Accepted',
flex: 1,
cellBuilder: (_, item) => Text(_formatQty(item.acceptedQty ?? 0)),
),
AppDataColumn(
label: 'Damaged',
flex: 1,
cellBuilder: (_, item) => Text(_formatQty(item.damagedQty ?? 0)),
),
AppDataColumn(
label: 'Short',
flex: 1,
cellBuilder: (_, item) => Text(_formatQty(item.shortQty ?? 0)),
),
AppDataColumn(
label: 'Excess',
flex: 1,
cellBuilder: (_, item) => Text(_formatQty(item.excessQty ?? 0)),
),
AppDataColumn(
label: 'Batch',
flex: 1,
cellBuilder: (_, item) => Text(item.batchNo ?? ''),
),
],
rows: items,
);
}
}

View File

@ -1,5 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/constants/app_constants.dart';
import '../../../../shared/models/export_file_result.dart';
import '../../data/repositories/master_repository_impl.dart';
import '../../domain/entities/master_definition.dart';
@ -57,18 +58,21 @@ class MasterFormState {
const MasterFormState({
this.values = const {},
this.dropdownOptions = const {},
this.existingRecords = const [],
this.isSubmitting = false,
this.errorMessage,
});
final Map<String, dynamic> values;
final Map<String, List<Map<String, dynamic>>> dropdownOptions;
final List<Map<String, dynamic>> existingRecords;
final bool isSubmitting;
final String? errorMessage;
MasterFormState copyWith({
Map<String, dynamic>? values,
Map<String, List<Map<String, dynamic>>>? dropdownOptions,
List<Map<String, dynamic>>? existingRecords,
bool? isSubmitting,
String? errorMessage,
bool clearError = false,
@ -76,6 +80,7 @@ class MasterFormState {
return MasterFormState(
values: values ?? this.values,
dropdownOptions: dropdownOptions ?? this.dropdownOptions,
existingRecords: existingRecords ?? this.existingRecords,
isSubmitting: isSubmitting ?? this.isSubmitting,
errorMessage: clearError ? null : errorMessage ?? this.errorMessage,
);
@ -221,6 +226,7 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
@override
Future<MasterFormState> build(MasterFormArgs arg) async {
final dropdownOptions = await _loadDropdownOptions();
final existingRecords = await _loadExistingRecords();
Map<String, dynamic> values = {};
if (arg.recordId != null) {
@ -240,9 +246,31 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
return MasterFormState(
values: values,
dropdownOptions: dropdownOptions,
existingRecords: existingRecords,
);
}
Future<List<Map<String, dynamic>>> _loadExistingRecords() async {
final allItems = <Map<String, dynamic>>[];
var page = 1;
while (true) {
final result = await ref.read(masterRepositoryProvider).list(
_definition,
page: page,
limit: AppConstants.maxPageSize,
);
if (result.failure != null) break;
final data = result.data!;
allItems.addAll(data.items);
if (page >= data.totalPages) break;
page++;
}
return allItems;
}
Future<Map<String, List<Map<String, dynamic>>>>
_loadDropdownOptions() async {
final options = <String, List<Map<String, dynamic>>>{};

View File

@ -147,12 +147,32 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
keyboardType: _keyboardTypeForFieldKey(field.key),
inputFormatters: formatters.isEmpty ? null : formatters,
decoration: InputDecoration(labelText: _fieldLabel(field)),
validator: (v) => Validators.forFieldKey(
field.key,
v,
required: field.required,
fieldName: field.label,
),
validator: (v) {
if (Validators.isMasterNameFieldKey(field.key)) {
return Validators.uniqueMasterName(
v,
nameKey: field.key,
existingRecords: formState.existingRecords,
currentRecordId: widget.recordId,
fieldName: field.label,
);
}
if (Validators.isMasterCodeFieldKey(field.key)) {
return Validators.uniqueMasterCode(
v,
codeKey: field.key,
existingRecords: formState.existingRecords,
currentRecordId: widget.recordId,
fieldName: field.label,
);
}
return Validators.forFieldKey(
field.key,
v,
required: field.required,
fieldName: field.label,
);
},
onChanged: (text) => notifier.updateValue(field.key, text),
);
}

View File

@ -65,14 +65,17 @@ class _PurchaseOrderDetailScreenState
),
data: (order) => SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageHeader(
title: order.poNo ?? 'Purchase Order #${order.id}',
subtitle:
'${poTypeLabel(order.poType)} · ${order.vendorName ?? ''}',
actions: [
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1200),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageHeader(
title: order.poNo ?? 'Purchase Order #${order.id}',
subtitle:
'${poTypeLabel(order.poType)} · ${order.vendorName ?? ''}',
actions: [
if (canExport)
OutlinedButton.icon(
onPressed: _isWorking ? null : () => _downloadPdf(order),
@ -146,17 +149,19 @@ class _PurchaseOrderDetailScreenState
children: [
PoStatusChip(status: order.status),
if (order.revisionNo != null && order.revisionNo! > 0)
Chip(label: Text('Revision ${order.revisionNo}')),
PoRevisionChip(revisionNo: order.revisionNo!),
],
),
const SizedBox(height: 16),
_OverviewCard(order: order),
const SizedBox(height: 16),
_LineItemsCard(items: order.items),
],
],
),
),
),
),
),
),
);
}
@ -340,70 +345,129 @@ class _OverviewCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final hasTerms = order.termsAndConditions?.trim().isNotEmpty == true;
final hasRemarks = order.remarks?.trim().isNotEmpty == true;
return AppCard(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Overview', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 16),
_infoGrid([
_Info('PO Date', DateFormatter.displayDate(order.poDate)),
_Info('Expected Delivery',
DateFormatter.displayDate(order.expectedDeliveryDate)),
_Info('Vendor', order.vendorName ?? ''),
_Info('Plant', order.plantName ?? ''),
_Info('Warehouse', order.warehouseName ?? ''),
_Info('Type', poTypeLabel(order.poType)),
_Info('Taxable', CurrencyFormatter.format(order.taxableAmount)),
_Info('Tax', CurrencyFormatter.format(order.taxAmount)),
_Info('Freight', CurrencyFormatter.format(order.freightCharges)),
_Info('Other Charges', CurrencyFormatter.format(order.otherCharges)),
_Info('Discount', CurrencyFormatter.format(order.discountAmount)),
_Info('Total', CurrencyFormatter.format(order.totalAmount)),
]),
if (order.termsAndConditions?.isNotEmpty == true) ...[
const SizedBox(height: 16),
Text('Terms & Conditions',
style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 4),
Text(order.termsAndConditions!),
],
if (order.remarks?.isNotEmpty == true) ...[
const SizedBox(height: 16),
Text('Remarks', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 4),
Text(order.remarks!),
Text(
'Overview',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 20),
Text(
'Order Details',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 12),
_InfoGrid(
columns: 4,
items: [
_Info('PO Date', DateFormatter.displayDate(order.poDate)),
_Info(
'Expected Delivery',
DateFormatter.displayDate(order.expectedDeliveryDate),
),
_Info('Vendor', order.vendorName ?? ''),
_Info('Type', poTypeLabel(order.poType)),
_Info('Plant', order.plantName ?? ''),
_Info('Warehouse', order.warehouseName ?? ''),
],
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 20),
child: Divider(height: 1),
),
Text(
'Amount Summary',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 12),
_InfoGrid(
columns: 6,
items: [
_Info('Taxable', CurrencyFormatter.format(order.taxableAmount)),
_Info('Tax', CurrencyFormatter.format(order.taxAmount)),
_Info('Freight', CurrencyFormatter.format(order.freightCharges)),
_Info(
'Other Charges',
CurrencyFormatter.format(order.otherCharges),
),
_Info('Discount', CurrencyFormatter.format(order.discountAmount)),
_Info(
'Total',
CurrencyFormatter.format(order.totalAmount),
emphasize: true,
),
],
),
if (hasTerms || hasRemarks) ...[
const Padding(
padding: EdgeInsets.symmetric(vertical: 20),
child: Divider(height: 1),
),
_InfoGrid(
columns: 2,
items: [
if (hasTerms)
_Info('Terms & Conditions', order.termsAndConditions!),
if (hasRemarks) _Info('Remarks', order.remarks!),
],
),
],
],
),
),
);
}
}
Widget _infoGrid(List<_Info> rows) {
class _InfoGrid extends StatelessWidget {
const _InfoGrid({
required this.items,
this.columns = 4,
});
final List<_Info> items;
final int columns;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final crossAxisCount = constraints.maxWidth > 900 ? 3 : 2;
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: 24,
runSpacing: 12,
children: rows
spacing: spacing,
runSpacing: 16,
children: items
.map(
(row) => SizedBox(
width: (constraints.maxWidth / crossAxisCount) - 24,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
row.label,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
Text(row.value),
],
(item) => SizedBox(
width: colWidth,
child: _DetailTile(
label: item.label,
value: item.value,
emphasize: item.emphasize,
),
),
)
@ -414,10 +478,51 @@ class _OverviewCard extends StatelessWidget {
}
}
class _Info {
const _Info(this.label, this.value);
class _DetailTile extends StatelessWidget {
const _DetailTile({
required this.label,
required this.value,
this.emphasize = false,
});
final String label;
final String value;
final bool emphasize;
@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: emphasize
? theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
color: theme.colorScheme.primary,
)
: theme.textTheme.bodyLarge,
),
],
);
}
}
class _Info {
const _Info(this.label, this.value, {this.emphasize = false});
final String label;
final String value;
final bool emphasize;
}
class _LineItemsCard extends StatelessWidget {

View File

@ -354,7 +354,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FormRowThree(
FormRowFour(
children: [
_DateField(
label: 'PO Date *',
@ -387,10 +387,6 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
validator: (v) =>
v == null ? 'Vendor is required' : null,
),
],
),
FormRowThree(
children: [
AppSearchableDropdown<int>(
label: 'Plant *',
value: _dropdownValue(_plantId, plantIds),
@ -400,6 +396,10 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
validator: (v) =>
v == null ? 'Plant is required' : null,
),
],
),
FormRowFour(
children: [
AppSearchableDropdown<int?>(
label: 'Warehouse',
value: _warehouseId,
@ -414,10 +414,6 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
options: _nullableIntOptions(lookups.brands),
onChanged: (v) => setState(() => _brandId = v),
),
],
),
FormRowThree(
children: [
AppSearchableDropdown<int?>(
label: 'Payment Term',
value: _paymentTermId,
@ -432,6 +428,10 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
options: _nullableIntOptions(lookups.deliveryTerms),
onChanged: (v) => setState(() => _deliveryTermId = v),
),
],
),
FormRowFour(
children: [
_DateField(
label: 'Expected Delivery',
value: _expectedDeliveryDate,
@ -441,10 +441,6 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
setState(() => _expectedDeliveryDate = d),
),
),
],
),
FormRowThree(
children: [
AppTextField(
controller: _discountController,
label: 'Discount Amount',
@ -468,17 +464,20 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
),
],
),
const SizedBox(height: 4),
AppTextField(
controller: _termsController,
label: 'Terms & Conditions',
maxLines: 3,
),
const SizedBox(height: 12),
AppTextField(
controller: _remarksController,
label: 'Remarks',
maxLines: 2,
FormRowFour(
spans: const [2, 2],
children: [
AppTextField(
controller: _termsController,
label: 'Terms & Conditions',
maxLines: 3,
),
AppTextField(
controller: _remarksController,
label: 'Remarks',
maxLines: 3,
),
],
),
const SizedBox(height: 24),
PurchaseOrderLineItemsEditor(
@ -576,16 +575,20 @@ class _DateField extends StatelessWidget {
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: InputDecorator(
decoration: InputDecoration(
labelText: label,
suffixIcon: const Icon(Icons.calendar_today_outlined),
),
child: Text(
value != null ? DateFormatter.displayDate(value) : 'Select date',
return Padding(
padding: const EdgeInsets.only(top: 8),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: InputDecorator(
decoration: InputDecoration(
labelText: label,
floatingLabelBehavior: FloatingLabelBehavior.always,
suffixIcon: const Icon(Icons.calendar_today_outlined),
),
child: Text(
value != null ? DateFormatter.displayDate(value) : 'Select date',
),
),
),
);

View File

@ -15,20 +15,7 @@ class PoStatusChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
final (color, label) = _resolveStatus(status);
return Chip(
label: Text(
label,
style: TextStyle(
color: color,
fontSize: compact ? 11 : 12,
fontWeight: FontWeight.w600,
),
),
backgroundColor: color.withValues(alpha: 0.12),
side: BorderSide(color: color.withValues(alpha: 0.3)),
visualDensity: compact ? VisualDensity.compact : VisualDensity.standard,
padding: compact ? EdgeInsets.zero : null,
);
return _PoChip(label: label, color: color, compact: compact);
}
(Color, String) _resolveStatus(String raw) {
@ -54,3 +41,61 @@ class PoStatusChip extends StatelessWidget {
}
}
}
class PoRevisionChip extends StatelessWidget {
const PoRevisionChip({
super.key,
required this.revisionNo,
this.compact = false,
});
final int revisionNo;
final bool compact;
@override
Widget build(BuildContext context) {
final color = Theme.of(context).colorScheme.primary;
return _PoChip(
label: 'Revision $revisionNo',
color: color,
compact: compact,
);
}
}
class _PoChip extends StatelessWidget {
const _PoChip({
required this.label,
required this.color,
this.compact = false,
});
final String label;
final Color color;
final bool compact;
@override
Widget build(BuildContext context) {
return Chip(
label: Text(
label,
style: TextStyle(
color: color,
fontSize: compact ? 11 : 12,
fontWeight: FontWeight.w600,
height: 1.2,
),
),
backgroundColor: color.withValues(alpha: 0.12),
side: BorderSide(color: color.withValues(alpha: 0.3)),
visualDensity: compact ? VisualDensity.compact : VisualDensity.standard,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: compact
? EdgeInsets.zero
: const EdgeInsets.symmetric(horizontal: 4),
labelPadding: compact
? EdgeInsets.zero
: const EdgeInsets.symmetric(horizontal: 4),
);
}
}

View File

@ -138,7 +138,7 @@ class _PurchaseOrderLineItemsEditorState extends State<PurchaseOrderLineItemsEdi
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _LineItemCard(
key: ValueKey('po-line-${line.lineNo}-$index'),
key: ObjectKey(line),
line: line,
items: widget.items,
uom: widget.uom,
@ -154,7 +154,7 @@ class _PurchaseOrderLineItemsEditorState extends State<PurchaseOrderLineItemsEdi
}
}
class _LineItemCard extends StatelessWidget {
class _LineItemCard extends StatefulWidget {
const _LineItemCard({
super.key,
required this.line,
@ -170,12 +170,24 @@ class _LineItemCard extends StatelessWidget {
final List<FilterOptionModel> gstRates;
final VoidCallback? onRemove;
@override
State<_LineItemCard> createState() => _LineItemCardState();
}
class _LineItemCardState extends State<_LineItemCard> {
int? _parseId(String value) => int.tryParse(value.trim());
void _updateLine(void Function() mutate) {
mutate();
setState(() {});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final itemOptions = items
final line = widget.line;
final lineKey = 'po-line-${line.lineNo}';
final itemOptions = widget.items
.map((e) {
final id = _parseId(e.id);
if (id == null) return null;
@ -183,7 +195,7 @@ class _LineItemCard extends StatelessWidget {
})
.whereType<AppDropdownOption<int>>()
.toList();
final uomOptions = uom
final uomOptions = widget.uom
.map((e) {
final id = _parseId(e.id);
if (id == null) return null;
@ -193,7 +205,7 @@ class _LineItemCard extends StatelessWidget {
.toList();
final gstOptions = [
const AppDropdownOption<int?>(value: null, label: 'No GST'),
...gstRates.map((e) {
...widget.gstRates.map((e) {
final id = _parseId(e.id);
if (id == null) return null;
return AppDropdownOption<int?>(value: id, label: e.name);
@ -201,38 +213,47 @@ class _LineItemCard extends StatelessWidget {
].whereType<AppDropdownOption<int?>>().toList();
return Container(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
border: Border.all(color: theme.dividerColor),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Text('Line ${line.lineNo}', style: theme.textTheme.titleSmall),
const Spacer(),
if (onRemove != null)
IconButton(
tooltip: 'Remove line',
icon: const Icon(Icons.delete_outline),
onPressed: onRemove,
),
],
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Text('Line ${line.lineNo}', style: theme.textTheme.titleSmall),
const Spacer(),
if (widget.onRemove != null)
IconButton(
tooltip: 'Remove line',
icon: const Icon(Icons.delete_outline),
onPressed: widget.onRemove,
),
],
),
),
const SizedBox(height: 12),
FormRowThree(
FormRow(
columnCount: 6,
horizontalPadding: 16,
spacing: 8,
stackBelowWidth: 992,
children: [
AppSearchableDropdown<int>(
key: ValueKey('$lineKey-item'),
label: 'Item *',
value: line.itemId,
searchHint: 'Search item...',
options: itemOptions,
onChanged: (v) => line.itemId = v,
onChanged: (v) => _updateLine(() => line.itemId = v),
validator: (v) => v == null ? 'Item is required' : null,
),
AppTextField(
key: ValueKey('$lineKey-qty'),
controller: line.qtyController,
label: 'Quantity *',
keyboardType:
@ -247,18 +268,16 @@ class _LineItemCard extends StatelessWidget {
},
),
AppSearchableDropdown<int>(
key: ValueKey('$lineKey-uom'),
label: 'UOM *',
value: line.uomId,
searchHint: 'Search UOM...',
options: uomOptions,
onChanged: (v) => line.uomId = v,
onChanged: (v) => _updateLine(() => line.uomId = v),
validator: (v) => v == null ? 'UOM is required' : null,
),
],
),
FormRowThree(
children: [
AppTextField(
key: ValueKey('$lineKey-rate'),
controller: line.rateController,
label: 'Rate *',
keyboardType:
@ -271,24 +290,36 @@ class _LineItemCard extends StatelessWidget {
},
),
AppTextField(
key: ValueKey('$lineKey-discount'),
controller: line.discountController,
label: 'Discount %',
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
),
AppSearchableDropdown<int?>(
key: ValueKey('$lineKey-gst'),
label: 'GST Rate',
value: line.gstRateId,
searchHint: 'Search GST rate...',
options: gstOptions,
onChanged: (v) => line.gstRateId = v,
onChanged: (v) => _updateLine(() => line.gstRateId = v),
),
],
),
const SizedBox(height: 12),
AppTextField(
controller: line.remarksController,
label: 'Remarks',
FormRow(
columnCount: 6,
spans: const [6],
horizontalPadding: 16,
spacing: 8,
stackBelowWidth: 992,
children: [
AppTextField(
key: ValueKey('$lineKey-remarks'),
controller: line.remarksController,
label: 'Remarks',
maxLines: 2,
),
],
),
],
),

View File

@ -12,7 +12,6 @@ import '../../../../shared/widgets/app_confirmation_dialog.dart';
import '../../../../shared/widgets/app_dropdown.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/error_view.dart';
import '../../../../shared/widgets/page_header.dart';
import '../providers/vendors_provider.dart';
@ -206,37 +205,58 @@ class _OverviewTab extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final hasRemarks = vendor.remarks?.trim().isNotEmpty == true;
return SingleChildScrollView(
child: AppCard(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
_DetailRow(label: 'Vendor Code', value: vendor.vendorCode ?? ''),
_DetailRow(label: 'Vendor Name', value: vendor.vendorName),
_DetailRow(label: 'Type', value: vendorTypeLabel(vendor.vendorType)),
_DetailRow(label: 'GSTIN', value: vendor.gstin ?? ''),
_DetailRow(label: 'PAN', value: vendor.pan ?? ''),
_DetailRow(
label: 'Payment Term',
value: vendor.paymentTermName ?? '',
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1200),
child: AppCard(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Vendor Details',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 12),
_VendorInfoGrid(
items: [
_VendorInfo('Vendor Code', vendor.vendorCode ?? ''),
_VendorInfo('Vendor Name', vendor.vendorName),
_VendorInfo('Type', vendorTypeLabel(vendor.vendorType)),
_VendorInfo('GSTIN', vendor.gstin ?? ''),
_VendorInfo('PAN', vendor.pan ?? ''),
_VendorInfo('Payment Term', vendor.paymentTermName ?? ''),
_VendorInfo(
'Credit Period',
vendor.creditPeriodDays != null
? '${vendor.creditPeriodDays} days'
: '',
),
_VendorInfo('Status', vendorStatusLabel(vendor.status)),
_VendorInfo('Active', vendor.isActive ? 'Yes' : 'No'),
],
),
if (hasRemarks) ...[
const Padding(
padding: EdgeInsets.symmetric(vertical: 20),
child: Divider(height: 1),
),
_VendorInfoGrid(
columns: 1,
items: [_VendorInfo('Remarks', vendor.remarks!)],
),
],
],
),
_DetailRow(
label: 'Credit Period',
value: vendor.creditPeriodDays != null
? '${vendor.creditPeriodDays} days'
: '',
),
_DetailRow(label: 'Remarks', value: vendor.remarks ?? ''),
_DetailRow(
label: 'Status',
value: vendorStatusLabel(vendor.status),
),
_DetailRow(
label: 'Active',
value: vendor.isActive ? 'Yes' : 'No',
),
],
),
),
),
),
@ -244,6 +264,85 @@ class _OverviewTab extends StatelessWidget {
}
}
class _VendorInfoGrid extends StatelessWidget {
const _VendorInfoGrid({
required this.items,
this.columns = 3,
});
final List<_VendorInfo> items;
final int columns;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth;
final cols = maxWidth < 600
? 1
: maxWidth < 900
? 2
: columns;
const spacing = 16.0;
final colWidth = (maxWidth - spacing * (cols - 1)) / cols;
return Wrap(
spacing: spacing,
runSpacing: 16,
children: items
.map(
(item) => SizedBox(
width: colWidth,
child: _VendorDetailTile(
label: item.label,
value: item.value,
),
),
)
.toList(),
);
},
);
}
}
class _VendorDetailTile extends StatelessWidget {
const _VendorDetailTile({
required this.label,
required this.value,
});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(value, style: theme.textTheme.bodyLarge),
],
);
}
}
class _VendorInfo {
const _VendorInfo(this.label, this.value);
final String label;
final String value;
}
class _AddressesTab extends ConsumerWidget {
const _AddressesTab({
required this.vendorId,
@ -287,43 +386,30 @@ class _AddressesTab extends ConsumerWidget {
description: 'Add registered, billing or dispatch addresses.',
icon: Icons.location_on_outlined,
)
: ListView.separated(
: GridView.builder(
padding: EdgeInsets.zero,
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 320,
mainAxisExtent: 190,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: addresses.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final address = addresses[index];
return _SubResourceCard(
title: addressTypeOptions
.where((e) => e.$1 == address.addressType)
.map((e) => e.$2)
.firstOrNull ??
address.addressType ??
'Address',
subtitle: [
address.addressLine1,
address.addressLine2,
address.city,
address.state,
address.pincode,
].where((e) => e != null && e.isNotEmpty).join(', '),
trailing: canEdit
? _SubResourceActions(
onEdit: () => openVendorAddressPanel(
context,
vendorId: vendorId,
address: address,
),
onDelete: () => _deleteAddress(
context,
ref,
vendorId,
address.id,
),
)
: null,
chip: AppStatusChip(
status: address.isActive ? 'active' : 'inactive',
compact: true,
return _VendorAddressCard(
address: address,
canEdit: canEdit,
onEdit: () => openVendorAddressPanel(
context,
vendorId: vendorId,
address: address,
),
onDelete: () => _deleteAddress(
context,
ref,
vendorId,
address.id,
),
);
},
@ -400,36 +486,30 @@ class _ContactsTab extends ConsumerWidget {
description: 'Add contact persons for this vendor.',
icon: Icons.person_outline,
)
: ListView.separated(
: GridView.builder(
padding: EdgeInsets.zero,
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 320,
mainAxisExtent: 175,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: contacts.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final contact = contacts[index];
return _SubResourceCard(
title: contact.contactName,
subtitle: [
contact.designation,
contact.phone,
contact.email,
].where((e) => e != null && e.isNotEmpty).join(' · '),
trailing: canEdit
? _SubResourceActions(
onEdit: () => openVendorContactPanel(
context,
vendorId: vendorId,
contact: contact,
),
onDelete: () => _deleteContact(
context,
ref,
vendorId,
contact.id,
),
)
: null,
chip: AppStatusChip(
status: contact.isActive ? 'active' : 'inactive',
compact: true,
return _VendorContactCard(
contact: contact,
canEdit: canEdit,
onEdit: () => openVendorContactPanel(
context,
vendorId: vendorId,
contact: contact,
),
onDelete: () => _deleteContact(
context,
ref,
vendorId,
contact.id,
),
);
},
@ -506,38 +586,30 @@ class _BankDetailsTab extends ConsumerWidget {
description: 'Add bank accounts for payments.',
icon: Icons.account_balance_outlined,
)
: ListView.separated(
: GridView.builder(
padding: EdgeInsets.zero,
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 320,
mainAxisExtent: 175,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: bankDetails.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final detail = bankDetails[index];
return _SubResourceCard(
title: detail.bankName ?? 'Bank Account',
subtitle: [
detail.branch,
detail.ifsc,
detail.accountHolderName,
if (detail.accountNumber != null)
'A/C: ${detail.accountNumber}',
].where((e) => e != null && e.isNotEmpty).join(' · '),
trailing: canEdit
? _SubResourceActions(
onEdit: () => openVendorBankDetailPanel(
context,
vendorId: vendorId,
bankDetail: detail,
),
onDelete: () => _deleteBankDetail(
context,
ref,
vendorId,
detail.id,
),
)
: null,
chip: AppStatusChip(
status: detail.isActive ? 'active' : 'inactive',
compact: true,
return _VendorBankDetailCard(
bankDetail: detail,
canEdit: canEdit,
onEdit: () => openVendorBankDetailPanel(
context,
vendorId: vendorId,
bankDetail: detail,
),
onDelete: () => _deleteBankDetail(
context,
ref,
vendorId,
detail.id,
),
);
},
@ -573,30 +645,150 @@ class _BankDetailsTab extends ConsumerWidget {
}
}
class _SubResourceCard extends StatelessWidget {
const _SubResourceCard({
required this.title,
required this.subtitle,
this.trailing,
this.chip,
class _VendorAddressCard extends StatelessWidget {
const _VendorAddressCard({
required this.address,
required this.canEdit,
required this.onEdit,
required this.onDelete,
});
final String title;
final String subtitle;
final Widget? trailing;
final Widget? chip;
final VendorAddressModel address;
final bool canEdit;
final VoidCallback onEdit;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final appearance = _addressCardAppearance(address.addressType);
final title = addressTypeOptions
.where((e) => e.$1 == address.addressType)
.map((e) => e.$2)
.firstOrNull ??
address.addressType ??
'Address';
final subtitle = [
address.addressLine1,
address.addressLine2,
address.city,
address.state,
address.pincode,
].where((e) => e != null && e.isNotEmpty).join(', ');
final statusColor = address.isActive
? const Color(0xFF16A34A)
: theme.colorScheme.onSurfaceVariant;
return AppCard(
child: ListTile(
title: Text(title),
subtitle: subtitle.isNotEmpty ? Text(subtitle) : null,
trailing: Row(
mainAxisSize: MainAxisSize.min,
elevation: 0,
enableHover: true,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (chip != null) ...[chip!, const SizedBox(width: 8)],
if (trailing != null) trailing!,
Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: appearance.color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
appearance.icon,
color: appearance.color,
size: 20,
),
),
const Spacer(),
if (canEdit) ...[
IconButton(
icon: const Icon(Icons.edit_outlined, size: 18),
onPressed: onEdit,
visualDensity: VisualDensity.compact,
),
IconButton(
icon: const Icon(Icons.delete_outline, size: 18),
onPressed: onDelete,
visualDensity: VisualDensity.compact,
),
],
],
),
const SizedBox(height: 8),
Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Expanded(
child: Text(
subtitle.isNotEmpty ? subtitle : '',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Row(
children: [
if (address.city != null && address.city!.isNotEmpty) ...[
Icon(
Icons.location_city_outlined,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Flexible(
child: Text(
address.city!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: 16),
],
if (address.pincode != null && address.pincode!.isNotEmpty) ...[
Icon(
Icons.pin_drop_outlined,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
address.pincode!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 16),
],
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: statusColor,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
Text(
address.isActive ? 'Active' : 'Inactive',
style: theme.textTheme.bodySmall?.copyWith(
color: statusColor,
fontWeight: FontWeight.w600,
),
),
],
),
],
),
),
@ -604,57 +796,338 @@ class _SubResourceCard extends StatelessWidget {
}
}
class _SubResourceActions extends StatelessWidget {
const _SubResourceActions({required this.onEdit, required this.onDelete});
({Color color, IconData icon}) _addressCardAppearance(String? type) {
switch (type?.toUpperCase()) {
case 'REGISTERED':
return (color: Color(0xFF2563EB), icon: Icons.home_work_outlined);
case 'BILLING':
return (color: Color(0xFF16A34A), icon: Icons.receipt_long_outlined);
case 'DISPATCH':
return (color: Color(0xFFCA8A04), icon: Icons.local_shipping_outlined);
default:
return (color: Color(0xFF0891B2), icon: Icons.location_on_outlined);
}
}
class _VendorContactCard extends StatelessWidget {
const _VendorContactCard({
required this.contact,
required this.canEdit,
required this.onEdit,
required this.onDelete,
});
final VendorContactModel contact;
final bool canEdit;
final VoidCallback onEdit;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Edit',
icon: const Icon(Icons.edit_outlined, size: 20),
onPressed: onEdit,
),
IconButton(
tooltip: 'Delete',
icon: const Icon(Icons.delete_outline, size: 20),
onPressed: onDelete,
),
],
);
}
}
final theme = Theme.of(context);
final appearance = _contactCardAppearance(contact);
final statusColor = contact.isActive
? const Color(0xFF16A34A)
: theme.colorScheme.onSurfaceVariant;
class _DetailRow extends StatelessWidget {
const _DetailRow({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 160,
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
return AppCard(
elevation: 0,
enableHover: true,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: appearance.color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
appearance.icon,
color: appearance.color,
size: 20,
),
),
const Spacer(),
if (canEdit) ...[
IconButton(
icon: const Icon(Icons.edit_outlined, size: 18),
onPressed: onEdit,
visualDensity: VisualDensity.compact,
),
IconButton(
icon: const Icon(Icons.delete_outline, size: 18),
onPressed: onDelete,
visualDensity: VisualDensity.compact,
),
],
],
),
),
Expanded(child: Text(value)),
],
const SizedBox(height: 8),
Text(
contact.contactName,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
if (contact.designation?.trim().isNotEmpty == true)
Text(
contact.designation!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
if (contact.email?.trim().isNotEmpty == true) ...[
const SizedBox(height: 8),
Row(
children: [
Icon(
Icons.email_outlined,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Expanded(
child: Text(
contact.email!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
],
const Spacer(),
Row(
children: [
if (contact.phone?.trim().isNotEmpty == true)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.phone_outlined,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
contact.phone!,
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(
contact.isActive ? 'Active' : 'Inactive',
style: theme.textTheme.bodySmall?.copyWith(
color: statusColor,
fontWeight: FontWeight.w600,
),
),
],
),
],
),
),
);
}
}
({Color color, IconData icon}) _contactCardAppearance(VendorContactModel contact) {
if (contact.isPrimary) {
return (color: Color(0xFF2563EB), icon: Icons.person_pin_outlined);
}
return (color: Color(0xFF7C3AED), icon: Icons.person_outline);
}
class _VendorBankDetailCard extends StatelessWidget {
const _VendorBankDetailCard({
required this.bankDetail,
required this.canEdit,
required this.onEdit,
required this.onDelete,
});
final VendorBankDetailModel bankDetail;
final bool canEdit;
final VoidCallback onEdit;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final appearance = _bankDetailCardAppearance(bankDetail);
final statusColor = bankDetail.isActive
? const Color(0xFF16A34A)
: theme.colorScheme.onSurfaceVariant;
final subtitle = [
bankDetail.branch,
bankDetail.accountHolderName,
].where((e) => e != null && e.trim().isNotEmpty).join(' · ');
return AppCard(
elevation: 0,
enableHover: true,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: appearance.color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
appearance.icon,
color: appearance.color,
size: 20,
),
),
const Spacer(),
if (canEdit) ...[
IconButton(
icon: const Icon(Icons.edit_outlined, size: 18),
onPressed: onEdit,
visualDensity: VisualDensity.compact,
),
IconButton(
icon: const Icon(Icons.delete_outline, size: 18),
onPressed: onDelete,
visualDensity: VisualDensity.compact,
),
],
],
),
const SizedBox(height: 8),
Text(
bankDetail.bankName?.trim().isNotEmpty == true
? bankDetail.bankName!
: 'Bank Account',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
if (subtitle.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
if (bankDetail.ifsc?.trim().isNotEmpty == true) ...[
const SizedBox(height: 8),
Row(
children: [
Icon(
Icons.tag_outlined,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Expanded(
child: Text(
bankDetail.ifsc!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
],
const Spacer(),
Row(
children: [
if (bankDetail.accountNumber?.trim().isNotEmpty == true)
Flexible(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.numbers_outlined,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Flexible(
child: Text(
'A/C: ${bankDetail.accountNumber!}',
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(
bankDetail.isActive ? 'Active' : 'Inactive',
style: theme.textTheme.bodySmall?.copyWith(
color: statusColor,
fontWeight: FontWeight.w600,
),
),
],
),
],
),
),
);
}
}
({Color color, IconData icon}) _bankDetailCardAppearance(
VendorBankDetailModel detail,
) {
if (detail.isPrimary) {
return (color: Color(0xFF2563EB), icon: Icons.account_balance);
}
return (color: Color(0xFF0891B2), icon: Icons.account_balance_outlined);
}

View File

@ -459,7 +459,6 @@ class _VendorBankDetailPanelState extends ConsumerState<VendorBankDetailPanel> {
AppTextField(
controller: _accountNumberController,
label: widget.isEditing ? 'Account Number' : 'Account Number *',
obscureText: true,
keyboardType: TextInputType.number,
inputFormatters: Validators.accountNumberInput,
validator: widget.isEditing

View File

@ -18,20 +18,15 @@ DateTime? _dateFromJsonNullable(Object? value) {
return DateTime.tryParse(value.toString());
}
Map<dynamic, dynamic>? _nestedPaymentTerm(Map<dynamic, dynamic> json) {
final nested = json['payment_terms'] ?? json['payment_term'];
return nested is Map ? Map<dynamic, dynamic>.from(nested) : null;
}
Object? _readPaymentTermName(Map<dynamic, dynamic> json, String key) {
final flat = json['payment_term_name'];
if (flat is String && flat.isNotEmpty) return flat;
final nested = json['payment_term'];
if (nested is Map) return nested['name'];
return null;
}
Object? _readPaymentTermId(Map<dynamic, dynamic> json, String key) {
final flat = json['payment_term_id'];
if (flat != null) return flat;
final nested = json['payment_term'];
if (nested is Map) return nested['id'];
return null;
return _nestedPaymentTerm(json)?['name'];
}
@freezed

View File

@ -36,6 +36,7 @@ class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
final _layerLink = LayerLink();
final _fieldKey = GlobalKey();
OverlayEntry? _overlayEntry;
bool _ignoreOutsideTap = false;
@override
void dispose() {
@ -55,6 +56,7 @@ class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
if (_overlayEntry == null) return;
_overlayEntry!.remove();
_overlayEntry = null;
_ignoreOutsideTap = false;
if (mounted) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() {});
@ -69,9 +71,17 @@ class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
return;
}
// Wait until layout is complete so LayerLink / RenderBox are ready.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _overlayEntry != null) return;
_showOverlay(field);
});
}
void _showOverlay(FormFieldState<T> field) {
final renderBox =
_fieldKey.currentContext?.findRenderObject() as RenderBox?;
if (renderBox == null) return;
if (renderBox == null || !renderBox.hasSize) return;
final fieldSize = renderBox.size;
final fieldTopLeft = renderBox.localToGlobal(Offset.zero);
@ -86,6 +96,7 @@ class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
final availableSpace = (showAbove ? spaceAbove : spaceBelow) - 8;
final maxPanelHeight = availableSpace.clamp(120.0, screenSize.height * 0.45);
_ignoreOutsideTap = true;
_overlayEntry = OverlayEntry(
builder: (overlayContext) {
final theme = Theme.of(overlayContext);
@ -107,7 +118,10 @@ class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
showAbove ? Alignment.bottomLeft : Alignment.topLeft,
offset: Offset(0, showAbove ? -4 : 4),
child: TapRegion(
onTapOutside: (_) => _removeOverlay(),
onTapOutside: (_) {
if (_ignoreOutsideTap) return;
_removeOverlay();
},
child: Material(
elevation: 8,
borderRadius: BorderRadius.circular(8),
@ -119,12 +133,13 @@ class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
child: _SearchableDropdownPanel<T>(
maxHeight: maxPanelHeight,
options: widget.options,
selected: widget.value,
selected: field.value ?? widget.value,
searchHint: widget.searchHint,
onSelected: (value) {
_removeOverlay();
field.didChange(value);
widget.onChanged(value);
field.validate();
},
),
),
@ -136,14 +151,19 @@ class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
},
);
Overlay.of(context).insert(_overlayEntry!);
final overlay = Overlay.maybeOf(context, rootOverlay: true) ??
Overlay.of(context);
overlay.insert(_overlayEntry!);
setState(() {});
WidgetsBinding.instance.addPostFrameCallback((_) {
_ignoreOutsideTap = false;
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final displayLabel = _labelForValue(widget.value);
return FormField<T>(
initialValue: widget.value,
@ -152,6 +172,8 @@ class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
final effectiveHint = widget.hint ?? 'Select ${widget.label.toLowerCase()}';
final canOpen = widget.enabled && widget.options.isNotEmpty;
final colors = theme.colorScheme;
final selected = field.value ?? widget.value;
final displayLabel = _labelForValue(selected);
// Top padding keeps the always-floating label from being clipped by
// tight parents (e.g. TabBarView toolbars).

View File

@ -206,13 +206,107 @@ class FormRowThree extends StatelessWidget {
final double spacing;
final double stackBelowWidth;
@override
Widget build(BuildContext context) {
return FormRow(
spacing: spacing,
stackBelowWidth: stackBelowWidth,
children: children,
);
}
}
/// Responsive row with up to four equal-width form fields.
class FormRowFour extends StatelessWidget {
const FormRowFour({
super.key,
required this.children,
this.spans,
this.spacing = 12,
this.horizontalPadding = 0,
this.stackBelowWidth = 992,
});
final List<Widget> children;
final List<int>? spans;
final double spacing;
final double horizontalPadding;
final double stackBelowWidth;
@override
Widget build(BuildContext context) {
return FormRow(
columnCount: 4,
spans: spans,
spacing: spacing,
horizontalPadding: horizontalPadding,
stackBelowWidth: stackBelowWidth,
children: children,
);
}
}
/// Responsive row of equal-width form fields.
class FormRow extends StatelessWidget {
const FormRow({
super.key,
required this.children,
this.columnCount,
this.spans,
this.spacing = 12,
this.horizontalPadding = 0,
this.stackBelowWidth = 768,
});
final List<Widget> children;
final int? columnCount;
final List<int>? spans;
final double spacing;
final double horizontalPadding;
final double stackBelowWidth;
List<({Widget child, int span})> _columnSlots() {
final cols = columnCount!;
if (spans != null) {
assert(
spans!.length == children.length,
'spans length must match children length',
);
return [
for (var i = 0; i < children.length; i++)
(child: children[i], span: spans![i]),
];
}
return [
for (var i = 0; i < cols; i++)
(
child: i < children.length ? children[i] : const SizedBox.shrink(),
span: 1,
),
];
}
double _spanWidth(double columnWidth, int span) {
if (span <= 1) return columnWidth;
return columnWidth * span + spacing * (span - 1);
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final contentPadding = EdgeInsets.fromLTRB(
horizontalPadding,
0,
horizontalPadding,
spacing,
);
if (constraints.maxWidth < stackBelowWidth) {
return Padding(
padding: EdgeInsets.only(bottom: spacing),
padding: contentPadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@ -225,8 +319,31 @@ class FormRowThree extends StatelessWidget {
);
}
if (columnCount != null) {
final slots = _columnSlots();
final cols = columnCount!;
final innerWidth = constraints.maxWidth - (horizontalPadding * 2);
final columnWidth = (innerWidth - (cols - 1) * spacing) / cols;
return Padding(
padding: contentPadding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < slots.length; i++) ...[
if (i > 0) SizedBox(width: spacing),
SizedBox(
width: _spanWidth(columnWidth, slots[i].span),
child: slots[i].child,
),
],
],
),
);
}
return Padding(
padding: EdgeInsets.only(bottom: spacing),
padding: contentPadding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [

View File

@ -37,22 +37,27 @@ class AppTextField extends StatelessWidget {
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
obscureText: obscureText,
keyboardType: keyboardType,
validator: validator,
onChanged: onChanged,
maxLines: maxLines,
maxLength: maxLength,
inputFormatters: inputFormatters,
enabled: enabled,
autofillHints: autofillHints,
decoration: InputDecoration(
labelText: label,
hintText: hint,
prefixIcon: prefixIcon,
suffixIcon: suffixIcon,
return Padding(
padding: const EdgeInsets.only(top: 8),
child: TextFormField(
controller: controller,
obscureText: obscureText,
keyboardType: keyboardType,
validator: validator,
onChanged: onChanged,
maxLines: maxLines,
maxLength: maxLength,
inputFormatters: inputFormatters,
enabled: enabled,
autofillHints: autofillHints,
decoration: InputDecoration(
labelText: label,
hintText: hint,
prefixIcon: prefixIcon,
suffixIcon: suffixIcon,
floatingLabelBehavior:
label != null ? FloatingLabelBehavior.always : null,
),
),
);
}

View File

@ -76,5 +76,110 @@ void main() {
isNull,
);
});
test('validates master name characters', () {
expect(
Validators.forFieldKey('name', 'Widget A-1', required: true),
isNull,
);
expect(
Validators.forFieldKey('name', 'Part (OEM) & Co.', required: true),
isNull,
);
expect(
Validators.forFieldKey('item_name', 'Item_01/Test', required: true),
isNull,
);
expect(
Validators.forFieldKey('name', 'Invalid@Name', required: true),
isNotNull,
);
});
});
group('uniqueMasterName', () {
test('rejects duplicate names case-insensitively', () {
expect(
Validators.uniqueMasterName(
'Widget A',
nameKey: 'name',
existingRecords: const [
{'id': '1', 'name': 'widget a'},
],
fieldName: 'Name',
),
isNotNull,
);
});
test('allows same name when editing current record', () {
expect(
Validators.uniqueMasterName(
'Widget A',
nameKey: 'name',
existingRecords: const [
{'id': '1', 'name': 'Widget A'},
],
currentRecordId: '1',
fieldName: 'Name',
),
isNull,
);
});
});
group('masterCode', () {
test('accepts valid code characters', () {
expect(
Validators.forFieldKey('code', 'ABC-12_test/item', required: true),
isNull,
);
expect(
Validators.forFieldKey('item_code', 'SKU_01/A', required: true),
isNull,
);
});
test('rejects invalid code characters', () {
expect(
Validators.forFieldKey('code', 'CODE@123', required: true),
isNotNull,
);
expect(
Validators.forFieldKey('code', 'CODE 123', required: true),
isNotNull,
);
});
});
group('uniqueMasterCode', () {
test('rejects duplicate codes case-insensitively', () {
expect(
Validators.uniqueMasterCode(
'UOM-01',
codeKey: 'code',
existingRecords: const [
{'id': '1', 'code': 'uom-01'},
],
fieldName: 'Code',
),
isNotNull,
);
});
test('allows same code when editing current record', () {
expect(
Validators.uniqueMasterCode(
'UOM-01',
codeKey: 'code',
existingRecords: const [
{'id': '1', 'code': 'UOM-01'},
],
currentRecordId: '1',
fieldName: 'Code',
),
isNull,
);
});
});
}