bug fix
This commit is contained in:
parent
bea7e37090
commit
b23a74442f
@ -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 [];
|
||||
}
|
||||
|
||||
|
||||
@ -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(' ');
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>>>{};
|
||||
|
||||
@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user