import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:tdesign_flutter/tdesign_flutter.dart'; import '../../core/i18n/app_localizations.dart'; import '../../core/utils/date_utils.dart' as du; import '../../shared/widgets/form_section.dart'; import '../../shared/widgets/form_field_row.dart'; import '../../shared/widgets/app_skeletons.dart'; import '../../shared/widgets/bill_status_bar.dart'; import '../../shared/widgets/loading_dialog.dart'; import '../../core/navigation/host_app_channel.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors_extension.dart'; import 'overtime_apply_model.dart'; import 'widgets/overtime_apply_detail_view_dialog.dart'; import 'overtime_apply_api.dart'; class OvertimeApplyDetailPage extends ConsumerStatefulWidget { final String billNo; final int queryId; const OvertimeApplyDetailPage({ super.key, required this.billNo, this.queryId = 0, }); @override ConsumerState createState() => _OvertimeApplyDetailPageState(); } class _OvertimeApplyDetailPageState extends ConsumerState { bool _loading = true; String? _error; OvertimeApplyModel? _data; BillStatusBar? _billStatusBar; @override void initState() { super.initState(); _loadData(); } @override void dispose() { super.dispose(); } Future _loadData() async { setState(() { _loading = true; _error = null; }); try { final api = ref.read(overtimeApplyApiProvider); final detail = await api.fetchDetail(widget.billNo); if (!mounted) return; // 单据状态 + 审核配置(非关键,尽力加载),与详情合并到一个 setState 避免中间闪烁 Map? status; Map? auditConfig; try { final results = await Future.wait([ api.getBillStatus(widget.billNo), api.getBillAuditConfig('JB'), ]); final Map s = results[0]; final Map c = results[1]; status = s; auditConfig = c; } catch (_) {} final hasAuditFlow = auditConfig?['hasAuditFlow'] == true; if (mounted) { setState(() { _data = detail; _billStatusBar = (status != null) ? BillStatusBar( billStatus: status, hasAuditFlow: hasAuditFlow, onEdit: () { context .push('/overtime-apply/edit/${widget.billNo}') .then((_) => _loadData()); }, onSubmit: () async { final l10n = AppLocalizations.of(context); final api = ref.read(overtimeApplyApiProvider); final jbDdStr = _data?.jbDd != null ? du.DateUtils.formatDate(_data!.jbDd!) : ''; if (jbDdStr.isEmpty) return; LoadingDialog.show(context, text: l10n.get('submitting')); try { await api.shSubmit(bilNo: widget.billNo, bilDd: jbDdStr); } finally { if (mounted) LoadingDialog.hide(context); } if (mounted) _loadData(); }, onCancelSubmit: () async { final l10n = AppLocalizations.of(context); final api = ref.read(overtimeApplyApiProvider); final jbDdStr = _data?.jbDd != null ? du.DateUtils.formatDate(_data!.jbDd!) : ''; if (jbDdStr.isEmpty) return; LoadingDialog.show(context, text: l10n.get('cancelling')); try { await api.shSubmit( bilNo: widget.billNo, bilDd: jbDdStr, isCancel: true, ); } finally { if (mounted) LoadingDialog.hide(context); } if (mounted) _loadData(); }, onTapStatusTag: () { _showAuditTrail('OT'); }, ) : null; _loading = false; }); } } catch (e) { if (mounted) { setState(() { _error = e.toString(); _loading = false; }); } } } @override Widget build(BuildContext context) { final colors = Theme.of(context).extension()!; final l10n = AppLocalizations.of(context); if (_loading) { return const SkeletonDetailPage(sectionRows: [8, 3]); } if (_error != null) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.error_outline, size: 48, color: colors.danger), const SizedBox(height: 16), Padding( padding: const EdgeInsets.symmetric(horizontal: 32), child: Text( _error!, textAlign: TextAlign.center, style: TextStyle( fontSize: AppFontSizes.body, color: colors.textSecondary, ), ), ), const SizedBox(height: 16), TDButton( text: l10n.get('retry'), size: TDButtonSize.medium, onTap: _loadData, ), ], ), ); } final app = _data!; return Column( children: [ Expanded( child: SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.all(16), child: Column( children: [ _buildBasicInfoSection(app, l10n, colors), const SizedBox(height: 16), _buildOvertimeDetailSection(app, l10n, colors), const SizedBox(height: 24), _buildPageFooter(colors), ], ), ), ), _billStatusBar?.buildActions(context) ?? const SizedBox.shrink(), ], ); } Future _showAuditTrail(String billId) async { await HostAppChannel.showAuditTrail(billId, widget.billNo); } // ═══ 基本信息 ═══ Widget _buildBasicInfoSection( OvertimeApplyModel app, AppLocalizations l10n, AppColorsExtension colors, ) { return FormSection( title: l10n.get('basicInfo'), leadingIcon: Icons.info_outline, trailing: _billStatusBar?.buildStatusTag(context), children: [ FormFieldRow( label: l10n.get('overtimeApplyNo'), value: app.jbNo, readOnly: true, showArrow: false, ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('date'), value: app.jbDd != null ? du.DateUtils.formatDate(app.jbDd!) : '-', readOnly: true, showArrow: false, ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('applicant'), value: app.salNo.isNotEmpty ? '${app.salNo}${app.salName.isNotEmpty ? '/${app.salName}' : ''}' : '-', readOnly: true, showArrow: false, ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('dep'), value: app.dep.isNotEmpty ? '${app.dep}${app.depName.isNotEmpty ? '/${app.depName}' : ''}' : '-', readOnly: true, showArrow: false, ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('overtimeReason'), value: app.reason.isNotEmpty ? app.reason : '-', readOnly: true, showArrow: false, bold: true, showMoreOnOverflow: true, ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('remark'), value: app.rem.isNotEmpty ? app.rem : '-', readOnly: true, showArrow: false, bold: false, showMoreOnOverflow: true, ), ], ); } // ═══ 加班明细 ═══ Widget _buildOvertimeDetailSection( OvertimeApplyModel app, AppLocalizations l10n, AppColorsExtension colors, ) { return FormSection( title: l10n.get('overtimeDetails'), leadingIcon: Icons.access_time_outlined, children: [ if (app.details.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Text( l10n.get('noDetailData'), style: TextStyle( fontSize: AppFontSizes.body, color: colors.textPlaceholder, ), ), ) else ...app.details.map((d) => _buildDetailCard(d, l10n, colors)), if (app.details.isNotEmpty) ...[ const SizedBox(height: 8), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( l10n.get('totalOvertimeHours'), style: TextStyle( fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.textPrimary, ), ), Text( '${_totalHours(app).toStringAsFixed(1)}${l10n.get('hours')}', style: TextStyle( fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w700, color: colors.timePrimary, ), ), ], ), ], ], ); } double _totalHours(OvertimeApplyModel app) => app.details.fold(0.0, (s, d) => s + d.jbHours); Widget _buildDetailCard( OvertimeApplyDetailModel d, AppLocalizations l10n, AppColorsExtension colors, ) { // 开始/结束时间格式化 String formatDateTime(DateTime dt) { return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ' '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; } String formatHHmm(DateTime dt) { return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; } return GestureDetector( onTap: () => OvertimeApplyDetailViewDialog.show(context, d), child: Container( margin: const EdgeInsets.symmetric(vertical: 6), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: colors.bgPage, borderRadius: BorderRadius.circular(8), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 第一行:员工 + 时长 Row( children: [ Expanded( child: Text( '${d.salNo}${d.salName.isNotEmpty ? '/${d.salName}' : ''}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: AppFontSizes.body, fontWeight: FontWeight.w500, color: colors.textPrimary, ), ), ), Text( '${d.jbHours.toStringAsFixed(1)}${l10n.get('hours')}', style: TextStyle( fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.timePrimary, ), ), ], ), if (d.jbType.isNotEmpty) ...[ const SizedBox(height: 2), _detailLabel( '${l10n.get('jbType')}: ${_jbTypeLabel(d.jbType, l10n)}', colors, ), if (d.jbDate != null) _detailLabel( '${l10n.get('jbDate')}: ${du.DateUtils.formatDate(d.jbDate!)}', colors, trailing: _weekdayTag( _weekdayLabel(d.jbDate!, l10n), ), ), ], if (d.startTime != null) ...[ const SizedBox(height: 2), _detailLabel( '${l10n.get('startTime')}: ${formatHHmm(d.startTime!)}', colors, ), ], if (d.endTime != null) ...[ const SizedBox(height: 2), _detailLabel( '${l10n.get('endTime')}: ${formatHHmm(d.endTime!)}', colors, ), ], // TODO: 加班天数暂时隐藏 // if (d.jbDays > 0) ...[ // const SizedBox(height: 2), // _detailLabel( // '${l10n.get('overtimeDays')}: ${d.jbDays.toStringAsFixed(1)}', // colors, // ), // ], if (d.attPeriod.isNotEmpty) ...[ const SizedBox(height: 2), _detailLabel('${l10n.get('attPeriod')}: ${d.attPeriod}', colors), ], // TODO: 补偿类型、补偿次数暂时隐藏 // if (d.compensationType.isNotEmpty) ...[ // const SizedBox(height: 2), // _detailLabel( // '${l10n.get('compensationType')}: ${_compensationTypeLabel(d.compensationType, l10n)}', // colors, // ), // if (d.compensationType != 'NO_COMPENSATION' && // d.compensationCount > 0) // _detailLabel( // '${l10n.get('compensationCount')}: ${d.compensationCount.toStringAsFixed(1)}', // colors, // ), // ], if (d.adr.isNotEmpty) ...[ const SizedBox(height: 2), _detailLabel('${l10n.get('adr')}: ${d.adr}', colors), ], if (d.reason.isNotEmpty) ...[ const SizedBox(height: 2), _detailLabel( '${l10n.get('overtimeDetailReason')}: ${d.reason}', colors, ), ], if (d.rem.isNotEmpty) ...[ const SizedBox(height: 2), _detailLabel('${l10n.get('remark')}: ${d.rem}', colors), ], ], ), ), ); } String _weekdayLabel(DateTime dt, AppLocalizations l10n) { switch (dt.weekday) { case 1: return l10n.get('monday'); case 2: return l10n.get('tuesday'); case 3: return l10n.get('wednesday'); case 4: return l10n.get('thursday'); case 5: return l10n.get('friday'); case 6: return l10n.get('saturday'); case 7: return l10n.get('sunday'); default: return ''; } } Widget _detailLabel(String text, AppColorsExtension colors, {Widget? trailing}) { return Padding( padding: const EdgeInsets.only(top: 2), child: Row( children: [ Flexible( child: Text( text, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textSecondary, ), ), ), if (trailing != null) ...[ const SizedBox(width: 6), trailing, ], ], ), ); } Widget _weekdayTag(String label) { final tdTheme = TDTheme.of(context); return Container( padding: const EdgeInsets.symmetric(horizontal: 6), decoration: BoxDecoration( color: tdTheme.brandColor1, borderRadius: BorderRadius.circular(4), ), child: TDText( label, font: tdTheme.fontBodySmall, fontWeight: FontWeight.w500, textColor: tdTheme.brandColor7, ), ); } String _jbTypeLabel(String type, AppLocalizations l10n) { switch (type) { case 'WORKING_DAY': return l10n.get('workingDay'); case 'REST_DAY': return l10n.get('restDay'); case 'PUBLIC_HOLIDAY': return l10n.get('publicHoliday'); case 'SPECIAL_HOLIDAY': return l10n.get('specialHoliday'); case 'OTHER': return l10n.get('other'); default: return type; } } String _compensationTypeLabel(String type, AppLocalizations l10n) { switch (type) { case 'OVERTIME_PAY': return l10n.get('overtimePay'); case 'COMPENSATORY_LEAVE': return l10n.get('compensatoryLeave'); case 'NO_COMPENSATION': return l10n.get('noCompensation'); case 'OTHER': return l10n.get('other'); default: return type; } } Widget _buildPageFooter(AppColorsExtension colors) { final l10n = AppLocalizations.of(context); return Center( child: Padding( padding: const EdgeInsets.only(bottom: 16), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.rocket_launch_outlined, size: 16, color: colors.textPlaceholder, ), const SizedBox(width: 6), Text( l10n.get('pageFooter'), style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textPlaceholder, ), ), ], ), ), ); } }