bharat_erp/lib/modules/assets/presentation/widgets/asset_maintenance_panel.dart

561 lines
17 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../../../core/utils/formatters.dart';
import '../../../../shared/models/asset_model.dart';
import '../../../../shared/widgets/api_feedback.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_date_popup.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../../data/repositories/asset_repository_impl.dart';
import '../providers/assets_provider.dart';
List<AssetMaintenanceChecklistItem> checklistForAsset(AssetModel asset) {
final fromSummary = asset.maintenance?.checklist;
if (fromSummary != null && fromSummary.isNotEmpty) {
return fromSummary;
}
return asset.maintenanceChecklistJson ?? const [];
}
Future<bool?> openSubmitMaintenancePanel(
BuildContext context,
WidgetRef ref, {
required AssetModel asset,
}) {
return showSidePanel<bool>(
context,
SubmitMaintenanceLogPanel(asset: asset),
width: 600,
);
}
class SubmitMaintenanceLogPanel extends ConsumerStatefulWidget {
const SubmitMaintenanceLogPanel({super.key, required this.asset});
final AssetModel asset;
@override
ConsumerState<SubmitMaintenanceLogPanel> createState() =>
_SubmitMaintenanceLogPanelState();
}
class _ChecklistRowState {
_ChecklistRowState({
required this.label,
required this.required,
}) : status = required ? null : 'OK';
final String label;
final bool required;
/// Null until the user picks a status (required items must choose explicitly).
String? status;
final TextEditingController remarksController = TextEditingController();
void dispose() => remarksController.dispose();
}
class _SubmitMaintenanceLogPanelState
extends ConsumerState<SubmitMaintenanceLogPanel> {
final _formKey = GlobalKey<FormState>();
final _remarksController = TextEditingController();
DateTime _performedDate = DateTime.now();
late final List<_ChecklistRowState> _rows;
bool _isSubmitting = false;
@override
void initState() {
super.initState();
final checklist = checklistForAsset(widget.asset);
_rows = checklist
.where((item) => item.label.trim().isNotEmpty)
.map(
(item) => _ChecklistRowState(
label: item.label.trim(),
required: item.required,
),
)
.toList();
}
@override
void dispose() {
_remarksController.dispose();
for (final row in _rows) {
row.dispose();
}
super.dispose();
}
Future<void> _pickPerformedDate() async {
final picked = await showAppDatePopup(
context: context,
initialDate: _performedDate,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
helpText: 'Performed date',
);
if (picked != null) {
setState(() => _performedDate = picked);
}
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
if (_rows.isEmpty) {
showSidePanelSnackBar(context, 'No checklist items configured for this asset');
return;
}
final missingRequired = _rows.where((row) {
if (!row.required) return false;
final statusMissing = row.status == null || row.status!.trim().isEmpty;
final remarksMissing = row.remarksController.text.trim().isEmpty;
return statusMissing || remarksMissing;
}).toList();
if (missingRequired.isNotEmpty) {
showSidePanelSnackBar(
context,
'Complete required checklist items: ${missingRequired.map((r) => r.label).join(', ')}',
);
return;
}
setState(() => _isSubmitting = true);
try {
final payload = <String, dynamic>{
'performed_date': DateFormatter.toApiDate(_performedDate),
'checklist_json': _rows
.map(
(row) {
final remarks = row.remarksController.text.trim();
return <String, dynamic>{
'label': row.label,
'status': row.status,
'remarks': remarks.isEmpty ? null : remarks,
'required': row.required,
};
},
)
.toList(),
if (_remarksController.text.trim().isNotEmpty)
'remarks': _remarksController.text.trim(),
};
final result = await ref
.read(assetRepositoryProvider)
.createMaintenanceLog(widget.asset.id, payload);
if (result.failure != null) throw result.failure!;
ref.invalidate(myMaintenanceProvider);
ref.invalidate(assetDetailProvider(widget.asset.id));
if (mounted) Navigator.of(context, rootNavigator: true).pop(true);
} catch (e) {
if (mounted) {
showSidePanelApiError(context, e);
}
} finally {
if (mounted) setState(() => _isSubmitting = false);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final assetLabel = widget.asset.assetCode?.trim().isNotEmpty == true
? '${widget.asset.assetName} (${widget.asset.assetCode})'
: widget.asset.assetName;
return SidePanelScaffold(
title: 'Submit Maintenance',
footer: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
OutlinedButton(
onPressed: _isSubmitting
? null
: () => Navigator.of(context, rootNavigator: true).pop(),
child: const Text('Cancel'),
),
const SizedBox(width: 12),
AppButton(
label: 'Submit Log',
expand: false,
icon: Icons.check,
isLoading: _isSubmitting,
onPressed: _isSubmitting ? null : _save,
),
],
),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
assetLabel,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
'Record checklist results for this maintenance visit.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
SidePanelSection(
title: 'Visit',
children: [
_DateField(
label: 'Performed Date *',
value: _performedDate,
onPick: _pickPerformedDate,
),
const SizedBox(height: 12),
AppTextField(
controller: _remarksController,
label: 'Remarks',
maxLines: 3,
),
],
),
const SizedBox(height: 16),
SidePanelSection(
title: 'Checklist',
children: [
if (_rows.isEmpty)
const AppEmptyState(
title: 'No checklist items',
description:
'Configure a maintenance checklist on the asset first.',
icon: Icons.checklist_outlined,
)
else
..._rows.asMap().entries.map((entry) {
final index = entry.key;
final row = entry.value;
return Padding(
padding: EdgeInsets.only(
bottom: index == _rows.length - 1 ? 0 : 12,
),
child: _ChecklistItemCard(
row: row,
onStatusChanged: (status) {
if (status == null) return;
setState(() => row.status = status);
},
),
);
}),
],
),
const SizedBox(height: 16),
AssetRecentMaintenanceLogsSection(assetId: widget.asset.id),
],
),
),
);
}
}
/// Expandable recent maintenance logs (asset detail + submit panel).
class AssetRecentMaintenanceLogsSection extends ConsumerStatefulWidget {
const AssetRecentMaintenanceLogsSection({
super.key,
required this.assetId,
this.limit = 5,
this.leadingActions,
this.trailingActions,
});
final String assetId;
final int limit;
/// Optional actions shown before Recent Logs (e.g. Log Maintenance).
final List<Widget>? leadingActions;
/// Optional actions shown after Recent Logs (e.g. Transfer History).
final List<Widget>? trailingActions;
@override
ConsumerState<AssetRecentMaintenanceLogsSection> createState() =>
_AssetRecentMaintenanceLogsSectionState();
}
class _AssetRecentMaintenanceLogsSectionState
extends ConsumerState<AssetRecentMaintenanceLogsSection> {
bool _showLogs = false;
List<AssetMaintenanceLogModel>? _logs;
bool _logsLoading = false;
String? _logsError;
Future<void> _loadLogs() async {
setState(() {
_showLogs = true;
_logsLoading = true;
_logsError = null;
});
final result = await ref
.read(assetRepositoryProvider)
.getMaintenanceLogs(widget.assetId);
if (!mounted) return;
setState(() {
_logsLoading = false;
if (result.failure != null) {
_logsError = result.failure!.message;
_logs = null;
} else {
_logs = result.data ?? [];
}
});
}
void _toggle() {
if (_showLogs && _logs != null) {
setState(() => _showLogs = !_showLogs);
return;
}
_loadLogs();
}
@override
Widget build(BuildContext context) {
// Refresh open logs after a new maintenance submit updates the asset.
ref.listen(assetDetailProvider(widget.assetId), (previous, next) {
if (_showLogs && previous != next) {
_loadLogs();
}
});
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Align(
alignment: Alignment.centerLeft,
child: Wrap(
spacing: 12,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
...?widget.leadingActions,
OutlinedButton.icon(
onPressed: _logsLoading ? null : _toggle,
icon: Icon(
_showLogs ? Icons.expand_less : Icons.history,
size: 18,
),
label: const Text('Recent Logs'),
),
...?widget.trailingActions,
],
),
),
if (_showLogs) ...[
const SizedBox(height: 12),
if (_logsLoading)
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(child: CircularProgressIndicator()),
)
else if (_logsError != null)
Text(
_logsError!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
)
else if (_logs == null || _logs!.isEmpty)
const AppEmptyState(
title: 'No logs yet',
description: 'Submitted maintenance visits will appear here.',
icon: Icons.history_toggle_off_outlined,
)
else
..._logs!.take(widget.limit).map(
(log) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _MaintenanceLogTile(log: log),
),
),
],
],
);
}
}
class _DateField extends StatelessWidget {
const _DateField({
required this.label,
required this.value,
required this.onPick,
});
final String label;
final DateTime? value;
final VoidCallback onPick;
@override
Widget build(BuildContext context) {
return TextFormField(
readOnly: true,
onTap: onPick,
decoration: InputDecoration(
labelText: label,
hintText: 'Select date',
suffixIcon: const Icon(Icons.calendar_today_outlined, size: 20),
),
controller: TextEditingController(
text: value != null ? DateFormatter.displayDate(value) : '',
),
validator: (_) => value == null ? 'Required' : null,
);
}
}
class _ChecklistItemCard extends StatelessWidget {
const _ChecklistItemCard({
required this.row,
required this.onStatusChanged,
});
final _ChecklistRowState row;
final ValueChanged<String?> onStatusChanged;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: theme.dividerColor),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
row.required ? '${row.label} *' : row.label,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 10),
AppDropdown<String>(
label: row.required ? 'Status *' : 'Status',
isDense: true,
value: row.status,
hint: 'Select status',
options: const [
AppDropdownOption(value: 'OK', label: 'OK'),
AppDropdownOption(value: 'NOT_OK', label: 'Not OK'),
AppDropdownOption(value: 'NA', label: 'N/A'),
],
onChanged: onStatusChanged,
validator: row.required
? (v) =>
(v == null || v.trim().isEmpty) ? 'Status is required' : null
: null,
),
const SizedBox(height: 10),
AppTextField(
controller: row.remarksController,
label: row.required ? 'Item remarks *' : 'Item remarks',
validator: row.required
? (v) => (v == null || v.trim().isEmpty)
? 'Remarks are required for this checklist item'
: null
: null,
),
],
),
);
}
}
class _MaintenanceLogTile extends StatelessWidget {
const _MaintenanceLogTile({required this.log});
final AssetMaintenanceLogModel log;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final dateFormat = DateFormat('dd MMM yyyy');
final performed = log.performedDate != null
? dateFormat.format(log.performedDate!)
: '';
final nextDue =
log.nextDueDate != null ? dateFormat.format(log.nextDueDate!) : null;
final subtitleParts = [
'Performed: $performed',
if (nextDue != null) 'Next due: $nextDue',
if (log.createdByName?.trim().isNotEmpty == true) log.createdByName!.trim(),
];
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
subtitleParts.join(' · '),
style: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
if (log.remarks?.trim().isNotEmpty == true) ...[
const SizedBox(height: 4),
Text(
log.remarks!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
if (log.checklistJson.isNotEmpty) ...[
const SizedBox(height: 6),
Wrap(
spacing: 6,
runSpacing: 4,
children: log.checklistJson.map((item) {
final label = item['label']?.toString().trim();
final rawStatus = item['status']?.toString().trim() ?? '';
final status = rawStatus.replaceAll('_', ' ');
final remarks = item['remarks']?.toString().trim();
final title =
(label != null && label.isNotEmpty) ? label : 'Item';
final chipText = (remarks != null && remarks.isNotEmpty)
? '$title: $status$remarks'
: '$title: $status';
return Chip(
visualDensity: VisualDensity.compact,
label: Text(
chipText,
style: theme.textTheme.labelSmall,
),
padding: EdgeInsets.zero,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
);
}).toList(),
),
],
],
),
);
}
}