diff --git a/lib/core/utils/validators.dart b/lib/core/utils/validators.dart index f6bca06..d0a9f64 100644 --- a/lib/core/utils/validators.dart +++ b/lib/core/utils/validators.dart @@ -207,6 +207,115 @@ class Validators { } static final RegExp _roleNamePattern = RegExp(r'^[a-zA-Z0-9 ]+$'); + static final RegExp _masterNamePattern = + RegExp(r'^[A-Za-z0-9 _/&.()\-]+$'); + static final RegExp _masterNameCharPattern = + RegExp(r'[A-Za-z0-9 _/&.()\-]'); + + static bool isMasterNameFieldKey(String key) { + final normalizedKey = key.trim().toLowerCase(); + return normalizedKey == 'name' || normalizedKey == 'item_name'; + } + + /// Required master name — allowed: A-Z a-z 0-9 space - _ / & . ( ) + static String? masterName(String? value, {String fieldName = 'Name'}) { + final requiredError = required(value, fieldName: fieldName); + if (requiredError != null) return requiredError; + + return _validateMasterNameChars(value!.trim(), fieldName: fieldName); + } + + static String? _validateMasterNameChars( + String raw, { + String fieldName = 'Name', + }) { + if (!_masterNamePattern.hasMatch(raw)) { + return '$fieldName can only contain letters, numbers, spaces, and - _ / & . ( )'; + } + return null; + } + + /// Validates master name format and uniqueness against existing records. + static String? uniqueMasterName( + String? value, { + required String nameKey, + required Iterable> 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 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> 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 get masterCodeInput => [ + FilteringTextInputFormatter.allow(_masterCodeCharPattern), + ]; /// Required role name — letters, numbers, and spaces only. static String? roleName(String? value) { @@ -258,6 +367,24 @@ class Validators { if (normalizedKey == 'account_number' || normalizedKey == 'account_no') { return required ? accountNumber(value) : optionalAccountNumber(value); } + if (isMasterNameFieldKey(normalizedKey)) { + if (required) { + return masterName(value, fieldName: fieldName); + } + if (value != null && value.trim().isNotEmpty) { + return _validateMasterNameChars(value.trim(), fieldName: fieldName); + } + return null; + } + if (isMasterCodeFieldKey(normalizedKey)) { + if (required) { + return masterCode(value, fieldName: fieldName); + } + if (value != null && value.trim().isNotEmpty) { + return _validateMasterCodeChars(value.trim(), fieldName: fieldName); + } + return null; + } if (required) { return Validators.required(value, fieldName: fieldName); @@ -290,6 +417,12 @@ class Validators { if (normalizedKey == 'pan') { return panInput; } + if (isMasterNameFieldKey(normalizedKey)) { + return masterNameInput; + } + if (isMasterCodeFieldKey(normalizedKey)) { + return masterCodeInput; + } return const []; } diff --git a/lib/modules/assets/presentation/screens/asset_detail_screen.dart b/lib/modules/assets/presentation/screens/asset_detail_screen.dart index 34b35eb..b9821e3 100644 --- a/lib/modules/assets/presentation/screens/asset_detail_screen.dart +++ b/lib/modules/assets/presentation/screens/asset_detail_screen.dart @@ -10,6 +10,7 @@ import '../../../../shared/models/asset_model.dart'; import '../../../../shared/providers/permissions_provider.dart'; import '../../../../shared/widgets/app_card.dart'; import '../../../../shared/widgets/app_confirmation_dialog.dart'; +import '../../../../shared/widgets/app_empty_state.dart'; import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_status_chip.dart'; import '../../../../shared/widgets/can_permission.dart'; @@ -182,33 +183,56 @@ class _OverviewTab extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); final dateFormat = DateFormat('dd MMM yyyy'); return SingleChildScrollView( - child: AppCard( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - children: [ - _DetailRow(label: 'Asset Name', value: asset.assetName), - _DetailRow(label: 'Asset Code', value: asset.assetCode ?? '—'), - _DetailRow(label: 'Category', value: asset.assetCategoryName ?? '—'), - _DetailRow(label: 'Plant', value: asset.plantName ?? '—'), - _DetailRow( - label: 'Warranty Expiry', - value: asset.warrantyExpiryDate != null - ? dateFormat.format(asset.warrantyExpiryDate!) - : '—', + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1200), + child: AppCard( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Asset Details', + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 12), + _AssetInfoGrid( + items: [ + _AssetInfo('Asset Name', asset.assetName), + _AssetInfo('Asset Code', asset.assetCode ?? '—'), + _AssetInfo('Category', asset.assetCategoryName ?? '—'), + _AssetInfo('Plant', asset.plantName ?? '—'), + _AssetInfo( + 'Warranty Expiry', + asset.warrantyExpiryDate != null + ? dateFormat.format(asset.warrantyExpiryDate!) + : '—', + ), + _AssetInfo( + 'Purchase Cost', + asset.purchaseCost != null ? '₹${asset.purchaseCost}' : '—', + ), + _AssetInfo.status( + 'Status', + AppStatusChip(status: asset.status ?? 'active'), + ), + _AssetInfo( + 'Active', + asset.isActive ? 'Yes' : 'No', + ), + ], + ), + ], ), - _DetailRow( - label: 'Purchase Cost', - value: asset.purchaseCost != null ? '₹${asset.purchaseCost}' : '—', - ), - _DetailRow( - label: 'Status', - valueWidget: AppStatusChip(status: asset.status ?? 'active'), - ), - ], + ), ), ), ), @@ -216,6 +240,88 @@ class _OverviewTab extends StatelessWidget { } } +class _AssetInfoGrid extends StatelessWidget { + const _AssetInfoGrid({required this.items}); + + final List<_AssetInfo> items; + static const int _columns = 3; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final maxWidth = constraints.maxWidth; + final cols = maxWidth < 600 + ? 1 + : maxWidth < 900 + ? 2 + : _columns; + const spacing = 16.0; + final colWidth = (maxWidth - spacing * (cols - 1)) / cols; + + return Wrap( + spacing: spacing, + runSpacing: 16, + children: items + .map( + (item) => SizedBox( + width: colWidth, + child: _AssetDetailTile( + label: item.label, + value: item.value, + valueWidget: item.valueWidget, + ), + ), + ) + .toList(), + ); + }, + ); + } +} + +class _AssetDetailTile extends StatelessWidget { + const _AssetDetailTile({ + required this.label, + this.value, + this.valueWidget, + }); + + final String label; + final String? value; + final Widget? valueWidget; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + valueWidget ?? Text(value ?? '—', style: theme.textTheme.bodyLarge), + ], + ); + } +} + +class _AssetInfo { + const _AssetInfo(this.label, this.value) : valueWidget = null; + + const _AssetInfo.status(this.label, this.valueWidget) : value = null; + + final String label; + final String? value; + final Widget? valueWidget; +} + class _AmcTab extends ConsumerWidget { const _AmcTab({required this.assetId, required this.contracts}); @@ -242,23 +348,22 @@ class _AmcTab extends ConsumerWidget { const SizedBox(height: 12), Expanded( child: contracts.isEmpty - ? const Center(child: Text('No AMC contracts')) - : ListView.separated( + ? const AppEmptyState( + title: 'No AMC contracts', + description: 'Add annual maintenance contracts for this asset.', + icon: Icons.handyman_outlined, + ) + : GridView.builder( + padding: EdgeInsets.zero, + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 320, + mainAxisExtent: 175, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + ), itemCount: contracts.length, - separatorBuilder: (_, __) => const SizedBox(height: 8), itemBuilder: (context, index) { - final c = contracts[index]; - return AppCard( - child: ListTile( - title: Text(c.contractNo ?? 'AMC #${c.id}'), - subtitle: Text( - '${c.vendorName ?? 'Vendor ${c.vendorId ?? '—'}'} · ' - '${DateFormat('dd MMM yyyy').format(c.startDate)} – ' - '${DateFormat('dd MMM yyyy').format(c.endDate)}', - ), - trailing: c.annualCost != null ? Text('₹${c.annualCost}') : null, - ), - ); + return _AssetAmcCard(contract: contracts[index]); }, ), ), @@ -306,22 +411,22 @@ class _ServiceVisitsTab extends ConsumerWidget { const SizedBox(height: 12), Expanded( child: visits.isEmpty - ? const Center(child: Text('No service visits')) - : ListView.separated( + ? const AppEmptyState( + title: 'No service visits', + description: 'Log service visits and maintenance work for this asset.', + icon: Icons.build_outlined, + ) + : GridView.builder( + padding: EdgeInsets.zero, + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 320, + mainAxisExtent: 175, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + ), itemCount: visits.length, - separatorBuilder: (_, __) => const SizedBox(height: 8), itemBuilder: (context, index) { - final v = visits[index]; - return AppCard( - child: ListTile( - title: Text(v.visitType), - subtitle: Text( - '${DateFormat('dd MMM yyyy').format(v.visitDate)}' - '${v.workDone != null ? ' · ${v.workDone}' : ''}', - ), - trailing: AppStatusChip(status: v.status, compact: true), - ), - ); + return _AssetServiceVisitCard(visit: visits[index]); }, ), ), @@ -369,23 +474,22 @@ class _InsuranceTab extends ConsumerWidget { const SizedBox(height: 12), Expanded( child: policies.isEmpty - ? const Center(child: Text('No insurance policies')) - : ListView.separated( + ? const AppEmptyState( + title: 'No insurance policies', + description: 'Add insurance policies for this asset.', + icon: Icons.shield_outlined, + ) + : GridView.builder( + padding: EdgeInsets.zero, + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 320, + mainAxisExtent: 175, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + ), itemCount: policies.length, - separatorBuilder: (_, __) => const SizedBox(height: 8), itemBuilder: (context, index) { - final p = policies[index]; - return AppCard( - child: ListTile( - title: Text(p.policyNo), - subtitle: Text( - '${p.insurerName} · ' - '${DateFormat('dd MMM yyyy').format(p.policyStartDate)} – ' - '${DateFormat('dd MMM yyyy').format(p.policyEndDate)}', - ), - trailing: p.sumInsured != null ? Text('₹${p.sumInsured}') : null, - ), - ); + return _AssetInsuranceCard(policy: policies[index]); }, ), ), @@ -407,37 +511,373 @@ class _InsuranceTab extends ConsumerWidget { } } -class _DetailRow extends StatelessWidget { - const _DetailRow({ - required this.label, - this.value, - this.valueWidget, - }); +class _AssetAmcCard extends StatelessWidget { + const _AssetAmcCard({required this.contract}); - final String label; - final String? value; - final Widget? valueWidget; + final AmcContractModel contract; @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - children: [ - SizedBox( - width: 160, - child: Text( - label, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + final theme = Theme.of(context); + final dateFormat = DateFormat('dd MMM yyyy'); + final statusColor = _assetStatusColor(contract.status, theme); + final vendor = contract.vendorName?.trim().isNotEmpty == true + ? contract.vendorName! + : contract.vendorId != null + ? 'Vendor ${contract.vendorId}' + : '—'; + + return AppCard( + elevation: 0, + enableHover: true, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: const Color(0xFFCA8A04).withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.handyman_outlined, + color: Color(0xFFCA8A04), + size: 20, + ), ), - ), - Expanded( - child: valueWidget ?? Text(value ?? '—', style: Theme.of(context).textTheme.bodyLarge), - ), - ], + const SizedBox(height: 8), + Text( + contract.contractNo?.trim().isNotEmpty == true + ? contract.contractNo! + : 'AMC #${contract.id}', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + vendor, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Icon( + Icons.calendar_today_outlined, + size: 14, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + '${dateFormat.format(contract.startDate)} – ' + '${dateFormat.format(contract.endDate)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + const Spacer(), + Row( + children: [ + if (contract.annualCost != null) + Text( + '₹${contract.annualCost}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: statusColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + _assetStatusLabel(contract.status), + style: theme.textTheme.bodySmall?.copyWith( + color: statusColor, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ], + ), ), ); } } + +class _AssetServiceVisitCard extends StatelessWidget { + const _AssetServiceVisitCard({required this.visit}); + + final ServiceVisitModel visit; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final dateFormat = DateFormat('dd MMM yyyy'); + final statusColor = _assetStatusColor(visit.status, theme); + + return AppCard( + elevation: 0, + enableHover: true, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: const Color(0xFF2563EB).withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.build_outlined, + color: Color(0xFF2563EB), + size: 20, + ), + ), + const SizedBox(height: 8), + Text( + visit.visitType, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + if (visit.workDone?.trim().isNotEmpty == true) ...[ + const SizedBox(height: 4), + Text( + visit.workDone!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + const SizedBox(height: 8), + Row( + children: [ + Icon( + Icons.event_outlined, + size: 14, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + dateFormat.format(visit.visitDate), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + const Spacer(), + Row( + children: [ + if (visit.nextServiceDate != null) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.schedule_outlined, + size: 14, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Text( + dateFormat.format(visit.nextServiceDate!), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + const Spacer(), + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: statusColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + _assetStatusLabel(visit.status), + style: theme.textTheme.bodySmall?.copyWith( + color: statusColor, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _AssetInsuranceCard extends StatelessWidget { + const _AssetInsuranceCard({required this.policy}); + + final InsurancePolicyModel policy; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final dateFormat = DateFormat('dd MMM yyyy'); + final statusColor = _assetStatusColor(policy.status, theme); + + return AppCard( + elevation: 0, + enableHover: true, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: const Color(0xFF16A34A).withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.shield_outlined, + color: Color(0xFF16A34A), + size: 20, + ), + ), + const SizedBox(height: 8), + Text( + policy.policyNo, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + policy.insurerName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Icon( + Icons.calendar_today_outlined, + size: 14, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + '${dateFormat.format(policy.policyStartDate)} – ' + '${dateFormat.format(policy.policyEndDate)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + const Spacer(), + Row( + children: [ + if (policy.sumInsured != null) + Text( + '₹${policy.sumInsured}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: statusColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + _assetStatusLabel(policy.status), + style: theme.textTheme.bodySmall?.copyWith( + color: statusColor, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +Color _assetStatusColor(String status, ThemeData theme) { + switch (status.toLowerCase()) { + case 'active': + case 'completed': + return const Color(0xFF16A34A); + case 'pending': + case 'scheduled': + return const Color(0xFFCA8A04); + case 'cancelled': + case 'inactive': + case 'expired': + return theme.colorScheme.onSurfaceVariant; + default: + return const Color(0xFF2563EB); + } +} + +String _assetStatusLabel(String status) { + if (status.isEmpty) return '—'; + final normalized = status.replaceAll('_', ' ').toLowerCase(); + return normalized.split(' ').map((word) { + if (word.isEmpty) return word; + return '${word[0].toUpperCase()}${word.substring(1)}'; + }).join(' '); +} diff --git a/lib/modules/grn/presentation/screens/grn_detail_screen.dart b/lib/modules/grn/presentation/screens/grn_detail_screen.dart index f8d4c15..1c761f3 100644 --- a/lib/modules/grn/presentation/screens/grn_detail_screen.dart +++ b/lib/modules/grn/presentation/screens/grn_detail_screen.dart @@ -58,73 +58,79 @@ class _GrnDetailScreenState extends ConsumerState { ), data: (grn) => SingleChildScrollView( padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - PageHeader( - title: grn.grnNumber ?? 'GRN #${grn.id}', - subtitle: - 'PO ${grn.poNumber ?? '—'} · ${grn.vendorName ?? '—'}', - actions: [ - if (canExport) - OutlinedButton.icon( - onPressed: _isWorking ? null : () => _downloadPdf(grn), - icon: const Icon(Icons.picture_as_pdf_outlined), - label: const Text('PDF'), - ), - if (canEdit && grn.canEdit) ...[ - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: _isWorking - ? null - : () => context.push( - '${RouteConstants.grn}/${grn.id}/edit', - ), - icon: const Icon(Icons.edit_outlined), - label: const Text('Edit'), - ), - ], - if (canEdit && grn.canCancel) ...[ - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: _isWorking ? null : () => _cancel(grn), - icon: const Icon(Icons.block_outlined), - label: const Text('Cancel'), - ), - ], - ], - ), - const SizedBox(height: 16), - GrnStatusChip(status: grn.status), - if (grn.cancellationReason != null && - grn.cancellationReason!.isNotEmpty) ...[ - const SizedBox(height: 8), - Text( - 'Cancellation reason: ${grn.cancellationReason}', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.error, - ), - ), - ], - const SizedBox(height: 16), - _OverviewCard(grn: grn), - const SizedBox(height: 16), - AppCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1200), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + PageHeader( + title: grn.grnNumber ?? 'GRN #${grn.id}', + subtitle: + 'PO ${grn.poNumber ?? '—'} · ${grn.vendorName ?? '—'}', + actions: [ + if (canExport) + OutlinedButton.icon( + onPressed: _isWorking ? null : () => _downloadPdf(grn), + icon: const Icon(Icons.picture_as_pdf_outlined), + label: const Text('PDF'), + ), + if (canEdit && grn.canEdit) ...[ + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: _isWorking + ? null + : () => context.push( + '${RouteConstants.grn}/${grn.id}/edit', + ), + icon: const Icon(Icons.edit_outlined), + label: const Text('Edit'), + ), + ], + if (canEdit && grn.canCancel) ...[ + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: _isWorking ? null : () => _cancel(grn), + icon: const Icon(Icons.block_outlined), + label: const Text('Cancel'), + ), + ], + ], + ), + const SizedBox(height: 16), + GrnStatusChip(status: grn.status), + if (grn.cancellationReason != null && + grn.cancellationReason!.isNotEmpty) ...[ + const SizedBox(height: 8), Text( - 'Line Items', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, + 'Cancellation reason: ${grn.cancellationReason}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.error, ), ), - const SizedBox(height: 12), - GrnItemsTable(items: grn.items), ], - ), + const SizedBox(height: 16), + _OverviewCard(grn: grn), + const SizedBox(height: 16), + AppCard( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Line Items', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 16), + GrnItemsTable(items: grn.items), + ], + ), + ), + ), + ], ), - ], + ), ), ), ), @@ -210,67 +216,138 @@ class _OverviewCard extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); + final hasRemarks = grn.remarks?.trim().isNotEmpty == true; + return AppCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Overview', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Overview', + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 12), + _GrnInfoGrid( + columns: 4, + items: [ + _GrnInfo('GRN Date', DateFormatter.displayDate(grn.grnDate)), + _GrnInfo('PO Number', grn.poNumber ?? '—'), + _GrnInfo('Vendor', grn.vendorName ?? '—'), + _GrnInfo('Warehouse', grn.warehouseName ?? '—'), + _GrnInfo('Vendor Invoice No', grn.vendorInvoiceNo ?? '—'), + _GrnInfo( + 'Vendor Invoice Date', + DateFormatter.displayDate(grn.vendorInvoiceDate), ), - ), - const SizedBox(height: 12), - _DetailRow(label: 'GRN Date', value: DateFormatter.displayDate(grn.grnDate)), - _DetailRow(label: 'PO Number', value: grn.poNumber ?? '—'), - _DetailRow(label: 'Vendor', value: grn.vendorName ?? '—'), - _DetailRow(label: 'Warehouse', value: grn.warehouseName ?? '—'), - _DetailRow(label: 'Vendor Invoice No', value: grn.vendorInvoiceNo ?? '—'), - _DetailRow( - label: 'Vendor Invoice Date', - value: DateFormatter.displayDate(grn.vendorInvoiceDate), - ), - _DetailRow( - label: 'Vendor Invoice Amount', - value: grn.vendorInvoiceAmount != null - ? CurrencyFormatter.format(grn.vendorInvoiceAmount!) - : '—', - ), - _DetailRow(label: 'Vehicle No', value: grn.vehicleNo ?? '—'), - _DetailRow(label: 'LR No', value: grn.lrNo ?? '—'), - _DetailRow(label: 'LR Date', value: DateFormatter.displayDate(grn.lrDate)), - _DetailRow(label: 'Remarks', value: grn.remarks ?? '—'), - ], + _GrnInfo( + 'Vendor Invoice Amount', + grn.vendorInvoiceAmount != null + ? CurrencyFormatter.format(grn.vendorInvoiceAmount!) + : '—', + ), + _GrnInfo('Vehicle No', grn.vehicleNo ?? '—'), + _GrnInfo('LR No', grn.lrNo ?? '—'), + _GrnInfo('LR Date', DateFormatter.displayDate(grn.lrDate)), + ], + ), + if (hasRemarks) ...[ + const Padding( + padding: EdgeInsets.symmetric(vertical: 20), + child: Divider(height: 1), + ), + _GrnInfoGrid( + columns: 1, + items: [_GrnInfo('Remarks', grn.remarks!)], + ), + ], + ], + ), ), ); } } -class _DetailRow extends StatelessWidget { - const _DetailRow({required this.label, required this.value}); +class _GrnInfoGrid extends StatelessWidget { + const _GrnInfoGrid({ + required this.items, + this.columns = 4, + }); + + final List<_GrnInfo> items; + final int columns; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final maxWidth = constraints.maxWidth; + final cols = maxWidth < 600 + ? 1 + : maxWidth < 900 + ? 2 + : columns; + const spacing = 16.0; + final colWidth = (maxWidth - spacing * (cols - 1)) / cols; + + return Wrap( + spacing: spacing, + runSpacing: 16, + children: items + .map( + (item) => SizedBox( + width: colWidth, + child: _GrnDetailTile( + label: item.label, + value: item.value, + ), + ), + ) + .toList(), + ); + }, + ); + } +} + +class _GrnDetailTile extends StatelessWidget { + const _GrnDetailTile({ + required this.label, + required this.value, + }); final String label; final String value; @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 180, - child: Text( - label, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w500, ), - Expanded(child: Text(value)), - ], - ), + ), + const SizedBox(height: 4), + Text(value, style: theme.textTheme.bodyLarge), + ], ); } } + +class _GrnInfo { + const _GrnInfo(this.label, this.value); + + final String label; + final String value; +} diff --git a/lib/modules/grn/presentation/widgets/grn_line_items_editor.dart b/lib/modules/grn/presentation/widgets/grn_line_items_editor.dart index c28a750..4c24ce6 100644 --- a/lib/modules/grn/presentation/widgets/grn_line_items_editor.dart +++ b/lib/modules/grn/presentation/widgets/grn_line_items_editor.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import '../../../../core/theme/app_colors.dart'; import '../../../../shared/models/grn_model.dart'; import '../../../../shared/models/purchase_order_model.dart'; +import '../../../../shared/widgets/app_data_table.dart'; import '../../../../shared/widgets/app_dropdown.dart'; import '../../../../shared/widgets/app_text_field.dart'; @@ -322,37 +323,48 @@ class GrnItemsTable extends StatelessWidget { @override Widget build(BuildContext context) { - if (items.isEmpty) { - return const SizedBox.shrink(); - } - - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: DataTable( - headingRowColor: WidgetStateProperty.all(AppColors.lightSurface), - columns: const [ - DataColumn(label: Text('Line')), - DataColumn(label: Text('Item')), - DataColumn(label: Text('Accepted')), - DataColumn(label: Text('Damaged')), - DataColumn(label: Text('Short')), - DataColumn(label: Text('Excess')), - DataColumn(label: Text('Batch')), - ], - rows: items.map((item) { - return DataRow( - cells: [ - DataCell(Text('${item.lineNo}')), - DataCell(Text(item.itemName ?? '—')), - DataCell(Text(_formatQty(item.acceptedQty ?? 0))), - DataCell(Text(_formatQty(item.damagedQty ?? 0))), - DataCell(Text(_formatQty(item.shortQty ?? 0))), - DataCell(Text(_formatQty(item.excessQty ?? 0))), - DataCell(Text(item.batchNo ?? '—')), - ], - ); - }).toList(), - ), + return AppDataTable( + wrapInCard: false, + shrinkWrap: true, + emptyMessage: 'No line items', + columns: [ + AppDataColumn( + label: '#', + flex: 1, + cellBuilder: (_, item) => Text('${item.lineNo ?? '—'}'), + ), + AppDataColumn( + label: 'Item', + flex: 3, + cellBuilder: (_, item) => Text(item.itemName ?? item.itemCode ?? '—'), + ), + AppDataColumn( + label: 'Accepted', + flex: 1, + cellBuilder: (_, item) => Text(_formatQty(item.acceptedQty ?? 0)), + ), + AppDataColumn( + label: 'Damaged', + flex: 1, + cellBuilder: (_, item) => Text(_formatQty(item.damagedQty ?? 0)), + ), + AppDataColumn( + label: 'Short', + flex: 1, + cellBuilder: (_, item) => Text(_formatQty(item.shortQty ?? 0)), + ), + AppDataColumn( + label: 'Excess', + flex: 1, + cellBuilder: (_, item) => Text(_formatQty(item.excessQty ?? 0)), + ), + AppDataColumn( + label: 'Batch', + flex: 1, + cellBuilder: (_, item) => Text(item.batchNo ?? '—'), + ), + ], + rows: items, ); } } diff --git a/lib/modules/master_data/presentation/providers/master_provider.dart b/lib/modules/master_data/presentation/providers/master_provider.dart index 60c716a..d4158d0 100644 --- a/lib/modules/master_data/presentation/providers/master_provider.dart +++ b/lib/modules/master_data/presentation/providers/master_provider.dart @@ -1,5 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/constants/app_constants.dart'; import '../../../../shared/models/export_file_result.dart'; import '../../data/repositories/master_repository_impl.dart'; import '../../domain/entities/master_definition.dart'; @@ -57,18 +58,21 @@ class MasterFormState { const MasterFormState({ this.values = const {}, this.dropdownOptions = const {}, + this.existingRecords = const [], this.isSubmitting = false, this.errorMessage, }); final Map values; final Map>> dropdownOptions; + final List> existingRecords; final bool isSubmitting; final String? errorMessage; MasterFormState copyWith({ Map? values, Map>>? dropdownOptions, + List>? existingRecords, bool? isSubmitting, String? errorMessage, bool clearError = false, @@ -76,6 +80,7 @@ class MasterFormState { return MasterFormState( values: values ?? this.values, dropdownOptions: dropdownOptions ?? this.dropdownOptions, + existingRecords: existingRecords ?? this.existingRecords, isSubmitting: isSubmitting ?? this.isSubmitting, errorMessage: clearError ? null : errorMessage ?? this.errorMessage, ); @@ -221,6 +226,7 @@ class MasterFormNotifier extends FamilyAsyncNotifier build(MasterFormArgs arg) async { final dropdownOptions = await _loadDropdownOptions(); + final existingRecords = await _loadExistingRecords(); Map values = {}; if (arg.recordId != null) { @@ -240,9 +246,31 @@ class MasterFormNotifier extends FamilyAsyncNotifier>> _loadExistingRecords() async { + final allItems = >[]; + 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>>> _loadDropdownOptions() async { final options = >>{}; diff --git a/lib/modules/master_data/presentation/widgets/master_form_panel.dart b/lib/modules/master_data/presentation/widgets/master_form_panel.dart index 0ba05f0..55e7667 100644 --- a/lib/modules/master_data/presentation/widgets/master_form_panel.dart +++ b/lib/modules/master_data/presentation/widgets/master_form_panel.dart @@ -147,12 +147,32 @@ class _MasterFormPanelState extends ConsumerState { keyboardType: _keyboardTypeForFieldKey(field.key), inputFormatters: formatters.isEmpty ? null : formatters, decoration: InputDecoration(labelText: _fieldLabel(field)), - validator: (v) => Validators.forFieldKey( - field.key, - v, - required: field.required, - fieldName: field.label, - ), + validator: (v) { + if (Validators.isMasterNameFieldKey(field.key)) { + return Validators.uniqueMasterName( + v, + nameKey: field.key, + existingRecords: formState.existingRecords, + currentRecordId: widget.recordId, + fieldName: field.label, + ); + } + if (Validators.isMasterCodeFieldKey(field.key)) { + return Validators.uniqueMasterCode( + v, + codeKey: field.key, + existingRecords: formState.existingRecords, + currentRecordId: widget.recordId, + fieldName: field.label, + ); + } + return Validators.forFieldKey( + field.key, + v, + required: field.required, + fieldName: field.label, + ); + }, onChanged: (text) => notifier.updateValue(field.key, text), ); } diff --git a/lib/modules/purchase_orders/presentation/screens/purchase_order_detail_screen.dart b/lib/modules/purchase_orders/presentation/screens/purchase_order_detail_screen.dart index 5e14925..e4e1204 100644 --- a/lib/modules/purchase_orders/presentation/screens/purchase_order_detail_screen.dart +++ b/lib/modules/purchase_orders/presentation/screens/purchase_order_detail_screen.dart @@ -65,14 +65,17 @@ class _PurchaseOrderDetailScreenState ), data: (order) => SingleChildScrollView( padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - PageHeader( - title: order.poNo ?? 'Purchase Order #${order.id}', - subtitle: - '${poTypeLabel(order.poType)} · ${order.vendorName ?? '—'}', - actions: [ + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1200), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + PageHeader( + title: order.poNo ?? 'Purchase Order #${order.id}', + subtitle: + '${poTypeLabel(order.poType)} · ${order.vendorName ?? '—'}', + actions: [ if (canExport) OutlinedButton.icon( onPressed: _isWorking ? null : () => _downloadPdf(order), @@ -146,17 +149,19 @@ class _PurchaseOrderDetailScreenState children: [ PoStatusChip(status: order.status), if (order.revisionNo != null && order.revisionNo! > 0) - Chip(label: Text('Revision ${order.revisionNo}')), + PoRevisionChip(revisionNo: order.revisionNo!), ], ), const SizedBox(height: 16), _OverviewCard(order: order), const SizedBox(height: 16), _LineItemsCard(items: order.items), - ], + ], + ), + ), + ), ), ), - ), ); } @@ -340,70 +345,129 @@ class _OverviewCard extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); + final hasTerms = order.termsAndConditions?.trim().isNotEmpty == true; + final hasRemarks = order.remarks?.trim().isNotEmpty == true; + return AppCard( child: Padding( padding: const EdgeInsets.all(20), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text('Overview', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 16), - _infoGrid([ - _Info('PO Date', DateFormatter.displayDate(order.poDate)), - _Info('Expected Delivery', - DateFormatter.displayDate(order.expectedDeliveryDate)), - _Info('Vendor', order.vendorName ?? '—'), - _Info('Plant', order.plantName ?? '—'), - _Info('Warehouse', order.warehouseName ?? '—'), - _Info('Type', poTypeLabel(order.poType)), - _Info('Taxable', CurrencyFormatter.format(order.taxableAmount)), - _Info('Tax', CurrencyFormatter.format(order.taxAmount)), - _Info('Freight', CurrencyFormatter.format(order.freightCharges)), - _Info('Other Charges', CurrencyFormatter.format(order.otherCharges)), - _Info('Discount', CurrencyFormatter.format(order.discountAmount)), - _Info('Total', CurrencyFormatter.format(order.totalAmount)), - ]), - if (order.termsAndConditions?.isNotEmpty == true) ...[ - const SizedBox(height: 16), - Text('Terms & Conditions', - style: Theme.of(context).textTheme.titleSmall), - const SizedBox(height: 4), - Text(order.termsAndConditions!), - ], - if (order.remarks?.isNotEmpty == true) ...[ - const SizedBox(height: 16), - Text('Remarks', style: Theme.of(context).textTheme.titleSmall), - const SizedBox(height: 4), - Text(order.remarks!), + Text( + 'Overview', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 20), + Text( + 'Order Details', + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 12), + _InfoGrid( + columns: 4, + items: [ + _Info('PO Date', DateFormatter.displayDate(order.poDate)), + _Info( + 'Expected Delivery', + DateFormatter.displayDate(order.expectedDeliveryDate), + ), + _Info('Vendor', order.vendorName ?? '—'), + _Info('Type', poTypeLabel(order.poType)), + _Info('Plant', order.plantName ?? '—'), + _Info('Warehouse', order.warehouseName ?? '—'), + ], + ), + const Padding( + padding: EdgeInsets.symmetric(vertical: 20), + child: Divider(height: 1), + ), + Text( + 'Amount Summary', + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 12), + _InfoGrid( + columns: 6, + items: [ + _Info('Taxable', CurrencyFormatter.format(order.taxableAmount)), + _Info('Tax', CurrencyFormatter.format(order.taxAmount)), + _Info('Freight', CurrencyFormatter.format(order.freightCharges)), + _Info( + 'Other Charges', + CurrencyFormatter.format(order.otherCharges), + ), + _Info('Discount', CurrencyFormatter.format(order.discountAmount)), + _Info( + 'Total', + CurrencyFormatter.format(order.totalAmount), + emphasize: true, + ), + ], + ), + if (hasTerms || hasRemarks) ...[ + const Padding( + padding: EdgeInsets.symmetric(vertical: 20), + child: Divider(height: 1), + ), + _InfoGrid( + columns: 2, + items: [ + if (hasTerms) + _Info('Terms & Conditions', order.termsAndConditions!), + if (hasRemarks) _Info('Remarks', order.remarks!), + ], + ), ], ], ), ), ); } +} - Widget _infoGrid(List<_Info> rows) { +class _InfoGrid extends StatelessWidget { + const _InfoGrid({ + required this.items, + this.columns = 4, + }); + + final List<_Info> items; + final int columns; + + @override + Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { - final crossAxisCount = constraints.maxWidth > 900 ? 3 : 2; + final maxWidth = constraints.maxWidth; + final cols = maxWidth < 600 + ? 1 + : maxWidth < 900 + ? 2 + : columns; + const spacing = 16.0; + final colWidth = (maxWidth - spacing * (cols - 1)) / cols; + return Wrap( - spacing: 24, - runSpacing: 12, - children: rows + spacing: spacing, + runSpacing: 16, + children: items .map( - (row) => SizedBox( - width: (constraints.maxWidth / crossAxisCount) - 24, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - row.label, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - Text(row.value), - ], + (item) => SizedBox( + width: colWidth, + child: _DetailTile( + label: item.label, + value: item.value, + emphasize: item.emphasize, ), ), ) @@ -414,10 +478,51 @@ class _OverviewCard extends StatelessWidget { } } -class _Info { - const _Info(this.label, this.value); +class _DetailTile extends StatelessWidget { + const _DetailTile({ + required this.label, + required this.value, + this.emphasize = false, + }); + final String label; final String value; + final bool emphasize; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text( + value, + style: emphasize + ? theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + color: theme.colorScheme.primary, + ) + : theme.textTheme.bodyLarge, + ), + ], + ); + } +} + +class _Info { + const _Info(this.label, this.value, {this.emphasize = false}); + final String label; + final String value; + final bool emphasize; } class _LineItemsCard extends StatelessWidget { diff --git a/lib/modules/purchase_orders/presentation/screens/purchase_order_form_screen.dart b/lib/modules/purchase_orders/presentation/screens/purchase_order_form_screen.dart index e595d47..8af0b68 100644 --- a/lib/modules/purchase_orders/presentation/screens/purchase_order_form_screen.dart +++ b/lib/modules/purchase_orders/presentation/screens/purchase_order_form_screen.dart @@ -354,7 +354,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState v == null ? 'Vendor is required' : null, ), - ], - ), - FormRowThree( - children: [ AppSearchableDropdown( label: 'Plant *', value: _dropdownValue(_plantId, plantIds), @@ -400,6 +396,10 @@ class _PurchaseOrderFormScreenState extends ConsumerState v == null ? 'Plant is required' : null, ), + ], + ), + FormRowFour( + children: [ AppSearchableDropdown( label: 'Warehouse', value: _warehouseId, @@ -414,10 +414,6 @@ class _PurchaseOrderFormScreenState extends ConsumerState setState(() => _brandId = v), ), - ], - ), - FormRowThree( - children: [ AppSearchableDropdown( label: 'Payment Term', value: _paymentTermId, @@ -432,6 +428,10 @@ class _PurchaseOrderFormScreenState extends ConsumerState setState(() => _deliveryTermId = v), ), + ], + ), + FormRowFour( + children: [ _DateField( label: 'Expected Delivery', value: _expectedDeliveryDate, @@ -441,10 +441,6 @@ class _PurchaseOrderFormScreenState extends ConsumerState _expectedDeliveryDate = d), ), ), - ], - ), - FormRowThree( - children: [ AppTextField( controller: _discountController, label: 'Discount Amount', @@ -468,17 +464,20 @@ class _PurchaseOrderFormScreenState extends ConsumerState gstRates; final VoidCallback? onRemove; + @override + State<_LineItemCard> createState() => _LineItemCardState(); +} + +class _LineItemCardState extends State<_LineItemCard> { int? _parseId(String value) => int.tryParse(value.trim()); + void _updateLine(void Function() mutate) { + mutate(); + setState(() {}); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); - final itemOptions = items + final line = widget.line; + final lineKey = 'po-line-${line.lineNo}'; + final itemOptions = widget.items .map((e) { final id = _parseId(e.id); if (id == null) return null; @@ -183,7 +195,7 @@ class _LineItemCard extends StatelessWidget { }) .whereType>() .toList(); - final uomOptions = uom + final uomOptions = widget.uom .map((e) { final id = _parseId(e.id); if (id == null) return null; @@ -193,7 +205,7 @@ class _LineItemCard extends StatelessWidget { .toList(); final gstOptions = [ const AppDropdownOption(value: null, label: 'No GST'), - ...gstRates.map((e) { + ...widget.gstRates.map((e) { final id = _parseId(e.id); if (id == null) return null; return AppDropdownOption(value: id, label: e.name); @@ -201,38 +213,47 @@ class _LineItemCard extends StatelessWidget { ].whereType>().toList(); return Container( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.symmetric(vertical: 16), decoration: BoxDecoration( border: Border.all(color: theme.dividerColor), borderRadius: BorderRadius.circular(8), ), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Row( - children: [ - Text('Line ${line.lineNo}', style: theme.textTheme.titleSmall), - const Spacer(), - if (onRemove != null) - IconButton( - tooltip: 'Remove line', - icon: const Icon(Icons.delete_outline), - onPressed: onRemove, - ), - ], + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Text('Line ${line.lineNo}', style: theme.textTheme.titleSmall), + const Spacer(), + if (widget.onRemove != null) + IconButton( + tooltip: 'Remove line', + icon: const Icon(Icons.delete_outline), + onPressed: widget.onRemove, + ), + ], + ), ), const SizedBox(height: 12), - FormRowThree( + FormRow( + columnCount: 6, + horizontalPadding: 16, + spacing: 8, + stackBelowWidth: 992, children: [ AppSearchableDropdown( + key: ValueKey('$lineKey-item'), label: 'Item *', value: line.itemId, searchHint: 'Search item...', options: itemOptions, - onChanged: (v) => line.itemId = v, + onChanged: (v) => _updateLine(() => line.itemId = v), validator: (v) => v == null ? 'Item is required' : null, ), AppTextField( + key: ValueKey('$lineKey-qty'), controller: line.qtyController, label: 'Quantity *', keyboardType: @@ -247,18 +268,16 @@ class _LineItemCard extends StatelessWidget { }, ), AppSearchableDropdown( + key: ValueKey('$lineKey-uom'), label: 'UOM *', value: line.uomId, searchHint: 'Search UOM...', options: uomOptions, - onChanged: (v) => line.uomId = v, + onChanged: (v) => _updateLine(() => line.uomId = v), validator: (v) => v == null ? 'UOM is required' : null, ), - ], - ), - FormRowThree( - children: [ AppTextField( + key: ValueKey('$lineKey-rate'), controller: line.rateController, label: 'Rate *', keyboardType: @@ -271,24 +290,36 @@ class _LineItemCard extends StatelessWidget { }, ), AppTextField( + key: ValueKey('$lineKey-discount'), controller: line.discountController, label: 'Discount %', keyboardType: const TextInputType.numberWithOptions(decimal: true), ), AppSearchableDropdown( + key: ValueKey('$lineKey-gst'), label: 'GST Rate', value: line.gstRateId, searchHint: 'Search GST rate...', options: gstOptions, - onChanged: (v) => line.gstRateId = v, + onChanged: (v) => _updateLine(() => line.gstRateId = v), ), ], ), - const SizedBox(height: 12), - AppTextField( - controller: line.remarksController, - label: 'Remarks', + FormRow( + columnCount: 6, + spans: const [6], + horizontalPadding: 16, + spacing: 8, + stackBelowWidth: 992, + children: [ + AppTextField( + key: ValueKey('$lineKey-remarks'), + controller: line.remarksController, + label: 'Remarks', + maxLines: 2, + ), + ], ), ], ), diff --git a/lib/modules/vendors/presentation/screens/vendor_detail_screen.dart b/lib/modules/vendors/presentation/screens/vendor_detail_screen.dart index 7e970e2..03be52b 100644 --- a/lib/modules/vendors/presentation/screens/vendor_detail_screen.dart +++ b/lib/modules/vendors/presentation/screens/vendor_detail_screen.dart @@ -12,7 +12,6 @@ import '../../../../shared/widgets/app_confirmation_dialog.dart'; import '../../../../shared/widgets/app_dropdown.dart'; import '../../../../shared/widgets/app_empty_state.dart'; import '../../../../shared/widgets/app_loading_view.dart'; -import '../../../../shared/widgets/app_status_chip.dart'; import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/page_header.dart'; import '../providers/vendors_provider.dart'; @@ -206,37 +205,58 @@ class _OverviewTab extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); + final hasRemarks = vendor.remarks?.trim().isNotEmpty == true; + return SingleChildScrollView( - child: AppCard( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - children: [ - _DetailRow(label: 'Vendor Code', value: vendor.vendorCode ?? '—'), - _DetailRow(label: 'Vendor Name', value: vendor.vendorName), - _DetailRow(label: 'Type', value: vendorTypeLabel(vendor.vendorType)), - _DetailRow(label: 'GSTIN', value: vendor.gstin ?? '—'), - _DetailRow(label: 'PAN', value: vendor.pan ?? '—'), - _DetailRow( - label: 'Payment Term', - value: vendor.paymentTermName ?? '—', + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1200), + child: AppCard( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Vendor Details', + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 12), + _VendorInfoGrid( + items: [ + _VendorInfo('Vendor Code', vendor.vendorCode ?? '—'), + _VendorInfo('Vendor Name', vendor.vendorName), + _VendorInfo('Type', vendorTypeLabel(vendor.vendorType)), + _VendorInfo('GSTIN', vendor.gstin ?? '—'), + _VendorInfo('PAN', vendor.pan ?? '—'), + _VendorInfo('Payment Term', vendor.paymentTermName ?? '—'), + _VendorInfo( + 'Credit Period', + vendor.creditPeriodDays != null + ? '${vendor.creditPeriodDays} days' + : '—', + ), + _VendorInfo('Status', vendorStatusLabel(vendor.status)), + _VendorInfo('Active', vendor.isActive ? 'Yes' : 'No'), + ], + ), + if (hasRemarks) ...[ + const Padding( + padding: EdgeInsets.symmetric(vertical: 20), + child: Divider(height: 1), + ), + _VendorInfoGrid( + columns: 1, + items: [_VendorInfo('Remarks', vendor.remarks!)], + ), + ], + ], ), - _DetailRow( - label: 'Credit Period', - value: vendor.creditPeriodDays != null - ? '${vendor.creditPeriodDays} days' - : '—', - ), - _DetailRow(label: 'Remarks', value: vendor.remarks ?? '—'), - _DetailRow( - label: 'Status', - value: vendorStatusLabel(vendor.status), - ), - _DetailRow( - label: 'Active', - value: vendor.isActive ? 'Yes' : 'No', - ), - ], + ), ), ), ), @@ -244,6 +264,85 @@ class _OverviewTab extends StatelessWidget { } } +class _VendorInfoGrid extends StatelessWidget { + const _VendorInfoGrid({ + required this.items, + this.columns = 3, + }); + + final List<_VendorInfo> items; + final int columns; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final maxWidth = constraints.maxWidth; + final cols = maxWidth < 600 + ? 1 + : maxWidth < 900 + ? 2 + : columns; + const spacing = 16.0; + final colWidth = (maxWidth - spacing * (cols - 1)) / cols; + + return Wrap( + spacing: spacing, + runSpacing: 16, + children: items + .map( + (item) => SizedBox( + width: colWidth, + child: _VendorDetailTile( + label: item.label, + value: item.value, + ), + ), + ) + .toList(), + ); + }, + ); + } +} + +class _VendorDetailTile extends StatelessWidget { + const _VendorDetailTile({ + required this.label, + required this.value, + }); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text(value, style: theme.textTheme.bodyLarge), + ], + ); + } +} + +class _VendorInfo { + const _VendorInfo(this.label, this.value); + + final String label; + final String value; +} + class _AddressesTab extends ConsumerWidget { const _AddressesTab({ required this.vendorId, @@ -287,43 +386,30 @@ class _AddressesTab extends ConsumerWidget { description: 'Add registered, billing or dispatch addresses.', icon: Icons.location_on_outlined, ) - : ListView.separated( + : GridView.builder( + padding: EdgeInsets.zero, + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 320, + mainAxisExtent: 190, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + ), itemCount: addresses.length, - separatorBuilder: (_, __) => const SizedBox(height: 12), itemBuilder: (context, index) { final address = addresses[index]; - return _SubResourceCard( - title: addressTypeOptions - .where((e) => e.$1 == address.addressType) - .map((e) => e.$2) - .firstOrNull ?? - address.addressType ?? - 'Address', - subtitle: [ - address.addressLine1, - address.addressLine2, - address.city, - address.state, - address.pincode, - ].where((e) => e != null && e.isNotEmpty).join(', '), - trailing: canEdit - ? _SubResourceActions( - onEdit: () => openVendorAddressPanel( - context, - vendorId: vendorId, - address: address, - ), - onDelete: () => _deleteAddress( - context, - ref, - vendorId, - address.id, - ), - ) - : null, - chip: AppStatusChip( - status: address.isActive ? 'active' : 'inactive', - compact: true, + return _VendorAddressCard( + address: address, + canEdit: canEdit, + onEdit: () => openVendorAddressPanel( + context, + vendorId: vendorId, + address: address, + ), + onDelete: () => _deleteAddress( + context, + ref, + vendorId, + address.id, ), ); }, @@ -400,36 +486,30 @@ class _ContactsTab extends ConsumerWidget { description: 'Add contact persons for this vendor.', icon: Icons.person_outline, ) - : ListView.separated( + : GridView.builder( + padding: EdgeInsets.zero, + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 320, + mainAxisExtent: 175, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + ), itemCount: contacts.length, - separatorBuilder: (_, __) => const SizedBox(height: 12), itemBuilder: (context, index) { final contact = contacts[index]; - return _SubResourceCard( - title: contact.contactName, - subtitle: [ - contact.designation, - contact.phone, - contact.email, - ].where((e) => e != null && e.isNotEmpty).join(' · '), - trailing: canEdit - ? _SubResourceActions( - onEdit: () => openVendorContactPanel( - context, - vendorId: vendorId, - contact: contact, - ), - onDelete: () => _deleteContact( - context, - ref, - vendorId, - contact.id, - ), - ) - : null, - chip: AppStatusChip( - status: contact.isActive ? 'active' : 'inactive', - compact: true, + return _VendorContactCard( + contact: contact, + canEdit: canEdit, + onEdit: () => openVendorContactPanel( + context, + vendorId: vendorId, + contact: contact, + ), + onDelete: () => _deleteContact( + context, + ref, + vendorId, + contact.id, ), ); }, @@ -506,38 +586,30 @@ class _BankDetailsTab extends ConsumerWidget { description: 'Add bank accounts for payments.', icon: Icons.account_balance_outlined, ) - : ListView.separated( + : GridView.builder( + padding: EdgeInsets.zero, + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 320, + mainAxisExtent: 175, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + ), itemCount: bankDetails.length, - separatorBuilder: (_, __) => const SizedBox(height: 12), itemBuilder: (context, index) { final detail = bankDetails[index]; - return _SubResourceCard( - title: detail.bankName ?? 'Bank Account', - subtitle: [ - detail.branch, - detail.ifsc, - detail.accountHolderName, - if (detail.accountNumber != null) - 'A/C: ${detail.accountNumber}', - ].where((e) => e != null && e.isNotEmpty).join(' · '), - trailing: canEdit - ? _SubResourceActions( - onEdit: () => openVendorBankDetailPanel( - context, - vendorId: vendorId, - bankDetail: detail, - ), - onDelete: () => _deleteBankDetail( - context, - ref, - vendorId, - detail.id, - ), - ) - : null, - chip: AppStatusChip( - status: detail.isActive ? 'active' : 'inactive', - compact: true, + return _VendorBankDetailCard( + bankDetail: detail, + canEdit: canEdit, + onEdit: () => openVendorBankDetailPanel( + context, + vendorId: vendorId, + bankDetail: detail, + ), + onDelete: () => _deleteBankDetail( + context, + ref, + vendorId, + detail.id, ), ); }, @@ -573,30 +645,150 @@ class _BankDetailsTab extends ConsumerWidget { } } -class _SubResourceCard extends StatelessWidget { - const _SubResourceCard({ - required this.title, - required this.subtitle, - this.trailing, - this.chip, +class _VendorAddressCard extends StatelessWidget { + const _VendorAddressCard({ + required this.address, + required this.canEdit, + required this.onEdit, + required this.onDelete, }); - final String title; - final String subtitle; - final Widget? trailing; - final Widget? chip; + final VendorAddressModel address; + final bool canEdit; + final VoidCallback onEdit; + final VoidCallback onDelete; @override Widget build(BuildContext context) { + final theme = Theme.of(context); + final appearance = _addressCardAppearance(address.addressType); + final title = addressTypeOptions + .where((e) => e.$1 == address.addressType) + .map((e) => e.$2) + .firstOrNull ?? + address.addressType ?? + 'Address'; + final subtitle = [ + address.addressLine1, + address.addressLine2, + address.city, + address.state, + address.pincode, + ].where((e) => e != null && e.isNotEmpty).join(', '); + final statusColor = address.isActive + ? const Color(0xFF16A34A) + : theme.colorScheme.onSurfaceVariant; + return AppCard( - child: ListTile( - title: Text(title), - subtitle: subtitle.isNotEmpty ? Text(subtitle) : null, - trailing: Row( - mainAxisSize: MainAxisSize.min, + elevation: 0, + enableHover: true, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (chip != null) ...[chip!, const SizedBox(width: 8)], - if (trailing != null) trailing!, + Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: appearance.color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + appearance.icon, + color: appearance.color, + size: 20, + ), + ), + const Spacer(), + if (canEdit) ...[ + IconButton( + icon: const Icon(Icons.edit_outlined, size: 18), + onPressed: onEdit, + visualDensity: VisualDensity.compact, + ), + IconButton( + icon: const Icon(Icons.delete_outline, size: 18), + onPressed: onDelete, + visualDensity: VisualDensity.compact, + ), + ], + ], + ), + const SizedBox(height: 8), + Text( + title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Expanded( + child: Text( + subtitle.isNotEmpty ? subtitle : '—', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + Row( + children: [ + if (address.city != null && address.city!.isNotEmpty) ...[ + Icon( + Icons.location_city_outlined, + size: 14, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Flexible( + child: Text( + address.city!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(width: 16), + ], + if (address.pincode != null && address.pincode!.isNotEmpty) ...[ + Icon( + Icons.pin_drop_outlined, + size: 14, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Text( + address.pincode!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 16), + ], + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: statusColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + address.isActive ? 'Active' : 'Inactive', + style: theme.textTheme.bodySmall?.copyWith( + color: statusColor, + fontWeight: FontWeight.w600, + ), + ), + ], + ), ], ), ), @@ -604,57 +796,338 @@ class _SubResourceCard extends StatelessWidget { } } -class _SubResourceActions extends StatelessWidget { - const _SubResourceActions({required this.onEdit, required this.onDelete}); +({Color color, IconData icon}) _addressCardAppearance(String? type) { + switch (type?.toUpperCase()) { + case 'REGISTERED': + return (color: Color(0xFF2563EB), icon: Icons.home_work_outlined); + case 'BILLING': + return (color: Color(0xFF16A34A), icon: Icons.receipt_long_outlined); + case 'DISPATCH': + return (color: Color(0xFFCA8A04), icon: Icons.local_shipping_outlined); + default: + return (color: Color(0xFF0891B2), icon: Icons.location_on_outlined); + } +} +class _VendorContactCard extends StatelessWidget { + const _VendorContactCard({ + required this.contact, + required this.canEdit, + required this.onEdit, + required this.onDelete, + }); + + final VendorContactModel contact; + final bool canEdit; final VoidCallback onEdit; final VoidCallback onDelete; @override Widget build(BuildContext context) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - tooltip: 'Edit', - icon: const Icon(Icons.edit_outlined, size: 20), - onPressed: onEdit, - ), - IconButton( - tooltip: 'Delete', - icon: const Icon(Icons.delete_outline, size: 20), - onPressed: onDelete, - ), - ], - ); - } -} + final theme = Theme.of(context); + final appearance = _contactCardAppearance(contact); + final statusColor = contact.isActive + ? const Color(0xFF16A34A) + : theme.colorScheme.onSurfaceVariant; -class _DetailRow extends StatelessWidget { - const _DetailRow({required this.label, required this.value}); - - final String label; - final String value; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 160, - child: Text( - label, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, + return AppCard( + elevation: 0, + enableHover: true, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: appearance.color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), ), + child: Icon( + appearance.icon, + color: appearance.color, + size: 20, + ), + ), + const Spacer(), + if (canEdit) ...[ + IconButton( + icon: const Icon(Icons.edit_outlined, size: 18), + onPressed: onEdit, + visualDensity: VisualDensity.compact, + ), + IconButton( + icon: const Icon(Icons.delete_outline, size: 18), + onPressed: onDelete, + visualDensity: VisualDensity.compact, + ), + ], + ], ), - ), - Expanded(child: Text(value)), - ], + const SizedBox(height: 8), + Text( + contact.contactName, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + if (contact.designation?.trim().isNotEmpty == true) + Text( + contact.designation!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + if (contact.email?.trim().isNotEmpty == true) ...[ + const SizedBox(height: 8), + Row( + children: [ + Icon( + Icons.email_outlined, + size: 14, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + contact.email!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ], + const Spacer(), + Row( + children: [ + if (contact.phone?.trim().isNotEmpty == true) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.phone_outlined, + size: 14, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Text( + contact.phone!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + const Spacer(), + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: statusColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + contact.isActive ? 'Active' : 'Inactive', + style: theme.textTheme.bodySmall?.copyWith( + color: statusColor, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ], + ), ), ); } } + +({Color color, IconData icon}) _contactCardAppearance(VendorContactModel contact) { + if (contact.isPrimary) { + return (color: Color(0xFF2563EB), icon: Icons.person_pin_outlined); + } + return (color: Color(0xFF7C3AED), icon: Icons.person_outline); +} + +class _VendorBankDetailCard extends StatelessWidget { + const _VendorBankDetailCard({ + required this.bankDetail, + required this.canEdit, + required this.onEdit, + required this.onDelete, + }); + + final VendorBankDetailModel bankDetail; + final bool canEdit; + final VoidCallback onEdit; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final appearance = _bankDetailCardAppearance(bankDetail); + final statusColor = bankDetail.isActive + ? const Color(0xFF16A34A) + : theme.colorScheme.onSurfaceVariant; + final subtitle = [ + bankDetail.branch, + bankDetail.accountHolderName, + ].where((e) => e != null && e.trim().isNotEmpty).join(' · '); + + return AppCard( + elevation: 0, + enableHover: true, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: appearance.color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + appearance.icon, + color: appearance.color, + size: 20, + ), + ), + const Spacer(), + if (canEdit) ...[ + IconButton( + icon: const Icon(Icons.edit_outlined, size: 18), + onPressed: onEdit, + visualDensity: VisualDensity.compact, + ), + IconButton( + icon: const Icon(Icons.delete_outline, size: 18), + onPressed: onDelete, + visualDensity: VisualDensity.compact, + ), + ], + ], + ), + const SizedBox(height: 8), + Text( + bankDetail.bankName?.trim().isNotEmpty == true + ? bankDetail.bankName! + : 'Bank Account', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + if (subtitle.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + if (bankDetail.ifsc?.trim().isNotEmpty == true) ...[ + const SizedBox(height: 8), + Row( + children: [ + Icon( + Icons.tag_outlined, + size: 14, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + bankDetail.ifsc!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ], + const Spacer(), + Row( + children: [ + if (bankDetail.accountNumber?.trim().isNotEmpty == true) + Flexible( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.numbers_outlined, + size: 14, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Flexible( + child: Text( + 'A/C: ${bankDetail.accountNumber!}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + const Spacer(), + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: statusColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + bankDetail.isActive ? 'Active' : 'Inactive', + style: theme.textTheme.bodySmall?.copyWith( + color: statusColor, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +({Color color, IconData icon}) _bankDetailCardAppearance( + VendorBankDetailModel detail, +) { + if (detail.isPrimary) { + return (color: Color(0xFF2563EB), icon: Icons.account_balance); + } + return (color: Color(0xFF0891B2), icon: Icons.account_balance_outlined); +} diff --git a/lib/modules/vendors/presentation/widgets/vendor_sub_resource_panels.dart b/lib/modules/vendors/presentation/widgets/vendor_sub_resource_panels.dart index 6e4adcf..5e9ab4a 100644 --- a/lib/modules/vendors/presentation/widgets/vendor_sub_resource_panels.dart +++ b/lib/modules/vendors/presentation/widgets/vendor_sub_resource_panels.dart @@ -459,7 +459,6 @@ class _VendorBankDetailPanelState extends ConsumerState { AppTextField( controller: _accountNumberController, label: widget.isEditing ? 'Account Number' : 'Account Number *', - obscureText: true, keyboardType: TextInputType.number, inputFormatters: Validators.accountNumberInput, validator: widget.isEditing diff --git a/lib/shared/models/vendor_model.dart b/lib/shared/models/vendor_model.dart index 7146aad..0b0c811 100644 --- a/lib/shared/models/vendor_model.dart +++ b/lib/shared/models/vendor_model.dart @@ -18,20 +18,15 @@ DateTime? _dateFromJsonNullable(Object? value) { return DateTime.tryParse(value.toString()); } +Map? _nestedPaymentTerm(Map json) { + final nested = json['payment_terms'] ?? json['payment_term']; + return nested is Map ? Map.from(nested) : null; +} + Object? _readPaymentTermName(Map json, String key) { final flat = json['payment_term_name']; if (flat is String && flat.isNotEmpty) return flat; - final nested = json['payment_term']; - if (nested is Map) return nested['name']; - return null; -} - -Object? _readPaymentTermId(Map json, String key) { - final flat = json['payment_term_id']; - if (flat != null) return flat; - final nested = json['payment_term']; - if (nested is Map) return nested['id']; - return null; + return _nestedPaymentTerm(json)?['name']; } @freezed diff --git a/lib/shared/widgets/app_searchable_dropdown.dart b/lib/shared/widgets/app_searchable_dropdown.dart index 256fa69..84ea81b 100644 --- a/lib/shared/widgets/app_searchable_dropdown.dart +++ b/lib/shared/widgets/app_searchable_dropdown.dart @@ -36,6 +36,7 @@ class _AppSearchableDropdownState extends State> { final _layerLink = LayerLink(); final _fieldKey = GlobalKey(); OverlayEntry? _overlayEntry; + bool _ignoreOutsideTap = false; @override void dispose() { @@ -55,6 +56,7 @@ class _AppSearchableDropdownState extends State> { if (_overlayEntry == null) return; _overlayEntry!.remove(); _overlayEntry = null; + _ignoreOutsideTap = false; if (mounted) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) setState(() {}); @@ -69,9 +71,17 @@ class _AppSearchableDropdownState extends State> { return; } + // Wait until layout is complete so LayerLink / RenderBox are ready. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _overlayEntry != null) return; + _showOverlay(field); + }); + } + + void _showOverlay(FormFieldState field) { final renderBox = _fieldKey.currentContext?.findRenderObject() as RenderBox?; - if (renderBox == null) return; + if (renderBox == null || !renderBox.hasSize) return; final fieldSize = renderBox.size; final fieldTopLeft = renderBox.localToGlobal(Offset.zero); @@ -86,6 +96,7 @@ class _AppSearchableDropdownState extends State> { final availableSpace = (showAbove ? spaceAbove : spaceBelow) - 8; final maxPanelHeight = availableSpace.clamp(120.0, screenSize.height * 0.45); + _ignoreOutsideTap = true; _overlayEntry = OverlayEntry( builder: (overlayContext) { final theme = Theme.of(overlayContext); @@ -107,7 +118,10 @@ class _AppSearchableDropdownState extends State> { showAbove ? Alignment.bottomLeft : Alignment.topLeft, offset: Offset(0, showAbove ? -4 : 4), child: TapRegion( - onTapOutside: (_) => _removeOverlay(), + onTapOutside: (_) { + if (_ignoreOutsideTap) return; + _removeOverlay(); + }, child: Material( elevation: 8, borderRadius: BorderRadius.circular(8), @@ -119,12 +133,13 @@ class _AppSearchableDropdownState extends State> { child: _SearchableDropdownPanel( maxHeight: maxPanelHeight, options: widget.options, - selected: widget.value, + selected: field.value ?? widget.value, searchHint: widget.searchHint, onSelected: (value) { _removeOverlay(); field.didChange(value); widget.onChanged(value); + field.validate(); }, ), ), @@ -136,14 +151,19 @@ class _AppSearchableDropdownState extends State> { }, ); - Overlay.of(context).insert(_overlayEntry!); + final overlay = Overlay.maybeOf(context, rootOverlay: true) ?? + Overlay.of(context); + overlay.insert(_overlayEntry!); setState(() {}); + + WidgetsBinding.instance.addPostFrameCallback((_) { + _ignoreOutsideTap = false; + }); } @override Widget build(BuildContext context) { final theme = Theme.of(context); - final displayLabel = _labelForValue(widget.value); return FormField( initialValue: widget.value, @@ -152,6 +172,8 @@ class _AppSearchableDropdownState extends State> { final effectiveHint = widget.hint ?? 'Select ${widget.label.toLowerCase()}'; final canOpen = widget.enabled && widget.options.isNotEmpty; final colors = theme.colorScheme; + final selected = field.value ?? widget.value; + final displayLabel = _labelForValue(selected); // Top padding keeps the always-floating label from being clipped by // tight parents (e.g. TabBarView toolbars). diff --git a/lib/shared/widgets/app_side_panel.dart b/lib/shared/widgets/app_side_panel.dart index ed79b23..04a1a17 100644 --- a/lib/shared/widgets/app_side_panel.dart +++ b/lib/shared/widgets/app_side_panel.dart @@ -206,13 +206,107 @@ class FormRowThree extends StatelessWidget { final double spacing; final double stackBelowWidth; + @override + Widget build(BuildContext context) { + return FormRow( + spacing: spacing, + stackBelowWidth: stackBelowWidth, + children: children, + ); + } +} + +/// Responsive row with up to four equal-width form fields. +class FormRowFour extends StatelessWidget { + const FormRowFour({ + super.key, + required this.children, + this.spans, + this.spacing = 12, + this.horizontalPadding = 0, + this.stackBelowWidth = 992, + }); + + final List children; + final List? 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 children; + final int? columnCount; + final List? spans; + final double spacing; + final double horizontalPadding; + final double stackBelowWidth; + + List<({Widget child, int span})> _columnSlots() { + final cols = columnCount!; + + if (spans != null) { + assert( + spans!.length == children.length, + 'spans length must match children length', + ); + return [ + for (var i = 0; i < children.length; i++) + (child: children[i], span: spans![i]), + ]; + } + + return [ + for (var i = 0; i < cols; i++) + ( + child: i < children.length ? children[i] : const SizedBox.shrink(), + span: 1, + ), + ]; + } + + double _spanWidth(double columnWidth, int span) { + if (span <= 1) return columnWidth; + return columnWidth * span + spacing * (span - 1); + } + @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { + final contentPadding = EdgeInsets.fromLTRB( + horizontalPadding, + 0, + horizontalPadding, + spacing, + ); + if (constraints.maxWidth < stackBelowWidth) { return Padding( - padding: EdgeInsets.only(bottom: spacing), + padding: contentPadding, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -225,8 +319,31 @@ class FormRowThree extends StatelessWidget { ); } + if (columnCount != null) { + final slots = _columnSlots(); + final cols = columnCount!; + final innerWidth = constraints.maxWidth - (horizontalPadding * 2); + final columnWidth = (innerWidth - (cols - 1) * spacing) / cols; + + return Padding( + padding: contentPadding, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < slots.length; i++) ...[ + if (i > 0) SizedBox(width: spacing), + SizedBox( + width: _spanWidth(columnWidth, slots[i].span), + child: slots[i].child, + ), + ], + ], + ), + ); + } + return Padding( - padding: EdgeInsets.only(bottom: spacing), + padding: contentPadding, child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/shared/widgets/app_text_field.dart b/lib/shared/widgets/app_text_field.dart index 045af5f..cd7f35f 100644 --- a/lib/shared/widgets/app_text_field.dart +++ b/lib/shared/widgets/app_text_field.dart @@ -37,22 +37,27 @@ class AppTextField extends StatelessWidget { @override Widget build(BuildContext context) { - return TextFormField( - controller: controller, - obscureText: obscureText, - keyboardType: keyboardType, - validator: validator, - onChanged: onChanged, - maxLines: maxLines, - maxLength: maxLength, - inputFormatters: inputFormatters, - enabled: enabled, - autofillHints: autofillHints, - decoration: InputDecoration( - labelText: label, - hintText: hint, - prefixIcon: prefixIcon, - suffixIcon: suffixIcon, + return Padding( + padding: const EdgeInsets.only(top: 8), + child: TextFormField( + controller: controller, + obscureText: obscureText, + keyboardType: keyboardType, + validator: validator, + onChanged: onChanged, + maxLines: maxLines, + maxLength: maxLength, + inputFormatters: inputFormatters, + enabled: enabled, + autofillHints: autofillHints, + decoration: InputDecoration( + labelText: label, + hintText: hint, + prefixIcon: prefixIcon, + suffixIcon: suffixIcon, + floatingLabelBehavior: + label != null ? FloatingLabelBehavior.always : null, + ), ), ); } diff --git a/test/core/validators_test.dart b/test/core/validators_test.dart index 41f4afc..ef7f8ec 100644 --- a/test/core/validators_test.dart +++ b/test/core/validators_test.dart @@ -76,5 +76,110 @@ void main() { isNull, ); }); + + test('validates master name characters', () { + expect( + Validators.forFieldKey('name', 'Widget A-1', required: true), + isNull, + ); + expect( + Validators.forFieldKey('name', 'Part (OEM) & Co.', required: true), + isNull, + ); + expect( + Validators.forFieldKey('item_name', 'Item_01/Test', required: true), + isNull, + ); + expect( + Validators.forFieldKey('name', 'Invalid@Name', required: true), + isNotNull, + ); + }); + }); + + group('uniqueMasterName', () { + test('rejects duplicate names case-insensitively', () { + expect( + Validators.uniqueMasterName( + 'Widget A', + nameKey: 'name', + existingRecords: const [ + {'id': '1', 'name': 'widget a'}, + ], + fieldName: 'Name', + ), + isNotNull, + ); + }); + + test('allows same name when editing current record', () { + expect( + Validators.uniqueMasterName( + 'Widget A', + nameKey: 'name', + existingRecords: const [ + {'id': '1', 'name': 'Widget A'}, + ], + currentRecordId: '1', + fieldName: 'Name', + ), + isNull, + ); + }); + }); + + group('masterCode', () { + test('accepts valid code characters', () { + expect( + Validators.forFieldKey('code', 'ABC-12_test/item', required: true), + isNull, + ); + expect( + Validators.forFieldKey('item_code', 'SKU_01/A', required: true), + isNull, + ); + }); + + test('rejects invalid code characters', () { + expect( + Validators.forFieldKey('code', 'CODE@123', required: true), + isNotNull, + ); + expect( + Validators.forFieldKey('code', 'CODE 123', required: true), + isNotNull, + ); + }); + }); + + group('uniqueMasterCode', () { + test('rejects duplicate codes case-insensitively', () { + expect( + Validators.uniqueMasterCode( + 'UOM-01', + codeKey: 'code', + existingRecords: const [ + {'id': '1', 'code': 'uom-01'}, + ], + fieldName: 'Code', + ), + isNotNull, + ); + }); + + test('allows same code when editing current record', () { + expect( + Validators.uniqueMasterCode( + 'UOM-01', + codeKey: 'code', + existingRecords: const [ + {'id': '1', 'code': 'UOM-01'}, + ], + currentRecordId: '1', + fieldName: 'Code', + ), + isNull, + ); + }); }); }