561 lines
17 KiB
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(),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|