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 _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.
|
/// Required role name — letters, numbers, and spaces only.
|
||||||
static String? roleName(String? value) {
|
static String? roleName(String? value) {
|
||||||
@ -258,6 +367,24 @@ class Validators {
|
|||||||
if (normalizedKey == 'account_number' || normalizedKey == 'account_no') {
|
if (normalizedKey == 'account_number' || normalizedKey == 'account_no') {
|
||||||
return required ? accountNumber(value) : optionalAccountNumber(value);
|
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) {
|
if (required) {
|
||||||
return Validators.required(value, fieldName: fieldName);
|
return Validators.required(value, fieldName: fieldName);
|
||||||
@ -290,6 +417,12 @@ class Validators {
|
|||||||
if (normalizedKey == 'pan') {
|
if (normalizedKey == 'pan') {
|
||||||
return panInput;
|
return panInput;
|
||||||
}
|
}
|
||||||
|
if (isMasterNameFieldKey(normalizedKey)) {
|
||||||
|
return masterNameInput;
|
||||||
|
}
|
||||||
|
if (isMasterCodeFieldKey(normalizedKey)) {
|
||||||
|
return masterCodeInput;
|
||||||
|
}
|
||||||
return const [];
|
return const [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import '../../../../shared/models/asset_model.dart';
|
|||||||
import '../../../../shared/providers/permissions_provider.dart';
|
import '../../../../shared/providers/permissions_provider.dart';
|
||||||
import '../../../../shared/widgets/app_card.dart';
|
import '../../../../shared/widgets/app_card.dart';
|
||||||
import '../../../../shared/widgets/app_confirmation_dialog.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_loading_view.dart';
|
||||||
import '../../../../shared/widgets/app_status_chip.dart';
|
import '../../../../shared/widgets/app_status_chip.dart';
|
||||||
import '../../../../shared/widgets/can_permission.dart';
|
import '../../../../shared/widgets/can_permission.dart';
|
||||||
@ -182,40 +183,145 @@ class _OverviewTab extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
final dateFormat = DateFormat('dd MMM yyyy');
|
final dateFormat = DateFormat('dd MMM yyyy');
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 1200),
|
||||||
child: AppCard(
|
child: AppCard(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_DetailRow(label: 'Asset Name', value: asset.assetName),
|
Text(
|
||||||
_DetailRow(label: 'Asset Code', value: asset.assetCode ?? '—'),
|
'Asset Details',
|
||||||
_DetailRow(label: 'Category', value: asset.assetCategoryName ?? '—'),
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
_DetailRow(label: 'Plant', value: asset.plantName ?? '—'),
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
_DetailRow(
|
fontWeight: FontWeight.w600,
|
||||||
label: 'Warranty Expiry',
|
),
|
||||||
value: asset.warrantyExpiryDate != null
|
),
|
||||||
|
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!)
|
? dateFormat.format(asset.warrantyExpiryDate!)
|
||||||
: '—',
|
: '—',
|
||||||
),
|
),
|
||||||
_DetailRow(
|
_AssetInfo(
|
||||||
label: 'Purchase Cost',
|
'Purchase Cost',
|
||||||
value: asset.purchaseCost != null ? '₹${asset.purchaseCost}' : '—',
|
asset.purchaseCost != null ? '₹${asset.purchaseCost}' : '—',
|
||||||
),
|
),
|
||||||
_DetailRow(
|
_AssetInfo.status(
|
||||||
label: 'Status',
|
'Status',
|
||||||
valueWidget: AppStatusChip(status: asset.status ?? 'active'),
|
AppStatusChip(status: asset.status ?? 'active'),
|
||||||
|
),
|
||||||
|
_AssetInfo(
|
||||||
|
'Active',
|
||||||
|
asset.isActive ? 'Yes' : 'No',
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
class _AmcTab extends ConsumerWidget {
|
||||||
const _AmcTab({required this.assetId, required this.contracts});
|
const _AmcTab({required this.assetId, required this.contracts});
|
||||||
|
|
||||||
@ -242,23 +348,22 @@ class _AmcTab extends ConsumerWidget {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: contracts.isEmpty
|
child: contracts.isEmpty
|
||||||
? const Center(child: Text('No AMC contracts'))
|
? const AppEmptyState(
|
||||||
: ListView.separated(
|
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,
|
itemCount: contracts.length,
|
||||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final c = contracts[index];
|
return _AssetAmcCard(contract: 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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -306,22 +411,22 @@ class _ServiceVisitsTab extends ConsumerWidget {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: visits.isEmpty
|
child: visits.isEmpty
|
||||||
? const Center(child: Text('No service visits'))
|
? const AppEmptyState(
|
||||||
: ListView.separated(
|
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,
|
itemCount: visits.length,
|
||||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final v = visits[index];
|
return _AssetServiceVisitCard(visit: 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),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -369,23 +474,22 @@ class _InsuranceTab extends ConsumerWidget {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: policies.isEmpty
|
child: policies.isEmpty
|
||||||
? const Center(child: Text('No insurance policies'))
|
? const AppEmptyState(
|
||||||
: ListView.separated(
|
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,
|
itemCount: policies.length,
|
||||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final p = policies[index];
|
return _AssetInsuranceCard(policy: 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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -407,37 +511,373 @@ class _InsuranceTab extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DetailRow extends StatelessWidget {
|
class _AssetAmcCard extends StatelessWidget {
|
||||||
const _DetailRow({
|
const _AssetAmcCard({required this.contract});
|
||||||
required this.label,
|
|
||||||
this.value,
|
|
||||||
this.valueWidget,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String label;
|
final AmcContractModel contract;
|
||||||
final String? value;
|
|
||||||
final Widget? valueWidget;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
final theme = Theme.of(context);
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
final dateFormat = DateFormat('dd MMM yyyy');
|
||||||
child: Row(
|
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: [
|
children: [
|
||||||
SizedBox(
|
Container(
|
||||||
width: 160,
|
width: 36,
|
||||||
child: Text(
|
height: 36,
|
||||||
label,
|
decoration: BoxDecoration(
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
color: const Color(0xFFCA8A04).withValues(alpha: 0.12),
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.handyman_outlined,
|
||||||
|
color: Color(0xFFCA8A04),
|
||||||
|
size: 20,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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(
|
Expanded(
|
||||||
child: valueWidget ?? Text(value ?? '—', style: Theme.of(context).textTheme.bodyLarge),
|
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,6 +58,9 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
|
|||||||
),
|
),
|
||||||
data: (grn) => SingleChildScrollView(
|
data: (grn) => SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 1200),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@ -110,24 +113,27 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
|
|||||||
_OverviewCard(grn: grn),
|
_OverviewCard(grn: grn),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
AppCard(
|
AppCard(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Line Items',
|
'Line Items',
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
const SizedBox(height: 12),
|
|
||||||
GrnItemsTable(items: grn.items),
|
GrnItemsTable(items: grn.items),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -210,67 +216,138 @@ class _OverviewCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final hasRemarks = grn.remarks?.trim().isNotEmpty == true;
|
||||||
|
|
||||||
return AppCard(
|
return AppCard(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Overview',
|
'Overview',
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_DetailRow(label: 'GRN Date', value: DateFormatter.displayDate(grn.grnDate)),
|
_GrnInfoGrid(
|
||||||
_DetailRow(label: 'PO Number', value: grn.poNumber ?? '—'),
|
columns: 4,
|
||||||
_DetailRow(label: 'Vendor', value: grn.vendorName ?? '—'),
|
items: [
|
||||||
_DetailRow(label: 'Warehouse', value: grn.warehouseName ?? '—'),
|
_GrnInfo('GRN Date', DateFormatter.displayDate(grn.grnDate)),
|
||||||
_DetailRow(label: 'Vendor Invoice No', value: grn.vendorInvoiceNo ?? '—'),
|
_GrnInfo('PO Number', grn.poNumber ?? '—'),
|
||||||
_DetailRow(
|
_GrnInfo('Vendor', grn.vendorName ?? '—'),
|
||||||
label: 'Vendor Invoice Date',
|
_GrnInfo('Warehouse', grn.warehouseName ?? '—'),
|
||||||
value: DateFormatter.displayDate(grn.vendorInvoiceDate),
|
_GrnInfo('Vendor Invoice No', grn.vendorInvoiceNo ?? '—'),
|
||||||
|
_GrnInfo(
|
||||||
|
'Vendor Invoice Date',
|
||||||
|
DateFormatter.displayDate(grn.vendorInvoiceDate),
|
||||||
),
|
),
|
||||||
_DetailRow(
|
_GrnInfo(
|
||||||
label: 'Vendor Invoice Amount',
|
'Vendor Invoice Amount',
|
||||||
value: grn.vendorInvoiceAmount != null
|
grn.vendorInvoiceAmount != null
|
||||||
? CurrencyFormatter.format(grn.vendorInvoiceAmount!)
|
? CurrencyFormatter.format(grn.vendorInvoiceAmount!)
|
||||||
: '—',
|
: '—',
|
||||||
),
|
),
|
||||||
_DetailRow(label: 'Vehicle No', value: grn.vehicleNo ?? '—'),
|
_GrnInfo('Vehicle No', grn.vehicleNo ?? '—'),
|
||||||
_DetailRow(label: 'LR No', value: grn.lrNo ?? '—'),
|
_GrnInfo('LR No', grn.lrNo ?? '—'),
|
||||||
_DetailRow(label: 'LR Date', value: DateFormatter.displayDate(grn.lrDate)),
|
_GrnInfo('LR Date', DateFormatter.displayDate(grn.lrDate)),
|
||||||
_DetailRow(label: 'Remarks', value: grn.remarks ?? '—'),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
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 {
|
class _GrnInfoGrid extends StatelessWidget {
|
||||||
const _DetailRow({required this.label, required this.value});
|
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 label;
|
||||||
final String value;
|
final String value;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
final theme = Theme.of(context);
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
|
||||||
child: Row(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
Text(
|
||||||
width: 180,
|
|
||||||
child: Text(
|
|
||||||
label,
|
label,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 4),
|
||||||
Expanded(child: Text(value)),
|
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 '../../../../core/theme/app_colors.dart';
|
||||||
import '../../../../shared/models/grn_model.dart';
|
import '../../../../shared/models/grn_model.dart';
|
||||||
import '../../../../shared/models/purchase_order_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_dropdown.dart';
|
||||||
import '../../../../shared/widgets/app_text_field.dart';
|
import '../../../../shared/widgets/app_text_field.dart';
|
||||||
|
|
||||||
@ -322,37 +323,48 @@ class GrnItemsTable extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (items.isEmpty) {
|
return AppDataTable<GrnItemModel>(
|
||||||
return const SizedBox.shrink();
|
wrapInCard: false,
|
||||||
}
|
shrinkWrap: true,
|
||||||
|
emptyMessage: 'No line items',
|
||||||
return SingleChildScrollView(
|
columns: [
|
||||||
scrollDirection: Axis.horizontal,
|
AppDataColumn(
|
||||||
child: DataTable(
|
label: '#',
|
||||||
headingRowColor: WidgetStateProperty.all(AppColors.lightSurface),
|
flex: 1,
|
||||||
columns: const [
|
cellBuilder: (_, item) => Text('${item.lineNo ?? '—'}'),
|
||||||
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(),
|
|
||||||
),
|
),
|
||||||
|
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 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../../../core/constants/app_constants.dart';
|
||||||
import '../../../../shared/models/export_file_result.dart';
|
import '../../../../shared/models/export_file_result.dart';
|
||||||
import '../../data/repositories/master_repository_impl.dart';
|
import '../../data/repositories/master_repository_impl.dart';
|
||||||
import '../../domain/entities/master_definition.dart';
|
import '../../domain/entities/master_definition.dart';
|
||||||
@ -57,18 +58,21 @@ class MasterFormState {
|
|||||||
const MasterFormState({
|
const MasterFormState({
|
||||||
this.values = const {},
|
this.values = const {},
|
||||||
this.dropdownOptions = const {},
|
this.dropdownOptions = const {},
|
||||||
|
this.existingRecords = const [],
|
||||||
this.isSubmitting = false,
|
this.isSubmitting = false,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Map<String, dynamic> values;
|
final Map<String, dynamic> values;
|
||||||
final Map<String, List<Map<String, dynamic>>> dropdownOptions;
|
final Map<String, List<Map<String, dynamic>>> dropdownOptions;
|
||||||
|
final List<Map<String, dynamic>> existingRecords;
|
||||||
final bool isSubmitting;
|
final bool isSubmitting;
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
|
|
||||||
MasterFormState copyWith({
|
MasterFormState copyWith({
|
||||||
Map<String, dynamic>? values,
|
Map<String, dynamic>? values,
|
||||||
Map<String, List<Map<String, dynamic>>>? dropdownOptions,
|
Map<String, List<Map<String, dynamic>>>? dropdownOptions,
|
||||||
|
List<Map<String, dynamic>>? existingRecords,
|
||||||
bool? isSubmitting,
|
bool? isSubmitting,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
bool clearError = false,
|
bool clearError = false,
|
||||||
@ -76,6 +80,7 @@ class MasterFormState {
|
|||||||
return MasterFormState(
|
return MasterFormState(
|
||||||
values: values ?? this.values,
|
values: values ?? this.values,
|
||||||
dropdownOptions: dropdownOptions ?? this.dropdownOptions,
|
dropdownOptions: dropdownOptions ?? this.dropdownOptions,
|
||||||
|
existingRecords: existingRecords ?? this.existingRecords,
|
||||||
isSubmitting: isSubmitting ?? this.isSubmitting,
|
isSubmitting: isSubmitting ?? this.isSubmitting,
|
||||||
errorMessage: clearError ? null : errorMessage ?? this.errorMessage,
|
errorMessage: clearError ? null : errorMessage ?? this.errorMessage,
|
||||||
);
|
);
|
||||||
@ -221,6 +226,7 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
|
|||||||
@override
|
@override
|
||||||
Future<MasterFormState> build(MasterFormArgs arg) async {
|
Future<MasterFormState> build(MasterFormArgs arg) async {
|
||||||
final dropdownOptions = await _loadDropdownOptions();
|
final dropdownOptions = await _loadDropdownOptions();
|
||||||
|
final existingRecords = await _loadExistingRecords();
|
||||||
Map<String, dynamic> values = {};
|
Map<String, dynamic> values = {};
|
||||||
|
|
||||||
if (arg.recordId != null) {
|
if (arg.recordId != null) {
|
||||||
@ -240,9 +246,31 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
|
|||||||
return MasterFormState(
|
return MasterFormState(
|
||||||
values: values,
|
values: values,
|
||||||
dropdownOptions: dropdownOptions,
|
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>>>>
|
Future<Map<String, List<Map<String, dynamic>>>>
|
||||||
_loadDropdownOptions() async {
|
_loadDropdownOptions() async {
|
||||||
final options = <String, List<Map<String, dynamic>>>{};
|
final options = <String, List<Map<String, dynamic>>>{};
|
||||||
|
|||||||
@ -147,12 +147,32 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
|||||||
keyboardType: _keyboardTypeForFieldKey(field.key),
|
keyboardType: _keyboardTypeForFieldKey(field.key),
|
||||||
inputFormatters: formatters.isEmpty ? null : formatters,
|
inputFormatters: formatters.isEmpty ? null : formatters,
|
||||||
decoration: InputDecoration(labelText: _fieldLabel(field)),
|
decoration: InputDecoration(labelText: _fieldLabel(field)),
|
||||||
validator: (v) => Validators.forFieldKey(
|
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,
|
field.key,
|
||||||
v,
|
v,
|
||||||
required: field.required,
|
required: field.required,
|
||||||
fieldName: field.label,
|
fieldName: field.label,
|
||||||
),
|
);
|
||||||
|
},
|
||||||
onChanged: (text) => notifier.updateValue(field.key, text),
|
onChanged: (text) => notifier.updateValue(field.key, text),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -65,6 +65,9 @@ class _PurchaseOrderDetailScreenState
|
|||||||
),
|
),
|
||||||
data: (order) => SingleChildScrollView(
|
data: (order) => SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 1200),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@ -146,7 +149,7 @@ class _PurchaseOrderDetailScreenState
|
|||||||
children: [
|
children: [
|
||||||
PoStatusChip(status: order.status),
|
PoStatusChip(status: order.status),
|
||||||
if (order.revisionNo != null && order.revisionNo! > 0)
|
if (order.revisionNo != null && order.revisionNo! > 0)
|
||||||
Chip(label: Text('Revision ${order.revisionNo}')),
|
PoRevisionChip(revisionNo: order.revisionNo!),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
@ -157,6 +160,8 @@ class _PurchaseOrderDetailScreenState
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -340,70 +345,129 @@ class _OverviewCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return AppCard(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Text('Overview', style: Theme.of(context).textTheme.titleMedium),
|
Text(
|
||||||
const SizedBox(height: 16),
|
'Overview',
|
||||||
_infoGrid([
|
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('PO Date', DateFormatter.displayDate(order.poDate)),
|
||||||
_Info('Expected Delivery',
|
_Info(
|
||||||
DateFormatter.displayDate(order.expectedDeliveryDate)),
|
'Expected Delivery',
|
||||||
|
DateFormatter.displayDate(order.expectedDeliveryDate),
|
||||||
|
),
|
||||||
_Info('Vendor', order.vendorName ?? '—'),
|
_Info('Vendor', order.vendorName ?? '—'),
|
||||||
|
_Info('Type', poTypeLabel(order.poType)),
|
||||||
_Info('Plant', order.plantName ?? '—'),
|
_Info('Plant', order.plantName ?? '—'),
|
||||||
_Info('Warehouse', order.warehouseName ?? '—'),
|
_Info('Warehouse', order.warehouseName ?? '—'),
|
||||||
_Info('Type', poTypeLabel(order.poType)),
|
],
|
||||||
|
),
|
||||||
|
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('Taxable', CurrencyFormatter.format(order.taxableAmount)),
|
||||||
_Info('Tax', CurrencyFormatter.format(order.taxAmount)),
|
_Info('Tax', CurrencyFormatter.format(order.taxAmount)),
|
||||||
_Info('Freight', CurrencyFormatter.format(order.freightCharges)),
|
_Info('Freight', CurrencyFormatter.format(order.freightCharges)),
|
||||||
_Info('Other Charges', CurrencyFormatter.format(order.otherCharges)),
|
_Info(
|
||||||
|
'Other Charges',
|
||||||
|
CurrencyFormatter.format(order.otherCharges),
|
||||||
|
),
|
||||||
_Info('Discount', CurrencyFormatter.format(order.discountAmount)),
|
_Info('Discount', CurrencyFormatter.format(order.discountAmount)),
|
||||||
_Info('Total', CurrencyFormatter.format(order.totalAmount)),
|
_Info(
|
||||||
]),
|
'Total',
|
||||||
if (order.termsAndConditions?.isNotEmpty == true) ...[
|
CurrencyFormatter.format(order.totalAmount),
|
||||||
const SizedBox(height: 16),
|
emphasize: true,
|
||||||
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),
|
if (hasTerms || hasRemarks) ...[
|
||||||
Text('Remarks', style: Theme.of(context).textTheme.titleSmall),
|
const Padding(
|
||||||
const SizedBox(height: 4),
|
padding: EdgeInsets.symmetric(vertical: 20),
|
||||||
Text(order.remarks!),
|
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(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
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(
|
return Wrap(
|
||||||
spacing: 24,
|
spacing: spacing,
|
||||||
runSpacing: 12,
|
runSpacing: 16,
|
||||||
children: rows
|
children: items
|
||||||
.map(
|
.map(
|
||||||
(row) => SizedBox(
|
(item) => SizedBox(
|
||||||
width: (constraints.maxWidth / crossAxisCount) - 24,
|
width: colWidth,
|
||||||
child: Column(
|
child: _DetailTile(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
label: item.label,
|
||||||
children: [
|
value: item.value,
|
||||||
Text(
|
emphasize: item.emphasize,
|
||||||
row.label,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(row.value),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@ -414,10 +478,51 @@ class _OverviewCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _Info {
|
class _DetailTile extends StatelessWidget {
|
||||||
const _Info(this.label, this.value);
|
const _DetailTile({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
this.emphasize = false,
|
||||||
|
});
|
||||||
|
|
||||||
final String label;
|
final String label;
|
||||||
final String value;
|
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 {
|
class _LineItemsCard extends StatelessWidget {
|
||||||
|
|||||||
@ -354,7 +354,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
FormRowThree(
|
FormRowFour(
|
||||||
children: [
|
children: [
|
||||||
_DateField(
|
_DateField(
|
||||||
label: 'PO Date *',
|
label: 'PO Date *',
|
||||||
@ -387,10 +387,6 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
|||||||
validator: (v) =>
|
validator: (v) =>
|
||||||
v == null ? 'Vendor is required' : null,
|
v == null ? 'Vendor is required' : null,
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
FormRowThree(
|
|
||||||
children: [
|
|
||||||
AppSearchableDropdown<int>(
|
AppSearchableDropdown<int>(
|
||||||
label: 'Plant *',
|
label: 'Plant *',
|
||||||
value: _dropdownValue(_plantId, plantIds),
|
value: _dropdownValue(_plantId, plantIds),
|
||||||
@ -400,6 +396,10 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
|||||||
validator: (v) =>
|
validator: (v) =>
|
||||||
v == null ? 'Plant is required' : null,
|
v == null ? 'Plant is required' : null,
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
FormRowFour(
|
||||||
|
children: [
|
||||||
AppSearchableDropdown<int?>(
|
AppSearchableDropdown<int?>(
|
||||||
label: 'Warehouse',
|
label: 'Warehouse',
|
||||||
value: _warehouseId,
|
value: _warehouseId,
|
||||||
@ -414,10 +414,6 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
|||||||
options: _nullableIntOptions(lookups.brands),
|
options: _nullableIntOptions(lookups.brands),
|
||||||
onChanged: (v) => setState(() => _brandId = v),
|
onChanged: (v) => setState(() => _brandId = v),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
FormRowThree(
|
|
||||||
children: [
|
|
||||||
AppSearchableDropdown<int?>(
|
AppSearchableDropdown<int?>(
|
||||||
label: 'Payment Term',
|
label: 'Payment Term',
|
||||||
value: _paymentTermId,
|
value: _paymentTermId,
|
||||||
@ -432,6 +428,10 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
|||||||
options: _nullableIntOptions(lookups.deliveryTerms),
|
options: _nullableIntOptions(lookups.deliveryTerms),
|
||||||
onChanged: (v) => setState(() => _deliveryTermId = v),
|
onChanged: (v) => setState(() => _deliveryTermId = v),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
FormRowFour(
|
||||||
|
children: [
|
||||||
_DateField(
|
_DateField(
|
||||||
label: 'Expected Delivery',
|
label: 'Expected Delivery',
|
||||||
value: _expectedDeliveryDate,
|
value: _expectedDeliveryDate,
|
||||||
@ -441,10 +441,6 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
|||||||
setState(() => _expectedDeliveryDate = d),
|
setState(() => _expectedDeliveryDate = d),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
FormRowThree(
|
|
||||||
children: [
|
|
||||||
AppTextField(
|
AppTextField(
|
||||||
controller: _discountController,
|
controller: _discountController,
|
||||||
label: 'Discount Amount',
|
label: 'Discount Amount',
|
||||||
@ -468,17 +464,20 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
FormRowFour(
|
||||||
|
spans: const [2, 2],
|
||||||
|
children: [
|
||||||
AppTextField(
|
AppTextField(
|
||||||
controller: _termsController,
|
controller: _termsController,
|
||||||
label: 'Terms & Conditions',
|
label: 'Terms & Conditions',
|
||||||
maxLines: 3,
|
maxLines: 3,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
|
||||||
AppTextField(
|
AppTextField(
|
||||||
controller: _remarksController,
|
controller: _remarksController,
|
||||||
label: 'Remarks',
|
label: 'Remarks',
|
||||||
maxLines: 2,
|
maxLines: 3,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
PurchaseOrderLineItemsEditor(
|
PurchaseOrderLineItemsEditor(
|
||||||
@ -576,18 +575,22 @@ class _DateField extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return InkWell(
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: InputDecorator(
|
child: InputDecorator(
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: label,
|
labelText: label,
|
||||||
|
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||||
suffixIcon: const Icon(Icons.calendar_today_outlined),
|
suffixIcon: const Icon(Icons.calendar_today_outlined),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
value != null ? DateFormatter.displayDate(value) : 'Select date',
|
value != null ? DateFormatter.displayDate(value) : 'Select date',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,20 +15,7 @@ class PoStatusChip extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final (color, label) = _resolveStatus(status);
|
final (color, label) = _resolveStatus(status);
|
||||||
return Chip(
|
return _PoChip(label: label, color: color, compact: compact);
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
(Color, String) _resolveStatus(String raw) {
|
(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(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
child: _LineItemCard(
|
child: _LineItemCard(
|
||||||
key: ValueKey('po-line-${line.lineNo}-$index'),
|
key: ObjectKey(line),
|
||||||
line: line,
|
line: line,
|
||||||
items: widget.items,
|
items: widget.items,
|
||||||
uom: widget.uom,
|
uom: widget.uom,
|
||||||
@ -154,7 +154,7 @@ class _PurchaseOrderLineItemsEditorState extends State<PurchaseOrderLineItemsEdi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _LineItemCard extends StatelessWidget {
|
class _LineItemCard extends StatefulWidget {
|
||||||
const _LineItemCard({
|
const _LineItemCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.line,
|
required this.line,
|
||||||
@ -170,12 +170,24 @@ class _LineItemCard extends StatelessWidget {
|
|||||||
final List<FilterOptionModel> gstRates;
|
final List<FilterOptionModel> gstRates;
|
||||||
final VoidCallback? onRemove;
|
final VoidCallback? onRemove;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_LineItemCard> createState() => _LineItemCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LineItemCardState extends State<_LineItemCard> {
|
||||||
int? _parseId(String value) => int.tryParse(value.trim());
|
int? _parseId(String value) => int.tryParse(value.trim());
|
||||||
|
|
||||||
|
void _updateLine(void Function() mutate) {
|
||||||
|
mutate();
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(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) {
|
.map((e) {
|
||||||
final id = _parseId(e.id);
|
final id = _parseId(e.id);
|
||||||
if (id == null) return null;
|
if (id == null) return null;
|
||||||
@ -183,7 +195,7 @@ class _LineItemCard extends StatelessWidget {
|
|||||||
})
|
})
|
||||||
.whereType<AppDropdownOption<int>>()
|
.whereType<AppDropdownOption<int>>()
|
||||||
.toList();
|
.toList();
|
||||||
final uomOptions = uom
|
final uomOptions = widget.uom
|
||||||
.map((e) {
|
.map((e) {
|
||||||
final id = _parseId(e.id);
|
final id = _parseId(e.id);
|
||||||
if (id == null) return null;
|
if (id == null) return null;
|
||||||
@ -193,7 +205,7 @@ class _LineItemCard extends StatelessWidget {
|
|||||||
.toList();
|
.toList();
|
||||||
final gstOptions = [
|
final gstOptions = [
|
||||||
const AppDropdownOption<int?>(value: null, label: 'No GST'),
|
const AppDropdownOption<int?>(value: null, label: 'No GST'),
|
||||||
...gstRates.map((e) {
|
...widget.gstRates.map((e) {
|
||||||
final id = _parseId(e.id);
|
final id = _parseId(e.id);
|
||||||
if (id == null) return null;
|
if (id == null) return null;
|
||||||
return AppDropdownOption<int?>(value: id, label: e.name);
|
return AppDropdownOption<int?>(value: id, label: e.name);
|
||||||
@ -201,38 +213,47 @@ class _LineItemCard extends StatelessWidget {
|
|||||||
].whereType<AppDropdownOption<int?>>().toList();
|
].whereType<AppDropdownOption<int?>>().toList();
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(color: theme.dividerColor),
|
border: Border.all(color: theme.dividerColor),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Text('Line ${line.lineNo}', style: theme.textTheme.titleSmall),
|
Text('Line ${line.lineNo}', style: theme.textTheme.titleSmall),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
if (onRemove != null)
|
if (widget.onRemove != null)
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Remove line',
|
tooltip: 'Remove line',
|
||||||
icon: const Icon(Icons.delete_outline),
|
icon: const Icon(Icons.delete_outline),
|
||||||
onPressed: onRemove,
|
onPressed: widget.onRemove,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
FormRowThree(
|
FormRow(
|
||||||
|
columnCount: 6,
|
||||||
|
horizontalPadding: 16,
|
||||||
|
spacing: 8,
|
||||||
|
stackBelowWidth: 992,
|
||||||
children: [
|
children: [
|
||||||
AppSearchableDropdown<int>(
|
AppSearchableDropdown<int>(
|
||||||
|
key: ValueKey('$lineKey-item'),
|
||||||
label: 'Item *',
|
label: 'Item *',
|
||||||
value: line.itemId,
|
value: line.itemId,
|
||||||
searchHint: 'Search item...',
|
searchHint: 'Search item...',
|
||||||
options: itemOptions,
|
options: itemOptions,
|
||||||
onChanged: (v) => line.itemId = v,
|
onChanged: (v) => _updateLine(() => line.itemId = v),
|
||||||
validator: (v) => v == null ? 'Item is required' : null,
|
validator: (v) => v == null ? 'Item is required' : null,
|
||||||
),
|
),
|
||||||
AppTextField(
|
AppTextField(
|
||||||
|
key: ValueKey('$lineKey-qty'),
|
||||||
controller: line.qtyController,
|
controller: line.qtyController,
|
||||||
label: 'Quantity *',
|
label: 'Quantity *',
|
||||||
keyboardType:
|
keyboardType:
|
||||||
@ -247,18 +268,16 @@ class _LineItemCard extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
AppSearchableDropdown<int>(
|
AppSearchableDropdown<int>(
|
||||||
|
key: ValueKey('$lineKey-uom'),
|
||||||
label: 'UOM *',
|
label: 'UOM *',
|
||||||
value: line.uomId,
|
value: line.uomId,
|
||||||
searchHint: 'Search UOM...',
|
searchHint: 'Search UOM...',
|
||||||
options: uomOptions,
|
options: uomOptions,
|
||||||
onChanged: (v) => line.uomId = v,
|
onChanged: (v) => _updateLine(() => line.uomId = v),
|
||||||
validator: (v) => v == null ? 'UOM is required' : null,
|
validator: (v) => v == null ? 'UOM is required' : null,
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
FormRowThree(
|
|
||||||
children: [
|
|
||||||
AppTextField(
|
AppTextField(
|
||||||
|
key: ValueKey('$lineKey-rate'),
|
||||||
controller: line.rateController,
|
controller: line.rateController,
|
||||||
label: 'Rate *',
|
label: 'Rate *',
|
||||||
keyboardType:
|
keyboardType:
|
||||||
@ -271,24 +290,36 @@ class _LineItemCard extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
AppTextField(
|
AppTextField(
|
||||||
|
key: ValueKey('$lineKey-discount'),
|
||||||
controller: line.discountController,
|
controller: line.discountController,
|
||||||
label: 'Discount %',
|
label: 'Discount %',
|
||||||
keyboardType:
|
keyboardType:
|
||||||
const TextInputType.numberWithOptions(decimal: true),
|
const TextInputType.numberWithOptions(decimal: true),
|
||||||
),
|
),
|
||||||
AppSearchableDropdown<int?>(
|
AppSearchableDropdown<int?>(
|
||||||
|
key: ValueKey('$lineKey-gst'),
|
||||||
label: 'GST Rate',
|
label: 'GST Rate',
|
||||||
value: line.gstRateId,
|
value: line.gstRateId,
|
||||||
searchHint: 'Search GST rate...',
|
searchHint: 'Search GST rate...',
|
||||||
options: gstOptions,
|
options: gstOptions,
|
||||||
onChanged: (v) => line.gstRateId = v,
|
onChanged: (v) => _updateLine(() => line.gstRateId = v),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
FormRow(
|
||||||
|
columnCount: 6,
|
||||||
|
spans: const [6],
|
||||||
|
horizontalPadding: 16,
|
||||||
|
spacing: 8,
|
||||||
|
stackBelowWidth: 992,
|
||||||
|
children: [
|
||||||
AppTextField(
|
AppTextField(
|
||||||
|
key: ValueKey('$lineKey-remarks'),
|
||||||
controller: line.remarksController,
|
controller: line.remarksController,
|
||||||
label: 'Remarks',
|
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_dropdown.dart';
|
||||||
import '../../../../shared/widgets/app_empty_state.dart';
|
import '../../../../shared/widgets/app_empty_state.dart';
|
||||||
import '../../../../shared/widgets/app_loading_view.dart';
|
import '../../../../shared/widgets/app_loading_view.dart';
|
||||||
import '../../../../shared/widgets/app_status_chip.dart';
|
|
||||||
import '../../../../shared/widgets/error_view.dart';
|
import '../../../../shared/widgets/error_view.dart';
|
||||||
import '../../../../shared/widgets/page_header.dart';
|
import '../../../../shared/widgets/page_header.dart';
|
||||||
import '../providers/vendors_provider.dart';
|
import '../providers/vendors_provider.dart';
|
||||||
@ -206,37 +205,58 @@ class _OverviewTab extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final hasRemarks = vendor.remarks?.trim().isNotEmpty == true;
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 1200),
|
||||||
child: AppCard(
|
child: AppCard(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_DetailRow(label: 'Vendor Code', value: vendor.vendorCode ?? '—'),
|
Text(
|
||||||
_DetailRow(label: 'Vendor Name', value: vendor.vendorName),
|
'Vendor Details',
|
||||||
_DetailRow(label: 'Type', value: vendorTypeLabel(vendor.vendorType)),
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
_DetailRow(label: 'GSTIN', value: vendor.gstin ?? '—'),
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
_DetailRow(label: 'PAN', value: vendor.pan ?? '—'),
|
fontWeight: FontWeight.w600,
|
||||||
_DetailRow(
|
|
||||||
label: 'Payment Term',
|
|
||||||
value: vendor.paymentTermName ?? '—',
|
|
||||||
),
|
),
|
||||||
_DetailRow(
|
),
|
||||||
label: 'Credit Period',
|
const SizedBox(height: 12),
|
||||||
value: vendor.creditPeriodDays != null
|
_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'
|
? '${vendor.creditPeriodDays} days'
|
||||||
: '—',
|
: '—',
|
||||||
),
|
),
|
||||||
_DetailRow(label: 'Remarks', value: vendor.remarks ?? '—'),
|
_VendorInfo('Status', vendorStatusLabel(vendor.status)),
|
||||||
_DetailRow(
|
_VendorInfo('Active', vendor.isActive ? 'Yes' : 'No'),
|
||||||
label: 'Status',
|
],
|
||||||
value: vendorStatusLabel(vendor.status),
|
|
||||||
),
|
),
|
||||||
_DetailRow(
|
if (hasRemarks) ...[
|
||||||
label: 'Active',
|
const Padding(
|
||||||
value: vendor.isActive ? 'Yes' : 'No',
|
padding: EdgeInsets.symmetric(vertical: 20),
|
||||||
|
child: Divider(height: 1),
|
||||||
|
),
|
||||||
|
_VendorInfoGrid(
|
||||||
|
columns: 1,
|
||||||
|
items: [_VendorInfo('Remarks', vendor.remarks!)],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -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 {
|
class _AddressesTab extends ConsumerWidget {
|
||||||
const _AddressesTab({
|
const _AddressesTab({
|
||||||
required this.vendorId,
|
required this.vendorId,
|
||||||
@ -287,27 +386,20 @@ class _AddressesTab extends ConsumerWidget {
|
|||||||
description: 'Add registered, billing or dispatch addresses.',
|
description: 'Add registered, billing or dispatch addresses.',
|
||||||
icon: Icons.location_on_outlined,
|
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,
|
itemCount: addresses.length,
|
||||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final address = addresses[index];
|
final address = addresses[index];
|
||||||
return _SubResourceCard(
|
return _VendorAddressCard(
|
||||||
title: addressTypeOptions
|
address: address,
|
||||||
.where((e) => e.$1 == address.addressType)
|
canEdit: canEdit,
|
||||||
.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(
|
onEdit: () => openVendorAddressPanel(
|
||||||
context,
|
context,
|
||||||
vendorId: vendorId,
|
vendorId: vendorId,
|
||||||
@ -319,12 +411,6 @@ class _AddressesTab extends ConsumerWidget {
|
|||||||
vendorId,
|
vendorId,
|
||||||
address.id,
|
address.id,
|
||||||
),
|
),
|
||||||
)
|
|
||||||
: null,
|
|
||||||
chip: AppStatusChip(
|
|
||||||
status: address.isActive ? 'active' : 'inactive',
|
|
||||||
compact: true,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@ -400,20 +486,20 @@ class _ContactsTab extends ConsumerWidget {
|
|||||||
description: 'Add contact persons for this vendor.',
|
description: 'Add contact persons for this vendor.',
|
||||||
icon: Icons.person_outline,
|
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,
|
itemCount: contacts.length,
|
||||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final contact = contacts[index];
|
final contact = contacts[index];
|
||||||
return _SubResourceCard(
|
return _VendorContactCard(
|
||||||
title: contact.contactName,
|
contact: contact,
|
||||||
subtitle: [
|
canEdit: canEdit,
|
||||||
contact.designation,
|
|
||||||
contact.phone,
|
|
||||||
contact.email,
|
|
||||||
].where((e) => e != null && e.isNotEmpty).join(' · '),
|
|
||||||
trailing: canEdit
|
|
||||||
? _SubResourceActions(
|
|
||||||
onEdit: () => openVendorContactPanel(
|
onEdit: () => openVendorContactPanel(
|
||||||
context,
|
context,
|
||||||
vendorId: vendorId,
|
vendorId: vendorId,
|
||||||
@ -425,12 +511,6 @@ class _ContactsTab extends ConsumerWidget {
|
|||||||
vendorId,
|
vendorId,
|
||||||
contact.id,
|
contact.id,
|
||||||
),
|
),
|
||||||
)
|
|
||||||
: null,
|
|
||||||
chip: AppStatusChip(
|
|
||||||
status: contact.isActive ? 'active' : 'inactive',
|
|
||||||
compact: true,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@ -506,22 +586,20 @@ class _BankDetailsTab extends ConsumerWidget {
|
|||||||
description: 'Add bank accounts for payments.',
|
description: 'Add bank accounts for payments.',
|
||||||
icon: Icons.account_balance_outlined,
|
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,
|
itemCount: bankDetails.length,
|
||||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final detail = bankDetails[index];
|
final detail = bankDetails[index];
|
||||||
return _SubResourceCard(
|
return _VendorBankDetailCard(
|
||||||
title: detail.bankName ?? 'Bank Account',
|
bankDetail: detail,
|
||||||
subtitle: [
|
canEdit: canEdit,
|
||||||
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(
|
onEdit: () => openVendorBankDetailPanel(
|
||||||
context,
|
context,
|
||||||
vendorId: vendorId,
|
vendorId: vendorId,
|
||||||
@ -533,12 +611,6 @@ class _BankDetailsTab extends ConsumerWidget {
|
|||||||
vendorId,
|
vendorId,
|
||||||
detail.id,
|
detail.id,
|
||||||
),
|
),
|
||||||
)
|
|
||||||
: null,
|
|
||||||
chip: AppStatusChip(
|
|
||||||
status: detail.isActive ? 'active' : 'inactive',
|
|
||||||
compact: true,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@ -573,88 +645,489 @@ class _BankDetailsTab extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SubResourceCard extends StatelessWidget {
|
class _VendorAddressCard extends StatelessWidget {
|
||||||
const _SubResourceCard({
|
const _VendorAddressCard({
|
||||||
required this.title,
|
required this.address,
|
||||||
required this.subtitle,
|
required this.canEdit,
|
||||||
this.trailing,
|
required this.onEdit,
|
||||||
this.chip,
|
required this.onDelete,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String title;
|
final VendorAddressModel address;
|
||||||
final String subtitle;
|
final bool canEdit;
|
||||||
final Widget? trailing;
|
|
||||||
final Widget? chip;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return AppCard(
|
|
||||||
child: ListTile(
|
|
||||||
title: Text(title),
|
|
||||||
subtitle: subtitle.isNotEmpty ? Text(subtitle) : null,
|
|
||||||
trailing: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
if (chip != null) ...[chip!, const SizedBox(width: 8)],
|
|
||||||
if (trailing != null) trailing!,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SubResourceActions extends StatelessWidget {
|
|
||||||
const _SubResourceActions({required this.onEdit, required this.onDelete});
|
|
||||||
|
|
||||||
final VoidCallback onEdit;
|
final VoidCallback onEdit;
|
||||||
final VoidCallback onDelete;
|
final VoidCallback onDelete;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Row(
|
final theme = Theme.of(context);
|
||||||
mainAxisSize: MainAxisSize.min,
|
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(
|
||||||
|
elevation: 0,
|
||||||
|
enableHover: true,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
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(
|
IconButton(
|
||||||
tooltip: 'Edit',
|
icon: const Icon(Icons.edit_outlined, size: 18),
|
||||||
icon: const Icon(Icons.edit_outlined, size: 20),
|
|
||||||
onPressed: onEdit,
|
onPressed: onEdit,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Delete',
|
icon: const Icon(Icons.delete_outline, size: 18),
|
||||||
icon: const Icon(Icons.delete_outline, size: 20),
|
|
||||||
onPressed: onDelete,
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DetailRow extends StatelessWidget {
|
({Color color, IconData icon}) _addressCardAppearance(String? type) {
|
||||||
const _DetailRow({required this.label, required this.value});
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final String label;
|
class _VendorContactCard extends StatelessWidget {
|
||||||
final String value;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
final theme = Theme.of(context);
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
final appearance = _contactCardAppearance(contact);
|
||||||
child: Row(
|
final statusColor = contact.isActive
|
||||||
|
? const Color(0xFF16A34A)
|
||||||
|
: theme.colorScheme.onSurfaceVariant;
|
||||||
|
|
||||||
|
return AppCard(
|
||||||
|
elevation: 0,
|
||||||
|
enableHover: true,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
Row(
|
||||||
width: 160,
|
children: [
|
||||||
child: Text(
|
Container(
|
||||||
label,
|
width: 36,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
height: 36,
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
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(
|
AppTextField(
|
||||||
controller: _accountNumberController,
|
controller: _accountNumberController,
|
||||||
label: widget.isEditing ? 'Account Number' : 'Account Number *',
|
label: widget.isEditing ? 'Account Number' : 'Account Number *',
|
||||||
obscureText: true,
|
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
inputFormatters: Validators.accountNumberInput,
|
inputFormatters: Validators.accountNumberInput,
|
||||||
validator: widget.isEditing
|
validator: widget.isEditing
|
||||||
|
|||||||
@ -18,20 +18,15 @@ DateTime? _dateFromJsonNullable(Object? value) {
|
|||||||
return DateTime.tryParse(value.toString());
|
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) {
|
Object? _readPaymentTermName(Map<dynamic, dynamic> json, String key) {
|
||||||
final flat = json['payment_term_name'];
|
final flat = json['payment_term_name'];
|
||||||
if (flat is String && flat.isNotEmpty) return flat;
|
if (flat is String && flat.isNotEmpty) return flat;
|
||||||
final nested = json['payment_term'];
|
return _nestedPaymentTerm(json)?['name'];
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
|
|||||||
@ -36,6 +36,7 @@ class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
|
|||||||
final _layerLink = LayerLink();
|
final _layerLink = LayerLink();
|
||||||
final _fieldKey = GlobalKey();
|
final _fieldKey = GlobalKey();
|
||||||
OverlayEntry? _overlayEntry;
|
OverlayEntry? _overlayEntry;
|
||||||
|
bool _ignoreOutsideTap = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@ -55,6 +56,7 @@ class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
|
|||||||
if (_overlayEntry == null) return;
|
if (_overlayEntry == null) return;
|
||||||
_overlayEntry!.remove();
|
_overlayEntry!.remove();
|
||||||
_overlayEntry = null;
|
_overlayEntry = null;
|
||||||
|
_ignoreOutsideTap = false;
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
@ -69,9 +71,17 @@ class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
|
|||||||
return;
|
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 =
|
final renderBox =
|
||||||
_fieldKey.currentContext?.findRenderObject() as RenderBox?;
|
_fieldKey.currentContext?.findRenderObject() as RenderBox?;
|
||||||
if (renderBox == null) return;
|
if (renderBox == null || !renderBox.hasSize) return;
|
||||||
|
|
||||||
final fieldSize = renderBox.size;
|
final fieldSize = renderBox.size;
|
||||||
final fieldTopLeft = renderBox.localToGlobal(Offset.zero);
|
final fieldTopLeft = renderBox.localToGlobal(Offset.zero);
|
||||||
@ -86,6 +96,7 @@ class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
|
|||||||
final availableSpace = (showAbove ? spaceAbove : spaceBelow) - 8;
|
final availableSpace = (showAbove ? spaceAbove : spaceBelow) - 8;
|
||||||
final maxPanelHeight = availableSpace.clamp(120.0, screenSize.height * 0.45);
|
final maxPanelHeight = availableSpace.clamp(120.0, screenSize.height * 0.45);
|
||||||
|
|
||||||
|
_ignoreOutsideTap = true;
|
||||||
_overlayEntry = OverlayEntry(
|
_overlayEntry = OverlayEntry(
|
||||||
builder: (overlayContext) {
|
builder: (overlayContext) {
|
||||||
final theme = Theme.of(overlayContext);
|
final theme = Theme.of(overlayContext);
|
||||||
@ -107,7 +118,10 @@ class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
|
|||||||
showAbove ? Alignment.bottomLeft : Alignment.topLeft,
|
showAbove ? Alignment.bottomLeft : Alignment.topLeft,
|
||||||
offset: Offset(0, showAbove ? -4 : 4),
|
offset: Offset(0, showAbove ? -4 : 4),
|
||||||
child: TapRegion(
|
child: TapRegion(
|
||||||
onTapOutside: (_) => _removeOverlay(),
|
onTapOutside: (_) {
|
||||||
|
if (_ignoreOutsideTap) return;
|
||||||
|
_removeOverlay();
|
||||||
|
},
|
||||||
child: Material(
|
child: Material(
|
||||||
elevation: 8,
|
elevation: 8,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
@ -119,12 +133,13 @@ class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
|
|||||||
child: _SearchableDropdownPanel<T>(
|
child: _SearchableDropdownPanel<T>(
|
||||||
maxHeight: maxPanelHeight,
|
maxHeight: maxPanelHeight,
|
||||||
options: widget.options,
|
options: widget.options,
|
||||||
selected: widget.value,
|
selected: field.value ?? widget.value,
|
||||||
searchHint: widget.searchHint,
|
searchHint: widget.searchHint,
|
||||||
onSelected: (value) {
|
onSelected: (value) {
|
||||||
_removeOverlay();
|
_removeOverlay();
|
||||||
field.didChange(value);
|
field.didChange(value);
|
||||||
widget.onChanged(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(() {});
|
setState(() {});
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_ignoreOutsideTap = false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final displayLabel = _labelForValue(widget.value);
|
|
||||||
|
|
||||||
return FormField<T>(
|
return FormField<T>(
|
||||||
initialValue: widget.value,
|
initialValue: widget.value,
|
||||||
@ -152,6 +172,8 @@ class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
|
|||||||
final effectiveHint = widget.hint ?? 'Select ${widget.label.toLowerCase()}';
|
final effectiveHint = widget.hint ?? 'Select ${widget.label.toLowerCase()}';
|
||||||
final canOpen = widget.enabled && widget.options.isNotEmpty;
|
final canOpen = widget.enabled && widget.options.isNotEmpty;
|
||||||
final colors = theme.colorScheme;
|
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
|
// Top padding keeps the always-floating label from being clipped by
|
||||||
// tight parents (e.g. TabBarView toolbars).
|
// tight parents (e.g. TabBarView toolbars).
|
||||||
|
|||||||
@ -206,13 +206,107 @@ class FormRowThree extends StatelessWidget {
|
|||||||
final double spacing;
|
final double spacing;
|
||||||
final double stackBelowWidth;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
|
final contentPadding = EdgeInsets.fromLTRB(
|
||||||
|
horizontalPadding,
|
||||||
|
0,
|
||||||
|
horizontalPadding,
|
||||||
|
spacing,
|
||||||
|
);
|
||||||
|
|
||||||
if (constraints.maxWidth < stackBelowWidth) {
|
if (constraints.maxWidth < stackBelowWidth) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: EdgeInsets.only(bottom: spacing),
|
padding: contentPadding,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
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(
|
return Padding(
|
||||||
padding: EdgeInsets.only(bottom: spacing),
|
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: contentPadding,
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@ -37,7 +37,9 @@ class AppTextField extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return TextFormField(
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: TextFormField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
obscureText: obscureText,
|
obscureText: obscureText,
|
||||||
keyboardType: keyboardType,
|
keyboardType: keyboardType,
|
||||||
@ -53,6 +55,9 @@ class AppTextField extends StatelessWidget {
|
|||||||
hintText: hint,
|
hintText: hint,
|
||||||
prefixIcon: prefixIcon,
|
prefixIcon: prefixIcon,
|
||||||
suffixIcon: suffixIcon,
|
suffixIcon: suffixIcon,
|
||||||
|
floatingLabelBehavior:
|
||||||
|
label != null ? FloatingLabelBehavior.always : null,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -76,5 +76,110 @@ void main() {
|
|||||||
isNull,
|
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