Bharat erp - bug fixes

This commit is contained in:
Surendiran 2026-07-09 18:37:57 +05:30
parent ad1c95d85d
commit fa610b7efd
24 changed files with 1091 additions and 363 deletions

View File

@ -40,9 +40,8 @@ class AssetRemoteDataSource {
await dio.delete(ApiEndpoints.assetById(id));
}
Future<AssetModel> transferAsset(String id, Map<String, dynamic> data) async {
final response = await dio.post(ApiEndpoints.assetTransfer(id), data: data);
return AssetModel.fromJson(response.data['data'] as Map<String, dynamic>);
Future<void> transferAsset(String id, Map<String, dynamic> data) async {
await dio.post(ApiEndpoints.assetTransfer(id), data: data);
}
Future<List<AssetTransferHistoryModel>> getTransferHistory(String assetId) async {
@ -291,24 +290,30 @@ class AssetRemoteDataSource {
);
}
Map<String, dynamic> _asStringMap(dynamic value) {
if (value is Map<String, dynamic>) return value;
if (value is Map) return Map<String, dynamic>.from(value);
return const {};
}
List<T> _parseList<T>(
dynamic body,
T Function(Map<String, dynamic>) fromJson,
) {
if (body is! Map<String, dynamic>) return [];
if (body is! Map) return [];
final raw = body['data'];
if (raw is List) {
return raw
.whereType<Map<String, dynamic>>()
.map(fromJson)
.whereType<Map>()
.map((item) => fromJson(_asStringMap(item)))
.toList();
}
if (raw is Map<String, dynamic>) {
if (raw is Map) {
final items = raw['items'];
if (items is List) {
return items
.whereType<Map<String, dynamic>>()
.map(fromJson)
.whereType<Map>()
.map((item) => fromJson(_asStringMap(item)))
.toList();
}
}

View File

@ -46,7 +46,7 @@ class AssetRepositoryImpl implements AssetRepository {
}
@override
Future<Result<AssetModel>> transferAsset(String id, Map<String, dynamic> data) {
Future<Result<void>> transferAsset(String id, Map<String, dynamic> data) {
return safeApiCall(() => dataSource.transferAsset(id, data));
}

View File

@ -8,7 +8,7 @@ abstract class AssetRepository {
Future<Result<AssetModel>> createAsset(Map<String, dynamic> data);
Future<Result<AssetModel>> updateAsset(String id, Map<String, dynamic> data);
Future<Result<void>> deleteAsset(String id);
Future<Result<AssetModel>> transferAsset(String id, Map<String, dynamic> data);
Future<Result<void>> transferAsset(String id, Map<String, dynamic> data);
Future<Result<List<AssetTransferHistoryModel>>> getTransferHistory(String assetId);
Future<Result<List<AssetCategoryModel>>> getCategories();
Future<Result<AssetDropdownOptionsModel>> getAssetOptions();

View File

@ -206,13 +206,13 @@ class AssetDetailNotifier extends FamilyAsyncNotifier<AssetDetailState, String>
return true;
}
Future<AssetModel?> transferAsset(Map<String, dynamic> data) async {
Future<void> transferAsset(Map<String, dynamic> data) async {
final repository = ref.read(assetRepositoryProvider);
final result = await repository.transferAsset(arg, data);
if (result.failure != null) throw result.failure!;
await reload();
ref.invalidate(assetsListProvider);
return result.data;
ref.invalidate(transferHistoryProvider(arg));
}
Future<AmcContractModel?> createAmc(Map<String, dynamic> data) async {

View File

@ -148,6 +148,11 @@ class _ExpiryAlertsTab extends ConsumerWidget {
],
),
const SizedBox(height: 16),
_AlertsOverview(
total: state.expiryAlerts.length,
label: 'Expiry alerts in selected window',
),
const SizedBox(height: 12),
Expanded(
child: state.expiryAlerts.isEmpty
? const AppEmptyState(
@ -230,6 +235,11 @@ class _ServiceAlertsTab extends ConsumerWidget {
],
),
const SizedBox(height: 16),
_AlertsOverview(
total: state.serviceAlerts.length,
label: 'Service reminders',
),
const SizedBox(height: 12),
Expanded(
child: state.serviceAlerts.isEmpty
? const AppEmptyState(
@ -272,30 +282,304 @@ class _AlertCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final visual = _alertVisualStyle(alert);
final subtitleParts = [
if (alert.assetCode?.trim().isNotEmpty == true) alert.assetCode!.trim(),
if (alert.plantName?.trim().isNotEmpty == true) alert.plantName!.trim(),
if (dateLabel != null) 'Due: $dateLabel',
];
return AppCard(
child: ListTile(
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: onTap,
leading: Icon(
alert.type?.toUpperCase() == 'AMC'
? Icons.handshake_outlined
: alert.type?.toUpperCase() == 'INSURANCE'
? Icons.shield_outlined
: Icons.notifications_outlined,
child: LayoutBuilder(
builder: (context, constraints) {
final isCompact = constraints.maxWidth < 680;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: visual.color.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
_alertTypeIcon(alert.type),
color: visual.color,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isCompact) ...[
Text(
alert.assetName ?? alert.title ?? 'Asset ${alert.assetId ?? ''}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
Align(
alignment: Alignment.centerRight,
child: Text(
visual.description,
textAlign: TextAlign.right,
style: theme.textTheme.bodySmall?.copyWith(
color: visual.color,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
),
] else
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
alert.assetName ??
alert.title ??
'Asset ${alert.assetId ?? ''}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: 10),
Flexible(
child: Text(
visual.description,
textAlign: TextAlign.right,
style: theme.textTheme.bodySmall?.copyWith(
color: visual.color,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
),
],
),
if (subtitleParts.isNotEmpty) ...[
const SizedBox(height: 3),
Text(
subtitleParts.join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 6,
children: [
if (alert.type?.trim().isNotEmpty == true)
_AlertTag(
label: alert.type!.replaceAll('_', ' ').toUpperCase(),
color: theme.colorScheme.primary,
),
if (alert.status?.trim().isNotEmpty == true)
_AlertTag(
label: alert.status!.replaceAll('_', ' '),
color: theme.colorScheme.secondary,
),
],
),
],
),
),
],
),
);
},
),
title: Text(alert.assetName ?? alert.title ?? 'Asset ${alert.assetId ?? ''}'),
subtitle: Text(
[
if (alert.assetCode != null) alert.assetCode,
if (alert.type != null) alert.type,
if (alert.plantName != null) alert.plantName,
if (dateLabel != null) 'Due: $dateLabel',
if (alert.daysRemaining != null) '${alert.daysRemaining} days',
].whereType<String>().join(' · '),
),
trailing: alert.status != null
? Chip(label: Text(alert.status!, style: const TextStyle(fontSize: 11)))
: null,
),
);
}
}
class _AlertTag extends StatelessWidget {
const _AlertTag({
required this.label,
required this.color,
this.isEmphasized = false,
});
final String label;
final Color color;
final bool isEmphasized;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: color.withValues(alpha: isEmphasized ? 0.16 : 0.12),
borderRadius: BorderRadius.circular(999),
),
child: Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: color,
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
),
),
);
}
}
class _AlertVisualStyle {
const _AlertVisualStyle({
required this.levelLabel,
required this.description,
required this.color,
});
final String levelLabel;
final String description;
final Color color;
}
_AlertVisualStyle _alertVisualStyle(AssetAlertModel alert) {
final days = alert.daysRemaining;
if (days == null) {
return const _AlertVisualStyle(
levelLabel: 'warning',
description: 'Check this asset notification.',
color: Color(0xFF2563EB),
);
}
if (days <= -2) {
final expiredDays = days.abs();
return _AlertVisualStyle(
levelLabel: 'warning',
description: 'Expired $expiredDays day${expiredDays == 1 ? '' : 's'} ago',
color: const Color(0xFFDC2626),
);
}
if (days == -1) {
return const _AlertVisualStyle(
levelLabel: 'warning',
description: 'Expired yesterday',
color: Color(0xFFEA580C),
);
}
if (days == 0) {
return const _AlertVisualStyle(
levelLabel: 'warning',
description: 'Expires today. Immediate action required.',
color: Color(0xFFCA8A04),
);
}
return _AlertVisualStyle(
levelLabel: 'warning',
description: 'Expires in $days day${days == 1 ? '' : 's'}',
color: const Color(0xFF16A34A),
);
}
IconData _alertTypeIcon(String? type) {
switch (type?.toUpperCase()) {
case 'AMC':
return Icons.handshake_outlined;
case 'INSURANCE':
return Icons.shield_outlined;
case 'WARRANTY':
return Icons.verified_user_outlined;
case 'SERVICE':
return Icons.build_outlined;
default:
return Icons.notifications_outlined;
}
}
class _AlertsOverview extends StatelessWidget {
const _AlertsOverview({
required this.total,
required this.label,
});
final int total;
final String label;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(10),
),
child: Wrap(
spacing: 12,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
'$total alerts',
style: theme.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w700),
),
Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const _LegendDot(color: Color(0xFFDC2626), label: 'Expired'),
const _LegendDot(color: Color(0xFFEA580C), label: 'Yesterday'),
const _LegendDot(color: Color(0xFFCA8A04), label: 'Today'),
const _LegendDot(color: Color(0xFF16A34A), label: 'Upcoming'),
],
),
);
}
}
class _LegendDot extends StatelessWidget {
const _LegendDot({
required this.color,
required this.label,
});
final Color color;
final String label;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Text(label, style: Theme.of(context).textTheme.labelSmall),
],
);
}
}

View File

@ -7,6 +7,7 @@ import '../../../../core/constants/enums.dart';
import '../../../../core/constants/route_constants.dart';
import '../../../../core/errors/failure.dart';
import '../../../../shared/models/asset_model.dart';
import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/providers/permissions_provider.dart';
import '../../../../shared/widgets/app_card.dart';
import '../../../../shared/widgets/app_confirmation_dialog.dart';
@ -18,6 +19,7 @@ import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../providers/assets_provider.dart';
import '../providers/asset_form_lookups_provider.dart';
import '../widgets/asset_form_panel.dart';
import '../widgets/asset_side_panels.dart';
@ -64,6 +66,11 @@ class _AssetDetailScreenState extends ConsumerState<AssetDetailScreen>
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageHeader(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: 'Back to Asset Master',
onPressed: () => context.go(RouteConstants.assets),
),
title: state.asset.assetName,
subtitle: state.asset.assetCode ?? 'Asset ID: ${state.asset.id}',
actions: [
@ -149,7 +156,7 @@ class _AssetDetailScreenState extends ConsumerState<AssetDetailScreen>
);
if (transferred == true && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Asset transferred')),
const SnackBar(content: Text('Asset transferred successfully')),
);
}
}
@ -361,6 +368,9 @@ class _AmcTab extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final vendors = ref.watch(assetFormLookupsProvider).valueOrNull?.vendors ??
const <FilterOptionModel>[];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -396,6 +406,7 @@ class _AmcTab extends ConsumerWidget {
itemBuilder: (context, index) {
return _AssetAmcCard(
contract: contracts[index],
vendors: vendors,
onEdit: () => _openEditAmcPanel(
context,
ref,
@ -629,10 +640,12 @@ class _InsuranceTab extends ConsumerWidget {
class _AssetAmcCard extends StatelessWidget {
const _AssetAmcCard({
required this.contract,
required this.vendors,
this.onEdit,
});
final AmcContractModel contract;
final List<FilterOptionModel> vendors;
final VoidCallback? onEdit;
@override
@ -640,11 +653,7 @@ class _AssetAmcCard extends StatelessWidget {
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}'
: '';
final vendor = _contractVendorLabel(contract, vendors);
return AppCard(
elevation: 0,
@ -1066,6 +1075,26 @@ Color _assetStatusColor(String status, ThemeData theme) {
}
}
String _contractVendorLabel(
AmcContractModel contract,
List<FilterOptionModel> vendors,
) {
final direct = contract.vendorName?.trim();
if (direct != null && direct.isNotEmpty) return direct;
final vendorId = contract.vendorId;
if (vendorId != null) {
final id = vendorId.toString();
for (final vendor in vendors) {
if (vendor.id == id && vendor.name.trim().isNotEmpty) {
return vendor.name.trim();
}
}
}
return '';
}
String _assetStatusLabel(String status) {
if (status.isEmpty) return '';
final normalized = status.replaceAll('_', ' ').toLowerCase();

View File

@ -294,7 +294,7 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
final confirmed = await showAppConfirmationDialog(
context: context,
title: 'Delete Asset',
message: 'Soft delete "${asset.assetName}"?',
message: 'Do you want to delete this "${asset.assetName}"?',
confirmLabel: 'Delete',
isDestructive: true,
);

View File

@ -1,14 +1,19 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/errors/failure.dart';
import '../../../../core/utils/formatters.dart';
import '../../../../core/utils/validators.dart';
import '../../../../shared/models/asset_model.dart';
import '../../../../shared/widgets/api_feedback.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_card.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../../../../shared/widgets/error_view.dart';
import '../providers/asset_form_lookups_provider.dart';
import '../providers/assets_provider.dart';
@ -1291,7 +1296,7 @@ class _TransferAssetPanelState extends ConsumerState<TransferAssetPanel> {
}
} catch (e) {
if (mounted) {
showSidePanelSnackBar(context, e.toString());
showSidePanelApiError(context, e);
}
} finally {
if (mounted) setState(() => _isSubmitting = false);
@ -1398,6 +1403,214 @@ class _TransferAssetPanelState extends ConsumerState<TransferAssetPanel> {
}
}
class _TransferHistoryEntry extends StatelessWidget {
const _TransferHistoryEntry({required this.item});
final AssetTransferHistoryModel item;
DateTime? _resolvedTransferDateTime() {
final transferDate = item.transferDate?.toLocal();
final createdAt = item.createdAt?.toLocal();
if (transferDate == null) return createdAt;
if (createdAt == null) return transferDate;
return DateTime(
transferDate.year,
transferDate.month,
transferDate.day,
createdAt.hour,
createdAt.minute,
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final resolvedDateTime = _resolvedTransferDateTime();
final transferDate = resolvedDateTime != null
? DateFormatter.displayDateTime(resolvedDateTime)
: '';
final reason = item.reason?.trim();
final showReason = reason != null && reason.isNotEmpty && reason.toLowerCase() != 'nil';
return AppCard(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.swap_horiz_rounded,
size: 18,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Text(
transferDate,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: 16),
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: _TransferLocationBlock(
label: 'From',
plant: item.fromPlantName,
department: item.fromDepartmentName,
warehouse: item.fromWarehouseName,
user: item.fromUserName,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Icon(
Icons.arrow_forward_rounded,
size: 18,
color: theme.colorScheme.outline,
),
),
Expanded(
child: _TransferLocationBlock(
label: 'To',
plant: item.toPlantName,
department: item.toDepartmentName,
warehouse: item.toWarehouseName,
user: item.toUserName,
),
),
],
),
),
if (showReason) ...[
const SizedBox(height: 16),
Divider(
height: 1,
color: theme.colorScheme.outline.withValues(alpha: 0.2),
),
const SizedBox(height: 12),
Text(
'Reason',
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
reason,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
],
),
),
);
}
}
class _TransferLocationBlock extends StatelessWidget {
const _TransferLocationBlock({
required this.label,
this.plant,
this.department,
this.warehouse,
this.user,
});
final String label;
final String? plant;
final String? department;
final String? warehouse;
final String? user;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
_TransferLocationRow(
icon: Icons.factory_outlined,
value: plant,
),
_TransferLocationRow(
icon: Icons.apartment_outlined,
value: department,
),
_TransferLocationRow(
icon: Icons.warehouse_outlined,
value: warehouse,
),
_TransferLocationRow(
icon: Icons.person_outline,
value: user,
),
],
);
}
}
class _TransferLocationRow extends StatelessWidget {
const _TransferLocationRow({
required this.icon,
this.value,
});
final IconData icon;
final String? value;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final display = value?.trim().isNotEmpty == true ? value!.trim() : '';
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
icon,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 6),
Expanded(
child: Text(
display,
style: theme.textTheme.bodySmall?.copyWith(
color: display == ''
? theme.colorScheme.onSurfaceVariant
: theme.colorScheme.onSurface,
),
),
),
],
),
);
}
}
class TransferHistoryPanel extends ConsumerWidget {
const TransferHistoryPanel({super.key, required this.assetId});
@ -1410,57 +1623,38 @@ class TransferHistoryPanel extends ConsumerWidget {
title: 'Transfer History',
child: historyAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(child: Text(error.toString())),
error: (error, _) => ErrorView(
message: error is Failure
? error.message
: 'Unable to load transfer history',
onRetry: () => ref.invalidate(transferHistoryProvider(assetId)),
),
data: (history) {
if (history.isEmpty) {
return const Center(
child: Text('No transfer history found'),
return const AppEmptyState(
title: 'No transfer history',
description: 'Transfers for this asset will appear here.',
icon: Icons.swap_horiz_outlined,
);
}
return ListView.separated(
itemCount: history.length,
separatorBuilder: (_, _) => const Divider(height: 24),
itemBuilder: (context, index) {
final item = history[index];
final transferDate = item.transferDate != null
? DateFormatter.displayDate(item.transferDate)
: '';
final fromParts = [
item.fromPlantName,
item.fromDepartmentName,
item.fromWarehouseName,
item.fromUserName,
].whereType<String>().where((e) => e.trim().isNotEmpty).toList();
final toParts = [
item.toPlantName,
item.toDepartmentName,
item.toWarehouseName,
item.toUserName,
].whereType<String>().where((e) => e.trim().isNotEmpty).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
transferDate,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text('From: ${fromParts.isEmpty ? '' : fromParts.join(' · ')}'),
const SizedBox(height: 4),
Text('To: ${toParts.isEmpty ? '' : toParts.join(' · ')}'),
if (item.reason?.trim().isNotEmpty == true) ...[
const SizedBox(height: 8),
Text(
'Reason: ${item.reason!}',
style: Theme.of(context).textTheme.bodyMedium,
),
],
],
);
},
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${history.length} transfer${history.length == 1 ? '' : 's'}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
for (var index = 0; index < history.length; index++) ...[
if (index > 0) const SizedBox(height: 12),
_TransferHistoryEntry(item: history[index]),
],
],
);
},
),

View File

@ -479,6 +479,24 @@ String masterCellValue(Map<String, dynamic> row, MasterFieldDef field) {
if (field.type == MasterFieldType.dropdown) {
final label = row['${field.key}_label'];
if (label != null && label.toString().isNotEmpty) return label.toString();
// Prefer explicit "<base>_name" or nested "<base>.name" from API payloads
// (e.g. asset_category_id -> asset_category_name / asset_category.name)
if (field.key.endsWith('_id')) {
final baseKey = field.key.substring(0, field.key.length - 3);
final explicitName = row['${baseKey}_name'];
if (explicitName != null && explicitName.toString().trim().isNotEmpty) {
return explicitName.toString().trim();
}
final nested = row[baseKey];
if (nested is Map) {
final nestedName = nested['name'];
if (nestedName != null && nestedName.toString().trim().isNotEmpty) {
return nestedName.toString().trim();
}
}
}
if (field.staticOptions != null) {
return value.toString().replaceAll('_', ' ');
}

View File

@ -290,7 +290,7 @@ class _PurchaseOrderDetailScreenState
final confirmed = await showAppConfirmationDialog(
context: context,
title: 'Delete Purchase Order',
message: 'Soft delete this purchase order?',
message: 'Do you want to delete this purchase order?',
confirmLabel: 'Delete',
isDestructive: true,
);

View File

@ -36,6 +36,16 @@ class PurchaseOrderListScreen extends ConsumerStatefulWidget {
class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScreen> {
final _searchController = TextEditingController();
final Map<String, String> _knownStatuses = {};
@override
void initState() {
super.initState();
// Keep base options visible even before first API payload.
for (final option in poStatusOptions) {
_knownStatuses[option.$1] = option.$2;
}
}
@override
void dispose() {
@ -68,9 +78,11 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
error is Failure ? error : Failure.unknown(message: error.toString()),
onRetry: () => ref.invalidate(purchaseOrdersListProvider),
),
data: (state) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
data: (state) {
_rememberStatuses(state.orders);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageHeader(
title: 'Purchase Orders',
subtitle: 'Create, approve and track procurement orders',
@ -94,6 +106,7 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
return _FiltersBar(
searchController: _searchController,
query: state.query,
statusOptions: _statusFilterOptions(state.orders),
wrapped: constraints.maxWidth < 900,
onSearch: ref.read(purchaseOrdersListProvider.notifier).setSearch,
onStatusChanged:
@ -148,8 +161,9 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
),
),
),
],
),
],
);
},
),
);
}
@ -189,12 +203,39 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
if (confirmed != true || !mounted) return;
await ref.read(purchaseOrdersListProvider.notifier).deletePurchaseOrder(order.id);
}
List<AppDropdownOption<String?>> _statusFilterOptions(
List<PurchaseOrderModel> orders,
) {
final options = _knownStatuses.entries
.map((entry) => AppDropdownOption<String?>(
value: entry.key,
label: entry.value,
))
.toList()
..sort((a, b) => a.label.compareTo(b.label));
return [
const AppDropdownOption<String?>(value: null, label: 'All statuses'),
...options,
];
}
void _rememberStatuses(List<PurchaseOrderModel> orders) {
for (final order in orders) {
final raw = order.status.trim();
if (raw.isEmpty) continue;
final normalized = raw.toUpperCase().replaceAll(' ', '_');
_knownStatuses.putIfAbsent(normalized, () => poStatusLabel(raw));
}
}
}
class _FiltersBar extends StatelessWidget {
const _FiltersBar({
required this.searchController,
required this.query,
required this.statusOptions,
required this.wrapped,
required this.onSearch,
required this.onStatusChanged,
@ -203,6 +244,7 @@ class _FiltersBar extends StatelessWidget {
final TextEditingController searchController;
final PurchaseOrderListQuery query;
final List<AppDropdownOption<String?>> statusOptions;
final bool wrapped;
final ValueChanged<String> onSearch;
final ValueChanged<String?> onStatusChanged;
@ -227,12 +269,7 @@ class _FiltersBar extends StatelessWidget {
value: query.status,
searchHint: 'Search status...',
isDense: true,
options: [
const AppDropdownOption(value: null, label: 'All statuses'),
...poStatusOptions.map(
(e) => AppDropdownOption(value: e.$1, label: e.$2),
),
],
options: statusOptions,
onChanged: onStatusChanged,
),
),

View File

@ -114,7 +114,7 @@ class _UsersRoleManagementScreenState
final confirmed = await showAppConfirmationDialog(
context: context,
title: 'Delete role',
message: 'Delete "${role.name}"? This is a soft delete.',
message: 'Do you want to delete this "${role.name}"?',
confirmLabel: 'Delete',
isDestructive: true,
);
@ -637,7 +637,7 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
final confirmed = await showAppConfirmationDialog(
context: context,
title: 'Deactivate user',
message: 'Deactivate ${user.fullName}? This is a soft delete.',
message: 'Do you want to deactivate ${user.fullName}?',
confirmLabel: 'Deactivate',
isDestructive: true,
);

View File

@ -162,7 +162,7 @@ class _UserListScreenState extends ConsumerState<UserListScreen> {
final confirmed = await showAppConfirmationDialog(
context: context,
title: 'Deactivate user',
message: 'Deactivate ${user.fullName}? This is a soft delete.',
message: 'Do you want to deactivate ${user.fullName}?',
confirmLabel: 'Deactivate',
isDestructive: true,
);

View File

@ -320,10 +320,19 @@ class VendorFormNotifier extends FamilyAsyncNotifier<VendorModel?, String?> {
Future<VendorModel> submitUpdate(String id, Map<String, dynamic> data) async {
final repository = ref.read(vendorRepositoryProvider);
final result = await repository.updateVendor(id, data);
final payload = Map<String, dynamic>.from(data);
final status = payload.remove('status')?.toString().trim();
final result = await repository.updateVendor(id, payload);
if (result.failure != null) throw result.failure!;
if (status != null && status.isNotEmpty) {
final statusResult = await repository.updateVendorStatus(id, status);
if (statusResult.failure != null) throw statusResult.failure!;
}
ref.invalidate(vendorsListProvider);
ref.invalidate(vendorDetailProvider(id));
return result.data!;
return (await repository.getVendorById(id)).data ?? result.data!;
}
}

View File

@ -61,6 +61,11 @@ class _VendorDetailScreenState extends ConsumerState<VendorDetailScreen>
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageHeader(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: 'Back to Vendors',
onPressed: () => context.go(RouteConstants.vendors),
),
title: state.vendor.vendorName,
subtitle: state.vendor.vendorCode ?? 'Vendor ID: ${state.vendor.id}',
actions: [
@ -181,7 +186,7 @@ class _VendorDetailScreenState extends ConsumerState<VendorDetailScreen>
final confirmed = await showAppConfirmationDialog(
context: context,
title: 'Delete Vendor',
message: 'Soft delete this vendor?',
message: 'Do you want to delete this vendor?',
confirmLabel: 'Delete',
isDestructive: true,
);
@ -837,128 +842,143 @@ class _VendorContactCard extends StatelessWidget {
final statusColor = contact.isActive
? const Color(0xFF16A34A)
: theme.colorScheme.onSurfaceVariant;
final isPrimary = contact.isPrimary;
final primaryColor = theme.colorScheme.primary;
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(
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),
child: Container(
decoration: BoxDecoration(
color: isPrimary ? primaryColor.withValues(alpha: 0.05) : null,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isPrimary ? primaryColor.withValues(alpha: 0.35) : Colors.transparent,
),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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,
),
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 (isPrimary) ...[
_PrimaryBadge(color: primaryColor),
const SizedBox(width: 6),
],
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 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!,
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(),
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,
),
),
],
),
],
),
],
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,
),
),
],
),
],
),
),
),
);
@ -992,6 +1012,8 @@ class _VendorBankDetailCard extends StatelessWidget {
final statusColor = bankDetail.isActive
? const Color(0xFF16A34A)
: theme.colorScheme.onSurfaceVariant;
final isPrimary = bankDetail.isPrimary;
final primaryColor = theme.colorScheme.primary;
final subtitle = [
bankDetail.branch,
bankDetail.accountHolderName,
@ -1000,131 +1022,169 @@ class _VendorBankDetailCard extends StatelessWidget {
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),
child: Container(
decoration: BoxDecoration(
color: isPrimary ? primaryColor.withValues(alpha: 0.05) : null,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isPrimary ? primaryColor.withValues(alpha: 0.35) : Colors.transparent,
),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.tag_outlined,
size: 14,
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 (isPrimary) ...[
_PrimaryBadge(color: primaryColor),
const SizedBox(width: 6),
],
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,
),
const SizedBox(width: 4),
Expanded(
child: Text(
bankDetail.ifsc!,
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,
),
),
],
),
],
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,
),
),
],
),
],
),
),
),
);
}
}
class _PrimaryBadge extends StatelessWidget {
const _PrimaryBadge({required this.color});
final Color color;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: color.withValues(alpha: 0.3)),
),
child: Text(
'Primary',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: color,
fontWeight: FontWeight.w700,
),
),
);

View File

@ -163,7 +163,7 @@ class _VendorListScreenState extends ConsumerState<VendorListScreen> {
final confirmed = await showAppConfirmationDialog(
context: context,
title: 'Delete Vendor',
message: 'Soft delete "${vendor.vendorName}"?',
message: 'Do you want to delete this "${vendor.vendorName}"?',
confirmLabel: 'Delete',
isDestructive: true,
);

View File

@ -63,6 +63,7 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
String? _sourceOfSupply;
int? _paymentTermId;
bool _isActive = true;
String? _currentStatus;
bool _isSubmitting = false;
String? _populatedSignature;
@ -79,9 +80,10 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
String _vendorSignature(VendorModel vendor) =>
'${vendor.id}:${vendor.vendorType}:${vendor.gstTreatment}:'
'${vendor.sourceOfSupply}:${vendor.paymentTermId}:'
'${vendor.creditPeriodDays}:${vendor.isActive}:${vendor.vendorName}';
'${vendor.creditPeriodDays}:${vendor.status}:${vendor.vendorName}';
void _populateFromVendor(VendorModel vendor) {
final normalizedStatus = (vendor.status ?? '').trim().toLowerCase();
setState(() {
_nameController.text = vendor.vendorName;
_vendorType = vendor.vendorType;
@ -93,10 +95,23 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
_creditDaysController.text =
vendor.creditPeriodDays?.toString() ?? '';
_remarksController.text = vendor.remarks ?? '';
_isActive = vendor.isActive;
_currentStatus = normalizedStatus;
if (normalizedStatus == 'active' || normalizedStatus == 'inactive') {
_isActive = normalizedStatus == 'active';
} else {
_isActive = vendor.isActive;
}
});
}
bool get _showActiveSwitch {
if (!widget.isEditing) return true;
return _currentStatus == null ||
_currentStatus!.isEmpty ||
_currentStatus == 'active' ||
_currentStatus == 'inactive';
}
Map<String, dynamic> _buildPayload() {
return {
'vendor_name': _nameController.text.trim(),
@ -112,7 +127,8 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
'credit_period_days': int.tryParse(_creditDaysController.text.trim()),
if (_remarksController.text.trim().isNotEmpty)
'remarks': _remarksController.text.trim(),
'is_active': _isActive,
if (!widget.isEditing || _showActiveSwitch)
'status': _isActive ? 'active' : 'inactive',
};
}
@ -309,13 +325,15 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
label: 'Remarks',
maxLines: 3,
),
const SizedBox(height: 12),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Active'),
value: _isActive,
onChanged: (v) => setState(() => _isActive = v),
),
if (_showActiveSwitch) ...[
const SizedBox(height: 12),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Active'),
value: _isActive,
onChanged: (v) => setState(() => _isActive = v),
),
],
],
),
);

View File

@ -93,7 +93,12 @@ Object? _readWarehouseName(Map<dynamic, dynamic> json, String key) {
Object? _readVendorNameFromNested(Map<dynamic, dynamic> json, String key) {
final flat = json['vendor_name'];
if (flat is String && flat.isNotEmpty) return flat;
return _readNestedName(json, 'vendor');
final nested = json['vendor'];
if (nested is Map) {
final name = nested['vendor_name'] ?? nested['name'];
if (name is String && name.isNotEmpty) return name;
}
return null;
}
@freezed
@ -293,7 +298,8 @@ class AmcContractModel with _$AmcContractModel {
@JsonKey(fromJson: _idFromJson) required String id,
@JsonKey(name: 'asset_id', fromJson: _idFromJson) String? assetId,
@JsonKey(name: 'vendor_id', fromJson: _intFromJsonNullable) int? vendorId,
@JsonKey(name: 'vendor_name') String? vendorName,
@JsonKey(name: 'vendor_name', readValue: _readVendorNameFromNested)
String? vendorName,
@JsonKey(name: 'contract_no') String? contractNo,
@JsonKey(name: 'contract_type') String? contractType,
@JsonKey(name: 'start_date', fromJson: _dateFromJson) required DateTime startDate,
@ -334,7 +340,8 @@ class ServiceVisitModel with _$ServiceVisitModel {
@JsonKey(name: 'engineer_name') String? engineerName,
@JsonKey(name: 'engineer_phone') String? engineerPhone,
@JsonKey(name: 'vendor_id', fromJson: _intFromJsonNullable) int? vendorId,
@JsonKey(name: 'vendor_name') String? vendorName,
@JsonKey(name: 'vendor_name', readValue: _readVendorNameFromNested)
String? vendorName,
@JsonKey(name: 'work_done') String? workDone,
@JsonKey(name: 'parts_replaced') String? partsReplaced,
@JsonKey(name: 'next_service_date', fromJson: _dateFromJsonNullable)
@ -445,9 +452,11 @@ class AssetTransferHistoryModel {
String? readNestedName(String key) {
final nested = json[key];
if (nested is Map<String, dynamic>) {
final name = nested['name'];
if (name is String && name.trim().isNotEmpty) return name.trim();
if (nested is Map) {
for (final field in ['name', 'full_name']) {
final value = nested[field];
if (value is String && value.trim().isNotEmpty) return value.trim();
}
}
return null;
}

View File

@ -1734,7 +1734,7 @@ mixin _$AmcContractModel {
String? get assetId => throw _privateConstructorUsedError;
@JsonKey(name: 'vendor_id', fromJson: _intFromJsonNullable)
int? get vendorId => throw _privateConstructorUsedError;
@JsonKey(name: 'vendor_name')
@JsonKey(name: 'vendor_name', readValue: _readVendorNameFromNested)
String? get vendorName => throw _privateConstructorUsedError;
@JsonKey(name: 'contract_no')
String? get contractNo => throw _privateConstructorUsedError;
@ -1791,7 +1791,8 @@ abstract class $AmcContractModelCopyWith<$Res> {
@JsonKey(fromJson: _idFromJson) String id,
@JsonKey(name: 'asset_id', fromJson: _idFromJson) String? assetId,
@JsonKey(name: 'vendor_id', fromJson: _intFromJsonNullable) int? vendorId,
@JsonKey(name: 'vendor_name') String? vendorName,
@JsonKey(name: 'vendor_name', readValue: _readVendorNameFromNested)
String? vendorName,
@JsonKey(name: 'contract_no') String? contractNo,
@JsonKey(name: 'contract_type') String? contractType,
@JsonKey(name: 'start_date', fromJson: _dateFromJson) DateTime startDate,
@ -1964,7 +1965,8 @@ abstract class _$$AmcContractModelImplCopyWith<$Res>
@JsonKey(fromJson: _idFromJson) String id,
@JsonKey(name: 'asset_id', fromJson: _idFromJson) String? assetId,
@JsonKey(name: 'vendor_id', fromJson: _intFromJsonNullable) int? vendorId,
@JsonKey(name: 'vendor_name') String? vendorName,
@JsonKey(name: 'vendor_name', readValue: _readVendorNameFromNested)
String? vendorName,
@JsonKey(name: 'contract_no') String? contractNo,
@JsonKey(name: 'contract_type') String? contractType,
@JsonKey(name: 'start_date', fromJson: _dateFromJson) DateTime startDate,
@ -2129,7 +2131,8 @@ class _$AmcContractModelImpl implements _AmcContractModel {
@JsonKey(fromJson: _idFromJson) required this.id,
@JsonKey(name: 'asset_id', fromJson: _idFromJson) this.assetId,
@JsonKey(name: 'vendor_id', fromJson: _intFromJsonNullable) this.vendorId,
@JsonKey(name: 'vendor_name') this.vendorName,
@JsonKey(name: 'vendor_name', readValue: _readVendorNameFromNested)
this.vendorName,
@JsonKey(name: 'contract_no') this.contractNo,
@JsonKey(name: 'contract_type') this.contractType,
@JsonKey(name: 'start_date', fromJson: _dateFromJson)
@ -2168,7 +2171,7 @@ class _$AmcContractModelImpl implements _AmcContractModel {
@JsonKey(name: 'vendor_id', fromJson: _intFromJsonNullable)
final int? vendorId;
@override
@JsonKey(name: 'vendor_name')
@JsonKey(name: 'vendor_name', readValue: _readVendorNameFromNested)
final String? vendorName;
@override
@JsonKey(name: 'contract_no')
@ -2325,7 +2328,8 @@ abstract class _AmcContractModel implements AmcContractModel {
@JsonKey(name: 'asset_id', fromJson: _idFromJson) final String? assetId,
@JsonKey(name: 'vendor_id', fromJson: _intFromJsonNullable)
final int? vendorId,
@JsonKey(name: 'vendor_name') final String? vendorName,
@JsonKey(name: 'vendor_name', readValue: _readVendorNameFromNested)
final String? vendorName,
@JsonKey(name: 'contract_no') final String? contractNo,
@JsonKey(name: 'contract_type') final String? contractType,
@JsonKey(name: 'start_date', fromJson: _dateFromJson)
@ -2365,7 +2369,7 @@ abstract class _AmcContractModel implements AmcContractModel {
@JsonKey(name: 'vendor_id', fromJson: _intFromJsonNullable)
int? get vendorId;
@override
@JsonKey(name: 'vendor_name')
@JsonKey(name: 'vendor_name', readValue: _readVendorNameFromNested)
String? get vendorName;
@override
@JsonKey(name: 'contract_no')
@ -2457,7 +2461,7 @@ mixin _$ServiceVisitModel {
String? get engineerPhone => throw _privateConstructorUsedError;
@JsonKey(name: 'vendor_id', fromJson: _intFromJsonNullable)
int? get vendorId => throw _privateConstructorUsedError;
@JsonKey(name: 'vendor_name')
@JsonKey(name: 'vendor_name', readValue: _readVendorNameFromNested)
String? get vendorName => throw _privateConstructorUsedError;
@JsonKey(name: 'work_done')
String? get workDone => throw _privateConstructorUsedError;
@ -2511,7 +2515,8 @@ abstract class $ServiceVisitModelCopyWith<$Res> {
@JsonKey(name: 'engineer_name') String? engineerName,
@JsonKey(name: 'engineer_phone') String? engineerPhone,
@JsonKey(name: 'vendor_id', fromJson: _intFromJsonNullable) int? vendorId,
@JsonKey(name: 'vendor_name') String? vendorName,
@JsonKey(name: 'vendor_name', readValue: _readVendorNameFromNested)
String? vendorName,
@JsonKey(name: 'work_done') String? workDone,
@JsonKey(name: 'parts_replaced') String? partsReplaced,
@JsonKey(name: 'next_service_date', fromJson: _dateFromJsonNullable)
@ -2693,7 +2698,8 @@ abstract class _$$ServiceVisitModelImplCopyWith<$Res>
@JsonKey(name: 'engineer_name') String? engineerName,
@JsonKey(name: 'engineer_phone') String? engineerPhone,
@JsonKey(name: 'vendor_id', fromJson: _intFromJsonNullable) int? vendorId,
@JsonKey(name: 'vendor_name') String? vendorName,
@JsonKey(name: 'vendor_name', readValue: _readVendorNameFromNested)
String? vendorName,
@JsonKey(name: 'work_done') String? workDone,
@JsonKey(name: 'parts_replaced') String? partsReplaced,
@JsonKey(name: 'next_service_date', fromJson: _dateFromJsonNullable)
@ -2868,7 +2874,8 @@ class _$ServiceVisitModelImpl implements _ServiceVisitModel {
@JsonKey(name: 'engineer_name') this.engineerName,
@JsonKey(name: 'engineer_phone') this.engineerPhone,
@JsonKey(name: 'vendor_id', fromJson: _intFromJsonNullable) this.vendorId,
@JsonKey(name: 'vendor_name') this.vendorName,
@JsonKey(name: 'vendor_name', readValue: _readVendorNameFromNested)
this.vendorName,
@JsonKey(name: 'work_done') this.workDone,
@JsonKey(name: 'parts_replaced') this.partsReplaced,
@JsonKey(name: 'next_service_date', fromJson: _dateFromJsonNullable)
@ -2925,7 +2932,7 @@ class _$ServiceVisitModelImpl implements _ServiceVisitModel {
@JsonKey(name: 'vendor_id', fromJson: _intFromJsonNullable)
final int? vendorId;
@override
@JsonKey(name: 'vendor_name')
@JsonKey(name: 'vendor_name', readValue: _readVendorNameFromNested)
final String? vendorName;
@override
@JsonKey(name: 'work_done')
@ -3076,7 +3083,8 @@ abstract class _ServiceVisitModel implements ServiceVisitModel {
@JsonKey(name: 'engineer_phone') final String? engineerPhone,
@JsonKey(name: 'vendor_id', fromJson: _intFromJsonNullable)
final int? vendorId,
@JsonKey(name: 'vendor_name') final String? vendorName,
@JsonKey(name: 'vendor_name', readValue: _readVendorNameFromNested)
final String? vendorName,
@JsonKey(name: 'work_done') final String? workDone,
@JsonKey(name: 'parts_replaced') final String? partsReplaced,
@JsonKey(name: 'next_service_date', fromJson: _dateFromJsonNullable)
@ -3133,7 +3141,7 @@ abstract class _ServiceVisitModel implements ServiceVisitModel {
@JsonKey(name: 'vendor_id', fromJson: _intFromJsonNullable)
int? get vendorId;
@override
@JsonKey(name: 'vendor_name')
@JsonKey(name: 'vendor_name', readValue: _readVendorNameFromNested)
String? get vendorName;
@override
@JsonKey(name: 'work_done')

View File

@ -142,7 +142,7 @@ _$AmcContractModelImpl _$$AmcContractModelImplFromJson(
id: _idFromJson(json['id']),
assetId: _idFromJson(json['asset_id']),
vendorId: _intFromJsonNullable(json['vendor_id']),
vendorName: json['vendor_name'] as String?,
vendorName: _readVendorNameFromNested(json, 'vendor_name') as String?,
contractNo: json['contract_no'] as String?,
contractType: json['contract_type'] as String?,
startDate: _dateFromJson(json['start_date']),
@ -205,7 +205,7 @@ _$ServiceVisitModelImpl _$$ServiceVisitModelImplFromJson(
engineerName: json['engineer_name'] as String?,
engineerPhone: json['engineer_phone'] as String?,
vendorId: _intFromJsonNullable(json['vendor_id']),
vendorName: json['vendor_name'] as String?,
vendorName: _readVendorNameFromNested(json, 'vendor_name') as String?,
workDone: json['work_done'] as String?,
partsReplaced: json['parts_replaced'] as String?,
nextServiceDate: _dateFromJsonNullable(json['next_service_date']),

View File

@ -211,7 +211,7 @@ const poTypeOptions = [
const poStatusOptions = [
('DRAFT', 'Draft'),
('SUBMITTED', 'Submitted'),
// ('SUBMITTED', 'Submitted'),
('PENDING_APPROVAL', 'Pending Approval'),
('APPROVED', 'Approved'),
('REJECTED', 'Rejected'),
@ -231,9 +231,15 @@ String poTypeLabel(String? value) {
String poStatusLabel(String? value) {
if (value == null) return '';
final normalizedKey = value.trim().toUpperCase().replaceAll(' ', '_');
return poStatusOptions
.where((e) => e.$1 == value)
.where((e) => e.$1 == normalizedKey)
.map((e) => e.$2)
.firstOrNull ??
value.replaceAll('_', ' ');
normalizedKey
.toLowerCase()
.split('_')
.where((word) => word.isNotEmpty)
.map((word) => '${word[0].toUpperCase()}${word.substring(1)}')
.join(' ');
}

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../core/errors/failure.dart';
import '../../core/network/api_handler.dart';
import 'app_side_panel.dart';
void showAccessDeniedSnackBar(BuildContext context, {String? message}) {
ScaffoldMessenger.of(context).showSnackBar(
@ -21,6 +22,15 @@ void showAccessDeniedSnackBar(BuildContext context, {String? message}) {
);
}
void showSidePanelApiError(BuildContext context, Object error) {
final message = error is Failure
? (error is ValidationFailure
? validationErrorMessage(error)
: error.message)
: 'Something went wrong. Please try again.';
showSidePanelSnackBar(context, message);
}
void showApiFailureSnackBar(BuildContext context, Failure failure) {
if (isForbiddenFailure(failure)) {
showAccessDeniedSnackBar(context, message: failure.message);

View File

@ -32,15 +32,37 @@ class AppStatusChip extends StatelessWidget {
}
(Color, String) _resolveStatus(String raw) {
switch (raw.toLowerCase()) {
final normalized = raw.trim().toLowerCase().replaceAll(' ', '_');
switch (normalized) {
case 'active':
return (Colors.green.shade700, EntityStatus.active.label);
case 'inactive':
return (Colors.grey.shade700, EntityStatus.inactive.label);
case 'locked':
return (Colors.orange.shade800, 'Locked');
case 'in_use':
return (const Color(0xFF16A34A), 'In Use');
case 'under_maintenance':
return (const Color(0xFFCA8A04), 'Under Maintenance');
case 'available':
return (const Color(0xFF2563EB), 'Available');
case 'disposed':
case 'scrapped':
return (Colors.grey.shade700, _humanizeStatus(raw));
default:
return (Colors.blueGrey, raw);
return (Colors.blueGrey, _humanizeStatus(raw));
}
}
String _humanizeStatus(String value) {
final normalized = value.trim().replaceAll('_', ' ');
if (normalized.isEmpty) return value;
return normalized
.split(RegExp(r'\s+'))
.map((word) {
final lower = word.toLowerCase();
return '${lower[0].toUpperCase()}${lower.substring(1)}';
})
.join(' ');
}
}

View File

@ -8,11 +8,13 @@ class PageHeader extends StatelessWidget {
required this.title,
this.subtitle,
this.actions,
this.leading,
});
final String title;
final String? subtitle;
final List<Widget>? actions;
final Widget? leading;
@override
Widget build(BuildContext context) {
@ -22,7 +24,20 @@ class PageHeader extends StatelessWidget {
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.headlineSmall),
Row(
children: [
if (leading != null) ...[
leading!,
const SizedBox(width: 8),
],
Expanded(
child: Text(
title,
style: Theme.of(context).textTheme.headlineSmall,
),
),
],
),
if (subtitle != null) ...[
const SizedBox(height: 4),
Text(
@ -40,6 +55,10 @@ class PageHeader extends StatelessWidget {
)
: Row(
children: [
if (leading != null) ...[
leading!,
const SizedBox(width: 8),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,